From d3e8fb1524822aa9a3e05aa60a370be400b057db Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Tue, 15 Sep 2026 10:53:27 +0200 Subject: [PATCH 1/2] feat(manager): mint a DID from the whole task surface, and read its log MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The New DID form sent 3 of the 20 members `vta/webvh/dids/create/1.0` accepts, and the DIDs pane showed `logEntryCount` with no way to read an entry — a number with nothing behind it. Anything past a server id and Portable meant leaving the console for the CLI. The form now covers the surface, grouped by the decision each member makes rather than by the payload's field order: - where it is published — server, domain, label, and the existing `PathPicker` with its live DID preview and the hosting server's own path rules - what it commits to — portable and pre-rotation as choices with their consequence beside them, since both are fixed in the first log entry - which keys it is built on — `signingKeyId` / `kaKeyId`, offering only active keys of the right type in the right context - what it advertises — `addMediatorService` / `addTspService`, plus a structured editor for `additionalServices` - what it is stamped from — templates from `didTemplateList`, a field per `requiredVars`, `defaults` seeding the controls above `setPrimary` is now sent explicitly as `false`. The agent defaults it to `true`, so the quiet path through the old form replaced the context's identity on every mint — which anything resolving a context's DID to find its keys, a room host at startup, would then follow. Clicking a DID expands the row into `vta/webvh/dids/get` with `includeLog`, fetched on open rather than with the listing: the log is the DID's whole history and a listing that pulled every one would be every history, to draw a table. A response carrying no log is drawn as a refusal to read, never as an absence of history, and one unreadable line is one unreadable entry rather than a blank panel. New DID templates pane, full CRUD, built-ins inspect-only. Update is a REPLACE, so the editor carries `optionalVars` and `defaults` it never draws; without that a save cleared them while the agent reported success. "Render the stored version" calls `didTemplateRender` so the operator sees the agent's own substitution rather than this console's guess at it. Three defects the new tests caught before they shipped: - `templateDefaults` coerced its hint with `Number()`, and `Number(null)` is 0 — a null `preRotationCount` hint would have silently switched pre-rotation off, the one setting that makes a stolen key unrecoverable - the agent answers a context-scoped template listing with global templates too, so asking both namespaces listed every global template twice, under one React key; `mergeTemplates` keys on scope AND name, which keeps two genuinely different templates of one name apart - the key pickers said "Reading…" with no context selected, when nothing had been asked for Reuse of a key is offered but never quiet: two DIDs publishing one key are provably one holder, so the form says so when a key is chosen and clears the choice after a mint — leaving it set would build the next DID on the same key by inaction. `webvhDidCreate` threads the members it was dropping. `pathMode` is exposed without the bare `path` shorthand, because sending both is an error and one way to say it cannot be said twice. `preRotationCount` and `addMediatorService` use `!== undefined` rather than a truthy test: 0 and false are the caller's answers, not absence. The four new `vta/did-templates/*` URIs join the admin guard in ci.yml — operator surface outside that list is unchecked. Verified after a build that all four appear only in `manager.js`, that the seeds guard is untouched, and that the console is still one chunk. Signed-off-by: Glenn Gore --- .github/workflows/ci.yml | 2 +- packages/core/src/webvh/dids.ts | 58 +- packages/extension/src/manager/did-log.ts | 126 +++ packages/extension/src/manager/icons.tsx | 9 +- packages/extension/src/manager/mint-keys.ts | 90 ++ .../src/manager/panes/did-create.tsx | 879 ++++++++++++++++++ .../src/manager/panes/did-detail.tsx | 296 ++++++ .../src/manager/panes/did-service-editor.tsx | 147 +++ .../src/manager/panes/did-templates.tsx | 612 ++++++++++++ packages/extension/src/manager/panes/dids.tsx | 164 +--- .../extension/src/manager/service-entries.ts | 149 +++ packages/extension/src/manager/shell.tsx | 16 + .../extension/src/manager/template-scope.ts | 115 +++ .../extension/src/manager/template-vars.ts | 171 ++++ packages/extension/src/ui.tsx | 53 +- .../tests/did-create.render.test.mts | 430 +++++++++ .../tests/did-detail.render.test.mts | 184 ++++ packages/extension/tests/did-log.test.mts | 143 +++ .../extension/tests/did-mint-keys.test.mts | 106 +++ .../tests/did-service-entries.test.mts | 127 +++ .../tests/did-template-vars.test.mts | 193 ++++ .../tests/did-templates.render.test.mts | 248 +++++ 22 files changed, 4195 insertions(+), 123 deletions(-) create mode 100644 packages/extension/src/manager/did-log.ts create mode 100644 packages/extension/src/manager/mint-keys.ts create mode 100644 packages/extension/src/manager/panes/did-create.tsx create mode 100644 packages/extension/src/manager/panes/did-detail.tsx create mode 100644 packages/extension/src/manager/panes/did-service-editor.tsx create mode 100644 packages/extension/src/manager/panes/did-templates.tsx create mode 100644 packages/extension/src/manager/service-entries.ts create mode 100644 packages/extension/src/manager/template-scope.ts create mode 100644 packages/extension/src/manager/template-vars.ts create mode 100644 packages/extension/tests/did-create.render.test.mts create mode 100644 packages/extension/tests/did-detail.render.test.mts create mode 100644 packages/extension/tests/did-log.test.mts create mode 100644 packages/extension/tests/did-mint-keys.test.mts create mode 100644 packages/extension/tests/did-service-entries.test.mts create mode 100644 packages/extension/tests/did-template-vars.test.mts create mode 100644 packages/extension/tests/did-templates.render.test.mts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ce640c..06882ca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,7 +94,7 @@ jobs: # contain this" a structural property rather than a convention. - name: Assert agent-administration surface is confined to the console run: | - for task in 'acl/grant/0.1' 'acl/revoke/0.1' 'acl/update/0.1' 'contexts/delete/1.0' 'keys/create/0.1' 'keys/sign/0.1' 'policy/upsert/0.2' 'device/wipe/0.1' 'config/patch/0.1' 'vta/did-templates/create/2.0' 'consent/approver-set/1.0' 'keys/import/0.1' 'did-management/did/delete/0.1' 'vta/services/enable/1.0' 'vta/services/disable/1.0' 'vta/credentials/issue/0.1' 'vta/credentials/revoke/0.1'; do + for task in 'acl/grant/0.1' 'acl/revoke/0.1' 'acl/update/0.1' 'contexts/delete/1.0' 'keys/create/0.1' 'keys/sign/0.1' 'policy/upsert/0.2' 'device/wipe/0.1' 'config/patch/0.1' 'vta/did-templates/create/2.0' 'vta/did-templates/list/2.0' 'vta/did-templates/update/2.0' 'vta/did-templates/delete/2.0' 'vta/did-templates/render/2.0' 'consent/approver-set/1.0' 'keys/import/0.1' 'did-management/did/delete/0.1' 'vta/services/enable/1.0' 'vta/services/disable/1.0' 'vta/credentials/issue/0.1' 'vta/credentials/revoke/0.1'; do leaked=$(grep -rlF "$task" packages/extension/dist/ | grep -v '^packages/extension/dist/manager\.js$' || true) if [ -n "$leaked" ]; then echo "::error::$leaked contains $task — @openvtc/pnm-core/admin must not be reachable from any wallet surface (check for a root-barrel import, or a shared chunk)" diff --git a/packages/core/src/webvh/dids.ts b/packages/core/src/webvh/dids.ts index 2363d3d..c875345 100644 --- a/packages/core/src/webvh/dids.ts +++ b/packages/core/src/webvh/dids.ts @@ -101,8 +101,21 @@ export interface WebvhDidCreateParams extends WebvhCall { serverId?: string; /** Where the log will be served, for the serverless case. */ url?: string; - /** How the path under the host is chosen. */ + /** + * How the path under the host is chosen. Absent means `autoAssign` — the + * hosting server allocates one. + * + * The specification also accepts a bare `path` as shorthand for + * `{ mode: "explicit", path }`, and **sending both is an error**. Only the + * structured member is exposed here, so there is one way to say it and no + * way to say it twice. + */ pathMode?: WebvhPathMode; + /** Hosting domain to publish under, where the server serves more than one. */ + domain?: string; + /** A human-readable label for the agent's own record. Not published — it + * does not reach the DID document or the log. */ + label?: string; /** Allow the DID to move location later. Cannot be added afterwards. */ portable?: boolean; /** @@ -115,6 +128,14 @@ export interface WebvhDidCreateParams extends WebvhCall { * keys at startup — so a DID minted beside another one must say `false`. */ setPrimary?: boolean; + /** + * Publish a `DIDCommMessaging` entry naming the agent's own mediator. + * + * The mediator is the agent's, not the caller's to choose: this says *whether* + * the DID advertises one, and the agent fills in which. A DID with no such + * entry is reachable only by whoever already knows how to reach it. + */ + addMediatorService?: boolean; /** * Publish a `TSPTransport` entry at the mediator the document names for * DIDComm, beside that entry. @@ -124,6 +145,29 @@ export interface WebvhDidCreateParams extends WebvhCall { * use. */ addTspService?: boolean; + /** + * Further service entries, written into the document verbatim. + * + * **The agent does not compose these and does not check them.** Whatever is + * here is published as-is and is then part of a log entry, which is append-only + * — a malformed entry is corrected by a further update rather than removed. A + * caller building these from a form validates before it sends. + */ + additionalServices?: Record[]; + /** + * How many successor keys to commit in advance. + * + * **`0` disables pre-rotation**, and disabling it is not merely a smaller + * commitment: with no successor committed, a thief holding the current key can + * rotate to their own as convincingly as the owner can, so a compromise cannot + * be recovered from. Absent leaves the agent's default, which is the answer a + * caller with no reason to differ should give. + */ + preRotationCount?: number; + /** An existing key to sign log entries with. Absent mints a fresh one. */ + signingKeyId?: string; + /** An existing key-agreement key to publish. Absent mints a fresh one. */ + kaKeyId?: string; /** * Render the document from a stored or built-in DID template. * @@ -180,7 +224,19 @@ export async function webvhDidCreate( // keeping the context's identity, and dropping it lets the agent's `true` // default replace it. ...(rest.setPrimary !== undefined ? { setPrimary: rest.setPrimary } : {}), + ...(rest.domain ? { domain: rest.domain } : {}), + ...(rest.label ? { label: rest.label } : {}), + ...(rest.addMediatorService !== undefined + ? { addMediatorService: rest.addMediatorService } + : {}), ...(rest.addTspService !== undefined ? { addTspService: rest.addTspService } : {}), + ...(rest.additionalServices?.length ? { additionalServices: rest.additionalServices } : {}), + // `!== undefined` rather than truthy: `0` is the caller switching + // pre-rotation off, and a truthy test would drop it and silently leave the + // agent's default on. + ...(rest.preRotationCount !== undefined ? { preRotationCount: rest.preRotationCount } : {}), + ...(rest.signingKeyId ? { signingKeyId: rest.signingKeyId } : {}), + ...(rest.kaKeyId ? { kaKeyId: rest.kaKeyId } : {}), ...(rest.template ? { template: rest.template } : {}), ...(rest.templateContext ? { templateContext: rest.templateContext } : {}), ...(rest.templateVars ? { templateVars: rest.templateVars } : {}), diff --git a/packages/extension/src/manager/did-log.ts b/packages/extension/src/manager/did-log.ts new file mode 100644 index 0000000..2b6977d --- /dev/null +++ b/packages/extension/src/manager/did-log.ts @@ -0,0 +1,126 @@ +// A did:webvh log, as something the console can list. +// +// `vta/webvh/dids/get` returns the log as **one string**: JSON Lines, one entry +// per line, in the order they were appended. That is the right wire shape — it +// is what the hosting server serves and what a verifier replays — and the wrong +// shape for a screen, so this turns it into records. +// +// **Every field here is optional, and the parser never fails on a line it does +// not recognise.** The log is the DID's whole history, written by whichever +// version of whichever agent held it at the time; a screen that refuses to draw +// a log because one entry has a member it did not expect is a screen that goes +// blank exactly for the DIDs whose history is worth reading. So an entry the +// parser cannot make sense of is still an entry: its raw line is kept, and the +// caller shows that. +// +// A plain module rather than part of the pane so a test can reach it — the pane +// is `.tsx`. + +/** One line of the log. */ +export interface LogEntry { + /** Position in the log, from 1. Derived from order, never from the line — + * `versionId` is `-` and an entry that omits it still has a place. */ + index: number; + /** `versionId` as the entry spells it, e.g. `1-Qm…`. */ + versionId?: string; + /** When this entry was appended, as the entry states it. */ + versionTime?: string; + /** The entry's `parameters`, which is where `portable`, `scid`, `method`, + * `updateKeys` and `nextKeyHashes` live. */ + parameters?: Record; + /** The DID document this entry puts into effect. */ + state?: Record; + /** Proofs on the entry. Counted rather than rendered — an unsigned entry is + * worth noticing, and the signatures themselves are not readable. */ + proofCount: number; + /** The line exactly as it arrived, for copying and for the entries this + * parser could not read. */ + raw: string; + /** Why this line was not understood, or `null`. */ + problem: string | null; +} + +const isObject = (v: unknown): v is Record => + typeof v === "object" && v !== null && !Array.isArray(v); + +/** + * Split a DID log into its entries. + * + * Blank lines are dropped — a trailing newline is normal JSONL and not an + * entry. Everything else becomes a record, understood or not. + */ +export function parseDidLog(log: string): LogEntry[] { + const lines = log.split("\n").filter((l) => l.trim() !== ""); + return lines.map((raw, i) => entryFromLine(raw, i + 1)); +} + +function entryFromLine(raw: string, index: number): LogEntry { + const base = { index, proofCount: 0, raw }; + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { ...base, problem: "This line is not JSON." }; + } + if (!isObject(parsed)) { + return { ...base, problem: "This line is JSON, but not an object." }; + } + + const proof = parsed["proof"]; + return { + ...base, + ...(typeof parsed["versionId"] === "string" ? { versionId: parsed["versionId"] } : {}), + ...(typeof parsed["versionTime"] === "string" ? { versionTime: parsed["versionTime"] } : {}), + ...(isObject(parsed["parameters"]) ? { parameters: parsed["parameters"] } : {}), + ...(isObject(parsed["state"]) ? { state: parsed["state"] } : {}), + // A single proof may be an object rather than a one-element array — both + // are legal in the data-integrity vocabulary, and counting only the array + // form would report a signed entry as unsigned. + proofCount: Array.isArray(proof) ? proof.length : isObject(proof) ? 1 : 0, + problem: null, + }; +} + +/** + * The service entries a log entry's document publishes, flattened for a list. + * + * `serviceEndpoint` is deliberately stringified rather than rendered: it is + * `string | object | array` in the DID Core vocabulary, and a screen that + * assumed a string would print `[object Object]` for the two shapes a mediator + * entry actually uses. + */ +export interface ServiceLine { + id: string; + type: string; + endpoint: string; +} + +export function servicesOf(entry: LogEntry): ServiceLine[] { + const raw = entry.state?.["service"]; + if (!Array.isArray(raw)) return []; + return raw.filter(isObject).map((s) => ({ + id: typeof s["id"] === "string" ? s["id"] : "", + type: + typeof s["type"] === "string" + ? s["type"] + : Array.isArray(s["type"]) + ? s["type"].join(", ") + : "", + endpoint: + typeof s["serviceEndpoint"] === "string" + ? s["serviceEndpoint"] + : s["serviceEndpoint"] === undefined + ? "" + : JSON.stringify(s["serviceEndpoint"]), + })); +} + +/** The verification-method ids a log entry's document declares. */ +export function methodsOf(entry: LogEntry): string[] { + const raw = entry.state?.["verificationMethod"]; + if (!Array.isArray(raw)) return []; + return raw + .filter(isObject) + .map((m) => m["id"]) + .filter((id): id is string => typeof id === "string"); +} diff --git a/packages/extension/src/manager/icons.tsx b/packages/extension/src/manager/icons.tsx index 375b828..1ffc7fc 100644 --- a/packages/extension/src/manager/icons.tsx +++ b/packages/extension/src/manager/icons.tsx @@ -42,7 +42,7 @@ import type { CSSProperties } from "react"; * name, which is what keeps `shell.tsx` from carrying a second lookup table. */ export type IconName = // Sections, one per `SectionId`. - | "contexts" | "keys" | "dids" + | "contexts" | "keys" | "dids" | "did-templates" | "credentials" | "persona" | "memory" | "app-state" | "rooms" | "services" | "maintenance" | "audit" | "access" | "approvals" | "policy" | "sessions" @@ -63,6 +63,12 @@ const PATHS: Record = { "M11.2 12h9.4M18 12v3.2M15.1 12v2.4", dids: "M3.9 10.9 10.9 3.9h6.6a2.6 2.6 0 0 1 2.6 2.6v6.6l-7 7a2.1 2.1 0 0 1-3 0l-6.2-6.2a2.1 2.1 0 0 1 0-3z", + // The DIDs tag with a second one behind it: a template is the shape a DID is + // stamped from, so the two sections read as kin in the rail. Both copies stay + // inside the 24-unit box — a path that runs to the edge is clipped on the + // stroke, which shows up as a glyph with one flat side. + "did-templates": + "M6.6 13.6 3.4 10.4a1.9 1.9 0 0 1 0-2.7l6.3-6.3h3M9.6 13.1l5.9-5.9h4.5a1.8 1.8 0 0 1 1.8 1.8v4.5l-5.9 5.9a1.5 1.5 0 0 1-2.1 0l-4.2-4.2a1.5 1.5 0 0 1 0-2.1z", credentials: "M6.9 8.2h7.2M6.9 11.6h4.6M14.9 19.7 14.3 23l2.3-1.4 2.3 1.4-.6-3.3", persona: @@ -117,6 +123,7 @@ const SHAPES: Partial> = { contexts: ``, keys: ``, dids: ``, + "did-templates": ``, credentials: ``, persona: ``, "app-state": ``, diff --git a/packages/extension/src/manager/mint-keys.ts b/packages/extension/src/manager/mint-keys.ts new file mode 100644 index 0000000..e4bcd10 --- /dev/null +++ b/packages/extension/src/manager/mint-keys.ts @@ -0,0 +1,90 @@ +// Which of the agent's existing keys a new DID could be built on. +// +// `signingKeyId` and `kaKeyId` on `vta/webvh/dids/create` say "publish this key +// I already hold" rather than "mint a fresh one". Absent is the right default +// and stays the default; this module exists so that when an operator does reach +// for the control, the list they are shown cannot contain a key that would make +// the DID broken or unsafe. +// +// **Three filters, and each one rules out a different kind of wrong.** +// +// - `x25519` never signs and `ed25519`/`p256` never perform key agreement. +// A DID whose signing method is an x25519 key cannot sign its own log +// entries, which means it can never be updated again — and the log's first +// entry, the one that would have to be signed, is written at mint time. So +// this is not a fault that shows up later. +// - A `revoked` key may not be named in a signing request, and a key is kept +// after revocation precisely so historic signatures stay attributable. +// Publishing one in a *new* DID reverses that: it attributes new material +// to a key someone decided to stop trusting. +// - A key's `contextId` must match the DID's. **Absence is not "every +// context"** — the specification says so directly, and says a consumer that +// reads it as a wildcard inverts the guarantee. A key with no context is +// reachable only by unrestricted authority, so it is not offered for a mint +// inside one. +// +// What this module deliberately does **not** decide is whether reusing a key is +// a good idea. Two DIDs sharing a key are provably controlled by the same +// holder, and that is a correlation decision only the operator can make — so it +// is said on screen beside the control, not filtered out here. +// +// A plain module rather than part of the form so a test can reach it. + +import type { KeyRecord } from "@openvtc/pnm-core/admin"; + +/** Key types that sign. `x25519` is absent because it never does. */ +export const SIGNING_TYPES: readonly string[] = ["ed25519", "p256"]; + +/** The one key type that performs key agreement. */ +export const AGREEMENT_TYPE = "x25519"; + +/** What a mint could be built on, split by the role each key can fill. */ +export interface MintKeys { + /** Keys that could sign this DID's log entries. */ + signing: KeyRecord[]; + /** Keys that could be published as its key-agreement method. */ + agreement: KeyRecord[]; +} + +/** + * Split `keys` into the two roles, keeping only what is usable for a DID in + * `contextId`. + * + * `contextId` is required rather than optional: a mint always names a context, + * and an overload that allowed "no context" would be the wildcard reading the + * key record's own documentation warns against. + */ +export function mintKeys(keys: KeyRecord[], contextId: string): MintKeys { + const usable = keys.filter( + (k) => k.status === "active" && k.contextId === contextId, + ); + return { + signing: usable.filter((k) => SIGNING_TYPES.includes(k.keyType)), + agreement: usable.filter((k) => k.keyType === AGREEMENT_TYPE), + }; +} + +/** + * How a key reads in a picker. + * + * The label first where there is one, because that is what the operator named + * it; the id always, because that is what is sent and what every other pane + * shows. A key type is included for the same reason the two lists are separate — + * it is the fact that decides which role the key can fill. + */ +export function keyLabel(key: KeyRecord): string { + const base = key.label ? `${key.label} (${key.keyId})` : key.keyId; + return `${base} · ${key.keyType}`; +} + +/** + * Whether a chosen key id is still a valid choice. + * + * The context can change under a selection — the operator picks a key, then + * changes the tree — and a stale id would be sent as a key that does not belong + * to the context being minted into. The form clears the selection on this + * answer rather than letting the agent refuse it. + */ +export function stillOffered(keyId: string, offered: KeyRecord[]): boolean { + return keyId === "" || offered.some((k) => k.keyId === keyId); +} diff --git a/packages/extension/src/manager/panes/did-create.tsx b/packages/extension/src/manager/panes/did-create.tsx new file mode 100644 index 0000000..d201131 --- /dev/null +++ b/packages/extension/src/manager/panes/did-create.tsx @@ -0,0 +1,879 @@ +// The New DID form. +// +// Split out of `dids.tsx` because the two answer different questions: that file +// is about the identifiers a context has published, this one is about what a new +// one would be. The form had grown to a server id and a Portable tick, which is +// a fraction of what `vta/webvh/dids/create/1.0` accepts — so an operator who +// wanted a named path, a mediator entry or a template had to mint through the +// CLI and the console was a viewer wearing a form. +// +// **The order of the sections is the order of the decisions, and two of them +// cannot be taken back.** `portable` is committed in the first log entry and can +// never be added afterwards; a `preRotationCount` of 0 means a stolen key cannot +// be rotated away from. Both are drawn as choices with their consequence written +// beside them rather than as ticks, because a tick with a consequence is a tick +// nobody reads. +// +// **A template, if one is chosen, seeds the rest of the form and then gets out +// of the way.** `defaults` on a template is declared as hints for exactly this +// kind of wizard, so they are applied once at selection. They do not keep +// overriding the controls: a checkbox that springs back is a checkbox that lies +// about what will be sent. + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + webvhDidCreate, + webvhServerList, + type WebvhServerRecord, +} from "@openvtc/pnm-core/webvh"; +import { + didTemplateList, + keysList, + servicesList, + type DidTemplateRecord, + type KeyRecord, +} from "@openvtc/pnm-core/admin"; +import { Button, CopyButton, Did, Note, Panel, Pill } from "../../ui.js"; +import { c, t, font } from "../../theme.js"; +import { managerSender } from "../sender.js"; +import { ConsentRequiredError } from "../carrier.js"; +import { ConsentCeremony, runMutation } from "../destructive.js"; +import { hasRole, type Authority, type Parties } from "../use-vta.js"; +import type { ContextSelection } from "../context-column.js"; +import { + additionalServices, + draftsReady, + type ServiceDraft, +} from "../service-entries.js"; +import { + missingVars, + seedVars, + templateDefaults, + templateVars, + varsToSend, + type TemplateVar, +} from "../template-vars.js"; +import { mergeTemplates, scopeKey, scopeSelector, scopeWord } from "../template-scope.js"; +import { keyLabel, mintKeys, stillOffered } from "../mint-keys.js"; +import { + agentMediators, + Choice, + choices, + Field, + fieldStyle, + Label, + PathPicker, + pathMode, + row, + SERVER_CHOOSES, + ServerSelect, + type PathChoice, +} from "./rooms-create-parts.js"; +import { ServiceDrafts } from "./did-service-editor.js"; + +/** A section of the form: what it decides, then the controls. */ +function Section({ + title, + why, + children, +}: { + title: string; + why: React.ReactNode; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+

+ {why} +

+
+ {children} +
+ ); +} + +const divider = ( +
+); + +/** What the agent said when it minted, kept on screen afterwards. */ +interface Minted { + did: string; + scid: string; + portable: boolean; + preRotationKeyCount: number; + signingKeyId: string; + kaKeyId: string; + mnemonic?: string | undefined; + logEntry?: string | undefined; +} + +export function CreateDid({ + parties, + contextId, + authority, + onCreated, +}: { + parties: Parties; + contextId: ContextSelection; + authority: Authority | null; + onCreated: () => void; +}) { + // ── Where it is published ── + const [serverId, setServerId] = useState(""); + const [servers, setServers] = useState(null); + const [serverError, setServerError] = useState(null); + const [path, setPath] = useState(SERVER_CHOOSES); + const [domain, setDomain] = useState(""); + const [label, setLabel] = useState(""); + + // ── What it commits to ── + const [portable, setPortable] = useState(false); + const [preRotation, setPreRotation] = useState(""); + const [setPrimary, setSetPrimary] = useState(false); + + // ── What it advertises ── + const [addMediator, setAddMediator] = useState(false); + const [addTsp, setAddTsp] = useState(false); + const [drafts, setDrafts] = useState([]); + const [mediatorDid, setMediatorDid] = useState(null); + + // ── Which keys it is built on ── + const [keys, setKeys] = useState(null); + const [keysError, setKeysError] = useState(null); + const [signingKeyId, setSigningKeyId] = useState(""); + const [kaKeyId, setKaKeyId] = useState(""); + + // ── What it is stamped from ── + const [templates, setTemplates] = useState(null); + const [templateError, setTemplateError] = useState(null); + const [template, setTemplate] = useState(""); + const [varValues, setVarValues] = useState>({}); + + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [pending, setPending] = useState(null); + const [minted, setMinted] = useState(null); + + // The hosting servers the agent can publish through. Its failure is reported + // rather than swallowed: an empty picker and an unaskable agent look the same, + // and only one of them means "register a server first". + useEffect(() => { + let live = true; + setServers(null); + setServerError(null); + webvhServerList(managerSender, parties).then( + (res) => live && setServers(res.servers ?? []), + (e: unknown) => live && setServerError(e instanceof Error ? e.message : String(e)), + ); + return () => { + live = false; + }; + }, [parties]); + + // The templates in scope. Both namespaces are asked for, because a name can + // exist in each and they are different templates — see `templateContext`. + useEffect(() => { + let live = true; + setTemplates(null); + setTemplateError(null); + const global = didTemplateList(managerSender, parties); + const scoped = contextId + ? didTemplateList(managerSender, { ...parties, contextId }) + : Promise.resolve([] as DidTemplateRecord[]); + Promise.all([global, scoped]).then( + // Merged rather than concatenated: the agent may answer a context-scoped + // listing with a global template of the same name, so the two sets + // overlap — see `mergeTemplates`. + ([g, s]) => live && setTemplates(mergeTemplates(s, g)), + (e: unknown) => live && setTemplateError(e instanceof Error ? e.message : String(e)), + ); + return () => { + live = false; + }; + }, [parties, contextId]); + + // The keys already held in this context, for the reuse control. + // + // Asked per context because a key's `contextId` is part of what makes it + // usable here — see `mint-keys.ts`. `limit` is the page size to ask for, not + // a cap: a context with more keys than this shows the first page and the + // control says so, rather than a truncated list passing as complete. + useEffect(() => { + let live = true; + setKeys(null); + setKeysError(null); + if (!contextId) return; + keysList(managerSender, { ...parties, contextId, status: "active", limit: 200 }).then( + (res) => live && setKeys(res.keys), + (e: unknown) => live && setKeysError(e instanceof Error ? e.message : String(e)), + ); + return () => { + live = false; + }; + }, [parties, contextId]); + + // Which mediator the agent would name, so the mediator tick can show it. + // Read-only: `addMediatorService` says *whether*, and the agent decides which. + useEffect(() => { + let live = true; + servicesList(managerSender, parties).then( + (res) => { + if (!live) return; + const first = agentMediators(res)[0]; + setMediatorDid(first ? first.did : ""); + }, + () => live && setMediatorDid(""), + ); + return () => { + live = false; + }; + }, [parties]); + + const offered = useMemo( + () => (contextId && keys ? mintKeys(keys, contextId) : { signing: [], agreement: [] }), + [keys, contextId], + ); + + // A selection the context no longer offers is dropped rather than sent. + // Changing the tree changes which keys are usable, and a stale id would reach + // the agent as a key belonging to a different context — refused, but only + // after it had derived everything else. + useEffect(() => { + if (!stillOffered(signingKeyId, offered.signing)) setSigningKeyId(""); + if (!stillOffered(kaKeyId, offered.agreement)) setKaKeyId(""); + }, [offered, signingKeyId, kaKeyId]); + + const chosen = useMemo( + () => templates?.find((tpl) => tpl.name === template) ?? null, + [templates, template], + ); + const vars: TemplateVar[] = useMemo(() => (chosen ? templateVars(chosen) : []), [chosen]); + + // Which namespace the chosen template came from. A context template and a + // global one of the same name are different records, and sending the wrong + // `templateContext` renders a different document — see `template-scope.ts`. + const templateContext = chosen ? scopeSelector(chosen) : undefined; + + /** Choosing a template seeds the form from its hints, once. */ + const pickTemplate = useCallback( + (name: string) => { + setTemplate(name); + const tpl = templates?.find((x) => x.name === name); + if (!tpl) { + setVarValues({}); + return; + } + const d = templateDefaults(tpl); + if (d.portable !== undefined) setPortable(d.portable); + if (d.addMediatorService !== undefined) setAddMediator(d.addMediatorService); + if (d.addTspService !== undefined) setAddTsp(d.addTspService); + if (d.preRotationCount !== undefined) setPreRotation(String(d.preRotationCount)); + setVarValues( + seedVars(templateVars(tpl), { + WEBVH_SERVER: serverId, + MEDIATOR_DID: mediatorDid ?? "", + CONTEXT: contextId ?? "", + }), + ); + }, + [templates, serverId, mediatorDid, contextId], + ); + + const missing = missingVars(vars, varValues); + const servicesReady = draftsReady(drafts); + const preRotationValue = preRotation.trim() === "" ? undefined : Number(preRotation); + const preRotationBad = + preRotationValue !== undefined && + (!Number.isInteger(preRotationValue) || preRotationValue < 0); + + const denied = authority && !hasRole(authority, "admin", "super-admin", "operator") + ? "Creating a DID needs an administrative role at this agent." + : !contextId + ? "Select a context in the tree first — a DID is created inside one." + : path.named && path.path.trim() === "" + ? "Name the path, or let the hosting server choose one." + : missing.length > 0 + ? `${chosen?.name} needs ${missing.join(", ")} — the agent refuses the render without ${missing.length === 1 ? "it" : "them"}, after it has already derived the keys.` + : !servicesReady + ? "One of the extra service entries is not ready." + : preRotationBad + ? "Pre-rotation is a whole number of successor keys, or blank for your agent's default." + : null; + + const submit = useCallback(async () => { + if (!contextId) return; + setBusy(true); + setError(null); + setPending(null); + setMinted(null); + const ok = await runMutation( + async () => { + const res = await webvhDidCreate(managerSender, { + ...parties, + contextId, + portable, + // Said rather than defaulted. The agent's default is `true`, which + // replaces whatever the context acted as before — so the quiet path + // through this form must be the one that changes nothing. + setPrimary, + addMediatorService: addMediator, + addTspService: addTsp, + ...(serverId.trim() ? { serverId: serverId.trim() } : {}), + ...pathMode(path), + ...(domain.trim() ? { domain: domain.trim() } : {}), + ...(label.trim() ? { label: label.trim() } : {}), + ...(preRotationValue !== undefined ? { preRotationCount: preRotationValue } : {}), + ...(signingKeyId ? { signingKeyId } : {}), + ...(kaKeyId ? { kaKeyId } : {}), + ...(drafts.length ? { additionalServices: additionalServices(drafts) } : {}), + ...(chosen + ? { + template: chosen.name, + ...(templateContext ? { templateContext } : {}), + templateVars: varsToSend(vars, varValues), + } + : {}), + }); + setMinted({ + did: res.did, + scid: res.scid, + portable: res.portable, + preRotationKeyCount: res.preRotationKeyCount, + signingKeyId: res.signingKeyId, + kaKeyId: res.kaKeyId, + mnemonic: res.mnemonic, + logEntry: res.logEntry, + }); + }, + { onConsent: setPending, onError: setError }, + ); + setBusy(false); + if (ok) { + // The identity decisions reset; where it publishes does not. Minting a + // second DID on the same server is the common case, and clearing the + // picker would make it a retyping exercise. + setPath(SERVER_CHOOSES); + setLabel(""); + setDrafts([]); + // Cleared, unlike the hosting server. Reusing a key is a decision about + // one DID, and leaving it set would silently build the *next* DID on the + // same key — which is the correlation the control warns about, arrived at + // by not touching anything. + setSigningKeyId(""); + setKaKeyId(""); + onCreated(); + } + }, [ + parties, contextId, serverId, path, domain, label, portable, setPrimary, + addMediator, addTsp, drafts, chosen, templateContext, vars, varValues, + preRotationValue, signingKeyId, kaKeyId, onCreated, + ]); + + return ( + + Created in {contextId} and published as + a did:webvh log on a hosting server. + Once published it is resolvable by anyone. + + ) : ( + "Select a context in the tree to create a DID inside it." + ) + } + > +
+
+ A hosting server serves the DID's log. Which server and which path the log sits + at both become part of the identifier, so this is not only a storage choice — unless + the DID is portable, it is the one place it can ever be served from. + + } + > + {serverError && ( + + Your agent would not list its hosting servers — {serverError}. That is a failure to + ask, not an agent with none registered; minting without one publishes nothing and + hands the log back for you to serve. + + )} +
+ + + + + setDomain(e.target.value)} + /> + + + setLabel(e.target.value)} + /> + +
+ +
+ + {divider} + +
+ Both of these are written into the log's first entry, which is append-only. + Neither can be changed later by updating the document. + + } + > +
+ setPortable(false)} + title="Tied to where it is published" + > + The identifier derives from this host, so the DID can never move. This is the usual + choice. + + setPortable(true)} + title="Portable" + > + It can be moved to another hosting domain later and keep its identity. Decided now — + portability cannot be added afterwards. + +
+
+ + setPreRotation(e.target.value)} + /> + + + Successor keys committed in advance.{" "} + {preRotationValue === 0 ? ( + + 0 switches pre-rotation off: with no successor committed, whoever steals the + current key can rotate to their own as convincingly as you can, so a compromise + cannot be recovered from. + + ) : ( + <>Rotating to an uncommitted key is what a thief would also be able to do. + )} + +
+ +
+ + {divider} + +
+ A DID publishes one key that signs — its own log entries, and anything it issues — + and one that others encrypt to. Your agent mints both unless you name keys it + already holds. + + } + > + {keysError && ( + + Your agent would not list this context's keys — {keysError}. Fresh keys can + still be minted; what is unavailable is reusing one you already hold. + + )} + {!keysError && keys === null && contextId && ( + + Reading the keys in {contextId}… + + )} + {/* With no context there is nothing to read, so the pickers are not + drawn at all rather than drawn saying "Reading…" — a control + claiming to be waiting on an agent nobody asked is the same false + claim as an empty list standing in for a failed one, one notch + quieter. Absent rather than hidden, because a hidden control is + still read out by a screen reader and still found by a search. */} + {contextId ? ( +
+ + + + + + +
+ ) : ( + + Keys belong to a context. Choose one in the tree to see what is already held there. + + )} + {/* The filter's own reasoning, said where it has an effect. A person + looking for a key they know exists should find out why it is not + here, rather than concluding the list is broken. */} + {contextId && ( + + Only active keys in{" "} + {contextId} are offered, and only ones + that can fill the role: a key-agreement key cannot sign, so a DID given one could + never update its own log — including the first entry, which is written now. + + )} + {(signingKeyId || kaKeyId) && ( + + Anyone who sees both documents can tell that this DID and every other one publishing + the same key are held by you. That link is the point where reuse is deliberate — a + service replacing its own identifier — and a correlation nobody chose where it is + not. + + )} +
+ + {divider} + +
+ Service entries in the document are how anyone else reaches whoever holds this DID. + Each one is a claim: a transport advertised here is one clients will choose, and + nothing later checks that something behind the DID actually speaks it. + + } + > + + + {addTsp && !addMediator && ( + + TSP is advertised at the mediator the document names for DIDComm, and this + document names none. Tick the mediator entry above, or the TSP entry has no endpoint + to sit beside. + + )} + +
+ + {divider} + +
+ A template is a DID document with placeholders — the method, the service entries and + the key shapes for a kind of thing, authored once. Choosing one replaces the + document this form would otherwise compose, and its hints seed the choices above. + + } + > + {templateError && ( + + Your agent would not list DID templates — {templateError}. Minting without one + composes the document from the choices above, which is what happens anyway when no + template is chosen. + + )} + {templates === null && !templateError && ( + Reading your agent's templates… + )} + {templates !== null && templates.length === 0 && ( + + Your agent holds no DID templates. The document is composed from the choices above. + + )} + {templates !== null && templates.length > 0 && ( +
+ pickTemplate("")} + title="No template" + > + Your agent composes the document from the choices above. + + {templates.map((tpl) => ( + pickTemplate(tpl.name)} + title={ + + {tpl.name} + {scopeWord(tpl)} + + } + > + {tpl.description || `A ${tpl.kind} DID.`} + {(tpl.requiredVars ?? []).length > 0 && ( + + Asks for {(tpl.requiredVars ?? []).join(", ")}. + + )} + + ))} +
+ )} + {vars.length > 0 && ( +
+ +
+ {vars.map((v) => ( + + + setVarValues((prev) => ({ ...prev, [v.name]: e.target.value })) + } + /> + + ))} +
+ {missing.length > 0 && ( + + {missing.join(", ")} {missing.length === 1 ? "is" : "are"} still empty. The agent + checks these after it derives the DID's keys, so a refusal here is + not free. + + )} +
+ )} +
+ + {error && {error}} + {pending && } + {minted && } + +
+ + {denied && {denied}} +
+
+
+ ); +} + +/** + * One of the agent's existing keys, or a fresh one. + * + * The empty option is first and is the default, because minting a fresh key is + * what a DID should normally do — the reuse is the exception and reads as one. + * An empty list says *why* it is empty rather than offering a picker with + * nothing in it: a context with no key of that type and a context whose keys + * could not be read are different facts, and only one of them means "make one + * first". + */ +function KeySelect({ + name, + keys, + ready, + value, + onChange, +}: { + name: string; + keys: KeyRecord[]; + ready: boolean; + value: string; + onChange: (id: string) => void; +}) { + if (ready && keys.length === 0) { + return ( + + No key of this kind here yet — your agent will mint one. + + ); + } + return ( + + ); +} + +/** + * What the agent answered, kept after the form resets. + * + * **`logEntry` is the reason this survives the submit.** For a serverless mint + * it is not a receipt — it is the only copy of the thing that has to be served, + * and the DID does not resolve until someone serves it. A form that cleared + * itself on success would throw it away. + */ +function MintedCard({ minted }: { minted: Minted }) { + return ( + +
+ Minted. +
+ + +
+ + {minted.portable ? "Portable" : "Tied to where it is published"} ·{" "} + {minted.preRotationKeyCount === 0 + ? "pre-rotation off" + : `${minted.preRotationKeyCount} successor key${minted.preRotationKeyCount === 1 ? "" : "s"} committed`} + {minted.mnemonic ? ` · hosted as ${minted.mnemonic}` : ""} + + + signing {minted.signingKeyId} · key agreement {minted.kaKeyId} + + {minted.logEntry && ( +
+ + No hosting server was named, so this DID does not resolve until you serve its log. + This is the first entry — the whole log so far. + +
+ +
+
+ )} +
+
+ ); +} diff --git a/packages/extension/src/manager/panes/did-detail.tsx b/packages/extension/src/manager/panes/did-detail.tsx new file mode 100644 index 0000000..9855ea8 --- /dev/null +++ b/packages/extension/src/manager/panes/did-detail.tsx @@ -0,0 +1,296 @@ +// One DID, opened. +// +// **The log is the whole point of this panel.** A `did:webvh` is not a document, +// it is a *history*: the current document is whatever the last entry put into +// effect, and the only thing a verifier can check it against is the chain of +// entries that led there. The list showed a count of those entries and no way to +// read one, which is the same shape as reporting an unreadable context as an +// empty one — a number with nothing behind it. +// +// `includeLog` is opt-in at the agent for a good reason: the log is the DID's +// whole history and can be large. So it is fetched when a row is opened, never +// with the listing. **Its absence in a response means it was not asked for and +// never that the DID has no history** — which is why a missing log here is drawn +// as a refusal to read rather than as an empty list. +// +// Everything on screen is copyable, because the reason to open this panel at all +// is usually to take something out of it: a version id to cite, an entry to +// replay, the whole log to hand to someone verifying. + +import { useEffect, useState } from "react"; +import { webvhDidGet, type WebvhDidRecord } from "@openvtc/pnm-core/webvh"; +import { CopyButton, Did, Note, Pill } from "../../ui.js"; +import { c, t, font } from "../../theme.js"; +import { managerSender } from "../sender.js"; +import { Loading, LoadError } from "../table.js"; +import { formatDate } from "../format.js"; +import { parseDidLog, servicesOf, methodsOf, type LogEntry } from "../did-log.js"; +import type { Parties } from "../use-vta.js"; + +const mono: React.CSSProperties = { + fontFamily: font.mono, + fontSize: t.xs, + wordBreak: "break-all", +}; + +const pre: React.CSSProperties = { + margin: 0, + padding: 10, + background: c.ground, + border: `1px solid ${c.line}`, + borderRadius: "var(--w-r-sm)", + fontFamily: font.mono, + fontSize: t.xs, + lineHeight: 1.55, + // The log's lines are single JSON documents with no break points. Wrapping + // them is the only way a 4KB entry is readable in a table cell, and the + // container scrolls vertically rather than the page sideways. + whiteSpace: "pre-wrap", + wordBreak: "break-all", + maxHeight: 280, + overflowY: "auto", +}; + +export function DidDetail({ + parties, + record, +}: { + parties: Parties; + record: WebvhDidRecord; +}) { + const [log, setLog] = useState(null); + const [error, setError] = useState(null); + const [open, setOpen] = useState(null); + + useEffect(() => { + let live = true; + setLog(null); + setError(null); + webvhDidGet(managerSender, { ...parties, did: record.did, includeLog: true }).then( + (res) => { + if (!live) return; + // Said apart from an error, because they are different facts. The agent + // answered; what it did not do is include a log, and a panel that drew + // that as "no entries" would claim this DID has no history. + setLog(res.log ?? ""); + if (res.log === undefined) { + setError("Your agent answered without the log, though it was asked for."); + } + }, + (e: unknown) => live && setError(e instanceof Error ? e.message : String(e)), + ); + return () => { + live = false; + }; + }, [parties, record.did]); + + const entries = log ? parseDidLog(log) : []; + + return ( +
+
+
+ + +
+
+ + SCID {record.scid} + + {record.mnemonic && ( + + hosted as {record.mnemonic} + + )} + created {formatDate(record.createdAt)} +
+
+ + {error && } + {log === null && !error && } + + {log !== null && log !== "" && ( +
+
+ + {entries.length} log {entries.length === 1 ? "entry" : "entries"} + + + {/* Said where the count is, because the two disagree only when the + agent's record is stale — and a reader comparing them is the + only way anyone would find out. */} + {entries.length !== record.logEntryCount && ( + + Your agent's record says {record.logEntryCount}. + + )} +
+
+ {entries.map((entry) => ( + setOpen(open === entry.index ? null : entry.index)} + /> + ))} +
+
+ )} + + {log === "" && !error && ( + + Your agent returned an empty log for this DID. A published did:webvh always + has at least the entry that created it, so this is a record that has lost its history + rather than a DID without one. + + )} +
+ ); +} + +/** + * One entry, closed until it is opened. + * + * Closed it draws what identifies the entry and what it changed; opened it adds + * the raw line. The raw line is the thing worth copying — it is what a verifier + * replays — and it is also 2–4KB of base64, so every entry showing one at once + * would bury the history in its own signatures. + */ +function LogEntryRow({ + entry, + open, + onToggle, +}: { + entry: LogEntry; + open: boolean; + onToggle: () => void; +}) { + const services = servicesOf(entry); + const methods = methodsOf(entry); + const params = entry.parameters ?? {}; + + return ( +
+
+ + {entry.versionTime && ( + + {formatDate(entry.versionTime)} + + )} + {/* An unsigned entry is worth noticing: the log's whole guarantee is + that each entry is signed by a key the previous one authorised. */} + {entry.proofCount === 0 ? ( + unsigned + ) : ( + + {entry.proofCount} proof{entry.proofCount === 1 ? "" : "s"} + + )} + {entry.problem && unreadable} + + + +
+ + {open && ( +
+ {entry.problem && ( + + {entry.problem} It is shown below exactly as your agent sent it — an entry this + console cannot read is still part of the DID's history. + + )} + {Object.keys(params).length > 0 && ( + +
+ {Object.entries(params).map(([k, v]) => ( +
+ {k}{" "} + {typeof v === "string" ? v : JSON.stringify(v)} +
+ ))} +
+
+ )} + {methods.length > 0 && ( + +
+ {methods.map((m) => ( +
+ {m} +
+ ))} +
+
+ )} + {services.length > 0 && ( + +
+ {services.map((s) => ( +
+
{s.id}
+
{s.type}
+
{s.endpoint}
+
+ ))} +
+
+ )} + +
{entry.raw}
+
+
+ )} +
+ ); +} + +function Detail({ title, children }: { title: string; children: React.ReactNode }) { + return ( +
+ + {title} + + {children} +
+ ); +} diff --git a/packages/extension/src/manager/panes/did-service-editor.tsx b/packages/extension/src/manager/panes/did-service-editor.tsx new file mode 100644 index 0000000..c96abb5 --- /dev/null +++ b/packages/extension/src/manager/panes/did-service-editor.tsx @@ -0,0 +1,147 @@ +// Extra service entries, as rows rather than as JSON. +// +// The validation lives in `service-entries.ts` and the reasoning is there; this +// file only draws. Two choices worth saying out loud: +// +// **The id is a fragment, not a whole id.** A service id is `#name`, and +// the DID does not exist until the entry naming it has been written — so there +// is no id to type. The operator names the fragment and the agent resolves +// `{DID}`, which is the same ambient placeholder a DID template uses. +// +// **A row is removed, never blanked.** `additionalServices` is published +// verbatim into an append-only log, so an entry half-filled and abandoned would +// be published as an entry. Clearing every field is how a row stops counting, +// and Remove is how it stops being on screen; the two must agree, which is what +// `isBlank` is for. + +import { useCallback, useId } from "react"; +import { Button } from "../../ui.js"; +import { c, t, font } from "../../theme.js"; +import { + draftProblems, + emptyDraft, + isBlank, + type ServiceDraft, +} from "../service-entries.js"; +import { Field, fieldStyle, Label } from "./rooms-create-parts.js"; + +export function ServiceDrafts({ + drafts, + onChange, +}: { + drafts: ServiceDraft[]; + onChange: (next: ServiceDraft[]) => void; +}) { + const seed = useId(); + + const add = useCallback(() => { + onChange([...drafts, emptyDraft(`${seed}-${drafts.length}-${Date.now()}`)]); + }, [drafts, onChange, seed]); + + const patch = useCallback( + (key: string, field: keyof ServiceDraft, value: string) => { + onChange(drafts.map((d) => (d.key === key ? { ...d, [field]: value } : d))); + }, + [drafts, onChange], + ); + + const remove = useCallback( + (key: string) => onChange(drafts.filter((d) => d.key !== key)), + [drafts, onChange], + ); + + const filled = drafts.filter((d) => !isBlank(d)); + + return ( +
+ + {drafts.length === 0 ? ( + + None. Add one to publish an endpoint of your own — a domain you control, an API others + should reach you at. Your agent writes these through unchanged and does not check them. + + ) : ( +
+ {drafts.map((draft) => { + const problems = isBlank(draft) ? {} : draftProblems(draft, filled); + return ( +
+
+ + patch(draft.key, "fragment", e.target.value)} + /> + + + patch(draft.key, "type", e.target.value)} + /> + + + patch(draft.key, "endpoint", e.target.value)} + /> + +
+ +
+
+ {(problems.fragment || problems.type || problems.endpoint) && ( +
    + {[problems.fragment, problems.type, problems.endpoint] + .filter((p): p is string => Boolean(p)) + .map((p) => ( +
  • + {p} +
  • + ))} +
+ )} + {!isBlank(draft) && draft.fragment.trim() && !problems.fragment && ( + + <the new DID># + {draft.fragment.trim()} + + )} +
+ ); + })} +
+ )} +
+ +
+
+ ); +} diff --git a/packages/extension/src/manager/panes/did-templates.tsx b/packages/extension/src/manager/panes/did-templates.tsx new file mode 100644 index 0000000..440b923 --- /dev/null +++ b/packages/extension/src/manager/panes/did-templates.tsx @@ -0,0 +1,612 @@ +// DID templates — the shapes this agent stamps DIDs from. +// +// **A template is a document that will be published under someone's identity, +// authored once and then used without being read again.** That is what makes it +// worth a pane and what makes the pane cautious: the operator who writes +// `room-host` is rarely the one who mints from it a month later, and a wrong +// service entry in a template is a wrong service entry in every DID stamped +// after it — each one a log entry, append-only, corrected only by superseding it. +// +// So three things are structural rather than nice to have: +// +// - **Render before save is offered on every template**, not only on new ones. +// `didTemplateRender` performs the agent's own substitution and creates +// nothing, and it is the only way to see what a template means. A local +// preview would be this console's guess at the agent's renderer. +// - **Update is a whole-template write, not a patch.** Anything the editor +// omits is omitted from the stored record, so the editor loads the record +// and sends it back entire. The same replace semantics the persona editor +// documents, with the same failure: a save that silently drops what it never +// read. +// - **Built-ins are not editable and say so.** `room` and `room-host` ship with +// the agent and the rooms flow stamps from them. The agent refuses the write; +// the button refuses first, which is the difference between a disabled +// control and a refusal after the form was filled in. +// +// Scope handling is in `template-scope.ts` and the reasoning is there — the +// short version is that a name in three namespaces is three templates. + +import { useCallback, useMemo, useState } from "react"; +import { + didTemplateCreate, + didTemplateDelete, + didTemplateList, + didTemplateRender, + didTemplateUpdate, + type DidTemplate, + type DidTemplateRecord, +} from "@openvtc/pnm-core/admin"; +import { Button, CopyButton, Note, Panel, Pill } from "../../ui.js"; +import { c, t, font } from "../../theme.js"; +import { managerSender } from "../sender.js"; +import { ConsentRequiredError } from "../carrier.js"; +import { ConsentCeremony, Destructive, runMutation } from "../destructive.js"; +import { Loading, LoadError, Table, type Column } from "../table.js"; +import { useAsync } from "../use-async.js"; +import { hasRole, type Authority, type Parties } from "../use-vta.js"; +import type { ContextSelection } from "../context-column.js"; +import { + isEditable, + mergeTemplates, + scopeKey, + scopeSelector, + scopeWord, +} from "../template-scope.js"; +import { AMBIENT_VARS } from "../template-vars.js"; +import { Field, fieldStyle, Label, row } from "./rooms-create-parts.js"; + +const areaStyle: React.CSSProperties = { + ...fieldStyle, + width: "100%", + fontFamily: font.mono, + lineHeight: 1.55, + minHeight: 220, + resize: "vertical", +}; + +const pre: React.CSSProperties = { + margin: 0, + padding: 11, + background: c.ground, + border: `1px solid ${c.line}`, + borderRadius: "var(--w-r-sm)", + fontFamily: font.mono, + fontSize: t.xs, + lineHeight: 1.55, + whiteSpace: "pre-wrap", + wordBreak: "break-all", + maxHeight: 340, + overflowY: "auto", +}; + +/** The editor's own state. Text, not a parsed template: the document is edited + * as JSON and a half-typed object is not one. */ +interface Draft { + /** The record being replaced, or `null` for a new template. Carried rather + * than re-looked-up by name, because `name` is editable on a new template + * and looking one up by the edited value would find the wrong record. */ + original: DidTemplateRecord | null; + name: string; + kind: string; + description: string; + methods: string; + requiredVars: string; + document: string; + /** Which namespace to write into. For an existing record this is the one it + * is already in and is not editable — moving a template between namespaces + * is a create and a delete, not an update. */ + contextId: string | undefined; +} + +function draftFrom(record: DidTemplateRecord): Draft { + return { + original: record, + name: record.name, + kind: record.kind, + description: record.description ?? "", + methods: (record.methods ?? []).join(", "), + requiredVars: (record.requiredVars ?? []).join(", "), + document: JSON.stringify(record.document, null, 2), + contextId: scopeSelector(record), + }; +} + +function blankDraft(contextId: string | undefined): Draft { + return { + original: null, + name: "", + kind: "", + description: "", + methods: "webvh", + requiredVars: "", + // Seeded rather than empty, because `document.id` MUST carry `{DID}` and a + // blank box gives no hint that the id is a placeholder the agent fills in. + document: JSON.stringify( + { + id: "{DID}", + verificationMethod: [ + { + id: "{DID}#key-1", + type: "Multikey", + controller: "{DID}", + publicKeyMultibase: "{SIGNING_KEY_MB}", + }, + ], + authentication: ["{DID}#key-1"], + service: [], + }, + null, + 2, + ), + contextId, + }; +} + +const list = (text: string): string[] => + text.split(",").map((s) => s.trim()).filter((s) => s !== ""); + +/** Why this draft is not ready, or `null`. Checked in the order the operator + * would meet the fields, so the message names the first thing to fix. */ +function draftProblem(draft: Draft): string | null { + if (draft.name.trim() === "") return "Give the template a name."; + if (!/^[a-z0-9-]+$/.test(draft.name.trim())) { + return "A template name is lowercase letters, digits and hyphens."; + } + if (draft.kind.trim() === "") { + return "Say what kind of thing this provisions — mediator, app, did-host-http."; + } + const ambient = list(draft.requiredVars).filter((v) => AMBIENT_VARS.includes(v)); + if (ambient.length > 0) { + // The specification says requiredVars MUST NOT name these. Refusing here + // rather than at the agent is worth it: the agent injects them anyway, so a + // template declaring one asks an operator for a value that is then thrown + // away, and nothing on screen would ever say so. + return `${ambient.join(", ")} ${ambient.length === 1 ? "is" : "are"} filled in by your agent and cannot be asked for.`; + } + let document: unknown; + try { + document = JSON.parse(draft.document); + } catch (e) { + return `The document is not valid JSON — ${e instanceof Error ? e.message : String(e)}`; + } + if (typeof document !== "object" || document === null || Array.isArray(document)) { + return "The document has to be a JSON object."; + } + const id = (document as Record)["id"]; + if (typeof id !== "string" || !id.includes("{DID}")) { + return "The document's `id` must contain `{DID}` — that is where your agent writes the DID it mints."; + } + return null; +} + +function templateFrom(draft: Draft): DidTemplate { + const methods = list(draft.methods); + const requiredVars = list(draft.requiredVars); + return { + schemaVersion: 1, + name: draft.name.trim(), + kind: draft.kind.trim(), + ...(draft.description.trim() ? { description: draft.description.trim() } : {}), + ...(methods.length ? { methods } : {}), + ...(requiredVars.length ? { requiredVars } : {}), + // Carried through untouched. The editor does not draw `optionalVars` or + // `defaults`, and an update is a REPLACE — so a template that had them would + // lose them on every save from this pane, silently, with the agent + // reporting success. + ...(draft.original?.optionalVars ? { optionalVars: draft.original.optionalVars } : {}), + ...(draft.original?.defaults ? { defaults: draft.original.defaults } : {}), + document: JSON.parse(draft.document) as Record, + }; +} + +export function DidTemplatesPane({ + parties, + authority, + contextId, + contextHeading, +}: { + parties: Parties; + authority: Authority | null; + contextId: ContextSelection; + contextHeading?: string | undefined; +}) { + // Both namespaces, always. A context template and the global one of the same + // name are different records, and showing only one of them is how an operator + // edits the wrong one. + const templates = useAsync(async () => { + const global = await didTemplateList(managerSender, parties); + const scoped = contextId + ? await didTemplateList(managerSender, { ...parties, contextId }) + : []; + return mergeTemplates(scoped, global); + }, [parties.holder.did, parties.service.did, contextId]); + + const [draft, setDraft] = useState(null); + + const denied = authority && !hasRole(authority, "admin", "super-admin") + ? "Writing DID templates needs the admin role at this agent." + : null; + + const columns: Column[] = [ + { + key: "name", + header: "Template", + width: "22ch", + render: (tpl) => ( +
+ {tpl.name} + {scopeWord(tpl)} +
+ ), + }, + { + key: "kind", + header: "Kind", + render: (tpl) => {tpl.kind}, + }, + { + key: "description", + header: "What it provisions", + render: (tpl) => ( + + {tpl.description || } + + ), + }, + { + key: "vars", + header: "Asks for", + render: (tpl) => + (tpl.requiredVars ?? []).length === 0 ? ( + nothing + ) : ( + + {(tpl.requiredVars ?? []).join(", ")} + + ), + }, + { + key: "actions", + header: "", + render: (tpl) => ( +
+ + {isEditable(tpl) && ( + + label="Delete" + disabledReason={denied} + preview={async () => tpl} + forceLabel="Delete anyway" + renderPreview={(p) => ( + <> + Deleting this template cannot be undone. + + Nothing already minted from {p.name}{" "} + changes — a DID is a published log and does not refer back to the template it was + stamped from. What stops working is minting another one: anything that names this + template, a setup flow or a script, is refused from now on. + + + )} + commit={async () => { + await didTemplateDelete(managerSender, { + ...parties, + name: tpl.name, + ...(scopeSelector(tpl) ? { contextId: scopeSelector(tpl)! } : {}), + }); + }} + onDone={() => { + if (draft?.original?.name === tpl.name) setDraft(null); + templates.reload(); + }} + /> + )} +
+ ), + }, + ]; + + return ( +
+ + The document shapes your agent stamps DIDs from — a method, service entries and key + shapes for a kind of thing, authored once. Global templates and this context's are + both shown: the same name in two scopes is two different templates, and the badge says + which you are looking at. + + } + > + {templates.error && } + {templates.loading && !templates.data && } + {templates.data && ( + `${scopeKey(tpl)}:${tpl.name}`} + empty="Your agent holds no DID templates. New DIDs are composed from the choices on the DIDs pane instead." + /> + )} +
+ + {denied && ( + {denied} + )} +
+ + + {draft && ( + setDraft(null)} + onSaved={() => { + setDraft(null); + templates.reload(); + }} + /> + )} + + ); +} + +function TemplateEditor({ + parties, + draft, + onChange, + denied, + onClose, + onSaved, +}: { + parties: Parties; + draft: Draft; + onChange: (next: Draft) => void; + denied: string | null; + onClose: () => void; + onSaved: () => void; +}) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [pending, setPending] = useState(null); + const [rendered, setRendered] = useState(null); + const [renderError, setRenderError] = useState(null); + const [renderVars, setRenderVars] = useState>({}); + + const existing = draft.original; + const editable = existing === null || isEditable(existing); + const problem = useMemo(() => draftProblem(draft), [draft]); + const patch = (fields: Partial) => onChange({ ...draft, ...fields }); + + const save = useCallback(async () => { + setBusy(true); + setError(null); + setPending(null); + const scoped = draft.contextId ? { contextId: draft.contextId } : {}; + const template = templateFrom(draft); + const ok = await runMutation( + async () => { + if (existing) { + await didTemplateUpdate(managerSender, { + ...parties, + ...scoped, + name: existing.name, + template, + }); + } else { + await didTemplateCreate(managerSender, { ...parties, ...scoped, template }); + } + }, + { onConsent: setPending, onError: setError }, + ); + setBusy(false); + if (ok) onSaved(); + }, [parties, draft, existing, onSaved]); + + /** + * Ask the agent what this template renders to. + * + * **Against the stored template, not the draft on screen.** `render` takes a + * name, so it can only render what the agent holds — which means on an edited + * draft it previews the version currently saved. Said on the button rather + * than hidden, because a preview that silently showed the old document while + * the operator read it as the new one is worse than no preview. + */ + const preview = useCallback(async () => { + if (!existing) return; + setRenderError(null); + setRendered(null); + try { + const doc = await didTemplateRender(managerSender, { + ...parties, + name: existing.name, + ...(draft.contextId ? { contextId: draft.contextId } : {}), + vars: renderVars, + }); + setRendered(JSON.stringify(doc, null, 2)); + } catch (e) { + setRenderError(e instanceof Error ? e.message : String(e)); + } + }, [parties, existing, draft.contextId, renderVars]); + + const askFor = (existing?.requiredVars ?? []).filter((v) => !AMBIENT_VARS.includes(v)); + + return ( + + Saving replaces the whole template — this is not a patch, and anything left out of the + form is left out of the stored record.{" "} + {existing ? "Nothing already minted from it changes." : null} + + ) : ( + <> + A built-in. It ships with your agent and the rooms flow stamps from it, so it cannot be + edited or deleted here — shown so you can read what it publishes. + + ) + } + > +
+
+ + patch({ name: e.target.value })} + /> + + + patch({ kind: e.target.value })} + /> + + + patch({ methods: e.target.value })} + /> + + + patch({ description: e.target.value })} + /> + +
+ + + patch({ requiredVars: e.target.value })} + /> + + + Your agent fills in {AMBIENT_VARS.join(", ")}{" "} + itself, so these are never asked for. The check runs after it has derived the DID's + keys, which is why a variable declared and not supplied is not a free refusal. + + +
+ +