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/scripts/sync-task-surface.mjs b/packages/core/scripts/sync-task-surface.mjs index 68b014a..e43f3fe 100644 --- a/packages/core/scripts/sync-task-surface.mjs +++ b/packages/core/scripts/sync-task-surface.mjs @@ -45,7 +45,20 @@ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync } from " import { join, resolve } from "node:path"; const PKG_ROOT = resolve(import.meta.dirname, ".."); -const OUT = join(PKG_ROOT, "task-surface.json"); + +// Where the snapshot is written. The checked-in file by default; a second +// argument overrides it. +// +// **The override is what lets `tests/task-surface-sync.mjs` run this script +// without touching the real snapshot**, and that is a correctness constraint +// rather than a convenience. `node --test` runs test FILES in parallel +// processes, and `tests/task-surface.mjs` reads the checked-in snapshot at +// module scope — so a sync test that wrote the real file and restored it +// afterwards raced the test next door, which read either the fixture's handful +// of tasks or a half-written file. It failed as "task-surface.json looks +// truncated" in CI and passed on every developer machine, because whether the +// two files overlap depends on core count. +const OUT = process.argv[3] ? resolve(process.argv[3]) : join(PKG_ROOT, "task-surface.json"); const DEFAULT_SDK = resolve( PKG_ROOT, 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/core/tests/task-surface-sync.mjs b/packages/core/tests/task-surface-sync.mjs index c76ec29..7f0a054 100644 --- a/packages/core/tests/task-surface-sync.mjs +++ b/packages/core/tests/task-surface-sync.mjs @@ -18,17 +18,29 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; const SCRIPT = resolve(import.meta.dirname, "../scripts/sync-task-surface.mjs"); -const SNAPSHOT = resolve(import.meta.dirname, "../task-surface.json"); -/** Build a throwaway `vta-sdk` whose `src/` holds `files`, and sync from it. */ +/** + * Build a throwaway `vta-sdk` whose `src/` holds `files`, and sync from it. + * + * **Into the throwaway directory, never over the checked-in snapshot.** This + * used to write the real `task-surface.json` and restore it in a `finally`, + * which is a race rather than a cleanup: `node --test` runs test files in + * parallel processes, and `tests/task-surface.mjs` reads that same file at + * module scope. It saw either this fixture's handful of tasks — surfacing as + * "task-surface.json looks truncated" and "implements 0 of 3 canonical task + * families" — or a half-written file, as `SyntaxError: Unexpected end of JSON + * input`. Both reproduced only where the two files happened to overlap, which + * on a developer machine with cores to spare is rarely, and on a two-core CI + * runner is often. + */ function syncFrom(files) { const root = mkdtempSync(join(tmpdir(), "fake-sdk-")); - const saved = readFileSync(SNAPSHOT, "utf8"); + const out = join(root, "task-surface.json"); mkdirSync(join(root, "src"), { recursive: true }); writeFileSync(join(root, "Cargo.toml"), 'version = "9.9.9"\n'); for (const [name, body] of Object.entries(files)) { @@ -38,15 +50,17 @@ function syncFrom(files) { let status = 0; let stderr = ""; try { - execFileSync(process.execPath, [SCRIPT, root], { stdio: ["ignore", "pipe", "pipe"] }); + execFileSync(process.execPath, [SCRIPT, root, out], { stdio: ["ignore", "pipe", "pipe"] }); } catch (e) { status = e.status ?? 1; stderr = String(e.stderr ?? ""); } - return { status, stderr, snapshot: JSON.parse(readFileSync(SNAPSHOT, "utf8")) }; + // A run that stopped deliberately writes nothing, which is the property + // two of these tests are about — so an absent file is reported as `null` + // rather than throwing here and losing the status that explains it. + const snapshot = existsSync(out) ? JSON.parse(readFileSync(out, "utf8")) : null; + return { status, stderr, snapshot }; } finally { - // The script writes the real snapshot, so put it back however this ends. - writeFileSync(SNAPSHOT, saved); rmSync(root, { recursive: true, force: true }); } } @@ -144,8 +158,14 @@ pub const ORPHAN_TYPE: &str = // The *declaration* line, not the value line below it: that is where the // constant is and where someone would edit it. assert.match(stderr, /p\.rs:2/, "the message does not say where to look"); - // And the snapshot on disk is untouched — a failed sync must not half-write. - assert.ok(snapshot.tasks.length > 0, "the real snapshot was overwritten by a failed run"); + // And nothing was written — a failed sync must not half-write. + // + // This used to read "the real snapshot is untouched", because the script had + // no way to write anywhere else and the fixture ran against the checked-in + // file. Writing to the run's own path states the property directly: a stop + // leaves no snapshot at all, rather than leaving one whose survival is only + // evidence that this particular run did not get as far as the write. + assert.equal(snapshot, null, "a stopped sync still produced a snapshot"); }); // `include_str!` constants are `&str` and are not tasks. The stop above must 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. + + +
+ +