diff --git a/docs/docs/Post Platform Guide/signing-for-a-company.md b/docs/docs/Post Platform Guide/signing-for-a-company.md new file mode 100644 index 000000000..098a0ef9e --- /dev/null +++ b/docs/docs/Post Platform Guide/signing-for-a-company.md @@ -0,0 +1,177 @@ +--- +sidebar_position: 12 +--- + +# Signing for a Company + +How a platform lets someone sign **for a company** and checks they're allowed to. Read [Company Delegation](/docs/W3DS%20Protocol/Company-Delegation) first for the model: directors, roles, delegations and how a chain is traced. + +Everything here is in `@metastate-foundation/auth`. You need no eID wallet changes and no eVault changes. + +```mermaid +sequenceDiagram + participant P as Your platform + participant W as Signer's eID wallet + participant R as Registry + participant E as Company eVault + P->>P: buildDelegatedSignRequest(…) + P->>W: w3ds://sign (session = w3ds-sign/v1 payload) + W->>P: POST redirect_uri {sessionId, signature, w3id} + P->>R: resolve signer + company, key-binding certificates + P->>E: /whois (Company record id), metaEnvelopeHistory + P->>P: verifyDelegatedSignature → valid, title, scopes, appLimits + P->>E: write DelegatedSignature (audit) +``` + +## 1. Ask for the signature + +```ts +import { buildDelegatedSignRequest } from "@metastate-foundation/auth"; + +const { uri, payload } = buildDelegatedSignRequest({ + onBehalfOf: companyEName, // the company + signer: userEName, // who signs + scope: "@esigner:nda", // what they sign, as a scope + delegationId, // the Delegation they rely on + documentHash: sha256Hex(fileBytes), // what exactly is signed + session: crypto.randomUUID(), // your own session id + redirectUri: `${apiBase}/api/company-signing/callback`, + title: "Head of Finance", // shown in the wallet + companyName: "Acme Ltd", +}); +// Show `uri` as a QR code or deep link. Keep `payload`: it is the session. +``` + +The wallet shows "Signing as Head of Finance for Acme Ltd" and signs `payload` exactly as it signs any session. eNames are normalised to their `@` form for you. + +**Which `delegationId`?** Delegations live in the company's eVault. A user can hold several; let them pick, or choose the one whose scopes cover your document. A Delegation's `delegateEName` is the user. + +## 2. Verify it in the callback + +The wallet POSTs `{ sessionId, signature, w3id, message }` to `redirect_uri`. `sessionId` is the payload. + +```ts +import { verifyDelegatedSignature } from "@metastate-foundation/auth"; + +const result = await verifyDelegatedSignature({ + payload: body.sessionId, + signature: body.signature, + registryBaseUrl: process.env.PUBLIC_REGISTRY_URL!, + platformToken, // your platform token, to read the company eVault +}); + +if (!result.valid) { + // result.error: invalid_payload | bad_signature | not_covered | chain_invalid + // | not_a_company | resolve_failed | history_failed | timeout + // result.detail: the precise reason, e.g. "REVOKED", "SCOPE_NOT_DELEGATED" + return reject(result); +} + +result.companyEName; // the company it was signed for +result.title; // the signer's title, e.g. "NDA signer" +result.scopes; // everything this delegation covers +result.chain; // delegation ids, signer first, up to the role assignment +result.appLimits; // every link's app limits, root first +result.payload; // the parsed w3ds-sign/v1 fields (documentHash, session, …) +``` + +It never throws. Check that `result.payload.session` and `documentHash` match what you issued. + +**Retry or refuse?** + +- `resolve_failed`, `history_failed` and `timeout` mean the Registry or an eVault was unreachable, including while checking a grant further up the chain. Retry these. +- Everything else means the signature is not good for this company. Refuse it. + +### App limits + +`appLimits` is a list of objects, one per link that set any, root first. The model doesn't interpret them. **Your platform must satisfy every one**, which is how a child can never loosen what a parent set: + +```ts +const maxAmount = Math.min(...result.appLimits.map((l) => Number(l.maxAmount ?? Infinity))); +if (invoice.amount > maxAmount) return reject("over the delegated amount"); +``` + +## 3. Record it + +After accepting, write a **DelegatedSignature** record into the company's eVault, for the company's audit trail: + +```ts +await evault.createMetaEnvelope(companyEName, { + ontology: DELEGATED_SIGNATURE_ONTOLOGY, // from @metastate-foundation/delegation + acl: ["*"], + payload: { + companyEName: result.companyEName, + signerEName: result.payload.signer, + delegationId: result.payload.delegationId, + title: result.title, + scope: result.payload.scope, + documentHash: result.payload.documentHash, + session: result.payload.session, + signedPayload: body.sessionId, + signature: body.signature, + platformEName: yourPlatformEName, + signedAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + }, +}); +``` + +## Granting: tools that manage a company + +A tool that creates companies, roles and delegations writes records that carry their grantor's signature. Use `buildGrantSignRequest`, and **pick the record id first**, because the signature is bound to it: + +```ts +import { buildGrantSignRequest } from "@metastate-foundation/auth"; + +const recordId = `@${crypto.randomUUID()}`; +const record = { + companyEName, delegateEName: bobEName, roleId, title: "Head of Finance", + scopes: ["@esigner:nda"], mayRedelegate: true, grantedBy: danaEName, + status: "active", createdAt: now, updatedAt: now, +}; +const request = await buildGrantSignRequest({ + ontology: DELEGATION_ONTOLOGY, companyEName, recordId, + signerEName: danaEName, record, + redirectUri: `${apiBase}/api/grant/callback`, + message: "Make Bob Head of Finance (NDAs)", +}); +// …the wallet signs request.uri and POSTs back { signature }… +await evault.updateMetaEnvelope(companyEName, recordId, { // creates it under recordId + ontology: DELEGATION_ONTOLOGY, + acl: ["*"], + payload: { + ...record, + authorization: { + signerEName: request.signerEName, + signedPayload: request.payload, + signature, + signedAt: request.signedAt, + }, + }, +}); +``` + +Rules your tool should follow (verifiers ignore records that break them): + +- **Company:** the first version must already list `directors`, signed by one of them. Later changes must be signed by a sitting director. +- **Role:** signed by a director, and `createdBy` must be that director. +- **Delegation from a role:** signed by a director, with `grantedBy` set to that director and scopes inside the role's. +- **Re-delegation:** signed by the parent's delegate, the parent must allow re-delegation, and scopes must be inside the parent's. +- **Revoking:** write a new signed version with `status: "revoked"` and `revokedBy` set to the signer (a director or the grantor). Revoking a parent fails everything below it without touching those records, but you may also write signed revocations for them so the data reads clearly. +- **Never delete** these records. Deletion doesn't revoke anything, because verifiers read history. + +## Logins + +`verifyLoginSignature` refuses any session starting with `w3ds-`. If your platform verifies logins some other way, refuse those sessions too. A login session should always be the UUID you issued, never a payload someone else supplied. + +## Try it + +`examples/company-delegation-demo` runs all of this on the local stack (`pnpm dev:core`), with in-process wallets, as a slide-by-slide story. It covers: + +- a chain Dana → Bob → Dave → Tim, +- traced signatures, +- a scope that isn't covered, +- an illegal re-delegation, +- a forged board, +- a login replay, +- a revocation. diff --git a/docs/docs/W3DS Protocol/Company-Delegation.md b/docs/docs/W3DS Protocol/Company-Delegation.md new file mode 100644 index 000000000..026f61ac3 --- /dev/null +++ b/docs/docs/W3DS Protocol/Company-Delegation.md @@ -0,0 +1,173 @@ +--- +sidebar_position: 8 +--- + +# Company Delegation + +This page explains how a company signs on W3DS. A company has its own eVault. Its **directors** hand out titled **roles**, and people with a role, or a narrower slice of one, sign **for** the company. Any platform can verify such a signature by tracing it back through the company's eVault to its board. + +For how a platform requests and verifies these signatures, see [Signing for a Company](/docs/Post%20Platform%20Guide/signing-for-a-company). + +## The idea in one paragraph + +Bob signs an NDA "for Acme" with **his own** wallet key. The signature names Acme, the delegation Bob relies on, and what he is signing. To accept it, a platform reads Acme's eVault. It checks that: + +- Bob's delegation was signed by someone entitled to grant it, +- that grant goes back, hop by hop, to a role created by one of Acme's directors, +- the directors are the ones Acme's Company record was born with, or later changed to by a sitting director, +- nothing on the way has been revoked or widened. + +**The eVault enforces none of this.** It stores whatever it is given, and all authority is decided by the verifier from signatures and the eVault's version history. + +```mermaid +flowchart RL + T["Tim signs an NDA
for Acme"] --> TD["Tim's delegation
NDA signer · granted by Dave"] + TD --> DD["Dave's delegation
Finance officer · granted by Bob"] + DD --> BD["Bob's delegation
Head of Finance · granted by Dana"] + BD --> R["Role: Head of Finance
created by Dana"] + R --> B["Company record
board: Dana"] +``` + +## Records + +All of these live in the **company's own eVault**, are public (`acl: ["*"]`) so any verifier can read them, and are written under an id the writer picks before signing. + +| Record | Ontology id | Says | +|---|---|---| +| Company | `0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f402` | Who the company is and its `directors[]` | +| Role | `65fd0e21-34b9-43ef-be76-c5b39727010e` | A title, the `scopes` it covers, `appLimits`, whether holders may re-delegate | +| Delegation | `0b2f15d8-c3f9-4dba-b959-5cfa11272dae` | Gives a person a role (`roleId`) or a narrower slice of a parent delegation (`parentDelegationId`) | +| Shareholding | `6382a144-5c28-450e-bf47-e37c747791c2` | A holding of shares; recorded by a director, carries no signing authority | +| DelegatedSignature | `e2736a06-176e-4004-8fda-b40b9a132669` | Audit record of a signature made for the company, written by the platform that verified it | + +### Company: born with its board + +- `directors` is a list of eNames. +- **Any one director** may create roles, grant delegations from a role, and revoke any delegation. +- Only a **sitting director** may change the board. +- A board only counts if the Company record's **very first version** already carries one, signed by one of its own directors. A Company record that started without directors can never acquire a board, so an existing company cannot be claimed by whoever adds directors first. +- The company's eVault `/whois` reports `type: "company"` with this record as its manifest. Verifiers start there. + +### Role + +- `title` is what people see, e.g. "Head of Finance". +- `scopes` says what holders may sign (see [Scopes](#scopes)). +- `mayRedelegate` says whether holders may pass a narrower slice on. +- `appLimits` holds platform-specific limits, e.g. `{ "maxAmount": 10000 }`, that the platform enforces itself. +- `createdBy` must be a director, and must be the one who signs. + +### Delegation and re-delegation + +Exactly one of `roleId` or `parentDelegationId` is set. + +- **From a role:** granted (signed) by a director. `scopes` must be a subset of the role's. +- **From a parent delegation:** granted by the parent's delegate, only if the parent has `mayRedelegate: true`. `scopes` must be a subset of the parent's, and the validity window must lie within the parent's. +- `grantedBy` is the signer. Immutable after creation: `companyEName`, `delegateEName`, `roleId`, `parentDelegationId`, `grantedBy`. +- **Revoking:** a director or the grantor writes a signed version with `status: "revoked"` and `revokedBy` set to themselves. Revocation is final. + +## Scopes + +A scope is either: + +- `ontology:`: records of that ontology, or +- `@:`: a document type a platform declares, e.g. `@esigner:nda`. + +**Core scopes can never be delegated.** No role or delegation may contain them, and verifiers reject them: + +- identity: the UserProfile and binding-document ontologies, +- the company's own authority: Company, Shareholding, Role, Delegation, DelegatedSignature, +- anything in the reserved `@w3ds` namespace (login, key and eVault management). + +## Signatures + +### Grants: `w3ds-grant/v1` + +Every Company, Role, Delegation and Shareholding record carries an `authorization`: + +```json +{ + "signerEName": "@dana…", + "signedPayload": "w3ds-grant/v1\n{\"companyEName\":…,\"ontology\":…,\"recordId\":…,\"recordSha256\":…,\"signedAt\":…,\"signer\":…}", + "signature": "", + "signedAt": "2026-10-09T07:42:04.000Z" +} +``` + +The signed string is short whatever the record's size, and binds: + +| Field | Prevents | +|---|---| +| `recordId` | Copying the authorization onto another record | +| `recordSha256` (hash of the record without `authorization`) | Editing any field after signing | +| `signedAt` | Writing an older signed state back over a newer one | +| `companyEName`, `ontology`, `signer` | Reusing it for another company, record type or signer | + +The signer's wallet signs it through an ordinary [`w3ds://sign`](/docs/W3DS%20Protocol/Signing) request: the string **is** the session. The wallet needs no changes. + +### Signing for the company: `w3ds-sign/v1` + +A delegate signs this payload, also sent as the `w3ds://sign` session: + +```text +w3ds-sign/v1 +{"delegationId":"…","documentHash":"…","issuedAt":"…","onBehalfOf":"@acme…","scope":"@esigner:nda","session":"…","signer":"@tim…"} +``` + +It is canonical JSON with sorted keys, exactly one valid encoding, and at most 1024 characters. + +### Never a login + +Every payload in this model starts with `w3ds-`. Login verifiers (`verifyLoginSignature`) refuse any session with that prefix. A signature made for a company, or over a grant, can never be replayed as a login, and a login can never claim to be on someone's behalf. + +## How a verifier traces a signature + +1. Parse the `w3ds-sign/v1` payload, and verify the signer's signature against their Registry-certified keys. +2. Resolve the company's eVault, and take its Company record id from `/whois` (`type` must be `company`). +3. Read the Company record's history and build the **board timeline**: + - The first version must carry a board signed by one of its own directors. + - Each later change counts only if signed by a director sitting at that time. +4. Resolve each link from the signer's delegation upwards. Each one is the **latest version** that: + - is validly signed and bound to its id, + - is not dated before the previous valid version, and not after the eVault stored it (5 minutes of clock skew allowed), + - keeps its immutable fields, + - was signed by someone entitled **when it was stored**: a director for roles and role grants, the parent's delegate for re-delegations, a director or the grantor for revocations. + + Unsigned, forged, copied or written-back versions are skipped as if absent. Grants made by a director remain valid after that director leaves the board. +5. Walk the chain to the role (at most 16 links). Every link must be: + - active and within its validity window, + - for the same company, + - narrowing its parent, + - granted by the parent's delegate, + - from a parent that allows re-delegation. +6. Check the payload against the chain: + - the right company, signer and delegation, + - the scope is in the chain's scopes, + - the scope is not core. + +**Revocation cascades implicitly.** Revoking or narrowing a role or delegation fails the chain of everything handed on from it at the next verification. Nothing else needs to be written. + +### Why a trace fails + +| Code | Meaning | +|---|---| +| `NOT_FOUND` | No valid version: never granted by anyone entitled, or copied from another record | +| `REVOKED` | This link, or one above it, was revoked | +| `NOT_A_SUBSET` | Scopes wider than the parent's or role's | +| `REDELEGATION_NOT_ALLOWED` | The parent (or role) did not allow passing it on | +| `WRONG_GRANTOR` | Not granted by the parent's delegate | +| `EXPIRED` / `NOT_YET_VALID` | Outside its validity window | +| `CORE_SCOPE` | Includes a scope that can never be delegated | +| `CYCLE` / `TOO_DEEP` | Parents loop, or more than 16 links | +| `SCOPE_NOT_DELEGATED` | The chain is fine but doesn't cover what was signed | + +## Limits and trade-offs + +- **eVault writes are not restricted.** Anyone with write access can store records in a company's eVault. They are ignored unless properly signed, but they cost verification time. +- **History size:** a verifier reads at most 2,000 versions of any one record. Flooding a record with writes is a denial of service, never a takeover. +- **Use counts** ("sign at most N times") are not part of the model. Use validity windows, or platform-enforced `appLimits`. + +## Code + +- `@metastate-foundation/delegation` (`packages/delegation`): scopes, payload formats, chain evaluation, history resolution. +- `@metastate-foundation/auth` (`packages/auth`): `verifyDelegatedSignature`, `buildDelegatedSignRequest`, `buildGrantSignRequest`. +- A runnable demonstrator: `examples/company-delegation-demo`, a slide-by-slide story on the local stack. 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..1b555c244 --- /dev/null +++ b/examples/company-delegation-demo/README.md @@ -0,0 +1,44 @@ +# 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 +``` + +The page is a short story in slides (← → to move). Each slide runs its step for real; the raw records are on `/dashboard.html`. + +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 | +|---|---| +| 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 + +```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..b28c0d7ef --- /dev/null +++ b/examples/company-delegation-demo/demo.config.json @@ -0,0 +1,48 @@ +{ + "company": { + "name": "Acme Ltd", + "legalName": "Acme Limited", + "jurisdiction": "GB" + }, + "people": { + "dana": { "name": "Dana", "about": "Director of Acme" }, + "bob": { "name": "Bob", "about": "Head of Finance" }, + "dave": { "name": "Dave", "about": "Finance officer" }, + "tim": { "name": "Tim", "about": "Junior clerk" }, + "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", "@esigner:invoice"], + "mayRedelegate": true + }, + "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/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/dashboard.html b/examples/company-delegation-demo/public/dashboard.html new file mode 100644 index 000000000..17fb8f83a --- /dev/null +++ b/examples/company-delegation-demo/public/dashboard.html @@ -0,0 +1,168 @@ + + + + + +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 new file mode 100644 index 000000000..da79529e1 --- /dev/null +++ b/examples/company-delegation-demo/public/index.html @@ -0,0 +1,654 @@ + + + + + +Who Signs for Acme + + + +
      +

      Who signs for Acme? · company delegation on W3DS

      +
      + raw records +
      + +
      +
      +
      +
      +
      +
      +
      + + + + diff --git a/examples/company-delegation-demo/src/app.ts b/examples/company-delegation-demo/src/app.ts new file mode 100644 index 000000000..3c4b0f17e --- /dev/null +++ b/examples/company-delegation-demo/src/app.ts @@ -0,0 +1,68 @@ +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(true); + }), + ); + app.post( + "/api/reset", + handle(async () => { + demo.reset(); + await demo.preparePeople(); + return demo.snapshot(true); + }), + ); + app.post( + "/api/scenario/:id", + handle(async (req) => { + 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 new file mode 100644 index 000000000..cf04b7e79 --- /dev/null +++ b/examples/company-delegation-demo/src/demo.ts @@ -0,0 +1,947 @@ +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, + verifyEnameSignature, + verifyLoginSignature, + type VerifyDelegatedSignatureResult, +} from "@metastate-foundation/auth"; +import { + COMPANY_ONTOLOGY, + DELEGATED_SIGNATURE_ONTOLOGY, + DELEGATION_ONTOLOGY, + evaluateFromHistory, + resolveBoard, + ROLE_ONTOLOGY, +} from "@metastate-foundation/delegation"; +import { provision, syncPublicKeyToEvaultWithOptions } from "wallet-sdk"; +import { + newRecordId, + platformToken, + PROVISIONER_URL, + provisionKeylessEVault, + readHistory, + 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; + /** Acme as stored and as verified right after this scenario. */ + view?: unknown; + /** The hops a verifier walked, signer first. */ + trace?: TraceStep[]; +}; + +export type TraceStep = { + kind: "delegation" | "role" | "board"; + /** The person this hop is about, by config key. */ + person?: string; + text: string; + /** true checked, false where it broke, null never reached. */ + ok: boolean | null; + reason?: string; +}; + +const now = () => new Date().toISOString(); + +export class Demo { + private state: State; + 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 lastSignature?: { 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; + 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}`); + await this.heal(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) => + this.verifyGrant(eName, payload, signature); + 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 --------------------------------------------------------- + + /** 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(); + 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 a delegation, and eSigner verifies it. */ + async signFor( + person: string, + delegation: string, + scope: string, + document: string, + ) { + const company = this.requireCompany(); + const delegationId = company.delegations[delegation]; + const title = config.delegations[delegation]?.title ?? "Owner"; + const request = buildDelegatedSignRequest({ + onBehalfOf: company.eName, + signer: this.eName(person), + scope, + delegationId, + 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 + ? `eSigner verified: ${config.people[person].name} signed ${document} for Acme as "${result.title}"` + : `eSigner 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, + title: result.title, + scope, + documentHash: result.payload?.documentHash, + session: result.payload?.session, + signedPayload: request.payload, + signature, + platformEName: "@company-delegation-demo", + signedAt: now(), + createdAt: now(), + }, + ); + } + const trace = await this.trace(delegationId, scope, result); + return { result, payload: request.payload, signature, trace }; + } + + /** + * The path a verifier walks for a signature: from the signer's delegation + * up through each parent to the role and the board, with the hop where it + * broke, if it did. + */ + private async trace( + delegationId: string, + scope: string, + result: VerifyDelegatedSignatureResult, + ): Promise { + const company = this.requireCompany(); + const keyOf = (eName: string) => + Object.entries(this.state.people).find( + ([, p]) => p.eName === eName, + )?.[0]; + const name = (eName: string) => + config.people[keyOf(eName) ?? ""]?.name ?? "someone"; + + // Where the chain evaluation stopped, read from Acme's history. + 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 chain = await evaluateFromHistory({ + delegationId, + companyEName: company.eName, + companyId: company.companyId, + source, + verify: (e, p, s) => this.verifyGrant(e, p, s), + }); + const board = await resolveBoard( + company.companyId, + company.eName, + source, + (e, p, s) => this.verifyGrant(e, p, s), + ); + const directors = board.at(-1)?.directors ?? []; + + const steps: (TraceStep & { id: string; grantor?: string })[] = []; + let id: string | undefined = delegationId; + for (let i = 0; id && i < 8; i++) { + const rec = await readRecord(company.eName, id); + if (!rec) break; + steps.push({ + id, + kind: "delegation", + person: keyOf(rec.delegateEName), + grantor: name(rec.grantedBy), + text: `${name(rec.delegateEName)} · ${rec.title} ← ${name(rec.grantedBy)}`, + ok: null, + }); + if (rec.roleId) { + const role = await readRecord(company.eName, rec.roleId); + steps.push({ + id: rec.roleId, + kind: "role", + text: `role ${role?.title ?? "?"} ← ${name(role?.createdBy)}`, + ok: null, + }); + steps.push({ + id: company.companyId, + kind: "board", + person: keyOf(directors[0] ?? ""), + text: `board: ${directors.map(name).join(", ") || "none"}`, + ok: null, + }); + break; + } + id = rec.parentDelegationId; + } + + const reasons: Record = { + REVOKED: "revoked", + REDELEGATION_NOT_ALLOWED: "couldn't pass it on", + NOT_FOUND: "nobody entitled granted it", + NOT_A_SUBSET: "wider than what was given", + EXPIRED: "expired", + }; + let failAt = -1; + let reason = ""; + if (result.valid) { + failAt = -1; + } else if (result.error === "not_covered") { + failAt = 0; + reason = `doesn't cover ${scope.split(":").pop()}`; + } else if (!chain.ok) { + failAt = Math.max( + 0, + steps.findIndex((s) => s.id === chain.at), + ); + reason = reasons[chain.code] ?? chain.code.toLowerCase(); + if (chain.code === "REDELEGATION_NOT_ALLOWED") + reason = `${steps[failAt]?.grantor ?? "the giver"} ${reason}`; + } else { + failAt = 0; + reason = result.detail ?? result.error ?? "refused"; + } + return steps.map(({ id: _id, grantor: _g, ...s }, i) => ({ + ...s, + ok: failAt < 0 || i < failAt ? true : i === failAt ? false : null, + reason: i === failAt ? reason : undefined, + })); + } + + private verifyGrant(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; + } + + /** `from` signs a re-delegation of their own delegation to `to`. */ + private async passOn( + from: string, + to: string, + title: string, + scopes: string[], + ) { + const company = this.requireCompany(); + const parent = company.delegations[from]; + const id = newRecordId(); + await this.grant( + from, + company.eName, + DELEGATION_ONTOLOGY, + id, + { + companyEName: company.eName, + delegateEName: this.eName(to), + parentDelegationId: parent, + title, + scopes, + mayRedelegate: false, + grantedBy: this.eName(from), + status: "active", + createdAt: now(), + updatedAt: now(), + }, + `Pass "${title}" on to ${config.people[to].name}`, + ); + company.delegations[`${to}-from-${from}`] = id; + save(this.state); + return `${to}-from-${from}`; + } + + // ---- scenarios ----------------------------------------------------- + + readonly scenarios: Record< + string, + { label: string; run: () => Promise } + > = { + "tim-nda": { + label: "Tim signs an NDA for Acme", + run: async () => { + const { result, payload, signature, trace } = + await this.signFor( + "tim", + "tim", + "@esigner:nda", + "nda-globex.pdf", + ); + this.lastSignature = { payload, signature }; + return outcome("tim-nda", "valid", result, trace); + }, + }, + "tim-invoice": { + label: "Tim signs an invoice", + run: async () => { + const { result, trace } = await this.signFor( + "tim", + "tim", + "@esigner:invoice", + "invoice-0042.pdf", + ); + return outcome("tim-invoice", "refused", result, trace); + }, + }, + "tim-passes-on": { + label: "Tim passes his badge to Mallory", + run: async () => { + const key = await this.passOn("tim", "mallory", "NDA signer", [ + "@esigner:nda", + ]); + const { result, trace } = await this.signFor( + "mallory", + key, + "@esigner:nda", + "nda-mallory.pdf", + ); + return outcome("tim-passes-on", "refused", result, trace); + }, + }, + "login-replay": { + label: "Tim's NDA signature replayed as a login", + run: async () => { + if (!this.lastSignature) await this.scenarios["tim-nda"].run(); + const { payload, signature } = this.lastSignature as { + payload: string; + signature: string; + }; + const login = await verifyLoginSignature({ + eName: this.eName("tim"), + 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", + run: async () => { + const company = this.requireCompany(); + const current = + (await readRecord(company.eName, company.companyId)) ?? {}; + const { authorization: _drop, ...board } = current; + 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); + const { result, trace } = await this.signFor( + "mallory", + "mallory", + "@esigner:nda", + "nda-mallory.pdf", + ); + return outcome("mallory-board", "refused", result, trace); + }, + }, + "revoke-bob": { + label: "Dana revokes Bob, then Tim signs 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 { result, trace } = await this.signFor( + "tim", + "tim", + "@esigner:nda", + "nda-after-revoke.pdf", + ); + return outcome("revoke-bob", "refused", result, trace); + }, + }, + }; + + // ---- view ------------------------------------------------------------ + + /** 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; + return 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; + } + + /** + * 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]) => [ + k, + { ...config.people[k], eName: p.eName }, + ]), + ), + company: company + ? { eName: company.eName, companyId: company.companyId } + : 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, + })), + verified: view?.verified ?? null, + outcomes: this.outcomes, + log: this.log.slice(-60), + }; + } + + /** + * Scenarios can run in any order, so each first gets what it needs: an + * Acme that exists in its eVault, and for the ones that rely on Tim's + * chain, one where Bob has not been revoked. Anything else starts a fresh + * Acme. + */ + private async heal(id: string): Promise { + const company = this.state.company; + let usable = false; + if (company) { + try { + usable = + (await readRecord(company.eName, company.companyId)) !== + null; + } catch { + usable = false; + } + } + const needsChain = ["tim-nda", "tim-invoice", "tim-passes-on"].includes( + id, + ); + const chainBroken = needsChain && !!this.outcomes["revoke-bob"]; + if (usable && !chainBroken) return; + const why = !company + ? "no Acme yet: founding it first" + : chainBroken + ? "Bob was revoked on this Acme: founding a fresh one" + : "Acme's eVault is gone: founding a fresh one"; + this.reset(); + this.note(undefined, why); + await this.setup(); + this.cachedView = await this.view(); + this.setupView = this.cachedView; + } + + 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, + trace?: TraceStep[], +): Outcome { + return { + trace, + 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..6f8de2b6b --- /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 = [ + "tim-nda", + "tim-invoice", + "tim-passes-on", + "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.run(id); + 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..4c0685878 --- /dev/null +++ b/examples/company-delegation-demo/src/stack.ts @@ -0,0 +1,169 @@ +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()}`; + +/** 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) return versions.reverse(); + versions.push(...conn.edges.map((e) => e.node)); + if (!conn.pageInfo.hasNextPage || !conn.pageInfo.endCursor) { + return versions.reverse(); + } + after = conn.pageInfo.endCursor; + } + // A truncated history would hide a record's first version, which is what + // decides its board, so refuse rather than judge a partial one. + throw new Error(`history of ${id} is longer than 2,000 versions`); +} 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/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" }) 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/* diff --git a/skills/w3ds/SKILL.md b/skills/w3ds/SKILL.md index d66ac7708..7c8750164 100644 --- a/skills/w3ds/SKILL.md +++ b/skills/w3ds/SKILL.md @@ -1,6 +1,6 @@ --- name: w3ds -description: "Use when the user is building on Web 3 Data Spaces (W3DS) or the MetaState prototype — building a post-platform, integrating an eVault, calling the eVault GraphQL API (createMetaEnvelope, updateMetaEnvelope, removeMetaEnvelope, bulkCreateMetaEnvelopes, uploadFile, bindingDocument*), wiring the Web3 Adapter, writing a webhook controller for /api/webhook, authoring mapping.json files, using the wallet-sdk, implementing the w3ds://auth or w3ds://sign flow, resolving W3IDs / eNames via the Registry, working with the Ontology service, proposing a new ontology, dealing with Binding Documents, dereferencing w3ds://file URIs, provisioning an eVault, syncing public keys, hosting a platform on GitW3 (the W3DS-aware Git forge — `.w3ds/platform.json`, platform eName, version eName, PPA certification, deployment records, porting an existing app, `w3ds-deployment-key.json`), or debugging local dev (Registry, Provisioner, eVault-core, Dev Sandbox, pnpm dev:core). Also use for any design decision about where data lives on a W3DS platform — adding a table, entity, model or field, deciding what to cache locally, or asking whether something belongs in the database or the eVault. Also use when the user asks what an eVault, W3ID, eName, MetaEnvelope, Envelope, Ontology, Web3 Adapter, Awareness Protocol, or Awareness-as-a-Service is." +description: "Use when the user is building on Web 3 Data Spaces (W3DS) or the MetaState prototype — building a post-platform, integrating an eVault, calling the eVault GraphQL API (createMetaEnvelope, updateMetaEnvelope, removeMetaEnvelope, bulkCreateMetaEnvelopes, uploadFile, bindingDocument*), wiring the Web3 Adapter, writing a webhook controller for /api/webhook, authoring mapping.json files, using the wallet-sdk, implementing the w3ds://auth or w3ds://sign flow, resolving W3IDs / eNames via the Registry, working with the Ontology service, proposing a new ontology, dealing with Binding Documents, dereferencing w3ds://file URIs, provisioning an eVault, syncing public keys, hosting a platform on GitW3 (the W3DS-aware Git forge — `.w3ds/platform.json`, platform eName, version eName, PPA certification, deployment records, porting an existing app, `w3ds-deployment-key.json`), or debugging local dev (Registry, Provisioner, eVault-core, Dev Sandbox, pnpm dev:core). Also use for any design decision about where data lives on a W3DS platform — adding a table, entity, model or field, deciding what to cache locally, or asking whether something belongs in the database or the eVault. Also use for company signing delegation — a company eVault with directors, titled roles, delegations and re-delegations, signing on behalf of a company, verifyDelegatedSignature / buildDelegatedSignRequest / buildGrantSignRequest. Also use when the user asks what an eVault, W3ID, eName, MetaEnvelope, Envelope, Ontology, Web3 Adapter, Awareness Protocol, or Awareness-as-a-Service is." license: Apache 2.0 --- @@ -98,6 +98,7 @@ The "digital self" is a triad: **eName + eID certificate + eVault**. Users hold | **w3ds://file** | URI scheme for file blobs; format `w3ds://file?id=@/` | [reference/protocols.md](reference/protocols.md) | | **AaaS** | Awareness-as-a-Service — production-grade replacement for eVault's direct webhook fanout | [reference/protocols.md](reference/protocols.md) | | **GitW3** | W3DS-aware Git forge; `.w3ds/platform.json`, platform / version / deployment eNames, PPA | [reference/gitw3.md](reference/gitw3.md) | +| **Company delegation** | Directors grant titled roles; people sign *for* a company; verifiers trace the chain to the board | [reference/company-delegation.md](reference/company-delegation.md) | ## Production URLs @@ -125,6 +126,7 @@ Load the reference file(s) below **before** writing any code or configuration. | Registry, `/resolve`, `/entropy`, `/list`, JWKS, key binding certificate, finding an ontology ID, proposing a new ontology, `/schemas`, `/domains` | [reference/registry.md](reference/registry.md) | | w3ds://auth, w3ds://sign, Awareness Protocol packet, signature verification, ECDSA P-256, multibase, base58btc, base64 signature format, `verifySignature`, AaaS, w3ds://file URI, dereferencing files | [reference/protocols.md](reference/protocols.md) | | eID Wallet, wallet-sdk, `provision`, `authenticate`, `syncPublicKeyToEvault`, `CryptoAdapter`, hardware vs software keys, `PATCH /public-key`, key delegation across devices | [reference/wallet.md](reference/wallet.md) | +| company, director, board, role, delegation, re-delegation, "sign on behalf of", "sign for a company", `verifyDelegatedSignature`, `buildDelegatedSignRequest`, `buildGrantSignRequest`, `w3ds-sign/v1`, `w3ds-grant/v1`, scopes, `appLimits`, Shareholding | [reference/company-delegation.md](reference/company-delegation.md) | | `pnpm dev:core`, Dev Sandbox, ports (4321 / 3001 / 4000 / 8080), `REGISTRY_ENTROPY_KEY_JWK`, `pnpm generate-entropy-jwk`, "webhook not firing", "signature verification fails", "duplicate entities" | [reference/dev-setup.md](reference/dev-setup.md) | If the question spans multiple topics (common for platform builds), load the two or three most relevant references in one turn rather than piecemeal. A build task almost always needs `w3ds-native.md` plus `platform.md`. diff --git a/skills/w3ds/reference/company-delegation.md b/skills/w3ds/reference/company-delegation.md new file mode 100644 index 000000000..3f356cbc5 --- /dev/null +++ b/skills/w3ds/reference/company-delegation.md @@ -0,0 +1,55 @@ +# Company delegation + +How a company signs on W3DS: directors hand out titled roles, people sign **for** the company with their own keys, and verifiers trace each signature back to the company's board. Sources: + +- [Company Delegation](https://docs.w3ds.metastate.foundation/docs/W3DS%20Protocol/Company-Delegation): the model. +- [Signing for a Company](https://docs.w3ds.metastate.foundation/docs/Post%20Platform%20Guide/signing-for-a-company): platform integration. + +Resolve the ontology ids (Company, Role, Delegation, Shareholding, DelegatedSignature) from the ontology service or the docs page. They are also exported as constants from `@metastate-foundation/delegation`. + +## Model in brief + +- **Company eVault.** It is keyless, and its `/whois` reports `type: "company"`, with the Company record as its manifest. +- **Company record.** It holds `directors[]`. A board counts only if the record's **first version** carries it, signed by one of its own directors. After that, only a sitting director may change it. Any one director may create roles and grant or revoke delegations. +- **Role.** It has a `title`, `scopes`, `mayRedelegate` and `appLimits`. It is created and signed by a director. +- **Delegation.** It sets exactly one of `roleId` (granted by a director) or `parentDelegationId` (granted by the parent's delegate, only if the parent allows it). Its scopes must be a subset of what it came from. Revocation is a signed version with `status: "revoked"`, and it is final. +- **Scopes.** Either `ontology:` or `@:`. Identity, the company's own authority records and the `@w3ds` namespace can **never** be delegated. +- **The eVault enforces nothing.** It stores any write. Authority is decided only by the verifier, from signatures and version history. + +## Signatures + +- Records carry `authorization: { signerEName, signedPayload, signature, signedAt }`. + - `signedPayload` is a `w3ds-grant/v1` string binding the company, ontology, **record id**, a **hash of the record** and **signedAt**. + - So a copied, edited or written-back record fails verification. + - Writers pick the record id before signing, and create the record with `updateMetaEnvelope(id, …)`. +- A delegate signs a `w3ds-sign/v1` payload: `onBehalfOf`, `signer`, `scope`, `delegationId`, `documentHash`, `session` and `issuedAt`, as canonical JSON. +- Both go through an ordinary `w3ds://sign` request, with **the payload as the session**. No wallet changes are needed. +- Anything starting with `w3ds-` is never a login. `verifyLoginSignature` refuses it. + +## Platform code + +```ts +import { buildDelegatedSignRequest, verifyDelegatedSignature } from "@metastate-foundation/auth"; + +const { uri, payload } = buildDelegatedSignRequest({ onBehalfOf, signer, scope, delegationId, + documentHash, session, redirectUri, title, companyName }); +// …wallet POSTs { sessionId: payload, signature } to redirectUri… +const r = await verifyDelegatedSignature({ payload: body.sessionId, signature: body.signature, + registryBaseUrl, platformToken }); +// r.valid, r.title, r.scopes, r.chain, r.appLimits (satisfy every one), r.error / r.detail +``` + +- **Retryable errors:** `resolve_failed`, `history_failed`, `timeout`. +- **Refuse:** everything else, e.g. `not_covered` (scope), `chain_invalid` (with `detail` such as `REVOKED`, `NOT_FOUND` or `REDELEGATION_NOT_ALLOWED`), `bad_signature`, `not_a_company`. +- After accepting, write a DelegatedSignature audit record into the company's eVault. +- Tools that grant use `buildGrantSignRequest`, which returns the `payload`, `signedAt` and normalised `signerEName` to store as `authorization`. + +## Gotchas + +- **Don't add company or delegation checks to evault-core.** The eVault stays a plain store, and checks belong in verifiers. +- **Don't delete authority records to revoke them.** Verifiers read history, so deleting does nothing. Write a signed revoked version instead. +- **Revoking a parent fails everything handed on from it** at the next verification, with no writes to the children. +- **`appLimits` are opaque to the model.** The platform enforces them, and must satisfy every link's limits. +- **Verifiers read at most 2,000 versions of any one record.** +- **There are no use-count limits.** Use validity windows instead. +- **Demonstrator:** `examples/company-delegation-demo`, run with `pnpm dev:core` and then `pnpm --filter company-delegation-demo dev`.