From 157e50adae117b01ebfc3e8ea48f2ad92988cd41 Mon Sep 17 00:00:00 2001 From: coodos Date: Fri, 9 Oct 2026 14:20:48 +0800 Subject: [PATCH 01/10] fix(evault): make manifest queries run on Neo4j 4.4 --- .../src/core/manifest/manifest.service.ts | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/infrastructure/evault-core/src/core/manifest/manifest.service.ts b/infrastructure/evault-core/src/core/manifest/manifest.service.ts index cd6aa7d94..0c0b1b524 100644 --- a/infrastructure/evault-core/src/core/manifest/manifest.service.ts +++ b/infrastructure/evault-core/src/core/manifest/manifest.service.ts @@ -117,16 +117,15 @@ export class ManifestService { ` OPTIONAL MATCH (u:User { eName: $eName }) WITH coalesce(size(u.publicKeys), 0) = 0 AS keyless - RETURN keyless, - EXISTS { - MATCH (m:MetaEnvelope { eName: $eName, ontology: $company })-[:LINKS_TO]->(n:Envelope) - WHERE n.ontology IN $enameFields AND n.value = $eName - } AS isCompany, - EXISTS { - MATCH (m:MetaEnvelope { eName: $eName })-[:LINKS_TO]->(n:Envelope) - WHERE m.ontology IN $groupOntologies - AND n.ontology IN $enameFields AND n.value = $eName - } AS hasGroupRecord + // Counted matches rather than EXISTS subqueries in RETURN, which + // Neo4j 4.4 (the local dev database) does not support. + OPTIONAL MATCH (c:MetaEnvelope { eName: $eName, ontology: $company })-[:LINKS_TO]->(cn:Envelope) + WHERE cn.ontology IN $enameFields AND cn.value = $eName + WITH keyless, count(c) > 0 AS isCompany + OPTIONAL MATCH (g:MetaEnvelope { eName: $eName })-[:LINKS_TO]->(gn:Envelope) + WHERE g.ontology IN $groupOntologies + AND gn.ontology IN $enameFields AND gn.value = $eName + RETURN keyless, isCompany, count(g) > 0 AS hasGroupRecord `, { eName, @@ -167,12 +166,22 @@ export class ManifestService { MATCH (m)-[:LINKS_TO]->(n:Envelope) WHERE n.ontology IN $enameFields AND n.value = $eName } + // Plain boolean logic: Neo4j 4.4 only allows EXISTS as a predicate, + // not as a value to compare. AND ( $shape = "any" - OR ($shape = "manifest") = ( - EXISTS { (m)-[:LINKS_TO]->(:Envelope { ontology: "members" }) } + OR ( + $shape = "manifest" + AND EXISTS { (m)-[:LINKS_TO]->(:Envelope { ontology: "members" }) } AND EXISTS { (m)-[:LINKS_TO]->(:Envelope { ontology: "owner" }) } ) + OR ( + $shape = "chat" + AND NOT ( + EXISTS { (m)-[:LINKS_TO]->(:Envelope { ontology: "members" }) } + AND EXISTS { (m)-[:LINKS_TO]->(:Envelope { ontology: "owner" }) } + ) + ) ) OPTIONAL MATCH (h:MetaEnvelopeHistory { metaEnvelopeId: m.id, eName: $eName }) OPTIONAL MATCH (m)-[:LINKS_TO]->(c:Envelope { ontology: "createdAt" }) From 58337423c393fee143d4a2edb4c4e976a70db215 Mon Sep 17 00:00:00 2001 From: coodos Date: Fri, 9 Oct 2026 14:20:48 +0800 Subject: [PATCH 02/10] feat(examples): company delegation demo on real eVaults with in-process wallets --- examples/company-delegation-demo/.gitignore | 2 + examples/company-delegation-demo/README.md | 42 ++ .../company-delegation-demo/demo.config.json | 39 ++ examples/company-delegation-demo/package.json | 24 + .../company-delegation-demo/public/index.html | 166 +++++ examples/company-delegation-demo/src/app.ts | 72 ++ examples/company-delegation-demo/src/demo.ts | 663 ++++++++++++++++++ .../company-delegation-demo/src/server.ts | 6 + examples/company-delegation-demo/src/smoke.ts | 38 + examples/company-delegation-demo/src/stack.ts | 125 ++++ .../company-delegation-demo/src/wallet.ts | 113 +++ .../company-delegation-demo/tsconfig.json | 13 + pnpm-lock.yaml | 28 + pnpm-workspace.yaml | 1 + 14 files changed, 1332 insertions(+) create mode 100644 examples/company-delegation-demo/.gitignore create mode 100644 examples/company-delegation-demo/README.md create mode 100644 examples/company-delegation-demo/demo.config.json create mode 100644 examples/company-delegation-demo/package.json create mode 100644 examples/company-delegation-demo/public/index.html create mode 100644 examples/company-delegation-demo/src/app.ts create mode 100644 examples/company-delegation-demo/src/demo.ts create mode 100644 examples/company-delegation-demo/src/server.ts create mode 100644 examples/company-delegation-demo/src/smoke.ts create mode 100644 examples/company-delegation-demo/src/stack.ts create mode 100644 examples/company-delegation-demo/src/wallet.ts create mode 100644 examples/company-delegation-demo/tsconfig.json diff --git a/examples/company-delegation-demo/.gitignore b/examples/company-delegation-demo/.gitignore new file mode 100644 index 000000000..5dd1f51ea --- /dev/null +++ b/examples/company-delegation-demo/.gitignore @@ -0,0 +1,2 @@ +.demo/ +node_modules/ diff --git a/examples/company-delegation-demo/README.md b/examples/company-delegation-demo/README.md new file mode 100644 index 000000000..c08a89024 --- /dev/null +++ b/examples/company-delegation-demo/README.md @@ -0,0 +1,42 @@ +# Company delegation demo + +A one-page demonstrator of company signing delegation on the real local stack: real eVaults, real wallets (wallet-sdk, in-process), real `w3ds://sign` requests and real P-256 signatures. No phone, no editor. + +Acme's board, roles and delegations are signed records in Acme's own eVault, set up from `demo.config.json`. Every signature made for Acme is checked by `verifyDelegatedSignature` against that eVault's history; the eVault itself enforces nothing. + +## Run + +```bash +pnpm dev:core # Registry :4321, provisioner :3001, eVault :4000 +pnpm --filter company-delegation-demo dev # http://localhost:5180 +``` + +Press **Set up Acme**, then run the scenarios: + +| Scenario | Expected | +|---|---| +| Bob signs an NDA for Acme | valid, as "Head of Finance" | +| Bob signs an invoice | refused, not in his delegation | +| Carol signs an NDA, re-delegated by Bob | valid, as "NDA signer" | +| Bob's NDA signature replayed as a login | refused, reserved payload | +| Mallory writes herself onto Acme's board and delegates to herself | refused, her records are ignored | +| Dana revokes Bob, then Bob and Carol try again | both refused | + +**Start over with a new Acme** provisions a fresh company eVault; the people's wallets are reused. + +## Headless + +```bash +pnpm --filter company-delegation-demo smoke +``` + +Runs setup and every scenario against the local stack and exits non-zero if any ends differently. + +## Where things live + +- `demo.config.json`: the company, people, roles and delegations. +- `.demo/state.json` (gitignored): generated eNames, wallet keys and record ids. +- `src/wallet.ts`: a wallet-sdk crypto adapter for Node, and the wallet's handling of a `w3ds://sign` URI (sign the session, POST to `redirect_uri`). +- `src/demo.ts`: setup and scenarios, using `buildGrantSignRequest`, `buildDelegatedSignRequest` and `verifyDelegatedSignature` from `@metastate-foundation/auth`. + +Environment overrides: `REGISTRY_URL`, `PROVISIONER_URL`, `DEMO_VERIFICATION_ID`, `DEMO_PORT`. diff --git a/examples/company-delegation-demo/demo.config.json b/examples/company-delegation-demo/demo.config.json new file mode 100644 index 000000000..c193e8950 --- /dev/null +++ b/examples/company-delegation-demo/demo.config.json @@ -0,0 +1,39 @@ +{ + "company": { + "name": "Acme Ltd", + "legalName": "Acme Limited", + "jurisdiction": "GB" + }, + "people": { + "dana": { "name": "Dana", "about": "Director of Acme" }, + "bob": { "name": "Bob", "about": "Head of Finance" }, + "carol": { "name": "Carol", "about": "Finance assistant" }, + "mallory": { "name": "Mallory", "about": "Not part of Acme" } + }, + "directors": ["dana"], + "roles": { + "finance": { + "title": "Head of Finance", + "scopes": ["@esigner:nda", "@esigner:invoice"], + "mayRedelegate": true + } + }, + "delegations": { + "bob": { + "to": "bob", + "role": "finance", + "grantedBy": "dana", + "title": "Head of Finance", + "scopes": ["@esigner:nda"], + "mayRedelegate": true + }, + "carol": { + "to": "carol", + "parent": "bob", + "grantedBy": "bob", + "title": "NDA signer", + "scopes": ["@esigner:nda"], + "mayRedelegate": false + } + } +} diff --git a/examples/company-delegation-demo/package.json b/examples/company-delegation-demo/package.json new file mode 100644 index 000000000..6f0855510 --- /dev/null +++ b/examples/company-delegation-demo/package.json @@ -0,0 +1,24 @@ +{ + "name": "company-delegation-demo", + "version": "0.1.0", + "private": true, + "description": "Demonstrator for company signing delegation on real eVaults with wallet-sdk wallets", + "type": "module", + "scripts": { + "dev": "tsx src/server.ts", + "smoke": "tsx src/smoke.ts", + "check-types": "tsc --noEmit" + }, + "dependencies": { + "@metastate-foundation/auth": "workspace:*", + "@metastate-foundation/delegation": "workspace:*", + "express": "^4.18.2", + "wallet-sdk": "workspace:*" + }, + "devDependencies": { + "@types/express": "^4.17.21", + "@types/node": "^20.11.24", + "tsx": "^4.7.0", + "typescript": "~5.6.2" + } +} diff --git a/examples/company-delegation-demo/public/index.html b/examples/company-delegation-demo/public/index.html new file mode 100644 index 000000000..8fddd0283 --- /dev/null +++ b/examples/company-delegation-demo/public/index.html @@ -0,0 +1,166 @@ + + + + + +Company Delegation Demo + + + +
+

Company delegation demo

+

+ Real eVaults on your local stack, real wallets (wallet-sdk) and real w3ds://sign requests. + Acme's board, roles and delegations are signed records in its own eVault. A platform checks every + signature made for Acme against that eVault's history; the eVault itself enforces nothing. +

+ +
+ + +
+ + +
+
+

Scenarios

+
+
+ +
+

Acme, as stored in its eVault

+

Not set up yet.

+
+ +
+

People (wallets)

+
    +
    + +
    +

    What happened

    +
      +
      +
      +
      + + + + diff --git a/examples/company-delegation-demo/src/app.ts b/examples/company-delegation-demo/src/app.ts new file mode 100644 index 000000000..ac6f339a7 --- /dev/null +++ b/examples/company-delegation-demo/src/app.ts @@ -0,0 +1,72 @@ +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import express from "express"; +import { Demo } from "./demo.js"; + +const PUBLIC = join(dirname(fileURLToPath(import.meta.url)), "..", "public"); + +/** The demo page, its API, and the callback the demo wallets POST to. */ +export function createApp(port: number) { + const demo = new Demo(`http://localhost:${port}/callback`); + const app = express(); + app.use(express.json()); + app.use(express.static(PUBLIC)); + + // One action at a time, so the story stays in order. + let queue: Promise = Promise.resolve(); + const serial = (work: () => Promise) => { + const next = queue.then(work, work); + queue = next.catch(() => undefined); + return next; + }; + const handle = + (work: (req: express.Request) => Promise) => + async (req: express.Request, res: express.Response) => { + try { + res.json(await serial(() => work(req))); + } catch (error) { + res.status(500).json({ + error: + error instanceof Error ? error.message : String(error), + }); + } + }; + + // The wallet's POST must not wait behind the action that asked for it. + app.post("/callback", (req, res) => { + res.status(demo.receiveSignature(req.body) ? 200 : 404).json({ + ok: true, + }); + }); + app.get( + "/api/state", + handle(() => demo.snapshot()), + ); + app.post( + "/api/setup", + handle(async () => { + await demo.setup(); + return demo.snapshot(); + }), + ); + app.post( + "/api/reset", + handle(async () => { + demo.reset(); + await demo.setup(); + return demo.snapshot(); + }), + ); + app.post( + "/api/scenario/:id", + handle(async (req) => { + const scenario = demo.scenarios[req.params.id]; + if (!scenario) throw new Error(`unknown scenario ${req.params.id}`); + return { + outcome: await scenario.run(), + state: await demo.snapshot(), + }; + }), + ); + return { app, demo }; +} diff --git a/examples/company-delegation-demo/src/demo.ts b/examples/company-delegation-demo/src/demo.ts new file mode 100644 index 000000000..87c06343c --- /dev/null +++ b/examples/company-delegation-demo/src/demo.ts @@ -0,0 +1,663 @@ +import { createHash, randomUUID } from "node:crypto"; +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + buildDelegatedSignRequest, + buildGrantSignRequest, + verifyDelegatedSignature, + verifyLoginSignature, + type VerifyDelegatedSignatureResult, +} from "@metastate-foundation/auth"; +import { + COMPANY_ONTOLOGY, + DELEGATED_SIGNATURE_ONTOLOGY, + DELEGATION_ONTOLOGY, + ROLE_ONTOLOGY, +} from "@metastate-foundation/delegation"; +import { provision, syncPublicKeyToEvaultWithOptions } from "wallet-sdk"; +import { + newRecordId, + platformToken, + PROVISIONER_URL, + provisionKeylessEVault, + readRecord, + REGISTRY_URL, + VERIFICATION_ID, + writeRecord, +} from "./stack.js"; +import { + NodeWalletAdapter, + scanSignRequest, + type StoredKey, +} from "./wallet.js"; + +const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); +const STATE_FILE = join(ROOT, ".demo", "state.json"); +const config = JSON.parse( + readFileSync(join(ROOT, "demo.config.json"), "utf8"), +) as Config; + +type Config = { + company: { name: string; legalName: string; jurisdiction: string }; + people: Record; + directors: string[]; + roles: Record< + string, + { title: string; scopes: string[]; mayRedelegate: boolean } + >; + delegations: Record< + string, + { + to: string; + role?: string; + parent?: string; + grantedBy: string; + title: string; + scopes: string[]; + mayRedelegate: boolean; + } + >; +}; + +/** Everything the demo generated, kept between runs. */ +type State = { + keys: Record; + people: Record; + company?: { + eName: string; + companyId: string; + roles: Record; + delegations: Record; + }; +}; + +export type LogEntry = { + at: string; + who?: string; + text: string; + detail?: string; +}; + +export type Outcome = { + scenario: string; + expected: "valid" | "refused"; + passed: boolean; + summary: string; + result?: unknown; +}; + +const now = () => new Date().toISOString(); + +export class Demo { + private state: State; + private adapter: NodeWalletAdapter; + private pending = new Map void>(); + readonly log: LogEntry[] = []; + private lastBobSignature?: { payload: string; signature: string }; + + constructor(private callbackUrl: string) { + this.state = load(); + this.adapter = new NodeWalletAdapter(this.state.keys); + } + + /** The wallet POSTed a signature to the callback. */ + receiveSignature(body: { + sessionId?: string; + signature?: string; + w3id?: string; + }) { + const resolve = body.sessionId + ? this.pending.get(body.sessionId) + : undefined; + if (!resolve || typeof body.signature !== "string") return false; + this.pending.delete(body.sessionId as string); + this.note(body.w3id, "wallet POSTed its signature to the callback"); + resolve(body.signature); + return true; + } + + reset() { + this.state.company = undefined; + save(this.state); + this.log.length = 0; + } + + // ---- setup --------------------------------------------------------- + + async setup(): Promise { + for (const key of Object.keys(config.people)) await this.person(key); + if (this.state.company) return; + + const company = await provisionKeylessEVault(); + this.note( + undefined, + `provisioned Acme's company eVault ${company.eName}`, + ); + const record = { + companyId: newRecordId(), + roles: {} as Record, + delegations: {} as Record, + }; + + // Born with its board: the first version already carries directors. + const director = config.directors[0]; + await this.grant( + director, + company.eName, + COMPANY_ONTOLOGY, + record.companyId, + { + id: "acme", + eName: company.eName, + directors: config.directors.map((d) => this.eName(d)), + displayName: config.company.name, + legalName: config.company.legalName, + jurisdiction: config.company.jurisdiction, + createdAt: now(), + updatedAt: now(), + }, + "Set Acme's board", + ); + + for (const [key, role] of Object.entries(config.roles)) { + record.roles[key] = newRecordId(); + await this.grant( + director, + company.eName, + ROLE_ONTOLOGY, + record.roles[key], + { + companyEName: company.eName, + title: role.title, + scopes: role.scopes, + mayRedelegate: role.mayRedelegate, + status: "active", + createdBy: this.eName(director), + createdAt: now(), + updatedAt: now(), + }, + `Create role "${role.title}"`, + ); + } + + for (const [key, d] of Object.entries(config.delegations)) { + record.delegations[key] = newRecordId(); + await this.grant( + d.grantedBy, + company.eName, + DELEGATION_ONTOLOGY, + record.delegations[key], + { + companyEName: company.eName, + delegateEName: this.eName(d.to), + ...(d.role + ? { roleId: record.roles[d.role] } + : { + parentDelegationId: + record.delegations[d.parent as string], + }), + title: d.title, + scopes: d.scopes, + mayRedelegate: d.mayRedelegate, + grantedBy: this.eName(d.grantedBy), + status: "active", + createdAt: now(), + updatedAt: now(), + }, + `Delegate "${d.title}" to ${config.people[d.to].name}`, + ); + } + + this.state.company = { eName: company.eName, ...record }; + save(this.state); + } + + /** A real wallet with a real eVault, via wallet-sdk. */ + private async person(key: string) { + if (this.state.people[key]) return; + const keyId = `demo-${key}`; + await this.adapter.ensureKey(keyId); + const result = await provision(this.adapter, { + registryUrl: REGISTRY_URL, + provisionerUrl: PROVISIONER_URL, + namespace: randomUUID(), + verificationId: VERIFICATION_ID, + keyId, + }); + await syncPublicKeyToEvaultWithOptions({ + evaultUrl: result.uri, + eName: result.w3id, + cryptoAdapter: this.adapter, + keyId, + context: "onboarding", + token: await platformToken(), + }); + this.state.people[key] = { eName: result.w3id, keyId }; + save(this.state); + this.note( + result.w3id, + `${config.people[key].name}'s wallet provisioned an eVault and bound its key`, + ); + } + + /** A director or delegate approves a record through w3ds://sign. */ + private async grant( + signer: string, + companyEName: string, + ontology: string, + recordId: string, + record: Record, + message: string, + ) { + const request = await buildGrantSignRequest({ + ontology, + companyEName, + recordId, + signerEName: this.eName(signer), + record, + redirectUri: this.callbackUrl, + message, + }); + const signature = await this.walletSigns(signer, request.uri, message); + await writeRecord(companyEName, recordId, ontology, { + ...record, + authorization: { + signerEName: request.signerEName, + signedPayload: request.payload, + signature, + signedAt: request.signedAt, + }, + }); + this.note( + this.eName(signer), + `wrote "${message}" into the company eVault`, + recordId, + ); + } + + private async walletSigns( + person: string, + uri: string, + what: string, + ): Promise { + const session = new URL( + uri.replace(/^w3ds:\/\//, "https://w3ds.invalid/"), + ).searchParams.get("session") as string; + const signed = new Promise((resolve, reject) => { + this.pending.set(session, resolve); + setTimeout( + () => reject(new Error("wallet never called back")), + 15_000, + ); + }); + this.note(this.eName(person), `scans w3ds://sign: ${what}`, uri); + await scanSignRequest(uri, { + eName: this.eName(person), + keyId: this.state.people[person].keyId, + adapter: this.adapter, + }); + return signed; + } + + // ---- signing for the company -------------------------------------- + + /** `person` signs `document` for Acme under their delegation, and a platform verifies it. */ + async signFor( + person: string, + delegation: string, + scope: string, + document: string, + ) { + const company = this.requireCompany(); + const title = config.delegations[delegation]?.title ?? "someone"; + const request = buildDelegatedSignRequest({ + onBehalfOf: company.eName, + signer: this.eName(person), + scope, + delegationId: company.delegations[delegation], + documentHash: createHash("sha256").update(document).digest("hex"), + session: randomUUID(), + redirectUri: this.callbackUrl, + title, + companyName: config.company.name, + }); + const signature = await this.walletSigns( + person, + request.uri, + `sign ${document} as ${title}`, + ); + const result = await verifyDelegatedSignature({ + payload: request.payload, + signature, + registryBaseUrl: REGISTRY_URL, + platformToken: await platformToken(), + }); + this.note( + undefined, + result.valid + ? `platform verified: ${config.people[person].name} signed ${document} for Acme as "${result.title}"` + : `platform refused: ${result.error}${result.detail ? ` (${result.detail})` : ""}`, + ); + if (result.valid) { + await writeRecord( + company.eName, + newRecordId(), + DELEGATED_SIGNATURE_ONTOLOGY, + { + companyEName: company.eName, + signerEName: this.eName(person), + delegationId: company.delegations[delegation], + title: result.title, + scope, + documentHash: result.payload?.documentHash, + session: result.payload?.session, + signedPayload: request.payload, + signature, + platformEName: "@company-delegation-demo", + signedAt: now(), + createdAt: now(), + }, + ); + } + return { result, payload: request.payload, signature }; + } + + // ---- scenarios ----------------------------------------------------- + + readonly scenarios: Record< + string, + { label: string; run: () => Promise } + > = { + "bob-nda": { + label: "Bob signs an NDA for Acme", + run: async () => { + const { result, payload, signature } = await this.signFor( + "bob", + "bob", + "@esigner:nda", + "nda-globex.pdf", + ); + this.lastBobSignature = { payload, signature }; + return outcome("bob-nda", "valid", result); + }, + }, + "bob-invoice": { + label: "Bob signs an invoice (not in his delegation)", + run: async () => + outcome( + "bob-invoice", + "refused", + ( + await this.signFor( + "bob", + "bob", + "@esigner:invoice", + "invoice-0042.pdf", + ) + ).result, + ), + }, + "carol-nda": { + label: "Carol signs an NDA (re-delegated by Bob)", + run: async () => + outcome( + "carol-nda", + "valid", + ( + await this.signFor( + "carol", + "carol", + "@esigner:nda", + "nda-initech.pdf", + ) + ).result, + ), + }, + "login-replay": { + label: "Bob's NDA signature replayed as a login", + run: async () => { + if (!this.lastBobSignature) + await this.scenarios["bob-nda"].run(); + const { payload, signature } = this.lastBobSignature as { + payload: string; + signature: string; + }; + const login = await verifyLoginSignature({ + eName: this.eName("bob"), + signature, + session: payload, + registryBaseUrl: REGISTRY_URL, + }); + this.note( + undefined, + `login check: ${login.valid ? "accepted" : `refused (${login.error})`}`, + ); + return { + scenario: "login-replay", + expected: "refused", + passed: !login.valid, + summary: login.valid + ? "accepted as a login" + : `refused: ${login.error}`, + result: login, + }; + }, + }, + "mallory-board": { + label: "Mallory writes herself onto Acme's board and delegates to herself", + run: async () => { + const company = this.requireCompany(); + const current = + (await readRecord(company.eName, company.companyId)) ?? {}; + const { authorization: _drop, ...board } = current; + // A version signed by Mallory over the real Company record. + await this.grant( + "mallory", + company.eName, + COMPANY_ONTOLOGY, + company.companyId, + { + ...board, + directors: [this.eName("mallory")], + updatedAt: now(), + }, + "Make Mallory the board", + ); + const roleId = newRecordId(); + await this.grant( + "mallory", + company.eName, + ROLE_ONTOLOGY, + roleId, + { + companyEName: company.eName, + title: "Owner", + scopes: ["@esigner:nda"], + mayRedelegate: false, + status: "active", + createdBy: this.eName("mallory"), + createdAt: now(), + updatedAt: now(), + }, + "Create role Owner", + ); + const delegationId = newRecordId(); + await this.grant( + "mallory", + company.eName, + DELEGATION_ONTOLOGY, + delegationId, + { + companyEName: company.eName, + delegateEName: this.eName("mallory"), + roleId, + title: "Owner", + scopes: ["@esigner:nda"], + mayRedelegate: false, + grantedBy: this.eName("mallory"), + status: "active", + createdAt: now(), + updatedAt: now(), + }, + "Delegate Owner to Mallory", + ); + company.delegations.mallory = delegationId; + save(this.state); + return outcome( + "mallory-board", + "refused", + ( + await this.signFor( + "mallory", + "mallory", + "@esigner:nda", + "nda-mallory.pdf", + ) + ).result, + ); + }, + }, + "revoke-bob": { + label: "Dana revokes Bob, then Bob and Carol try again", + run: async () => { + const company = this.requireCompany(); + const current = + (await readRecord( + company.eName, + company.delegations.bob, + )) ?? {}; + const { authorization: _drop, ...delegation } = current; + await this.grant( + "dana", + company.eName, + DELEGATION_ONTOLOGY, + company.delegations.bob, + { + ...delegation, + status: "revoked", + revokedAt: now(), + revokedBy: this.eName("dana"), + revocationReason: "revoked", + updatedAt: now(), + }, + "Revoke Bob's delegation", + ); + const bob = ( + await this.signFor( + "bob", + "bob", + "@esigner:nda", + "nda-after-revoke.pdf", + ) + ).result; + const carol = ( + await this.signFor( + "carol", + "carol", + "@esigner:nda", + "nda-after-revoke.pdf", + ) + ).result; + const passed = !bob.valid && !carol.valid; + return { + scenario: "revoke-bob", + expected: "refused", + passed, + summary: `Bob: ${describe(bob)} · Carol: ${describe(carol)}`, + result: { bob, carol }, + }; + }, + }, + }; + + // ---- view ------------------------------------------------------------ + + async snapshot() { + const company = this.state.company; + const records = company + ? { + company: await readRecord(company.eName, company.companyId), + roles: await Promise.all( + Object.values(company.roles).map((id) => + readRecord(company.eName, id), + ), + ), + delegations: await Promise.all( + Object.entries(company.delegations).map( + async ([key, id]) => ({ + key, + id, + record: await readRecord(company.eName, id), + }), + ), + ), + } + : null; + return { + people: Object.fromEntries( + Object.entries(this.state.people).map(([k, p]) => [ + k, + { ...config.people[k], eName: p.eName }, + ]), + ), + company: company + ? { eName: company.eName, companyId: company.companyId } + : null, + records, + scenarios: Object.entries(this.scenarios).map(([id, s]) => ({ + id, + label: s.label, + })), + log: this.log.slice(-60), + }; + } + + private requireCompany() { + if (!this.state.company) throw new Error("run setup first"); + return this.state.company; + } + + private eName(person: string) { + const p = this.state.people[person]; + if (!p) throw new Error(`unknown person ${person}`); + return p.eName; + } + + private note(who: string | undefined, text: string, detail?: string) { + this.log.push({ at: now(), who, text, detail }); + } +} + +function describe(result: VerifyDelegatedSignatureResult) { + return result.valid + ? `valid as "${result.title}"` + : `refused: ${result.error}${result.detail ? ` (${result.detail})` : ""}`; +} + +function outcome( + scenario: string, + expected: "valid" | "refused", + result: VerifyDelegatedSignatureResult, +): Outcome { + return { + scenario, + expected, + passed: expected === "valid" ? result.valid : !result.valid, + summary: describe(result), + result, + }; +} + +function load(): State { + try { + return JSON.parse(readFileSync(STATE_FILE, "utf8")) as State; + } catch { + return { keys: {}, people: {} }; + } +} + +function save(state: State) { + mkdirSync(dirname(STATE_FILE), { recursive: true }); + writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); +} diff --git a/examples/company-delegation-demo/src/server.ts b/examples/company-delegation-demo/src/server.ts new file mode 100644 index 000000000..386d81c7c --- /dev/null +++ b/examples/company-delegation-demo/src/server.ts @@ -0,0 +1,6 @@ +import { createApp } from "./app.js"; + +const port = Number(process.env.DEMO_PORT ?? 5180); +createApp(port).app.listen(port, () => { + console.log(`Company delegation demo on http://localhost:${port}`); +}); diff --git a/examples/company-delegation-demo/src/smoke.ts b/examples/company-delegation-demo/src/smoke.ts new file mode 100644 index 000000000..2bfa1f885 --- /dev/null +++ b/examples/company-delegation-demo/src/smoke.ts @@ -0,0 +1,38 @@ +import type { Server } from "node:http"; +import { createApp } from "./app.js"; + +/** + * Runs the whole story headless against the local stack and fails if any + * scenario does not end the way the model says it must. + */ +const port = Number(process.env.DEMO_PORT ?? 5181); +const { app, demo } = createApp(port); +const server: Server = app.listen(port); + +const ORDER = [ + "bob-nda", + "bob-invoice", + "carol-nda", + "login-replay", + "mallory-board", + "revoke-bob", +]; +let failed = 0; +try { + demo.reset(); + await demo.setup(); + console.log("setup ✓"); + for (const id of ORDER) { + const outcome = await demo.scenarios[id].run(); + console.log( + `${outcome.passed ? "✓" : "✗"} ${demo.scenarios[id].label}: ${outcome.summary}`, + ); + if (!outcome.passed) failed++; + } +} catch (error) { + console.error(error); + failed++; +} finally { + server.close(); +} +process.exit(failed ? 1 : 0); diff --git a/examples/company-delegation-demo/src/stack.ts b/examples/company-delegation-demo/src/stack.ts new file mode 100644 index 000000000..b343962c8 --- /dev/null +++ b/examples/company-delegation-demo/src/stack.ts @@ -0,0 +1,125 @@ +import { randomUUID } from "node:crypto"; + +/** The local stack `pnpm dev:core` runs. */ +export const REGISTRY_URL = process.env.REGISTRY_URL ?? "http://localhost:4321"; +export const PROVISIONER_URL = + process.env.PROVISIONER_URL ?? "http://localhost:3001"; +/** The provisioner's demo verification code. */ +export const VERIFICATION_ID = + process.env.DEMO_VERIFICATION_ID ?? "d66b7138-538a-465f-a6ce-f6985854c3f4"; +export const PLATFORM_NAME = "company-delegation-demo"; + +async function json(res: Response, what: string): Promise { + if (!res.ok) + throw new Error(`${what} failed: ${res.status} ${await res.text()}`); + return (await res.json()) as T; +} + +let token: Promise | null = null; + +/** A platform token from the Registry, for reading and writing eVaults. */ +export function platformToken(): Promise { + token ??= fetch(new URL("/platforms/certification", REGISTRY_URL), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ platform: PLATFORM_NAME }), + }) + .then((r) => json<{ token: string }>(r, "platform certification")) + .then((r) => r.token); + return token; +} + +export async function resolveEVault(eName: string): Promise { + const url = new URL("/resolve", REGISTRY_URL); + url.searchParams.set("w3id", eName); + const body = await json<{ uri: string }>( + await fetch(url), + `resolve ${eName}`, + ); + return body.uri; +} + +/** A keyless eVault, the way group and company eVaults are made. */ +export async function provisionKeylessEVault(): Promise<{ + eName: string; + uri: string; +}> { + const { token: registryEntropy } = await json<{ token: string }>( + await fetch(new URL("/entropy", REGISTRY_URL)), + "entropy", + ); + const body = await json<{ w3id: string; uri: string }>( + await fetch(new URL("/provision", PROVISIONER_URL), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + registryEntropy, + namespace: randomUUID(), + verificationId: VERIFICATION_ID, + }), + }), + "provision company eVault", + ); + return { eName: body.w3id, uri: body.uri }; +} + +async function graphql( + eName: string, + query: string, + variables: Record, +): Promise { + const uri = await resolveEVault(eName); + const body = await json<{ data?: T; errors?: { message: string }[] }>( + await fetch(new URL("/graphql", uri), { + method: "POST", + headers: { + "content-type": "application/json", + "X-ENAME": eName, + authorization: `Bearer ${await platformToken()}`, + }, + body: JSON.stringify({ query, variables }), + }), + "graphql", + ); + if (body.errors?.length) + throw new Error(body.errors.map((e) => e.message).join("; ")); + return body.data as T; +} + +/** + * Writes a record under an id the writer chose (the eVault creates it in + * place), which is what lets a grant signature name the id beforehand. + */ +export async function writeRecord( + eName: string, + id: string, + ontology: string, + payload: Record, +): Promise { + const data = await graphql<{ + updateMetaEnvelope: { errors: { message: string }[] | null }; + }>( + eName, + `mutation Write($id: ID!, $input: MetaEnvelopeInput!) { + updateMetaEnvelope(id: $id, input: $input) { metaEnvelope { id } errors { message } } + }`, + { id, input: { ontology, payload, acl: ["*"] } }, + ); + const errors = data.updateMetaEnvelope.errors ?? []; + if (errors.length) throw new Error(errors.map((e) => e.message).join("; ")); +} + +export async function readRecord( + eName: string, + id: string, +): Promise | null> { + const data = await graphql<{ + metaEnvelope: { parsed: Record } | null; + }>(eName, `query Read($id: ID!) { metaEnvelope(id: $id) { parsed } }`, { + id, + }); + return data.metaEnvelope?.parsed ?? null; +} + +/** A fresh record id, chosen before the record is signed and written. */ +export const newRecordId = () => `@${randomUUID()}`; diff --git a/examples/company-delegation-demo/src/wallet.ts b/examples/company-delegation-demo/src/wallet.ts new file mode 100644 index 000000000..94eddbb75 --- /dev/null +++ b/examples/company-delegation-demo/src/wallet.ts @@ -0,0 +1,113 @@ +import type { CryptoAdapter } from "wallet-sdk"; + +const KEY_ALG = { name: "ECDSA", namedCurve: "P-256" } as const; +const SIGN_ALG = { name: "ECDSA", hash: "SHA-256" } as const; + +/** A key pair as kept in the demo's state file. */ +export type StoredKey = { spki: string; pkcs8: string }; + +const b64 = (buf: ArrayBuffer) => Buffer.from(buf).toString("base64"); +const unb64 = (s: string) => Uint8Array.from(Buffer.from(s, "base64")); + +/** + * A wallet-sdk crypto adapter for Node: P-256 keys like the eID wallet's + * software keys (public key `z` + hex SPKI, base64 raw signatures), kept in a + * plain object the demo persists to its state file. + */ +export class NodeWalletAdapter implements CryptoAdapter { + private pairs = new Map(); + + constructor(private store: Record) {} + + async createKey(keyId: string): Promise { + const pair = (await crypto.subtle.generateKey(KEY_ALG, true, [ + "sign", + "verify", + ])) as CryptoKeyPair; + const [spki, pkcs8] = await Promise.all([ + crypto.subtle.exportKey("spki", pair.publicKey), + crypto.subtle.exportKey("pkcs8", pair.privateKey), + ]); + this.store[keyId] = { spki: b64(spki), pkcs8: b64(pkcs8) }; + this.pairs.set(keyId, pair); + } + + async getPublicKey(keyId: string): Promise { + const stored = this.store[keyId]; + if (!stored) return undefined; + return `z${Buffer.from(unb64(stored.spki)).toString("hex")}`; + } + + async signPayload(keyId: string, _context: string, payload: string) { + const pair = await this.pair(keyId); + const sig = await crypto.subtle.sign( + SIGN_ALG, + pair.privateKey, + new TextEncoder().encode(payload), + ); + return b64(sig); + } + + async ensureKey(keyId: string) { + if (this.store[keyId]) return { created: false }; + await this.createKey(keyId); + return { created: true }; + } + + private async pair(keyId: string): Promise { + const cached = this.pairs.get(keyId); + if (cached) return cached; + const stored = this.store[keyId]; + if (!stored) throw new Error(`no key ${keyId}`); + const pair = { + privateKey: await crypto.subtle.importKey( + "pkcs8", + unb64(stored.pkcs8), + KEY_ALG, + true, + ["sign"], + ), + publicKey: await crypto.subtle.importKey( + "spki", + unb64(stored.spki), + KEY_ALG, + true, + ["verify"], + ), + }; + this.pairs.set(keyId, pair); + return pair; + } +} + +/** + * What the eID wallet does with a scanned `w3ds://sign` URI: sign the + * `session` string as-is and POST the signature to `redirect_uri`. + */ +export async function scanSignRequest( + uri: string, + wallet: { eName: string; keyId: string; adapter: NodeWalletAdapter }, +): Promise { + const url = new URL(uri.replace(/^w3ds:\/\//, "https://w3ds.invalid/")); + if (url.pathname !== "/sign") throw new Error("not a w3ds://sign URI"); + const session = url.searchParams.get("session"); + const redirect = url.searchParams.get("redirect_uri"); + if (!session || !redirect) + throw new Error("sign URI is missing session or redirect_uri"); + const signature = await wallet.adapter.signPayload( + wallet.keyId, + "signing", + session, + ); + const res = await fetch(redirect, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + sessionId: session, + signature, + w3id: wallet.eName, + message: session, + }), + }); + if (!res.ok) throw new Error(`wallet callback failed: ${res.status}`); +} diff --git a/examples/company-delegation-demo/tsconfig.json b/examples/company-delegation-demo/tsconfig.json new file mode 100644 index 000000000..86c6c43bc --- /dev/null +++ b/examples/company-delegation-demo/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f3cf58752..e4319b624 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -88,6 +88,34 @@ importers: specifier: ~5.6.2 version: 5.6.3 + examples/company-delegation-demo: + dependencies: + '@metastate-foundation/auth': + specifier: workspace:* + version: link:../../packages/auth + '@metastate-foundation/delegation': + specifier: workspace:* + version: link:../../packages/delegation + express: + specifier: ^4.18.2 + version: 4.22.1 + wallet-sdk: + specifier: workspace:* + version: link:../../packages/wallet-sdk + devDependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + '@types/node': + specifier: ^20.11.24 + version: 20.19.26 + tsx: + specifier: ^4.7.0 + version: 4.21.0 + typescript: + specifier: ~5.6.2 + version: 5.6.3 + infrastructure/blindvote: dependencies: '@noble/curves': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index b3e69f2e0..c36ba8e26 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,7 @@ packages: - platforms/* - apps/* - packages/* + - examples/* - platforms/*/* - infrastructure/* - services/* From 84d76c98faa6390f10790d5352b02e6839ba3192 Mon Sep 17 00:00:00 2001 From: coodos Date: Fri, 9 Oct 2026 14:26:57 +0800 Subject: [PATCH 03/10] feat(examples): tell the delegation demo as a slide story with stick figures --- examples/company-delegation-demo/README.md | 2 +- .../public/dashboard.html | 166 ++++++ .../company-delegation-demo/public/index.html | 517 +++++++++++++----- examples/company-delegation-demo/src/app.ts | 2 +- examples/company-delegation-demo/src/demo.ts | 7 +- 5 files changed, 558 insertions(+), 136 deletions(-) create mode 100644 examples/company-delegation-demo/public/dashboard.html diff --git a/examples/company-delegation-demo/README.md b/examples/company-delegation-demo/README.md index c08a89024..7f5e90317 100644 --- a/examples/company-delegation-demo/README.md +++ b/examples/company-delegation-demo/README.md @@ -11,7 +11,7 @@ pnpm dev:core # Registry :4321, provisioner :30 pnpm --filter company-delegation-demo dev # http://localhost:5180 ``` -Press **Set up Acme**, then run the scenarios: +The page is a short story in slides (← → to move). Each slide runs its step for real; the raw records are on `/dashboard.html`. | Scenario | Expected | |---|---| diff --git a/examples/company-delegation-demo/public/dashboard.html b/examples/company-delegation-demo/public/dashboard.html new file mode 100644 index 000000000..f3a6378db --- /dev/null +++ b/examples/company-delegation-demo/public/dashboard.html @@ -0,0 +1,166 @@ + + + + + +Delegation Demo Dashboard + + + +
      +

      Company delegation demo

      +

      + Real eVaults on your local stack, real wallets (wallet-sdk) and real w3ds://sign requests. + Acme's board, roles and delegations are signed records in its own eVault. A platform checks every + signature made for Acme against that eVault's history; the eVault itself enforces nothing. +

      + +
      + + +
      + + +
      +
      +

      Scenarios

      +
      +
      + +
      +

      Acme, as stored in its eVault

      +

      Not set up yet.

      +
      + +
      +

      People (wallets)

      +
        +
        + +
        +

        What happened

        +
          +
          +
          +
          + + + + diff --git a/examples/company-delegation-demo/public/index.html b/examples/company-delegation-demo/public/index.html index 8fddd0283..9e31cb567 100644 --- a/examples/company-delegation-demo/public/index.html +++ b/examples/company-delegation-demo/public/index.html @@ -3,164 +3,415 @@ -Company Delegation Demo +Who Signs for Acme +
          +

          Who signs for Acme? · company delegation on W3DS

          +
          + dashboard view +
          +
          -

          Company delegation demo

          -

          - Real eVaults on your local stack, real wallets (wallet-sdk) and real w3ds://sign requests. - Acme's board, roles and delegations are signed records in its own eVault. A platform checks every - signature made for Acme against that eVault's history; the eVault itself enforces nothing. -

          - -
          - - -
          - - -
          -
          -

          Scenarios

          -
          -
          - -
          -

          Acme, as stored in its eVault

          -

          Not set up yet.

          -
          - -
          -

          People (wallets)

          -
            -
            - -
            -

            What happened

            -
              -
              -
              +
              +
              +
              + + Every step runs for real on your local stack: real eVaults, real wallets, real signatures. ← → keys work too. + +
              + diff --git a/examples/company-delegation-demo/src/app.ts b/examples/company-delegation-demo/src/app.ts index ac6f339a7..d8cec8ebd 100644 --- a/examples/company-delegation-demo/src/app.ts +++ b/examples/company-delegation-demo/src/app.ts @@ -53,7 +53,7 @@ export function createApp(port: number) { "/api/reset", handle(async () => { demo.reset(); - await demo.setup(); + await demo.preparePeople(); return demo.snapshot(); }), ); diff --git a/examples/company-delegation-demo/src/demo.ts b/examples/company-delegation-demo/src/demo.ts index 87c06343c..8779a5a2f 100644 --- a/examples/company-delegation-demo/src/demo.ts +++ b/examples/company-delegation-demo/src/demo.ts @@ -125,8 +125,13 @@ export class Demo { // ---- setup --------------------------------------------------------- - async setup(): Promise { + /** Everyone's wallet and eVault; created once and reused. */ + async preparePeople(): Promise { for (const key of Object.keys(config.people)) await this.person(key); + } + + async setup(): Promise { + await this.preparePeople(); if (this.state.company) return; const company = await provisionKeylessEVault(); From f812aa9a27d75c1cee39f5ecb274b240ff3bdddc Mon Sep 17 00:00:00 2001 From: coodos Date: Fri, 9 Oct 2026 14:32:51 +0800 Subject: [PATCH 04/10] fix(examples): match reset wording to behaviour and lock acts that need Bob after he is revoked --- examples/company-delegation-demo/README.md | 2 +- .../company-delegation-demo/public/dashboard.html | 6 ++++-- examples/company-delegation-demo/public/index.html | 11 +++++++++-- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/examples/company-delegation-demo/README.md b/examples/company-delegation-demo/README.md index 7f5e90317..5db584859 100644 --- a/examples/company-delegation-demo/README.md +++ b/examples/company-delegation-demo/README.md @@ -22,7 +22,7 @@ The page is a short story in slides (← → to move). Each slide runs its step | Mallory writes herself onto Acme's board and delegates to herself | refused, her records are ignored | | Dana revokes Bob, then Bob and Carol try again | both refused | -**Start over with a new Acme** provisions a fresh company eVault; the people's wallets are reused. +**Start fresh** (slide 0) clears Acme and Act 1 provisions a fresh company eVault; on the dashboard, **Start over with a new Acme** does both. Wallets are reused. Revoking Bob is permanent for that Acme, so the acts that need him lock until you start fresh. ## Headless diff --git a/examples/company-delegation-demo/public/dashboard.html b/examples/company-delegation-demo/public/dashboard.html index f3a6378db..45713d3de 100644 --- a/examples/company-delegation-demo/public/dashboard.html +++ b/examples/company-delegation-demo/public/dashboard.html @@ -138,7 +138,8 @@

              What happened

              $("scenarios").innerHTML = state.scenarios.map((s) => { const r = results[s.id]; const badge = r ? `${r.passed ? "as expected" : "unexpected"}` : ""; - return `
              + const locked = ["bob-nda", "carol-nda"].includes(s.id) && !!results["revoke-bob"]; + return `
              ${r ? `
              ${badge} ${esc(r.summary)}
              ` : ""}
              `; }).join(""); $("scenarios").querySelectorAll("button").forEach((b) => b.addEventListener("click", () => run(b.dataset.id))); @@ -157,7 +158,8 @@

              What happened

              $("setup").addEventListener("click", async () => { const s = await call("POST", "/api/setup"); if (s) { state = s; render(); } }); $("reset").addEventListener("click", async () => { - const s = await call("POST", "/api/reset"); + if (!(await call("POST", "/api/reset"))) return; + const s = await call("POST", "/api/setup"); if (s) { for (const k in results) delete results[k]; state = s; render(); } }); call("GET", "/api/state").then((s) => { if (s) { state = s; render(); } }); diff --git a/examples/company-delegation-demo/public/index.html b/examples/company-delegation-demo/public/index.html index 9e31cb567..15901ed46 100644 --- a/examples/company-delegation-demo/public/index.html +++ b/examples/company-delegation-demo/public/index.html @@ -224,7 +224,7 @@

              Who signs for Acme? · comp scene: () => svg(person("dana", 90, 200, { phone: true, wave: true, sub: "director" }) + person("bob", 210, 200, { phone: true, sub: "finance" }) + person("carol", 320, 200, { phone: true, sub: "assistant" }) + person("mallory", 640, 200, { phone: true, sub: "outsider" }) + building(470, 160) + `${ready() ? "everyone has an eVault" : "nobody exists yet"}`), - action: { label: "Start fresh", run: () => api("POST", "/api/reset"), note: "Creates the wallets and a brand-new Acme." }, + action: { label: "Start fresh", run: () => api("POST", "/api/reset"), note: "Makes sure everyone has a wallet and clears the old Acme. Act 1 creates the new one." }, }, { kicker: "Act 1", @@ -316,6 +316,11 @@

              Who signs for Acme? · comp } const expectedValid = (o) => o.expected === "valid"; +/* Revoking Bob is permanent for this Acme, so acts that need Bob's + delegation can only be replayed on a fresh one. */ +const NEEDS_BOB = ["bob-nda", "carol-nda"]; +const lockedByRevoke = (id) => NEEDS_BOB.includes(id) && !!outcomes["revoke-bob"]; + /* ---------- rendering ---------- */ let index = 0; @@ -332,8 +337,10 @@

              Who signs for Acme? · comp

              ${esc(s.action.note)}

              `; } if (s.id) { - html += `
              `; + const locked = lockedByRevoke(s.id); + html += `
              `; if (!ready()) html += `

              Create Acme in Act 1 first.

              `; + if (locked) html += `

              Dana revoked Bob in Act 8, which is permanent for this Acme. Start fresh (slide 0) to replay this act.

              `; if (o) html += verdictHtml(o) + (s.after || ""); } if (s.recap) html += recapHtml(); From 0606d2ba6c36bd1aefaedf3286c8293ae1c3eff8 Mon Sep 17 00:00:00 2001 From: coodos Date: Fri, 9 Oct 2026 15:36:20 +0800 Subject: [PATCH 05/10] feat(examples): play the delegation demo as one continuous story with a verified view of Acme --- .../company-delegation-demo/public/index.html | 666 +++++++++++------- examples/company-delegation-demo/src/app.ts | 12 +- examples/company-delegation-demo/src/demo.ts | 108 ++- examples/company-delegation-demo/src/smoke.ts | 2 +- examples/company-delegation-demo/src/stack.ts | 40 ++ 5 files changed, 568 insertions(+), 260 deletions(-) diff --git a/examples/company-delegation-demo/public/index.html b/examples/company-delegation-demo/public/index.html index 15901ed46..237e3add7 100644 --- a/examples/company-delegation-demo/public/index.html +++ b/examples/company-delegation-demo/public/index.html @@ -7,7 +7,7 @@

              Who signs for Acme? · company delegation on W3DS

              - dashboard view + raw records
              -
              +
              -
              -
              - -
              - - Every step runs for real on your local stack: real eVaults, real wallets, real signatures. ← → keys work too. - -
              +
              +
              +
              +
              +

              diff --git a/examples/company-delegation-demo/src/app.ts b/examples/company-delegation-demo/src/app.ts index d8cec8ebd..3c4b0f17e 100644 --- a/examples/company-delegation-demo/src/app.ts +++ b/examples/company-delegation-demo/src/app.ts @@ -46,7 +46,7 @@ export function createApp(port: number) { "/api/setup", handle(async () => { await demo.setup(); - return demo.snapshot(); + return demo.snapshot(true); }), ); app.post( @@ -54,18 +54,14 @@ export function createApp(port: number) { handle(async () => { demo.reset(); await demo.preparePeople(); - return demo.snapshot(); + return demo.snapshot(true); }), ); app.post( "/api/scenario/:id", handle(async (req) => { - const scenario = demo.scenarios[req.params.id]; - if (!scenario) throw new Error(`unknown scenario ${req.params.id}`); - return { - outcome: await scenario.run(), - state: await demo.snapshot(), - }; + const outcome = await demo.run(req.params.id); + return { outcome, state: await demo.snapshot() }; }), ); return { app, demo }; diff --git a/examples/company-delegation-demo/src/demo.ts b/examples/company-delegation-demo/src/demo.ts index 8779a5a2f..ac64e8568 100644 --- a/examples/company-delegation-demo/src/demo.ts +++ b/examples/company-delegation-demo/src/demo.ts @@ -6,6 +6,7 @@ import { buildDelegatedSignRequest, buildGrantSignRequest, verifyDelegatedSignature, + verifyEnameSignature, verifyLoginSignature, type VerifyDelegatedSignatureResult, } from "@metastate-foundation/auth"; @@ -13,6 +14,8 @@ import { COMPANY_ONTOLOGY, DELEGATED_SIGNATURE_ONTOLOGY, DELEGATION_ONTOLOGY, + evaluateFromHistory, + resolveBoard, ROLE_ONTOLOGY, } from "@metastate-foundation/delegation"; import { provision, syncPublicKeyToEvaultWithOptions } from "wallet-sdk"; @@ -21,6 +24,7 @@ import { platformToken, PROVISIONER_URL, provisionKeylessEVault, + readHistory, readRecord, REGISTRY_URL, VERIFICATION_ID, @@ -85,6 +89,8 @@ export type Outcome = { passed: boolean; summary: string; result?: unknown; + /** Acme as stored and as verified right after this scenario. */ + view?: unknown; }; const now = () => new Date().toISOString(); @@ -94,6 +100,14 @@ export class Demo { private adapter: NodeWalletAdapter; private pending = new Map void>(); readonly log: LogEntry[] = []; + /** The latest outcome per scenario, so a reloaded page shows them. */ + readonly outcomes: Record = {}; + /** Acme as stored and as verified right after setup. */ + private setupView: unknown = null; + /** The last computed view; recomputed only after something changed. */ + private cachedView: Awaited> | undefined; + /** Grant signatures never change, so each is checked against the Registry once. */ + private checked = new Map>(); private lastBobSignature?: { payload: string; signature: string }; constructor(private callbackUrl: string) { @@ -121,6 +135,69 @@ export class Demo { this.state.company = undefined; save(this.state); this.log.length = 0; + for (const k of Object.keys(this.outcomes)) delete this.outcomes[k]; + this.setupView = null; + } + + /** Runs a scenario and remembers how it ended. */ + async run(id: string): Promise { + const scenario = this.scenarios[id]; + if (!scenario) throw new Error(`unknown scenario ${id}`); + const outcome = await scenario.run(); + outcome.view = this.cachedView = await this.view(); + this.outcomes[id] = outcome; + return outcome; + } + + /** + * Acme as a verifier sees it: the board and each delegation resolved from + * the eVault's history, where unsigned or unentitled writes count for nothing. + */ + private async verifiedView() { + const company = this.state.company; + if (!company) return null; + // Each record's history is read once per view, however many chains use it. + const histories = new Map>(); + const source = { + versions: (id: string) => { + if (!histories.has(id)) + histories.set(id, readHistory(company.eName, id)); + return histories.get(id) as ReturnType; + }, + }; + const verify = (eName: string, payload: string, signature: string) => { + const key = JSON.stringify([eName, payload, signature]); + if (!this.checked.has(key)) { + this.checked.set( + key, + verifyEnameSignature({ + eName, + payload, + signature, + registryBaseUrl: REGISTRY_URL, + }).then((r) => r.valid), + ); + } + return this.checked.get(key) as Promise; + }; + const board = await resolveBoard( + company.companyId, + company.eName, + source, + verify, + ); + const delegations: Record = {}; + for (const [key, id] of Object.entries(company.delegations)) { + const chain = await evaluateFromHistory({ + delegationId: id, + companyEName: company.eName, + companyId: company.companyId, + source, + verify, + }); + delegations[key] = chain.ok ? "valid" : chain.code; + } + return { board: board.at(-1)?.directors ?? [], delegations }; } // ---- setup --------------------------------------------------------- @@ -578,9 +655,19 @@ export class Demo { // ---- view ------------------------------------------------------------ - async snapshot() { + /** Acme's records as stored, and as a verifier resolves them. */ + private async view() { + const company = this.state.company; + if (!company) return null; + return { + records: await this.records(), + verified: await this.verifiedView(), + }; + } + + private async records() { const company = this.state.company; - const records = company + return company ? { company: await readRecord(company.eName, company.companyId), roles: await Promise.all( @@ -599,6 +686,18 @@ export class Demo { ), } : null; + } + + /** + * The page's state. Reading Acme's history costs eVault requests, so the + * view is only recomputed after setup, reset or a scenario changed it. + */ + async snapshot(refresh = false) { + const company = this.state.company; + if (refresh || this.cachedView === undefined) + this.cachedView = await this.view(); + const view = this.cachedView; + if (company && !this.setupView) this.setupView = view; return { people: Object.fromEntries( Object.entries(this.state.people).map(([k, p]) => [ @@ -609,11 +708,14 @@ export class Demo { company: company ? { eName: company.eName, companyId: company.companyId } : null, - records, + records: view?.records ?? null, + setupView: this.setupView, scenarios: Object.entries(this.scenarios).map(([id, s]) => ({ id, label: s.label, })), + verified: view?.verified ?? null, + outcomes: this.outcomes, log: this.log.slice(-60), }; } diff --git a/examples/company-delegation-demo/src/smoke.ts b/examples/company-delegation-demo/src/smoke.ts index 2bfa1f885..2c64fc8eb 100644 --- a/examples/company-delegation-demo/src/smoke.ts +++ b/examples/company-delegation-demo/src/smoke.ts @@ -23,7 +23,7 @@ try { await demo.setup(); console.log("setup ✓"); for (const id of ORDER) { - const outcome = await demo.scenarios[id].run(); + const outcome = await demo.run(id); console.log( `${outcome.passed ? "✓" : "✗"} ${demo.scenarios[id].label}: ${outcome.summary}`, ); diff --git a/examples/company-delegation-demo/src/stack.ts b/examples/company-delegation-demo/src/stack.ts index b343962c8..349b4778d 100644 --- a/examples/company-delegation-demo/src/stack.ts +++ b/examples/company-delegation-demo/src/stack.ts @@ -123,3 +123,43 @@ export async function readRecord( /** A fresh record id, chosen before the record is signed and written. */ export const newRecordId = () => `@${randomUUID()}`; + +/** A record's versions, oldest first, from the eVault's history API. */ +export async function readHistory( + eName: string, + id: string, +): Promise< + { + version: number; + operation: "create" | "update" | "delete"; + ontology: string; + parsed: Record | null; + createdAt: string; + }[] +> { + const versions: any[] = []; + let after: string | null = null; + for (let page = 0; page < 20; page++) { + const data: { + metaEnvelopeHistory: { + edges: { node: any }[]; + pageInfo: { hasNextPage: boolean; endCursor: string | null }; + } | null; + } = await graphql( + eName, + `query History($id: ID!, $after: String) { + metaEnvelopeHistory(id: $id, first: 100, after: $after) { + edges { node { version operation ontology parsed createdAt } } + pageInfo { hasNextPage endCursor } + } + }`, + { id, after }, + ); + const conn = data.metaEnvelopeHistory; + if (!conn) break; + versions.push(...conn.edges.map((e) => e.node)); + if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor) break; + after = conn.pageInfo.endCursor; + } + return versions.reverse(); +} From 3d34c7f506a9589027a7160717e8a23e5b4e8a7e Mon Sep 17 00:00:00 2001 From: coodos Date: Fri, 9 Oct 2026 15:43:54 +0800 Subject: [PATCH 06/10] feat(examples): walk through Acme's founding records and what each signature binds --- examples/company-delegation-demo/README.md | 4 + .../company-delegation-demo/public/index.html | 216 +++++++++++++++--- examples/company-delegation-demo/src/demo.ts | 8 + 3 files changed, 194 insertions(+), 34 deletions(-) diff --git a/examples/company-delegation-demo/README.md b/examples/company-delegation-demo/README.md index 5db584859..6cf8b8747 100644 --- a/examples/company-delegation-demo/README.md +++ b/examples/company-delegation-demo/README.md @@ -13,6 +13,10 @@ pnpm --filter company-delegation-demo dev # http://localhost:5180 The page is a short story in slides (← → to move). Each slide runs its step for real; the raw records are on `/dashboard.html`. +Founding (six slides) shows Acme being created and every record it holds, read back from its eVault: the keyless company eVault, the Company record born with its board, what a `w3ds-grant/v1` signature binds (record id, record hash, time), the Head of Finance role, Bob's delegation and Carol's re-delegation, each with its real fields and signer. + +Then the acts: + | Scenario | Expected | |---|---| | Bob signs an NDA for Acme | valid, as "Head of Finance" | diff --git a/examples/company-delegation-demo/public/index.html b/examples/company-delegation-demo/public/index.html index 237e3add7..e00fedb56 100644 --- a/examples/company-delegation-demo/public/index.html +++ b/examples/company-delegation-demo/public/index.html @@ -270,8 +270,8 @@

              Who signs for Acme? · comp - ${["dana", "bob", "carol", "mallory"].map((k) => person(k, { dim: !on(k), wave: layers.wave === k })).join("")} - ${building(!on("acme"))}${cabinet(layers.cabinet, !on("acme"), layers.hl ?? -1, layers.hlDelay ?? 0)} + ${["dana", "bob", "carol", "mallory"].filter((k) => !(layers.hide || []).includes(k)).map((k) => person(k, { dim: !on(k), wave: layers.wave === k })).join("")} + ${layers.card ? "" : building(!on("acme")) + cabinet(layers.cabinet, !on("acme"), layers.hl ?? -1, layers.hlDelay ?? 0)} ${on("door") && !on("laptop") ? "" : laptop(!on("laptop"))}${on("door") ? door(false) : ""} ${layers.body || ""} ${layers.caption ? caption(layers.caption, layers.captionDelay ?? 0) : ""} @@ -341,39 +341,54 @@

              Who signs for Acme? · comp }), }, { - kicker: "Act 1", - title: "Acme is born with a board", - text: `Dana writes Acme's Company record into Acme's eVault with herself as director, and signs it with her wallet. A company is born with its board; a record that started without one can never get one later.`, - action: { label: "Create Acme", step: "setup", note: "Dana's wallet signs a real w3ds://sign request; the record lands in Acme's eVault." }, + kicker: "Founding 1 / 6", + title: "Acme gets its own eVault", + text: `Before Acme can have records it needs somewhere to keep them. The demo provisions a keyless eVault for Acme: it has an eName like everyone else, but nobody holds a key to it. It is a plain store, and it doesn't care what goes in it. This one button also runs everything on the next five slides for real.`, + action: { label: "Create Acme", step: "setup", note: "Provisions Acme's eVault, then Dana and Bob sign each record through real w3ds://sign requests." }, scene: () => { const running = busy === "setup"; const done = ready() && !running; return world(["dana", "acme"], { - cabinet: cabinetLines(), hl: done ? 0 : -1, hlDelay: .8, - body: say("dana", done ? "Acme is official. I'm its director." : "Let's make Acme official.", .1) - + (running ? carried("paper", "board: Dana", [POS.dana + 20, 330], at.cabinet(), 0, true) + link([POS.dana + 20, 320], at.cabinet(), true) : "") - + (done ? carried("paper", "board: Dana", [POS.dana + 20, 330], [POS.cabinet[0] - 160, POS.cabinet[1] - 40], .2) : ""), - caption: running ? "Dana's wallet is signing the board…" : done ? "Born with its board, signed by its own director." : "Press Create Acme.", - captionDelay: done ? 1.0 : 0, + cabinet: done ? [{ text: "eName " + short(state.company.eName, 20) }, { text: "keys: none (keyless)" }, { text: "type: company" }] : cabinetLines(), + body: say("dana", done ? "Acme has a home." : "Acme needs somewhere to keep its records.", .1) + + (running ? link([POS.dana + 20, 320], at.cabinet(), true) + carried("paper", "provision", [POS.dana + 20, 330], at.cabinet(), 0, true) : ""), + caption: running ? "Provisioning Acme's eVault and signing its records…" : done ? "A keyless eVault: nobody holds Acme's keys. It just stores what it's given." : "Press Create Acme.", + captionDelay: done ? .8 : 0, }); }, }, { - kicker: "Act 2", - title: "Titles, handed down", - text: `Dana creates the role Head of Finance (NDAs and invoices) and gives Bob a narrower slice: NDAs only, with permission to pass it on. Bob passes NDA signing on to Carol. Every hand-over is a record signed by whoever gives it, and nobody can give away more than they hold. (These were signed in Act 1.)`, - scene: () => world(["dana", "bob", "carol", "acme"], { - cabinet: cabinetLines(), - body: say("dana", "Bob, you sign NDAs for Acme.", .2) - + carried("badge", "Head of Finance · NDA", [POS.dana, 330], [POS.bob, BADGE_Y], .8) - + say("carol", "And me?", 2.0) - + say("bob", "Carol, you can do NDAs too.", 2.6) - + carried("badge", "NDA signer", [POS.bob, 330], [POS.carol, BADGE_Y], 3.2), - caption: ready() ? "Each badge is a signed record in Acme's eVault." : "Create Acme in Act 1 first.", captionDelay: 4.2, - }), + kicker: "Founding 2 / 6", + title: "The Company record: Acme, born with its board", + text: `The first record in Acme's eVault is the Company record. It says who Acme is and who its directors are, and it carries Dana's signature. A board only counts if the record was born with it: a Company record that started without directors can never get one later, so nobody can claim an existing company. Any one director can grant roles; only a sitting director can change the board.`, + scene: () => recordScene("company"), + }, + { + kicker: "Founding 3 / 6", + title: "How a signature gets onto a record", + text: `Every record that hands out authority is signed by whoever is entitled to hand it out. The signature covers a short w3ds-grant/v1 string. Because it names the record's id, a copy elsewhere fails. Because it includes a hash of the record, any edit breaks it. Because it carries a time, an older version can't be written back over a newer one. The wallet needs no changes: it signs the string like any other.`, + scene: () => signingScene(), }, { - kicker: "Act 3", id: "bob-nda", + kicker: "Founding 4 / 6", + title: "A role: Head of Finance", + text: `Dana defines what a title is worth: a Role with a title, the scopes it covers (here NDAs and invoices on eSigner) and whether holders may pass it on. Only a director can create one. Scopes are ontology ids or platform keywords like @esigner:nda; anything touching identity, keys, logins or the company's own authority can never be a scope.`, + scene: () => recordScene("role"), + }, + { + kicker: "Founding 5 / 6", + title: "Bob's delegation", + text: `A Delegation gives a person a role, or a narrower slice of it. Dana gives Bob Head of Finance, but NDAs only, and lets him pass it on. It points at the role, its scopes must fit inside the role's, and it is signed by Dana, who granted it.`, + scene: () => recordScene("bob"), + }, + { + kicker: "Founding 6 / 6", + title: "Carol's re-delegation, signed by Bob", + text: `Bob was allowed to pass his slice on, so he gives Carol NDA signer. This delegation points at Bob's delegation instead of the role, can only narrow it, and is signed by Bob, not Dana. Nobody can hand on more than they hold, and if Bob's delegation goes, Carol's goes with it.`, + scene: () => recordScene("carol"), + }, + { + kicker: "Act 1", id: "bob-nda", title: "Bob signs an NDA for Acme", text: `eSigner asks Bob to sign nda-globex.pdf for Acme. His wallet signs it like anything else; eSigner then reads Acme's eVault and walks the chain back to Dana's board.`, scene: (o) => actScene(o, "bob", { @@ -383,7 +398,7 @@

              Who signs for Acme? · comp }), }, { - kicker: "Act 4", id: "bob-invoice", + kicker: "Act 2", id: "bob-invoice", title: "Bob tries an invoice", text: `Bob's role covers invoices, but his badge only covers NDAs. Dana handed him the narrower slice.`, scene: (o) => actScene(o, "bob", { @@ -393,7 +408,7 @@

              Who signs for Acme? · comp }), }, { - kicker: "Act 5", id: "carol-nda", + kicker: "Act 3", id: "carol-nda", title: "Carol signs, through Bob", text: `Carol signs an NDA for Acme. eSigner follows her chain: Carol → Bob → Head of Finance → Dana's board, checking every signature on the way.`, scene: (o) => actScene(o, "carol", { @@ -404,7 +419,7 @@

              Who signs for Acme? · comp }), }, { - kicker: "Act 6", id: "login-replay", + kicker: "Act 4", id: "login-replay", title: "Bob's signature, replayed as a login", text: `Bob's NDA signature is real. Could someone use it to log in as Bob? No: everything signed for a company starts with w3ds-, and login checks refuse that.`, scene: (o) => { @@ -421,7 +436,7 @@

              Who signs for Acme? · comp }, }, { - kicker: "Act 7", id: "mallory-board", + kicker: "Act 5", id: "mallory-board", title: "Mallory breaks into the filing cabinet", text: `An eVault is a plain store, so Mallory can write into Acme's. She signs herself onto the board with her own wallet, makes a role, delegates it to herself, then signs an NDA "for Acme".`, scene: (o) => { @@ -438,7 +453,7 @@

              Who signs for Acme? · comp }, }, { - kicker: "Act 8", id: "revoke-bob", + kicker: "Act 6", id: "revoke-bob", title: "Dana revokes Bob", text: `Dana signs a revocation of Bob's delegation. Nobody touches Carol's record, yet her authority came through Bob's, so it falls with it.`, scene: (o) => { @@ -470,7 +485,140 @@

              Who signs for Acme? · comp }, ]; -/* Acts 3-5 share a shape: someone signs for Acme, eSigner checks. */ + +/* ===== founding: records, drawn as cards pulled from Acme's eVault ===== */ +const short = (v, n = 14) => { const t = String(v ?? ""); return t.length > n ? t.slice(0, n - 4) + "…" + t.slice(-3) : t; }; +const who = (eName) => `${nameOf(eName)} (${short(eName, 12)})`; +const setupRecords = () => (state && (state.setupView || {}).records) || null; +const isoShort = (t) => String(t || "").replace("T", " ").slice(0, 19); + +function card({ x, y, w, title, sub, rows, auth, d = 0 }) { + const rowH = 19, top = 46; + const body = rows.map((r, i) => { + const ty = y + top + 14 + i * rowH; + return `${esc(r.k)} + ${esc(r.v)} + ${r.note ? `${esc(r.note)}` : ""}`; + }).join(""); + const ay = y + top + 10 + rows.length * rowH + 10; + const authRows = (auth || []).map((r, i) => { + const ty = ay + 34 + i * rowH; + return `${esc(r.k)} + ${esc(r.v)} + ${r.note ? `${esc(r.note)}` : ""}`; + }).join(""); + const h = (auth ? ay + 34 + auth.length * rowH : ay) - y + 6; + return ` + + + ↑ pulled from Acme's eVault + ${esc(title)} + ${esc(sub)} + + ${body} + ${auth ? ` + authorization${authRows}` : ""} + `; +} + +/* The real record from Acme's eVault, as stored after setup. */ +function recordScene(which) { + const recs = setupRecords(); + const ids = state && state.ids; + if (!ready() || !recs || !ids) { + return world(["dana", "acme"], { cabinet: cabinetLines(), caption: "Create Acme first (Founding 1)." }); + } + const delegation = (key) => (recs.delegations.find((x) => x.key === key) || {}).record || {}; + const role = recs.roles.filter(Boolean)[0] || {}; + let cast, speaker, line, title, sub, rows, rec; + if (which === "company") { + rec = recs.company || {}; + cast = ["dana", "acme"]; speaker = "dana"; line = "I sign Acme into being, with me on the board."; + title = "Company record"; sub = `ontology Company · id ${short(ids.company, 18)}`; + rows = [ + { k: "displayName", v: rec.displayName }, + { k: "legalName", v: rec.legalName }, + { k: "jurisdiction", v: rec.jurisdiction }, + { k: "eName", v: short(rec.eName, 22), note: "Acme's own eVault" }, + { k: "directors", v: (rec.directors || []).map(nameOf).join(", "), note: "born with its board" }, + { k: "createdAt", v: isoShort(rec.createdAt) }, + ]; + } else if (which === "role") { + rec = role; + cast = ["dana", "acme"]; speaker = "dana"; line = "Head of Finance: NDAs and invoices."; + title = "Role record"; sub = `ontology Role · id ${short(Object.values(ids.roles)[0], 18)}`; + rows = [ + { k: "title", v: rec.title, note: "what people see" }, + { k: "scopes", v: (rec.scopes || []).join(", "), note: "what it covers" }, + { k: "mayRedelegate", v: String(rec.mayRedelegate), note: "holders may pass it on" }, + { k: "createdBy", v: who(rec.createdBy), note: "must be a director" }, + { k: "status", v: rec.status }, + { k: "companyEName", v: short(rec.companyEName, 22) }, + ]; + } else { + rec = delegation(which); + const isCarol = which === "carol"; + cast = isCarol ? ["bob", "carol", "acme"] : ["dana", "bob", "acme"]; + speaker = isCarol ? "bob" : "dana"; + line = isCarol ? "Carol, you can sign NDAs too." : "Bob, you sign NDAs for Acme."; + title = isCarol ? "Delegation record (re-delegation)" : "Delegation record"; + sub = `ontology Delegation · id ${short(ids.delegations[which], 18)}`; + rows = [ + { k: "delegateEName", v: who(rec.delegateEName), note: "who may sign" }, + isCarol + ? { k: "parentDelegationId", v: short(rec.parentDelegationId, 18), note: "→ Bob's delegation" } + : { k: "roleId", v: short(rec.roleId, 18), note: "→ Head of Finance" }, + { k: "title", v: rec.title }, + { k: "scopes", v: (rec.scopes || []).join(", "), note: isCarol ? "inside Bob's" : "narrower than the role" }, + { k: "mayRedelegate", v: String(rec.mayRedelegate) }, + { k: "grantedBy", v: who(rec.grantedBy), note: isCarol ? "the parent's delegate" : "a director" }, + { k: "status", v: rec.status }, + ]; + } + const a = rec.authorization || {}; + const auth = [ + { k: "signerEName", v: who(a.signerEName), note: "checked against the board" }, + { k: "signedAt", v: isoShort(a.signedAt) }, + { k: "signature", v: short(a.signature, 30), note: "P-256, from the wallet" }, + ]; + const badge = which === "bob" ? carried("badge", "Head of Finance · NDA", [POS.dana, 330], [POS.bob, BADGE_Y], .4) + : which === "carol" ? carried("badge", "NDA signer", [POS.bob, 330], [POS.carol, BADGE_Y], .4) : ""; + return world(cast, { + cabinet: cabinetLines(), card: true, hide: ["mallory"], + body: say(speaker, line, .1) + badge + card({ x: 400, y: 150, w: 586, title, sub, rows, auth, d: .6 }), + caption: "Stored in Acme's eVault exactly like this; the eVault never checks it.", captionDelay: 1.4, + }); +} + +/* How Dana's signature ends up on the Company record. */ +function signingScene() { + const recs = setupRecords(); + if (!ready() || !recs) return world(["dana", "acme"], { cabinet: cabinetLines(), caption: "Create Acme first (Founding 1)." }); + const a = (recs.company || {}).authorization || {}; + let p = {}; + try { p = JSON.parse(String(a.signedPayload || "").split("\n")[1] || "{}"); } catch {} + const steps = [ + { k: "1 pick the id", v: short(p.recordId, 22), note: "before anything is signed" }, + { k: "2 build the grant", v: "w3ds-grant/v1 {", note: "" }, + { k: " companyEName", v: short(p.companyEName, 20), note: "which company" }, + { k: " ontology", v: "Company", note: "which kind of record" }, + { k: " recordId", v: short(p.recordId, 20), note: "a copy elsewhere fails" }, + { k: " recordSha256", v: short(p.recordSha256, 20), note: "any edit breaks it" }, + { k: " signedAt", v: isoShort(p.signedAt), note: "no older version comes back" }, + { k: " signer", v: nameOf(p.signer) + " }", note: "who vouches" }, + { k: "3 w3ds://sign", v: "wallet signs, POSTs back", note: "no wallet changes" }, + { k: "4 write", v: "record + authorization", note: "under the chosen id" }, + ]; + return world(["dana", "acme"], { + cabinet: cabinetLines(), card: true, hide: ["mallory"], + body: say("dana", "I approve: Set Acme's board.", .1) + + carried("paper", "w3ds-grant/v1", [POS.dana + 20, 330], [330, 300], 1.4) + + card({ x: 400, y: 150, w: 586, title: "What Dana's wallet actually signs", sub: "the w3ds-grant/v1 string from the Company record", rows: steps, d: .5 }), + caption: "Dana signs one short string; the record's id, contents and time are all bound to it.", captionDelay: 2.0, + }); +} + +/* Acts 1-3 share a shape: someone signs for Acme, eSigner checks. */ function actScene(o, who, c) { const running = busy && busy === current().id; const done = !!o && !running; @@ -494,7 +642,7 @@

              Who signs for Acme? · comp return world(cast, { cabinet: cabinetLines(), body, - caption: !ready() ? "Create Acme in Act 1 first." : running ? "eSigner is reading Acme's eVault history…" : done ? (accepted ? c.okCaption : (c.badCaption || "Refused.")) : "Press Play this scene.", + caption: !ready() ? "Create Acme first (Founding 1)." : running ? "eSigner is reading Acme's eVault history…" : done ? (accepted ? c.okCaption : (c.badCaption || "Refused.")) : "Press Play this scene.", captionDelay: done ? 2.2 : 0, }); } @@ -527,8 +675,8 @@

              Who signs for Acme? · comp } else if (s.id) { const blocked = !ready() || locked(s.id); controls += ``; - if (!ready()) controls += `Create Acme in Act 1 first.`; - if (locked(s.id)) controls += `Bob was revoked in Act 8. Start fresh to replay.`; + if (!ready()) controls += `Create Acme first (Founding 1).`; + if (locked(s.id)) controls += `Bob was revoked in Act 6. Start fresh to replay.`; } controls += ``; document.getElementById("controls").innerHTML = controls; diff --git a/examples/company-delegation-demo/src/demo.ts b/examples/company-delegation-demo/src/demo.ts index ac64e8568..9e52284f7 100644 --- a/examples/company-delegation-demo/src/demo.ts +++ b/examples/company-delegation-demo/src/demo.ts @@ -710,6 +710,14 @@ export class Demo { : null, records: view?.records ?? null, setupView: this.setupView, + // MetaEnvelope ids, so the page can show where each record lives. + ids: company + ? { + company: company.companyId, + roles: company.roles, + delegations: company.delegations, + } + : null, scenarios: Object.entries(this.scenarios).map(([id, s]) => ({ id, label: s.label, From 8a9352da181fcc403d55eb799e6d7a5de186cadb Mon Sep 17 00:00:00 2001 From: coodos Date: Fri, 9 Oct 2026 15:58:31 +0800 Subject: [PATCH 07/10] feat(examples): tell the demo through the Dana, Bob, Dave, Tim chain with hop-by-hop verifier traces --- examples/company-delegation-demo/README.md | 20 +- .../company-delegation-demo/demo.config.json | 17 +- .../public/dashboard.html | 4 +- .../company-delegation-demo/public/index.html | 491 +++++++----------- examples/company-delegation-demo/src/demo.ts | 345 ++++++++---- examples/company-delegation-demo/src/smoke.ts | 6 +- 6 files changed, 468 insertions(+), 415 deletions(-) diff --git a/examples/company-delegation-demo/README.md b/examples/company-delegation-demo/README.md index 6cf8b8747..1b555c244 100644 --- a/examples/company-delegation-demo/README.md +++ b/examples/company-delegation-demo/README.md @@ -13,20 +13,18 @@ pnpm --filter company-delegation-demo dev # http://localhost:5180 The page is a short story in slides (← → to move). Each slide runs its step for real; the raw records are on `/dashboard.html`. -Founding (six slides) shows Acme being created and every record it holds, read back from its eVault: the keyless company eVault, the Company record born with its board, what a `w3ds-grant/v1` signature binds (record id, record hash, time), the Head of Finance role, Bob's delegation and Carol's re-delegation, each with its real fields and signer. - -Then the acts: +Dana founds Acme and makes Bob Head of Finance; Bob passes it to Dave, and Dave gives Tim NDAs only. Each hand-over is a Delegation record signed by the giver. Then eSigner verifies signatures by tracing each one back through Acme's eVault, hop by hop, to the board: | Scenario | Expected | |---|---| -| Bob signs an NDA for Acme | valid, as "Head of Finance" | -| Bob signs an invoice | refused, not in his delegation | -| Carol signs an NDA, re-delegated by Bob | valid, as "NDA signer" | -| Bob's NDA signature replayed as a login | refused, reserved payload | -| Mallory writes herself onto Acme's board and delegates to herself | refused, her records are ignored | -| Dana revokes Bob, then Bob and Carol try again | both refused | - -**Start fresh** (slide 0) clears Acme and Act 1 provisions a fresh company eVault; on the dashboard, **Start over with a new Acme** does both. Wallets are reused. Revoking Bob is permanent for that Acme, so the acts that need him lock until you start fresh. +| Tim signs an NDA for Acme | valid: Tim ← Dave ← Bob ← Dana | +| Tim signs an invoice | refused at Tim: Dave only gave him NDAs | +| Tim passes his badge to Mallory | refused at Mallory: Tim couldn't pass it on | +| Tim's signature replayed as a login | refused: reserved payload | +| Mallory writes herself onto the board | refused: nobody entitled granted it | +| Dana revokes Bob, Tim signs again | refused at Bob: revoked | + +**Start fresh** (slide 0) clears Acme and Act 1 provisions a fresh company eVault; on the dashboard, **Start over with a new Acme** does both. Wallets are reused. Revoking Bob is permanent for that Acme, so the acts that need Tim's chain lock until you start fresh. ## Headless diff --git a/examples/company-delegation-demo/demo.config.json b/examples/company-delegation-demo/demo.config.json index c193e8950..b28c0d7ef 100644 --- a/examples/company-delegation-demo/demo.config.json +++ b/examples/company-delegation-demo/demo.config.json @@ -7,7 +7,8 @@ "people": { "dana": { "name": "Dana", "about": "Director of Acme" }, "bob": { "name": "Bob", "about": "Head of Finance" }, - "carol": { "name": "Carol", "about": "Finance assistant" }, + "dave": { "name": "Dave", "about": "Finance officer" }, + "tim": { "name": "Tim", "about": "Junior clerk" }, "mallory": { "name": "Mallory", "about": "Not part of Acme" } }, "directors": ["dana"], @@ -24,13 +25,21 @@ "role": "finance", "grantedBy": "dana", "title": "Head of Finance", - "scopes": ["@esigner:nda"], + "scopes": ["@esigner:nda", "@esigner:invoice"], "mayRedelegate": true }, - "carol": { - "to": "carol", + "dave": { + "to": "dave", "parent": "bob", "grantedBy": "bob", + "title": "Finance officer", + "scopes": ["@esigner:nda", "@esigner:invoice"], + "mayRedelegate": true + }, + "tim": { + "to": "tim", + "parent": "dave", + "grantedBy": "dave", "title": "NDA signer", "scopes": ["@esigner:nda"], "mayRedelegate": false diff --git a/examples/company-delegation-demo/public/dashboard.html b/examples/company-delegation-demo/public/dashboard.html index 45713d3de..32fa8f8d2 100644 --- a/examples/company-delegation-demo/public/dashboard.html +++ b/examples/company-delegation-demo/public/dashboard.html @@ -85,7 +85,7 @@

              What happened