From 9a3c7265f20c51ed20b052ead8a97bb4c9f03927 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Tue, 1 Sep 2026 10:19:48 +0200 Subject: [PATCH 01/24] feat(console): VTA management console in the wallet, on the Trust Context tree (#155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(console): a VTA management console in the wallet, on the context tree Everything an operator does *to* their agent — contexts, keys, ACL, approvals, policy, services — has only ever been reachable from the `pnm` CLI, which means from a laptop with a keyring. The client library for it already existed: `@openvtc/pnm-core/admin` covers all of the CLI's command tree bar backup, bootstrap and auth-credential, and its doc comments were written for a console it did not yet have. This is the spine of that console, plus Contexts end to end. Trust contexts are the navigation, not a section. `ContextRecord` already carries `parent`/`basePath` and `contextId` is a filter parameter on `keysList`, `aclList` and `auditList` — so the tree is a persistent column and the selection scopes every pane to its right. Two properties are load-bearing: The console holds no key material. It composes typed documents with the `admin/*` helpers and the offscreen document mints and signs them, so `RUNTIME_MANAGER_TASK` carries only `type` and `payload` — the rule that the device mints the envelope does not soften because the composer is an extension page. Reusing `OFFSCREEN_REQUEST_TASK` inherits transport selection, health recording and the approver ceremony; `offscreen.ts` is unchanged. This is also why `admin/*` now types its envelope parties as `TaskParty` (just a DID) rather than `Identity`: only `.did` was ever read, and a surface typed on `Identity` can only be called from somewhere holding a private key. The wallet still ships no operator authority. The CI guard is narrowed rather than dropped — admin task URIs are banned everywhere in `dist/` except `manager.js`, which is its own vite build with `codeSplitting: false` so Rollup cannot hoist a shared chunk into a wallet surface. A second assertion fails if it ever emits more than one chunk. The relay is gated on `sender.url`, since every content script carries this extension's id. It does not prompt per call — the operator is the caller, and twelve dialogs to draw one screen is dismissal, not consent; the agent's ACL and policy engine remain the authority, and a `requireConsent` renders as a match-code ceremony rather than a red error string. Signed-off-by: Glenn Gore * feat(console): keys, DIDs, access, approvals, policy, transports and audit Fills out the console's remaining eight panes on the spine landed in the previous commit. Every one is scoped by the persistent context selection where the underlying task takes a `contextId`, so choosing `work/eng` once scopes Keys, DIDs, Access, Policy and Audit together. Shared vocabulary rather than nine copies of it: `table.tsx` separates the three states a list actually has — being fetched, refused, and answered with nothing — because written per-pane they collapse and an operator reads "you have no keys" off a permission error. `use-async.ts` keeps `data` null until the agent answers for the same reason. Where the agent draws a distinction, the pane draws it too: - Keys shows `internal` origin as unrecoverable, because such a key derives from no seed and nothing reconstitutes it. `keysSign` is deliberately not surfaced — signing is use, not administration, and a console that offers a "sign this" box turns key management into an oracle. - Access renders an entry with no scopes as "everywhere" and no expiry as "never", both in the caution colour. `aclChangeRole` carries `fromRole` from the row it was opened on, so a role someone else changed in between rejects instead of silently overwriting them. - Policy carries `expectedVersion` on upsert *and* delete — without it two operators editing one module is last-write-wins and the loser never finds out. - Transports pairs advertised state against observed, and presents `unknown` as "not observed" rather than a failure; a constructed REST channel is not evidence of anything. - Audit renders `truncated` above the table, not below: a warning under a long list is a warning nobody reads, and concluding "nothing else happened" from a partial page is the failure an audit trail exists to prevent. - Sessions is headed "your sessions" — the task returns only the caller's, and the agent's all-sessions route is deliberately not wired here. `webvh/dids.ts` takes the same `TaskParty` widening as `admin/*`: it too read only `.did` off its envelope parties. Verified by rendering the built bundle against stubbed extension APIs in Chrome, light and dark. Admin surface remains confined to manager.js. Signed-off-by: Glenn Gore * refactor(console): one import path for ConsentRequiredError carrier.ts defines it; sender.ts re-exported it, so half the panes reached for one path and half the other. Two routes to one symbol invite the question of whether they could ever be different classes — which matters here, because every pane distinguishes this class from Error by identity to decide between rendering a consent ceremony and rendering a red string. Signed-off-by: Glenn Gore --------- Signed-off-by: Glenn Gore --- .github/workflows/ci.yml | 43 +- CLAUDE.md | 61 +++ packages/core/src/admin/acl.ts | 8 +- packages/core/src/admin/consent.ts | 8 +- packages/core/src/admin/contexts.ts | 8 +- packages/core/src/admin/credentials.ts | 8 +- packages/core/src/admin/devices.ts | 8 +- packages/core/src/admin/did-templates.ts | 8 +- packages/core/src/admin/keys.ts | 8 +- packages/core/src/admin/memory.ts | 8 +- packages/core/src/admin/observability.ts | 8 +- packages/core/src/admin/policy.ts | 8 +- packages/core/src/admin/services.ts | 8 +- packages/core/src/admin/sessions.ts | 8 +- packages/core/src/vta/channel.ts | 20 + packages/core/src/vta/contexts.ts | 46 ++- packages/core/src/webvh/dids.ts | 11 +- packages/extension/manager.html | 12 + packages/extension/package.json | 2 +- packages/extension/src/app-shell.tsx | 27 ++ packages/extension/src/background.ts | 85 ++++ packages/extension/src/bridge-protocol.ts | 31 ++ packages/extension/src/manager-theme.css | 68 ++++ packages/extension/src/manager.tsx | 32 ++ packages/extension/src/manager/carrier.ts | 122 ++++++ .../extension/src/manager/context-column.tsx | 197 +++++++++ .../extension/src/manager/context-tree.ts | 130 ++++++ .../extension/src/manager/destructive.tsx | 265 ++++++++++++ .../extension/src/manager/panes/access.tsx | 366 +++++++++++++++++ .../extension/src/manager/panes/approvals.tsx | 327 +++++++++++++++ .../extension/src/manager/panes/audit.tsx | 218 ++++++++++ .../extension/src/manager/panes/contexts.tsx | 385 ++++++++++++++++++ packages/extension/src/manager/panes/dids.tsx | 258 ++++++++++++ packages/extension/src/manager/panes/keys.tsx | 380 +++++++++++++++++ .../extension/src/manager/panes/policy.tsx | 334 +++++++++++++++ .../extension/src/manager/panes/services.tsx | 285 +++++++++++++ .../extension/src/manager/panes/sessions.tsx | 131 ++++++ packages/extension/src/manager/sender.ts | 43 ++ packages/extension/src/manager/shell.tsx | 343 ++++++++++++++++ packages/extension/src/manager/table.tsx | 169 ++++++++ packages/extension/src/manager/use-async.ts | 59 +++ packages/extension/src/manager/use-vta.ts | 108 +++++ .../extension/src/manager/whoami-banner.tsx | 98 +++++ .../tests/manager-context-tree.test.mts | 115 ++++++ .../extension/tests/manager-sender.test.mts | 149 +++++++ .../extension/tests/manager-surface.test.mts | 109 +++++ packages/extension/vite.config.manager.ts | 51 +++ 47 files changed, 5085 insertions(+), 91 deletions(-) create mode 100644 packages/extension/manager.html create mode 100644 packages/extension/src/manager-theme.css create mode 100644 packages/extension/src/manager.tsx create mode 100644 packages/extension/src/manager/carrier.ts create mode 100644 packages/extension/src/manager/context-column.tsx create mode 100644 packages/extension/src/manager/context-tree.ts create mode 100644 packages/extension/src/manager/destructive.tsx create mode 100644 packages/extension/src/manager/panes/access.tsx create mode 100644 packages/extension/src/manager/panes/approvals.tsx create mode 100644 packages/extension/src/manager/panes/audit.tsx create mode 100644 packages/extension/src/manager/panes/contexts.tsx create mode 100644 packages/extension/src/manager/panes/dids.tsx create mode 100644 packages/extension/src/manager/panes/keys.tsx create mode 100644 packages/extension/src/manager/panes/policy.tsx create mode 100644 packages/extension/src/manager/panes/services.tsx create mode 100644 packages/extension/src/manager/panes/sessions.tsx create mode 100644 packages/extension/src/manager/sender.ts create mode 100644 packages/extension/src/manager/shell.tsx create mode 100644 packages/extension/src/manager/table.tsx create mode 100644 packages/extension/src/manager/use-async.ts create mode 100644 packages/extension/src/manager/use-vta.ts create mode 100644 packages/extension/src/manager/whoami-banner.tsx create mode 100644 packages/extension/tests/manager-context-tree.test.mts create mode 100644 packages/extension/tests/manager-sender.test.mts create mode 100644 packages/extension/tests/manager-surface.test.mts create mode 100644 packages/extension/vite.config.manager.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aaee75c..fede3b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,19 +66,46 @@ jobs: echo "OK: $bundle is a single bundle with no dynamic import()" # `@openvtc/pnm-core/admin` is operator surface — granting authority at an - # agent, revoking it, destroying contexts. A wallet has no business - # shipping any of it, and the way it would arrive is someone importing it - # from the package root instead of the subpath. The task URIs are the - # tell: they only appear in a bundle that pulled the module in. - - name: Assert the wallet ships no agent-administration surface + # agent, revoking it, destroying contexts. The task URIs are the tell: + # they only appear in a bundle that pulled the module in. + # + # The management console (`manager.html`) administers the agent, so it + # imports the module deliberately. Every *wallet* surface — the service + # worker, the content and page-world scripts, the popup, the confirm + # window, the offscreen document, the options page — still must not, and + # the way it would arrive is someone importing from the package root + # instead of the subpath, or Rollup hoisting a shared chunk. + # + # Hence: banned everywhere in dist/ **except** `manager.js`. Phrased as an + # exclusion rather than a list of permitted files so it keeps holding as + # entries are added. `vite.config.manager.ts` builds the console alone + # with `codeSplitting: false`, which is what makes "exactly one file may + # 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 - if grep -rlF "$task" packages/extension/dist/; then - echo "::error::the extension bundle contains $task — @openvtc/pnm-core/admin must not be reachable from the wallet (check for a root-barrel import)" + 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)" exit 1 fi done - echo "OK: no admin task URIs in the extension bundle" + echo "OK: admin task URIs appear only in manager.js" + + # The console's isolation rests on it being one self-contained file: the + # guard above names exactly one exception, so a second chunk would be a + # file nothing checks. Losing `codeSplitting: false` in a future upgrade + # is silent otherwise. + - name: Assert the console is a single self-contained bundle + run: | + bundle=packages/extension/dist/manager.js + test -f "$bundle" || { echo "::error::$bundle was not emitted — did the manager build run?"; exit 1; } + extra=$(ls packages/extension/dist/manager-split-*.js 2>/dev/null || true) + if [ -n "$extra" ]; then + echo "::error::the console emitted extra chunks ($extra); codeSplitting: false was lost and the admin guard now has unchecked files" + exit 1 + fi + echo "OK: manager.js is a single bundle" # Build the Chrome Web Store upload artefact from the dist/ that the # Build step just produced (scripts/package.mjs re-stages it; it does diff --git a/CLAUDE.md b/CLAUDE.md index 2f1bb1f..212c515 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -128,6 +128,67 @@ routing either through a channel would overwrite or duplicate a proof. document as the counterparty receives it — a signature copied from another document satisfies an "is there a `proof` member" check and fails this one. +## The wallet ships no operator authority — the console does + +`@openvtc/pnm-core/admin` is operator surface: granting authority at an agent, +revoking it, destroying contexts. It is deliberately absent from the package +root barrel, and CI greps the built output for 17 of its task URIs. + +That guard used to read "banned anywhere in `dist/`", on the grounds that a +wallet has no business shipping any of it. The **management console** +(`manager.html`) makes that statement false on purpose — administering the agent +is its whole job — so the guard was **narrowed, not deleted**: banned everywhere +in `dist/` *except* `manager.js`. Every wallet surface (service worker, content +and page-world scripts, popup, confirm, offscreen, options) keeps the property +the guard was protecting. + +**The console is its own vite build** (`vite.config.manager.ts`, +`codeSplitting: false`). That is what makes "exactly one file may contain admin" +structural rather than a convention: the main build emits popup, options, +confirm and offscreen *together*, and Rollup is free to hoist shared code into a +common `assets/*.js` chunk that wallet surfaces load. Building the console alone +means there is no other entry to share with. A second CI assertion fails if it +ever emits more than one chunk, because the first guard names exactly one +exception and an extra chunk is a file nothing checks. + +**The console holds no key material.** It composes typed documents with the +`admin/*` helpers and the offscreen document signs them, so an XSS there cannot +exfiltrate a key. This is why `admin/*` and `vta/contexts.ts` type their +envelope parties as `TaskParty` (`vta/channel.ts`) — just a DID — rather than +`Identity` and `RemoteDidcommEndpoint`: only `.did` was ever read, and a +surface typed on `Identity` can only be called from somewhere holding a private +key. The REST convenience wrappers (`vtaListContexts`, `vtaCreateContext`) still +take the stricter pair, because they *build a channel*, and a channel signs. + +**Only `type` and `payload` cross the bridge.** `RUNTIME_MANAGER_TASK` carries +those two members and nothing else; `carrier.ts` strips the envelope the admin +helper built, and `offscreen.ts`'s existing `OFFSCREEN_REQUEST_TASK` mints the +real one and signs it. `core/src/vta/request-task.ts` explains why the device +must mint it, and that reasoning does not soften because the composer is an +extension page: a wallet that counter-signs a document composed elsewhere +attests to fields it never checked. Reusing that path also inherits transport +selection, `TransportHealth`, and the same-browser approver ceremony for free — +`offscreen.ts` needed no change at all. + +**The relay is gated on `sender.url`, not `sender.id`.** Every content script +carries this extension's id, so `sender.id` cannot separate a page from an +extension surface. `isExtensionPageSender` compares against +`chrome.runtime.getURL("")`. Unlike the page-facing `RUNTIME_REQUEST_TASK`, this +one does **not** prompt per call — the caller is the operator driving their own +console, and twelve identical dialogs to render one screen is dismissal, not +consent. What stands in its place: the agent's ACL, its policy engine (a +`requireConsent` comes back as `ConsentRequiredError` and renders as a match-code +ceremony, never as a red string), and preview-then-confirm on every irreversible +action, showing the agent's own account of what would be destroyed. + +**What breaks it:** importing `admin` from the package root instead of the +subpath; folding `manager.html` into `vite.config.ts` (a shared chunk then +carries admin into wallet surfaces); losing `codeSplitting: false`; adding +`RUNTIME_MANAGER_TASK` to `PAGE_FACING_RUNTIME_TYPES` or to `content.ts`'s +dispatch table; gating on `sender.id`; or widening the carrier to pass the +envelope through. `tests/manager-sender.test.mts`, +`tests/manager-surface.test.mts` and the two CI assertions pin each of these. + ## Advertisement is not availability A VTA's DID document says what it *offers*. `buildVtaSession` skips a channel diff --git a/packages/core/src/admin/acl.ts b/packages/core/src/admin/acl.ts index daa78e4..55a4550 100644 --- a/packages/core/src/admin/acl.ts +++ b/packages/core/src/admin/acl.ts @@ -12,9 +12,7 @@ // from the Rust structs, which is a copy that drifts, and got the nullability of // `acl/show`'s response wrong in the process. -import type { Identity } from "../didcomm/index.js"; -import type { TrustTaskSender } from "../vta/channel.js"; -import type { RemoteDidcommEndpoint } from "../vta/didcomm.js"; +import type { TaskParty, TrustTaskSender } from "../vta/channel.js"; import { buildTrustTask } from "../vta/trust-task.js"; import { @@ -73,9 +71,9 @@ export type { AclEntry }; export interface AclCallerParams { /** Envelope `issuer` — the caller's DIDComm identity. Its DID needs a role * the agent accepts for this task; the whole family is manage-gated. */ - holder: Identity; + holder: TaskParty; /** The agent — envelope `recipient`. */ - service: RemoteDidcommEndpoint; + service: TaskParty; } export interface AclGrantParams extends AclCallerParams { diff --git a/packages/core/src/admin/consent.ts b/packages/core/src/admin/consent.ts index 742a758..bf219da 100644 --- a/packages/core/src/admin/consent.ts +++ b/packages/core/src/admin/consent.ts @@ -15,9 +15,7 @@ // consent and carries a challenge the approver's decision is bound to. Most // consoles will use `consentList`, `consentDecision` and `consentRevoke`. -import type { Identity } from "../didcomm/index.js"; -import type { TrustTaskSender } from "../vta/channel.js"; -import type { RemoteDidcommEndpoint } from "../vta/didcomm.js"; +import type { TaskParty, TrustTaskSender } from "../vta/channel.js"; import { buildTrustTask } from "../vta/trust-task.js"; import { @@ -63,8 +61,8 @@ import { export type { ConsentGrant, ConsentSubject, ApproverBinding }; export interface ConsentCallerParams { - holder: Identity; - service: RemoteDidcommEndpoint; + holder: TaskParty; + service: TaskParty; } export interface ConsentListParams extends ConsentCallerParams { diff --git a/packages/core/src/admin/contexts.ts b/packages/core/src/admin/contexts.ts index 673f139..d06e5ae 100644 --- a/packages/core/src/admin/contexts.ts +++ b/packages/core/src/admin/contexts.ts @@ -10,9 +10,7 @@ // snake_case. Both fields happen to be single words, which is exactly the kind // of coincidence that hides a casing bug until someone adds `dry_run`. -import type { Identity } from "../didcomm/index.js"; -import type { TrustTaskSender } from "../vta/channel.js"; -import type { RemoteDidcommEndpoint } from "../vta/didcomm.js"; +import type { TaskParty, TrustTaskSender } from "../vta/channel.js"; import { buildTrustTask } from "../vta/trust-task.js"; const TASK_CONTEXTS_DELETE = "https://trusttasks.org/spec/vta/contexts/delete/1.0"; @@ -20,8 +18,8 @@ const TASK_CONTEXTS_PREVIEW_DELETE = "https://trusttasks.org/spec/vta/contexts/preview-delete/1.0"; export interface ContextDeleteParams { - holder: Identity; - service: RemoteDidcommEndpoint; + holder: TaskParty; + service: TaskParty; /** Context id (full path for a nested context). */ id: string; /** Delete even when the context still holds keys or DIDs. Default false. */ diff --git a/packages/core/src/admin/credentials.ts b/packages/core/src/admin/credentials.ts index 62b9286..103f16b 100644 --- a/packages/core/src/admin/credentials.ts +++ b/packages/core/src/admin/credentials.ts @@ -11,9 +11,7 @@ // two families version independently; a matching pair is a coincidence, not a // rule, so read the import paths rather than assuming. -import type { Identity } from "../didcomm/index.js"; -import type { TrustTaskSender } from "../vta/channel.js"; -import type { RemoteDidcommEndpoint } from "../vta/didcomm.js"; +import type { TaskParty, TrustTaskSender } from "../vta/channel.js"; import { buildTrustTask } from "../vta/trust-task.js"; import { @@ -32,9 +30,9 @@ import { /** Both calls are issued by an operator identity, to an agent. */ export interface CredentialIssuerCallerParams { /** Envelope `issuer` — needs an agent role that carries issuing authority. */ - holder: Identity; + holder: TaskParty; /** The issuing agent — envelope `recipient`. */ - service: RemoteDidcommEndpoint; + service: TaskParty; } export interface IssueCredentialParams extends CredentialIssuerCallerParams { diff --git a/packages/core/src/admin/devices.ts b/packages/core/src/admin/devices.ts index a018fdb..b545ec0 100644 --- a/packages/core/src/admin/devices.ts +++ b/packages/core/src/admin/devices.ts @@ -10,9 +10,7 @@ // request missing either: a wipe with no recorded reason is an audit gap. That // is a deliberate obstacle, so this wrapper adds no default for either. -import type { Identity } from "../didcomm/index.js"; -import type { TrustTaskSender } from "../vta/channel.js"; -import type { RemoteDidcommEndpoint } from "../vta/didcomm.js"; +import type { TaskParty, TrustTaskSender } from "../vta/channel.js"; import { buildTrustTask } from "../vta/trust-task.js"; import { @@ -41,8 +39,8 @@ export type { DeviceBinding }; export type WipeScope = DeviceWipePayload["scope"]; export interface DeviceCallerParams { - holder: Identity; - service: RemoteDidcommEndpoint; + holder: TaskParty; + service: TaskParty; } export interface DeviceListParams extends DeviceCallerParams { diff --git a/packages/core/src/admin/did-templates.ts b/packages/core/src/admin/did-templates.ts index a90f767..66be7f9 100644 --- a/packages/core/src/admin/did-templates.ts +++ b/packages/core/src/admin/did-templates.ts @@ -16,9 +16,7 @@ // the operation to that context's templates; omitting it addresses the global // set. The two are different namespaces, and a name can exist in both. -import type { Identity } from "../didcomm/index.js"; -import type { TrustTaskSender } from "../vta/channel.js"; -import type { RemoteDidcommEndpoint } from "../vta/didcomm.js"; +import type { TaskParty, TrustTaskSender } from "../vta/channel.js"; import { buildTrustTask } from "../vta/trust-task.js"; import { @@ -60,8 +58,8 @@ import { export type { DidTemplate, DidTemplateRecord }; export interface DidTemplateCallerParams { - holder: Identity; - service: RemoteDidcommEndpoint; + holder: TaskParty; + service: TaskParty; /** Scope the call to one context's templates. Omit for the global set — * a different namespace, in which the same name may also exist. */ contextId?: string; diff --git a/packages/core/src/admin/keys.ts b/packages/core/src/admin/keys.ts index 2eaa2a7..1d1758b 100644 --- a/packages/core/src/admin/keys.ts +++ b/packages/core/src/admin/keys.ts @@ -23,9 +23,7 @@ // the parameter type enforces it here; the cleartext warning cannot be // enforced by any type and is stated where a caller will read it. -import type { Identity } from "../didcomm/index.js"; -import type { TrustTaskSender } from "../vta/channel.js"; -import type { RemoteDidcommEndpoint } from "../vta/didcomm.js"; +import type { TaskParty, TrustTaskSender } from "../vta/channel.js"; import { buildTrustTask } from "../vta/trust-task.js"; import { @@ -103,9 +101,9 @@ export type KeyRecord = SpecKeyRecord; export interface KeysCallerParams { /** Envelope `issuer` — the caller's DIDComm identity. */ - holder: Identity; + holder: TaskParty; /** The agent — envelope `recipient`. */ - service: RemoteDidcommEndpoint; + service: TaskParty; } export interface KeysCreateParams extends KeysCallerParams { diff --git a/packages/core/src/admin/memory.ts b/packages/core/src/admin/memory.ts index 37a4283..ead8435 100644 --- a/packages/core/src/admin/memory.ts +++ b/packages/core/src/admin/memory.ts @@ -9,9 +9,7 @@ // counterpart only in the sense that both name a key — the list is deliberately // a directory, so enumerating memory does not spill its contents. -import type { Identity } from "../didcomm/index.js"; -import type { TrustTaskSender } from "../vta/channel.js"; -import type { RemoteDidcommEndpoint } from "../vta/didcomm.js"; +import type { TaskParty, TrustTaskSender } from "../vta/channel.js"; import { buildTrustTask } from "../vta/trust-task.js"; import { @@ -34,8 +32,8 @@ import { } from "@openvtc/trust-tasks/vta/memory/delete/0.1/payload"; export interface MemoryCallerParams { - holder: Identity; - service: RemoteDidcommEndpoint; + holder: TaskParty; + service: TaskParty; /** Required — memory has no global namespace. */ contextId: string; } diff --git a/packages/core/src/admin/observability.ts b/packages/core/src/admin/observability.ts index c8f1cb2..2480d0d 100644 --- a/packages/core/src/admin/observability.ts +++ b/packages/core/src/admin/observability.ts @@ -5,9 +5,7 @@ // "under what settings", and `config/patch` is the one write, kept in the same // file because you should never be looking at the second without the first. -import type { Identity } from "../didcomm/index.js"; -import type { TrustTaskSender } from "../vta/channel.js"; -import type { RemoteDidcommEndpoint } from "../vta/didcomm.js"; +import type { TaskParty, TrustTaskSender } from "../vta/channel.js"; import { buildTrustTask } from "../vta/trust-task.js"; import { @@ -40,8 +38,8 @@ import { export type { AuditEnvelope, ConfigField }; export interface ObservabilityCallerParams { - holder: Identity; - service: RemoteDidcommEndpoint; + holder: TaskParty; + service: TaskParty; } export interface AuditListParams extends ObservabilityCallerParams { diff --git a/packages/core/src/admin/policy.ts b/packages/core/src/admin/policy.ts index 635fc95..2b1ba35 100644 --- a/packages/core/src/admin/policy.ts +++ b/packages/core/src/admin/policy.ts @@ -13,9 +13,7 @@ // // Payload and response types come from `@openvtc/trust-tasks`. -import type { Identity } from "../didcomm/index.js"; -import type { TrustTaskSender } from "../vta/channel.js"; -import type { RemoteDidcommEndpoint } from "../vta/didcomm.js"; +import type { TaskParty, TrustTaskSender } from "../vta/channel.js"; import { buildTrustTask } from "../vta/trust-task.js"; import { @@ -46,8 +44,8 @@ import { export type { PolicyModule }; export interface PolicyCallerParams { - holder: Identity; - service: RemoteDidcommEndpoint; + holder: TaskParty; + service: TaskParty; } export interface PolicyListParams extends PolicyCallerParams { diff --git a/packages/core/src/admin/services.ts b/packages/core/src/admin/services.ts index 19cd30e..d99a154 100644 --- a/packages/core/src/admin/services.ts +++ b/packages/core/src/admin/services.ts @@ -11,9 +11,7 @@ // calls it off. A caller that treats `disable` as instantaneous will report an // agent as off while it is still answering. -import type { Identity } from "../didcomm/index.js"; -import type { TrustTaskSender } from "../vta/channel.js"; -import type { RemoteDidcommEndpoint } from "../vta/didcomm.js"; +import type { TaskParty, TrustTaskSender } from "../vta/channel.js"; import { buildTrustTask } from "../vta/trust-task.js"; import { @@ -70,9 +68,9 @@ export type { ServiceState, ServiceKind }; /** Every `vta/services/*` call is issued by an operator identity, to an agent. */ export interface ServicesCallerParams { /** Envelope `issuer`. Needs an admin role — the whole family is manage-gated. */ - holder: Identity; + holder: TaskParty; /** The agent — envelope `recipient`. */ - service: RemoteDidcommEndpoint; + service: TaskParty; } /** Transport configuration. Which members apply depends on the `ServiceKind`. */ diff --git a/packages/core/src/admin/sessions.ts b/packages/core/src/admin/sessions.ts index c03926c..b8f72b1 100644 --- a/packages/core/src/admin/sessions.ts +++ b/packages/core/src/admin/sessions.ts @@ -15,9 +15,7 @@ // // Payload and response types come from `@openvtc/trust-tasks`. -import type { Identity } from "../didcomm/index.js"; -import type { TrustTaskSender } from "../vta/channel.js"; -import type { RemoteDidcommEndpoint } from "../vta/didcomm.js"; +import type { TaskParty, TrustTaskSender } from "../vta/channel.js"; import { buildTrustTask } from "../vta/trust-task.js"; import { @@ -40,8 +38,8 @@ import { export type { Session }; export interface SessionCallerParams { - holder: Identity; - service: RemoteDidcommEndpoint; + holder: TaskParty; + service: TaskParty; } /** diff --git a/packages/core/src/vta/channel.ts b/packages/core/src/vta/channel.ts index e76c7a2..4785d5b 100644 --- a/packages/core/src/vta/channel.ts +++ b/packages/core/src/vta/channel.ts @@ -25,6 +25,26 @@ export interface TrustTaskSender { send(envelope: TrustTask, opts?: SendOpts): Promise; } +/** + * A party named on a Trust-Task envelope — the `issuer` or the `recipient`. + * + * Deliberately just the DID. Building an envelope needs nothing else: an + * `Identity` carries key material and a `RemoteDidcommEndpoint` carries a + * key-agreement JWK, and neither is read when the only question is "whose DID + * goes in this field". Both structurally satisfy this, so a caller that holds + * one passes it unchanged. + * + * Asking for more than this is not free. A surface typed on `Identity` can only + * be called from somewhere holding a private key, which forces key material + * into callers that compose documents without ever signing them — the + * management console being the case in point: it builds admin tasks and hands + * them to the device to mint and sign, and holds no key of its own. Typing the + * envelope's parties by what they actually are keeps that possible. + */ +export interface TaskParty { + did: string; +} + export interface SendOpts { /** Expected response document `type` (the `#response` URI). When * set, a reply whose `type` is neither this nor a trust-task-error is a diff --git a/packages/core/src/vta/contexts.ts b/packages/core/src/vta/contexts.ts index 9d1c6d4..42a7273 100644 --- a/packages/core/src/vta/contexts.ts +++ b/packages/core/src/vta/contexts.ts @@ -13,9 +13,7 @@ // exposes a bespoke `GET/POST /contexts` REST route (now deprecated) — the // trust-task dispatcher form is the canonical one. -import type { Identity } from "../didcomm/index.js"; -import type { TrustTaskSender } from "./channel.js"; -import type { RemoteDidcommEndpoint } from "./didcomm.js"; +import type { TaskParty, TrustTaskSender } from "./channel.js"; import { RestChannel, type RestChannelOptions } from "./rest-channel.js"; import { buildTrustTask } from "./trust-task.js"; @@ -95,9 +93,9 @@ export interface ContextsListParams { /** Envelope `issuer` — the holder's DIDComm identity. Its DID must be in * the VTA's ACL with any role (`contexts/list` is auth-gated, not * admin-only; the VTA filters by `has_context_access`). */ - holder: Identity; + holder: TaskParty; /** The VTA — envelope `recipient`. */ - service: RemoteDidcommEndpoint; + service: TaskParty; } /** List the contexts the holder can access. @@ -121,8 +119,8 @@ export async function contextsList( } export interface ContextsCreateParams { - holder: Identity; - service: RemoteDidcommEndpoint; + holder: TaskParty; + service: TaskParty; /** Leaf segment when `parent` is set (full path = `/`), else a * top-level id. Must be unique; a conflict rejects. */ id: string; @@ -159,15 +157,22 @@ export async function contextsCreate( } /** @deprecated REST-transport options. Kept for existing call sites; prefer - * {@link contextsList} with a channel from a `VtaSession`. */ -export interface VtaListContextsOptions extends ContextsListParams, RestChannelOptions {} + * {@link contextsList} with a channel from a `VtaSession`. + * + * `holder` and `service` come from {@link RestChannelOptions}, not from + * {@link ContextsListParams}: composing the envelope needs only the two DIDs, but this + * wrapper also *builds the channel*, and a channel signs and encrypts. The + * narrower pair is what the wire actually requires here. */ +export interface VtaListContextsOptions + extends Omit, + RestChannelOptions {} /** @deprecated Use {@link contextsList} with a channel from a `VtaSession`. * List over REST — builds a one-shot {@link RestChannel} (dispatches * `contexts/list/1.0` over `/trust-tasks`, NOT the bespoke `/contexts`). */ export interface ContextsGetParams { - holder: Identity; - service: RemoteDidcommEndpoint; + holder: TaskParty; + service: TaskParty; /** Context id — the full path for a nested context. */ id: string; } @@ -198,8 +203,8 @@ export async function contextsGet( } export interface ContextsUpdateParams { - holder: Identity; - service: RemoteDidcommEndpoint; + holder: TaskParty; + service: TaskParty; /** Context to update. The id itself cannot be changed. */ id: string; /** New human-readable name. Omit to leave unchanged. */ @@ -239,8 +244,8 @@ export async function contextsUpdate( } export interface ContextsUpdateDidParams { - holder: Identity; - service: RemoteDidcommEndpoint; + holder: TaskParty; + service: TaskParty; /** Context whose DID is being set. */ id: string; /** The DID to associate with this context. */ @@ -269,8 +274,15 @@ export function vtaListContexts(opts: VtaListContextsOptions): Promise, + RestChannelOptions {} /** @deprecated Use {@link contextsCreate} with a channel from a `VtaSession`. * Create over REST — builds a one-shot {@link RestChannel}. */ diff --git a/packages/core/src/webvh/dids.ts b/packages/core/src/webvh/dids.ts index 1040805..e595938 100644 --- a/packages/core/src/webvh/dids.ts +++ b/packages/core/src/webvh/dids.ts @@ -58,17 +58,18 @@ import { type Response as DidsRegisterWithServerResponse, } from "@openvtc/trust-tasks/vta/webvh/dids/register-with-server/1.0/payload"; -import type { Identity } from "../didcomm/index.js"; -import type { TrustTaskSender } from "../vta/channel.js"; -import type { RemoteDidcommEndpoint } from "../vta/didcomm.js"; +import type { TaskParty, TrustTaskSender } from "../vta/channel.js"; import { buildTrustTask } from "../vta/trust-task.js"; export type { WebvhDidRecord, WebvhPathMode }; /** Who is asking, and which agent is being asked. */ export interface WebvhCall { - holder: Identity; - service: RemoteDidcommEndpoint; + /** Envelope `issuer`. Only the DID is read — see `TaskParty` in + * `vta/channel.ts` for why this is not typed on `Identity`. */ + holder: TaskParty; + /** Envelope `recipient`. */ + service: TaskParty; } const send = ( diff --git a/packages/extension/manager.html b/packages/extension/manager.html new file mode 100644 index 0000000..a5514c6 --- /dev/null +++ b/packages/extension/manager.html @@ -0,0 +1,12 @@ + + + + + + VTA Console + + +
+ + + diff --git a/packages/extension/package.json b/packages/extension/package.json index 23e296a..57d4bfd 100644 --- a/packages/extension/package.json +++ b/packages/extension/package.json @@ -6,7 +6,7 @@ "type": "module", "scripts": { "dev": "vite build --watch --mode development", - "build": "tsc -b && vite build && vite build -c vite.config.background.ts", + "build": "tsc -b && vite build && vite build -c vite.config.background.ts && vite build -c vite.config.manager.ts", "lint": "tsc -b", "test": "node --test tests/*.test.mts", "package": "npm run build && node scripts/package.mjs", diff --git a/packages/extension/src/app-shell.tsx b/packages/extension/src/app-shell.tsx index 5f0e9ef..6c107da 100644 --- a/packages/extension/src/app-shell.tsx +++ b/packages/extension/src/app-shell.tsx @@ -265,6 +265,33 @@ export function AppShell({ advanced, vault }: { advanced: React.ReactNode; vault ); })} + {/* The management console — a separate surface, and a separate tab. + Not a pane here: these four are the *wallet's* settings, and + administering the agent (granting authority, destroying contexts) is + a different job with a different blast radius. It also ships as its + own bundle, which is what keeps `@openvtc/pnm-core/admin` out of + every wallet surface — see `vite.config.manager.ts`. */} + + {/* Both roles, always visible. The wallet's whole authority model is that these are two identities with different powers; showing only one of them made the split something you had to go and look for. diff --git a/packages/extension/src/background.ts b/packages/extension/src/background.ts index 8e3bea8..baceb91 100644 --- a/packages/extension/src/background.ts +++ b/packages/extension/src/background.ts @@ -106,6 +106,7 @@ import { RUNTIME_ONBOARD_PREPARE, PAGE_FACING_RUNTIME_TYPES, RUNTIME_REQUEST_TASK, + RUNTIME_MANAGER_TASK, RUNTIME_SIGN_TRUST_TASK, RUNTIME_TASK_CONSENT, CONSENT_KEEPALIVE_PORT, @@ -153,6 +154,8 @@ import { OFFSCREEN_REQUEST_TASK, type RuntimeRequestTaskRequest, type RuntimeRequestTaskResponse, + type RuntimeManagerTaskRequest, + type RuntimeManagerTaskResponse, type RuntimeSignTrustTaskRequest, type RuntimeSignTrustTaskResponse, type RuntimeStepUpConsentRequest, @@ -1676,6 +1679,68 @@ async function handleRequestTask( return res; } +/** + * True when this message came from one of the extension's own pages. + * + * `sender.id === chrome.runtime.id` — already checked for every message — does + * NOT answer this: a content script is our script, injected into someone else's + * page, and passes it. `sender.url` is set by the browser from the context the + * message actually left, so an extension page reads + * `chrome-extension:///manager.html` while a content script reads the page + * it is running in. That is the whole distinction, and it is not forgeable from + * page content. + * + * Used to gate the management console's relay, which — unlike the page-facing + * one — does not stop to ask a human before each task. + */ +function isExtensionPageSender(sender: chrome.runtime.MessageSender): boolean { + const base = chrome.runtime.getURL(""); + return typeof sender.url === "string" && sender.url.startsWith(base); +} + +/** + * Run one administration task proposed by the management console. + * + * ## Why this does not prompt, when `handleRequestTask` always does + * + * That prompt exists because an arbitrary web page is proposing an arbitrary + * task, and under a generic relay a remembered grant would mean "this site may + * ask my agent to do anything at all". Neither half is true here: the caller is + * an extension page the operator opened themselves, and there is no origin to + * remember or to be wrong about. Prompting per call would also make the surface + * unusable — a console reads a dozen lists to draw one screen, and a human who + * clicks through twelve identical dialogs to see a page is not consenting to + * anything, they are dismissing an obstacle. + * + * What still stands between the console and a destructive change: the agent's + * own policy engine, which answers `requireConsent` as a `consentRequired` + * outcome that the console renders as an approval ceremony rather than an + * error; the agent's ACL, which is the only authority that decides whether this + * caller may act at all; and the console's own preview-then-confirm on every + * irreversible action, which shows the agent's account of what would be + * destroyed rather than a generic "are you sure". + * + * The origin stamped into the task is this extension's own. That is the honest + * answer — the console really is the caller — and it is what the agent's audit + * trail will record. + */ +async function handleManagerTask( + req: RuntimeManagerTaskRequest, +): Promise { + const active = await readActiveConnection(); + if (!active.ok) return { ok: false, error: active.error }; + + await ensureOffscreenDocument(); + return (await chrome.runtime.sendMessage({ + target: OFFSCREEN_TARGET, + type: OFFSCREEN_REQUEST_TASK, + vtaDid: active.conn.vtaDid, + restBaseUrl: active.conn.restBaseUrl, + origin: new URL(chrome.runtime.getURL("")).origin, + params: req.params, + })) as RuntimeManagerTaskResponse; +} + // Sign a Trust-Task envelope with the wallet's holder did:peer #key-2. // Forward to the offscreen which loads the holder identity + calls the core // `signTrustTask` helper. @@ -2663,6 +2728,26 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { return true; // async sendResponse } + if ((message as { type?: string })?.type === RUNTIME_MANAGER_TASK) { + // Extension pages only. A content script carries our extension id but not + // our URL, and this relay does not stop to ask a human — so the gate is the + // whole security boundary for the console's authority. + if (!isExtensionPageSender(sender)) { + // eslint-disable-next-line no-console + console.warn( + `[background] rejecting ${RUNTIME_MANAGER_TASK} from non-extension sender url=${sender.url}`, + ); + sendResponse({ ok: false, error: "manager surface is not page-reachable" }); + return false; + } + handleManagerTask(message as RuntimeManagerTaskRequest) + .then(sendResponse) + .catch((e: unknown) => + sendResponse({ ok: false, error: e instanceof Error ? e.message : String(e) }), + ); + return true; // async sendResponse + } + if ((message as { type?: string })?.type === RUNTIME_REQUEST_TASK) { handleRequestTask(message as RuntimeRequestTaskRequest) .then(sendResponse) diff --git a/packages/extension/src/bridge-protocol.ts b/packages/extension/src/bridge-protocol.ts index 3d0a7d8..ad36dba 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -1747,6 +1747,37 @@ export interface OffscreenRequestTaskRequest { params: RequestTaskParams; } +/** manager console → background: run one administration task at the agent. + * + * **Deliberately NOT in {@link PAGE_FACING_RUNTIME_TYPES}, and deliberately + * absent from `content.ts`'s dispatch table.** Granting authority at an agent, + * revoking it and destroying contexts is operator surface; a web page has no + * business proposing any of it, and the relay it *is* allowed to use + * ({@link RUNTIME_REQUEST_TASK}) prompts a human for every single call. + * + * `sender.id === chrome.runtime.id` does not separate the two — a content + * script passes it. The discriminator is `sender.url`, which the browser sets + * and a page cannot influence: an extension page's is under + * `chrome.runtime.getURL("")`, a content script's is the page it was injected + * into. `background.ts` gates on exactly that. + * + * Carries `params` and nothing else. The console composes a typed envelope + * with the `@openvtc/pnm-core/admin` helpers, but only its `type` and + * `payload` cross this boundary — the device mints `id`, `issuedAt`, `issuer` + * and `recipient` inside its own trust boundary and the channel signs the + * result, exactly as it does for a page-proposed task. See + * `core/src/vta/request-task.ts` for why that division is not negotiable. */ +export const RUNTIME_MANAGER_TASK = "vta-wallet/manager-task" as const; + +export interface RuntimeManagerTaskRequest { + type: typeof RUNTIME_MANAGER_TASK; + params: RequestTaskParams; +} + +export type RuntimeManagerTaskResponse = + | { ok: true; result: RequestTaskResult } + | { ok: false; error: string }; + /** * Every runtime message type a *web page* can originate through the content * script — the exact set whose origin must be the browser's, not the message diff --git a/packages/extension/src/manager-theme.css b/packages/extension/src/manager-theme.css new file mode 100644 index 0000000..dec25d0 --- /dev/null +++ b/packages/extension/src/manager-theme.css @@ -0,0 +1,68 @@ +/* Act colours — the console's only addition to the wallet's design tokens. + * + * `theme.css` stays the base: ground, surface, line, text, and the semantic + * ok/warn/danger triple are shared with the popup, options and confirm + * surfaces, so the console reads as the same product and there is exactly one + * palette to keep correct across light and dark. + * + * What the console needs *on top* is a way to say which act of the stack a + * section belongs to. The workshop deck does this with a left rail that changes + * colour by act, and it works because the grouping is real — the three groups + * answer three different questions: + * + * identity (purple) who you are, and what you hold + * wire (lime) how bytes get there, and whether they did + * graph (gold) who may act, and who has to agree + * + * These are navigation colours and nothing else. They never encode state: a + * section is not "more purple" when something is wrong. `--w-ok` / `--w-warn` / + * `--w-danger` remain the only colours that mean anything, which is the reason + * the accent was kept separate from the semantics in the first place — "this is + * actionable" and "this verified" must stay visually separable in a security + * UI, and adding three more meaningful colours would undo that. + * + * Light values are darkened well past their names (a literal lime is unreadable + * on a near-white ground); dark values are lifted. Both are checked against + * `--w-surface`, not against white. + */ + +:root { + --m-act-identity: #6b3fd4; + --m-act-identity-soft: #f0ebfd; + --m-act-wire: #4b7d16; + --m-act-wire-soft: #eef6e2; + --m-act-graph: #8f6410; + --m-act-graph-soft: #fbf2dc; + + /* The rail's own ground — a half-step off `--w-ground` so the three columns + * read as three columns without a hard border doing the work. */ + --m-rail: #f2f4f8; + --m-tree: #fafbfd; +} + +@media (prefers-color-scheme: dark) { + :root { + --m-act-identity: #a98bff; + --m-act-identity-soft: #1d1733; + --m-act-wire: #a4d552; + --m-act-wire-soft: #182310; + --m-act-graph: #e0b355; + --m-act-graph-soft: #292010; + + --m-rail: #0e131c; + --m-tree: #10151f; + } +} + +/* The console is a full-page surface and owns the viewport. The wallet's other + * surfaces are sized by their container (a popup, an options frame), so this + * belongs here rather than in `theme.css`. */ +html, +body, +#root { + height: 100%; +} + +body { + overflow: hidden; +} diff --git a/packages/extension/src/manager.tsx b/packages/extension/src/manager.tsx new file mode 100644 index 0000000..8fa6681 --- /dev/null +++ b/packages/extension/src/manager.tsx @@ -0,0 +1,32 @@ +/// + +// The management console's entry point. +// +// A separate bundle from every other extension surface, and the separation is +// load-bearing rather than tidy: `@openvtc/pnm-core/admin` is operator +// authority — granting it, revoking it, destroying contexts — and CI asserts +// that none of it reaches the wallet's own bundles. Its own vite config +// (`vite.config.manager.ts`) with `codeSplitting: false` is what makes that +// assertion structural instead of a promise: Rollup cannot emit a shared chunk +// between this entry and the popup, offscreen or service worker, because it +// does not build them together. +// +// Reached from the popup and options page via `chrome.tabs.create`. Deliberately +// NOT `options_ui` (the wallet's settings are the options page, and this is not +// settings) and deliberately not a `web_accessible_resource` — no page should +// be able to frame or navigate to it. + +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { ManagerShell } from "./manager/shell.js"; +import "./theme.css"; +import "./manager-theme.css"; + +const root = document.getElementById("root"); +if (!root) throw new Error("manager.html is missing its #root element"); + +createRoot(root).render( + + + , +); diff --git a/packages/extension/src/manager/carrier.ts b/packages/extension/src/manager/carrier.ts new file mode 100644 index 0000000..a3cda7f --- /dev/null +++ b/packages/extension/src/manager/carrier.ts @@ -0,0 +1,122 @@ +// What crosses the bridge when the console runs an admin task — and what does +// not. +// +// Every helper in `@openvtc/pnm-core/admin` builds a canonical envelope with +// `buildTrustTask` and hands it to a `TrustTaskSender`. That gives the console +// the API it wants: typed payloads and responses, with the wire shapes owned by +// the generated `@openvtc/trust-tasks` bindings rather than transcribed here. +// +// But `core/src/vta/request-task.ts` is explicit that **the device mints the +// envelope**. A wallet that counter-signs a document composed somewhere else +// attests to every field the agent will subsequently trust *because the wallet +// signed it* — issuer, recipient, expiry, id — none of which it checked. That +// rule does not soften because the composer happens to be an extension page: +// what the signature claims is the same either way. +// +// So the envelope the admin helper builds is a **carrier**, not a document. +// `carrierParams` takes the only two members a caller is entitled to propose +// and drops the rest; the offscreen document mints the real envelope and the +// channel signs it (`signOutboundTask`, SPEC §7.2 item 7a). +// +// Deliberately free of relative imports so it can be unit-tested in plain Node +// — the same constraint every other tested module in this package observes, and +// the reason the chrome-facing half lives in `sender.ts`. + +import type { TrustTask } from "@openvtc/pnm-core"; + +/** The two members that may travel. Mirrors `RequestTaskParams`. */ +export interface CarrierParams { + type: string; + payload: Record; +} + +/** + * Strip a carrier envelope down to what may cross the bridge. + * + * An absent payload becomes `{}` rather than `undefined`: `auth/whoami` and + * friends legitimately send an empty payload, and relaying `undefined` reaches + * the agent as a missing member that fails schema validation for no visible + * reason. + */ +export function carrierParams(envelope: TrustTask): CarrierParams { + return { + type: envelope.type, + payload: (envelope.payload ?? {}) as Record, + }; +} + +/** + * The agent will not run this task until a human approves it. + * + * **Not a failure, and it must never be rendered as one.** The refusal carries + * the executor-signed consent requests an approver has to see and the salted + * digest whose prefix the operator matches on their approving device. A console + * that printed "Error: consent required" would discard the informed-consent + * ceremony at the exact moment the human was supposed to act. + * + * `TrustTaskSender.send` returns a value or throws, so this arrives as a + * *typed* throw. The panes catch this class specifically; nothing else in the + * console is allowed to catch it. + */ +export class ConsentRequiredError extends Error { + /** Salted digest of the exact payload awaiting approval; a prefix is the + * cross-device match code. */ + readonly payloadDigest: string; + readonly challenge: string; + readonly approverSet: string; + readonly minApprovals: number; + /** Executor-signed `task-consent/request` documents, one per approver. */ + readonly consentRequests: unknown[]; + /** Task type the operator was attempting, for the ceremony's copy. */ + readonly taskType: string; + + constructor(taskType: string, outcome: Record) { + super(`${taskType} needs human approval before the agent will run it`); + this.name = "ConsentRequiredError"; + this.taskType = taskType; + this.payloadDigest = typeof outcome.payloadDigest === "string" ? outcome.payloadDigest : ""; + this.challenge = typeof outcome.challenge === "string" ? outcome.challenge : ""; + this.approverSet = typeof outcome.approverSet === "string" ? outcome.approverSet : ""; + this.minApprovals = typeof outcome.minApprovals === "number" ? outcome.minApprovals : 1; + this.consentRequests = Array.isArray(outcome.consentRequests) ? outcome.consentRequests : []; + } +} + +/** The bridge's reply, structurally. Kept here rather than imported so this + * module stays free of relative imports; `sender.ts` passes the real typed + * value, and a drift between the two breaks its build. */ +export interface RelayReply { + ok: boolean; + error?: string; + result?: Record; +} + +/** + * Turn the bridge's reply into a result, or throw the right kind of error. + * + * Three outcomes, and the middle one is the reason this is a function rather + * than an `if (!ok) throw`: `accepted` unwraps, `consentRequired` throws the + * typed ceremony, and anything else means the relay changed shape underneath + * this file. That last case must be loud — returning it would hand a pane an + * object whose members all read `undefined`, which renders as a convincing + * empty result. + */ +export function interpretOutcome( + taskType: string, + label: string, + reply: RelayReply, +): Res { + if (!reply.ok) throw new Error(`${label} failed: ${reply.error ?? "unknown error"}`); + + const outcome = reply.result; + if (outcome?.kind === "consentRequired") { + throw new ConsentRequiredError(taskType, outcome); + } + if (outcome?.kind !== "accepted") { + throw new Error( + `${label} returned an outcome this console does not understand ` + + `(kind=${String(outcome?.kind)}). The wallet and console builds may differ.`, + ); + } + return outcome.result as Res; +} diff --git a/packages/extension/src/manager/context-column.tsx b/packages/extension/src/manager/context-column.tsx new file mode 100644 index 0000000..38dd625 --- /dev/null +++ b/packages/extension/src/manager/context-column.tsx @@ -0,0 +1,197 @@ +// The persistent context column. +// +// This is navigation, not a pane: the selection made here scopes Keys, DIDs, +// Access and Audit, because `contextId` is a filter parameter on all of them. +// Which is why it sits beside the sections rather than inside one — an operator +// picks `work/eng` once and every question they ask afterwards is about +// `work/eng` until they say otherwise. + +import { useMemo, useState } from "react"; +import { c, t, font } from "../theme.js"; +import { buildContextTree, flattenContextTree, type ContextNode } from "./context-tree.js"; +import type { ContextRecord } from "@openvtc/pnm-core"; + +/** `null` means "all contexts" — the filter cleared, not a context named null. */ +export type ContextSelection = string | null; + +function Row({ + node, + depth, + hasChildren, + collapsed, + selected, + onToggle, + onSelect, +}: { + node: ContextNode; + depth: number; + hasChildren: boolean; + collapsed: boolean; + selected: boolean; + onToggle: () => void; + onSelect: () => void; +}) { + // A placeholder stands for a parent this caller's ACL does not reach. It is + // drawn so its reachable children are not silently orphaned, but there is no + // record behind it — nothing to scope a pane to, nothing to rename, nothing + // to delete. So it is not selectable, and says why. + const unreachable = !node.record; + + return ( +
+ + +
+ ); +} + +export function ContextTree({ + records, + selected, + onSelect, + loading, + error, +}: { + records: ContextRecord[]; + selected: ContextSelection; + onSelect: (id: ContextSelection) => void; + loading: boolean; + error: string | null; +}) { + const [collapsed, setCollapsed] = useState>(new Set()); + const roots = useMemo(() => buildContextTree(records), [records]); + const rows = useMemo(() => flattenContextTree(roots, collapsed), [roots, collapsed]); + + const toggle = (id: string | undefined) => { + if (!id) return; + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + return ( + + ); +} diff --git a/packages/extension/src/manager/context-tree.ts b/packages/extension/src/manager/context-tree.ts new file mode 100644 index 0000000..fb0c0ea --- /dev/null +++ b/packages/extension/src/manager/context-tree.ts @@ -0,0 +1,130 @@ +// Flat `ContextRecord[]` → the tree the console navigates by. +// +// Contexts are the spine of this surface, not one section of it: `contextId` is +// already a filter parameter on `keysList`, `aclList` and `auditList`, so the +// selection made here scopes every pane to its right. That is why the tree is a +// persistent column rather than the content of a "Contexts" page. +// +// ## Why an unreachable parent is drawn rather than dropped +// +// A caller sees the contexts their ACL entries reach, and those entries are not +// obliged to cover a whole subtree — `work/eng` can be granted without `work`. +// So `parent` routinely names a context this caller cannot list, and the +// obvious tree build (index by id, attach to `byId[parent]`, keep what +// attached) silently loses exactly those children. +// +// That failure is invisible in the worst way: the console renders a shorter, +// entirely plausible tree, and an operator concludes they have no access to a +// context they are in fact administering. So an orphan gets a **placeholder** +// parent instead — drawn, named by the DID-less id the record itself carries, +// and marked unreachable. A placeholder is not selectable: there is no record +// behind it, so there is nothing to scope a pane to and nothing to delete. +// +// The placeholder stands for the parent and nothing more. It deliberately does +// not try to reconstruct the chain above it from `basePath`, whose segmentation +// this code does not own — `basePath` is shown verbatim as the node's path +// instead, which is the agent's own answer and cannot drift from it. + +import type { ContextRecord } from "@openvtc/pnm-core"; + +export interface ContextNode { + /** Context id — the value panes filter on. Absent on a placeholder. */ + id?: string; + /** What to draw. A placeholder shows the id its child named as its parent. */ + name: string; + /** The agent's own resolved path, shown verbatim. Absent on a placeholder. */ + basePath?: string; + /** The record behind this node. Absent on a placeholder, which is the test + * for "this node is real" — a node with no record can be expanded and read + * but never selected, renamed or deleted. */ + record?: ContextRecord; + children: ContextNode[]; +} + +/** Depth-first order with the tree's shape flattened for rendering. */ +export interface FlatContextNode { + node: ContextNode; + depth: number; + /** Whether any descendant exists — drives the disclosure triangle. */ + hasChildren: boolean; +} + +function byName(a: ContextNode, b: ContextNode): number { + return a.name.localeCompare(b.name); +} + +/** + * Build the forest. + * + * Roots are records with no `parent`, plus one placeholder per distinct + * unreachable parent. Order is by name at every level, so the tree does not + * reshuffle between reads of the same agent. + */ +export function buildContextTree(records: readonly ContextRecord[]): ContextNode[] { + const nodes = new Map(); + for (const record of records) { + nodes.set(record.id, { + id: record.id, + name: record.name || record.id, + basePath: record.basePath, + record, + children: [], + }); + } + + const roots: ContextNode[] = []; + const placeholders = new Map(); + + for (const record of records) { + const node = nodes.get(record.id); + if (!node) continue; + if (!record.parent) { + roots.push(node); + continue; + } + const parent = nodes.get(record.parent); + if (parent) { + parent.children.push(node); + continue; + } + // Unreachable parent — see the header. One placeholder per parent id, so + // two siblings under the same invisible parent share a node rather than + // producing two identical roots. + let stand = placeholders.get(record.parent); + if (!stand) { + stand = { name: record.parent, children: [] }; + placeholders.set(record.parent, stand); + roots.push(stand); + } + stand.children.push(node); + } + + const sortDeep = (list: ContextNode[]): void => { + list.sort(byName); + for (const n of list) sortDeep(n.children); + }; + sortDeep(roots); + return roots; +} + +/** + * Depth-first flatten, honouring a collapsed set. + * + * `collapsed` holds node ids; a placeholder has none and so can never be + * collapsed — hiding an unreachable parent would hide the reachable children it + * exists to reveal. + */ +export function flattenContextTree( + roots: readonly ContextNode[], + collapsed: ReadonlySet, +): FlatContextNode[] { + const out: FlatContextNode[] = []; + const walk = (node: ContextNode, depth: number): void => { + const hasChildren = node.children.length > 0; + out.push({ node, depth, hasChildren }); + if (node.id && collapsed.has(node.id)) return; + for (const child of node.children) walk(child, depth + 1); + }; + for (const root of roots) walk(root, 0); + return out; +} diff --git a/packages/extension/src/manager/destructive.tsx b/packages/extension/src/manager/destructive.tsx new file mode 100644 index 0000000..ac818a5 --- /dev/null +++ b/packages/extension/src/manager/destructive.tsx @@ -0,0 +1,265 @@ +// Preview-then-confirm, and the consent ceremony that can interrupt it. +// +// ## Why the preview comes from the agent +// +// A generic "Are you sure?" is worth nothing: the operator already believes +// they are sure, and the dialog adds a click rather than information. What +// changes a decision is *what would actually be destroyed*, and only the agent +// knows that — `contextPreviewDelete` returns the real keys and DIDs a context +// holds, which is precisely the list an operator does not have in their head. +// +// So every irreversible action here is two calls: ask the agent what the change +// would cost, render its answer verbatim, then send the change. `force` is a +// separate explicit tick rather than something the confirm button implies, +// because the agent refuses a non-empty deletion on purpose and overriding that +// refusal is a second decision. +// +// ## Why `consentRequired` renders here +// +// The agent may answer "a human must approve this first". That is not a +// failure — the refusal carries the salted payload digest whose prefix the +// operator matches on their approving device. Rendering it as a red error would +// discard the informed-consent ceremony at the exact moment the human was meant +// to act, so it gets the same surface as the preview: this is the one place the +// console catches `ConsentRequiredError`. + +import { useCallback, useState, type ReactNode } from "react"; +import { Button, Note } from "../ui.js"; +import { c, t, font } from "../theme.js"; +import { ConsentRequiredError } from "./carrier.js"; + +/** How much of the digest an operator compares across devices. The full value + * is unreadable aloud and nobody checks 64 characters; the prefix is what the + * approving surface shows too. */ +const MATCH_CODE_LENGTH = 8; + +function MatchCode({ digest }: { digest: string }) { + const code = digest.slice(0, MATCH_CODE_LENGTH).toUpperCase(); + return ( + + {code || "—"} + + ); +} + +/** The ceremony panel. Shown in place of the confirm button once the agent has + * asked for approval; the operator's next move is on another device. */ +export function ConsentCeremony({ pending }: { pending: ConsentRequiredError }) { + return ( + +
+ Your agent will not run this until a human approves it. +
+ Match code + +
+ + Approve on your approving device, and check that the code shown there is the same. It + is derived from this exact change — a different code means you would be approving + something else. + {pending.minApprovals > 1 + ? ` ${pending.minApprovals} approvals are required.` + : ""} + +
+
+ ); +} + +export interface DestructiveProps

{ + /** Button copy for the action itself, e.g. "Delete context". */ + label: string; + /** Disabled reason, or null when the action is available. Shown rather than + * hiding the control — see `hasRole` in `use-vta.ts`. */ + disabledReason?: string | null; + /** Ask the agent what the change would cost. */ + preview: () => Promise

; + /** Render the agent's answer. Returning `false` from `blocking` keeps the + * confirm button disabled until `force` is ticked. */ + renderPreview: (preview: P) => ReactNode; + /** Whether this preview describes collateral that `force` must override. */ + needsForce?: (preview: P) => boolean; + /** Copy for the force tick, when `needsForce` is true. */ + forceLabel?: string; + /** Perform it. */ + commit: (force: boolean) => Promise; + /** Called after a successful commit, so the caller can refetch. */ + onDone: () => void; +} + +type Phase

= + | { kind: "idle" } + | { kind: "previewing" } + | { kind: "preview"; preview: P } + | { kind: "committing"; preview: P } + | { kind: "consent"; pending: ConsentRequiredError } + | { kind: "error"; message: string }; + +/** + * The shared two-step for every irreversible action in this console. + * + * Generic over the preview shape because the previews differ (a context's keys + * and DIDs, a key's usages, an ACL subject's grants) while the *shape of the + * decision* does not: see the cost, then decide, with `force` as its own step. + */ +export function Destructive

({ + label, + disabledReason = null, + preview, + renderPreview, + needsForce, + forceLabel = "Delete anyway, destroying the items listed above", + commit, + onDone, +}: DestructiveProps

) { + const [phase, setPhase] = useState>({ kind: "idle" }); + const [force, setForce] = useState(false); + + const reset = useCallback(() => { + setPhase({ kind: "idle" }); + setForce(false); + }, []); + + const start = useCallback(async () => { + setPhase({ kind: "previewing" }); + try { + setPhase({ kind: "preview", preview: await preview() }); + } catch (e) { + setPhase({ + kind: "error", + message: e instanceof Error ? e.message : String(e), + }); + } + }, [preview]); + + const run = useCallback( + async (p: P) => { + setPhase({ kind: "committing", preview: p }); + try { + await commit(force); + reset(); + onDone(); + } catch (e) { + // The one class that is not an error. See the header. + if (e instanceof ConsentRequiredError) { + setPhase({ kind: "consent", pending: e }); + return; + } + setPhase({ kind: "error", message: e instanceof Error ? e.message : String(e) }); + } + }, + [commit, force, onDone, reset], + ); + + if (phase.kind === "idle") { + return ( + + ); + } + + if (phase.kind === "previewing") { + return Asking your agent what this would destroy…; + } + + if (phase.kind === "consent") { + return ( +

+ +
+ +
+
+ ); + } + + if (phase.kind === "error") { + return ( +
+ {phase.message} +
+ +
+
+ ); + } + + const p = phase.preview; + const blocked = Boolean(needsForce?.(p)) && !force; + const busy = phase.kind === "committing"; + + return ( +
+ +
{renderPreview(p)}
+
+ + {needsForce?.(p) && ( + + )} + +
+ + +
+
+ ); +} + +/** + * Run a non-destructive mutation, routing a consent refusal to the ceremony. + * + * Every mutating call in the console goes through this or through + * {@link Destructive}; nothing calls an admin helper and catches `Error` + * directly, because that is how a `ConsentRequiredError` ends up rendered as a + * red string. + */ +export async function runMutation( + action: () => Promise, + handlers: { + onConsent: (pending: ConsentRequiredError) => void; + onError: (message: string) => void; + }, +): Promise { + try { + await action(); + return true; + } catch (e) { + if (e instanceof ConsentRequiredError) { + handlers.onConsent(e); + return false; + } + handlers.onError(e instanceof Error ? e.message : String(e)); + return false; + } +} diff --git a/packages/extension/src/manager/panes/access.tsx b/packages/extension/src/manager/panes/access.tsx new file mode 100644 index 0000000..d772ace --- /dev/null +++ b/packages/extension/src/manager/panes/access.tsx @@ -0,0 +1,366 @@ +// Access — who may act at this agent, in which contexts, until when. +// +// The sharpest pane in the console: an ACL entry is the authority itself, and +// `acl/grant` is how someone who could do nothing here comes to be able to do +// everything. +// +// Two things the agent enforces that this pane must not paper over: +// +// - **`aclUpdate` replaces scopes wholesale, and refuses to narrow.** Sending +// a shorter set is not "remove these" — the agent rejects a reduction here +// on purpose, because a narrowing typed into an edit box looks identical to +// a mistake. Taking authority away goes through `aclRevoke`, which says so. +// - **`aclChangeRole` compare-and-swaps against the current role.** The form +// carries `fromRole` from the row it was opened on, so a role someone else +// changed in between rejects rather than silently overwriting their change. + +import { useCallback, useState } from "react"; +import { + aclChangeRole, + aclList, + aclRevoke, + aclGrant, + type AclEntry, +} from "@openvtc/pnm-core/admin"; +import { Button, 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, Destructive, runMutation } from "../destructive.js"; +import { Loading, LoadError, Redacted, Table, Truncated, 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"; + +const fieldStyle: React.CSSProperties = { + boxSizing: "border-box", + padding: "6px 9px", + background: c.ground, + color: c.text, + border: `1px solid ${c.line}`, + borderRadius: "var(--w-r-sm)", + fontSize: t.sm, +}; + +function Expiry({ entry }: { entry: AclEntry }) { + if (!entry.expiresAt) { + // Not "—". An entry that never expires is a standing grant, and that is a + // decision worth reading as one. + return never; + } + const when = new Date(entry.expiresAt); + const past = when.getTime() < Date.now(); + return ( + + {when.toLocaleDateString()} + {past ? " (expired)" : ""} + + ); +} + +function ChangeRole({ + parties, + entry, + onDone, +}: { + parties: Parties; + entry: AclEntry; + onDone: () => void; +}) { + const [open, setOpen] = useState(false); + const [toRole, setToRole] = useState(entry.role); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [pending, setPending] = useState(null); + + if (!open) { + return ( + + ); + } + + return ( +
+ setToRole(e.target.value)} /> + + from {entry.role} — rejected if it changed since this list was read + + {error && {error}} + {pending && } +
+ + +
+
+ ); +} + +function GrantAccess({ + parties, + contextId, + authority, + onGranted, +}: { + parties: Parties; + contextId: ContextSelection; + authority: Authority | null; + onGranted: () => void; +}) { + const [subject, setSubject] = useState(""); + const [role, setRole] = useState(""); + const [label, setLabel] = useState(""); + const [expiresAt, setExpiresAt] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [pending, setPending] = useState(null); + + const denied = authority && !hasRole(authority, "admin", "super-admin") + ? "Granting access needs the admin role at this agent." + : null; + + const submit = useCallback(async () => { + setBusy(true); + setError(null); + setPending(null); + const entry: AclEntry = { + subject: subject.trim(), + role: role.trim(), + ...(contextId ? { scopes: [contextId] } : {}), + ...(label.trim() ? { label: label.trim() } : {}), + // A date input gives a local day; the wire wants an instant. + ...(expiresAt ? { expiresAt: new Date(`${expiresAt}T23:59:59`).toISOString() } : {}), + }; + const ok = await runMutation( + async () => { + await aclGrant(managerSender, { ...parties, entry }); + }, + { onConsent: setPending, onError: setError }, + ); + setBusy(false); + if (ok) { + setSubject(""); + setRole(""); + setLabel(""); + setExpiresAt(""); + onGranted(); + } + }, [parties, subject, role, label, expiresAt, contextId, onGranted]); + + return ( + + Scoped to {contextId}. The subject will + be able to act at this agent within that context, as the role allows. + + ) : ( + <> + Unscoped. With no context selected this grant reaches everything the + role permits, everywhere. Select a context in the tree to confine it. + + ) + } + > +
+ +
+ + + +
+ + {!expiresAt && ( + + With no expiry this is a standing grant — it lasts until someone revokes it. An expiry + is the difference between access you decided to give and access you forgot about. + + )} + {!contextId && ( + + No context is selected, so this grant is not confined to one. That is rarely what you + want. + + )} + {error && {error}} + {pending && } + +
+ +
+ {denied && {denied}} +
+
+ ); +} + +export function AccessPane({ + parties, + authority, + contextId, +}: { + parties: Parties; + authority: Authority | null; + contextId: ContextSelection; +}) { + const list = useAsync( + () => aclList(managerSender, { ...parties, ...(contextId ? { scope: contextId } : {}) }), + [parties.holder.did, parties.service.did, contextId], + ); + + const revokeDenied = authority && !hasRole(authority, "admin", "super-admin") + ? "Revoking access needs the admin role at this agent." + : null; + + const columns: Column[] = [ + { + key: "subject", + header: "Subject", + render: (e) => ( +
+ + {e.label && {e.label}} +
+ ), + }, + { key: "role", header: "Role", render: (e) => {e.role} }, + { + key: "scopes", + header: "Contexts", + render: (e) => + e.scopes?.length ? ( + {e.scopes.join(", ")} + ) : ( + // An entry with no scopes is not restricted to none — it is + // restricted to nothing, i.e. everywhere. Say which. + everywhere + ), + }, + { key: "expires", header: "Expires", render: (e) => }, + { + key: "actions", + header: "", + render: (e) => ( +
+ + + label="Revoke" + disabledReason={revokeDenied} + preview={async () => e} + renderPreview={(p) => ( + <> + Revoking this entry takes away all of its authority. + + loses the {p.role} role + {p.scopes?.length ? ` in ${p.scopes.join(", ")}` : " everywhere"}. Anything + running as that subject stops working immediately — including, if it is a + device or an agent you rely on, one you may not be watching. + + + )} + commit={async () => { + await aclRevoke(managerSender, { ...parties, subject: e.subject }); + }} + onDone={list.reload} + /> +
+ ), + }, + ]; + + return ( +
+ + {list.error && } + {list.loading && !list.data && } + {list.data && ( + <> + + e.subject} + empty={ + contextId + ? `Nobody holds an entry scoped to ${contextId}.` + : "No entries you can read. Grants you administer appear here." + } + /> + {list.data.truncated && } + + )} + + + + + ); +} diff --git a/packages/extension/src/manager/panes/approvals.tsx b/packages/extension/src/manager/panes/approvals.tsx new file mode 100644 index 0000000..ec826c4 --- /dev/null +++ b/packages/extension/src/manager/panes/approvals.tsx @@ -0,0 +1,327 @@ +// Approvals — who gets asked when your agent will not act alone. +// +// This is the pane with the most leverage over the wallet's own behaviour. An +// approver binding decides which party is sent the `task-consent/request` that +// the wallet renders as a consent prompt; a wrong or missing one is a gated +// action that never got its human check, which is the failure the whole consent +// path exists to prevent. +// +// Two lists, and they answer different questions. **Approvers** is +// configuration — who will be asked, per platform and context. **Grants** is +// history — what has already been consented to, and until when. + +import { useCallback, useState } from "react"; +import { + consentApproverList, + consentApproverSet, + consentList, + consentRevoke, + type ApproverBinding, + type ConsentGrant, +} from "@openvtc/pnm-core/admin"; +import { Button, 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, 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"; + +const fieldStyle: React.CSSProperties = { + boxSizing: "border-box", + padding: "6px 9px", + background: c.ground, + color: c.text, + border: `1px solid ${c.line}`, + borderRadius: "var(--w-r-sm)", + fontSize: t.sm, +}; + +function SetApprover({ + parties, + contextId, + authority, + onSet, +}: { + parties: Parties; + contextId: ContextSelection; + authority: Authority | null; + onSet: () => void; +}) { + const [platform, setPlatform] = useState(""); + const [approver, setApprover] = useState(""); + const [route, setRoute] = useState<"wake" | "bridge-relay">("wake"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [pending, setPending] = useState(null); + + const denied = authority && !hasRole(authority, "admin", "super-admin") + ? "Setting an approver needs the admin role at this agent." + : !contextId + ? "Select a context in the tree — an approver is bound to one." + : null; + + const submit = useCallback(async () => { + if (!contextId) return; + setBusy(true); + setError(null); + setPending(null); + const ok = await runMutation( + async () => { + await consentApproverSet(managerSender, { + ...parties, + platform: platform.trim(), + context: contextId, + approver: approver.trim(), + route, + }); + }, + { onConsent: setPending, onError: setError }, + ); + setBusy(false); + if (ok) { + setPlatform(""); + setApprover(""); + onSet(); + } + }, [parties, platform, contextId, approver, route, onSet]); + + return ( + +
+ + + +
+ + {error && {error}} + {pending && } + +
+ +
+ {denied && {denied}} +
+ ); +} + +export function ApprovalsPane({ + parties, + authority, + contextId, +}: { + parties: Parties; + authority: Authority | null; + contextId: ContextSelection; +}) { + const approvers = useAsync( + () => + consentApproverList(managerSender, { + ...parties, + ...(contextId ? { context: contextId } : {}), + }), + [parties.holder.did, parties.service.did, contextId], + ); + + const grants = useAsync( + () => consentList(managerSender, parties), + [parties.holder.did, parties.service.did], + ); + + const revokeDenied = authority && !hasRole(authority, "admin", "super-admin") + ? "Revoking consent needs the admin role at this agent." + : null; + + const approverColumns: Column[] = [ + { key: "platform", header: "Platform", render: (a) => a.platform }, + { + key: "context", + header: "Context", + render: (a) => {a.context}, + }, + { key: "approver", header: "Approver", render: (a) => }, + { + key: "route", + header: "Route", + render: (a) => ( +
+ {a.route ?? "wake"} + {a.routeHint && {a.routeHint}} +
+ ), + }, + ]; + + const grantColumns: Column[] = [ + { + key: "subject", + header: "Conversation", + render: (g) => ( +
+ + {g.subject.platform} · {g.subject.kind} + + + {g.subject.conversationRef} + +
+ ), + }, + { key: "agent", header: "Agent", render: (g) => }, + { + key: "effect", + header: "Effect", + render: (g) => {g.effect}, + }, + { + key: "scope", + header: "Scope", + render: (g) => {g.scope ?? "—"}, + }, + { + key: "granted", + header: "Granted", + render: (g) => ( + + {new Date(g.grantedAt).toLocaleDateString()} + + ), + }, + { + key: "expires", + header: "Expires", + render: (g) => + g.expiresAt ? ( + + {new Date(g.expiresAt).toLocaleDateString()} + + ) : ( + never + ), + }, + { + key: "actions", + header: "", + render: (g) => ( +
+ + label="Revoke" + disabledReason={revokeDenied} + preview={async () => g} + renderPreview={(p) => ( + <> + Revoking this consent takes effect immediately. + + {p.subject.agent} loses its {p.effect} decision for this + conversation. The next thing it tries will be gated again, and somebody will be + asked. + + + )} + commit={async () => { + await consentRevoke(managerSender, { ...parties, subject: g.subject }); + }} + onDone={grants.reload} + /> +
+ ), + }, + ]; + + return ( +
+ + {approvers.error && } + {approvers.loading && !approvers.data && } + {approvers.data && ( + <> +
`${a.platform}:${a.context}:${a.approver}`} + empty={ + contextId + ? `No approver is bound for ${contextId}. Tasks in this context that need a ` + + "human have nobody to ask." + : "No approvers bound. Tasks that need a human have nobody to ask." + } + /> + {approvers.data.length === 0 && ( + + With no approver bound, a task your agent's policy gates on human consent cannot + be approved by anyone — it will be refused rather than queued. + + )} + + )} + + + + + + {grants.error && } + {grants.loading && !grants.data && } + {grants.data && ( +
`${g.subject.platform}:${g.subject.conversationRef}:${g.subject.agent}`} + empty="Nothing has been consented to yet. Decisions your approvers make appear here." + /> + )} + + + ); +} diff --git a/packages/extension/src/manager/panes/audit.tsx b/packages/extension/src/manager/panes/audit.tsx new file mode 100644 index 0000000..30e6703 --- /dev/null +++ b/packages/extension/src/manager/panes/audit.tsx @@ -0,0 +1,218 @@ +// Audit — the agent's account of what happened. +// +// **Truncation is rendered, never swallowed.** `AuditListResult.truncated` says +// the agent stopped early, and its own documentation is explicit about why that +// matters: an operator reading "nothing else occurred" off a partial page is +// exactly the failure an audit trail exists to prevent. A list that silently +// dropped the flag would be worse than no audit view at all, because it looks +// authoritative. +// +// Entries are hash-chained (`prevHash`/`entryHash`). This pane shows that the +// chain is there but does **not** claim to have verified it — checking a chain +// requires the whole chain, and this is a page of it. Saying "verified" over a +// page would be a claim nothing here can support. + +import { useCallback, useState } from "react"; +import { auditList, type AuditEnvelope } from "@openvtc/pnm-core/admin"; +import { Button, Did, Note, Panel, Pill } from "../../ui.js"; +import { c, t, font } from "../../theme.js"; +import { managerSender } from "../sender.js"; +import { Loading, LoadError, Table, type Column } from "../table.js"; +import { useAsync } from "../use-async.js"; +import type { Parties } from "../use-vta.js"; +import type { ContextSelection } from "../context-column.js"; + +const PAGE = 50; + +const fieldStyle: React.CSSProperties = { + boxSizing: "border-box", + padding: "6px 9px", + background: c.ground, + color: c.text, + border: `1px solid ${c.line}`, + borderRadius: "var(--w-r-sm)", + fontSize: t.sm, +}; + +function Outcome({ outcome }: { outcome: string | undefined }) { + if (!outcome) return ; + const ok = /^(ok|success|allow|allowed|granted)$/i.test(outcome); + const bad = /^(deny|denied|error|fail|failed|refused|rejected)$/i.test(outcome); + return {outcome}; +} + +export function AuditPane({ + parties, + contextId, +}: { + parties: Parties; + contextId: ContextSelection; +}) { + const [action, setAction] = useState(""); + const [actor, setActor] = useState(""); + // Applied filters, separate from the inputs: typing should not fire a request + // per keystroke against an agent over a mediator. + const [applied, setApplied] = useState({ action: "", actor: "" }); + const [pageSize, setPageSize] = useState(PAGE); + + const list = useAsync( + () => + auditList(managerSender, { + ...parties, + pageSize, + ...(contextId ? { contextId } : {}), + ...(applied.action ? { action: applied.action } : {}), + ...(applied.actor ? { actor: applied.actor } : {}), + }), + [ + parties.holder.did, + parties.service.did, + contextId, + applied.action, + applied.actor, + pageSize, + ], + ); + + const apply = useCallback(() => { + setPageSize(PAGE); + setApplied({ action: action.trim(), actor: actor.trim() }); + }, [action, actor]); + + const columns: Column[] = [ + { + key: "when", + header: "When", + render: (e) => ( + + {new Date(e.recordedAt).toLocaleString()} + + ), + }, + { + key: "action", + header: "Action", + render: (e) => {e.action}, + }, + { + key: "actor", + header: "Actor", + render: (e) => + e.actor ? : , + }, + { + key: "target", + header: "Target", + render: (e) => + e.target ? ( + + {e.target} + + ) : ( + + ), + }, + { key: "outcome", header: "Outcome", render: (e) => }, + { + key: "context", + header: "Context", + render: (e) => ( + + {e.contextId ?? "—"} + + ), + }, + { + key: "chain", + header: "Chained", + render: (e) => + e.entryHash ? ( + + yes + + ) : ( + no + ), + }, + ]; + + return ( +
+ +
+ + +
+ +
+
+ + {list.error && } + {list.loading && !list.data && } + + {list.data && ( + <> + {/* Before the table, not after. A warning under a long list is a + warning nobody reads. */} + {list.data.truncated && ( + + This is not the whole record. Your agent stopped early, so + anything you conclude from what is below — including that something did + not happen — may be wrong.{" "} + + , or narrow the filters. + + )} +
e.eventId} + empty={ + applied.action || applied.actor + ? "Nothing matches those filters. Clear them to see the rest." + : "Your agent has recorded nothing here yet." + } + /> + + )} + + + ); +} diff --git a/packages/extension/src/manager/panes/contexts.tsx b/packages/extension/src/manager/panes/contexts.tsx new file mode 100644 index 0000000..300a3da --- /dev/null +++ b/packages/extension/src/manager/panes/contexts.tsx @@ -0,0 +1,385 @@ +// Contexts — the pane behind the tree. +// +// Reads and writes the same records the tree navigates, so the two are driven +// from one fetch in the shell rather than each holding its own copy: a rename +// that updated the pane but not the tree would leave the operator looking at +// two names for one context and no way to tell which is current. + +import { useCallback, useState } from "react"; +import { + contextsCreate, + contextsUpdate, + type ContextRecord, +} from "@openvtc/pnm-core"; +import { contextDelete, contextPreviewDelete } from "@openvtc/pnm-core/admin"; +import { Button, Did, Empty, Note, Panel } from "../../ui.js"; +import { c, t, font } from "../../theme.js"; +import { managerSender } from "../sender.js"; +import { ConsentRequiredError } from "../carrier.js"; +import { Destructive, ConsentCeremony, runMutation } from "../destructive.js"; +import { hasRole, type Authority, type Parties } from "../use-vta.js"; +import type { ContextSelection } from "../context-column.js"; + +const fieldStyle: React.CSSProperties = { + width: "100%", + boxSizing: "border-box", + padding: "7px 10px", + background: c.ground, + color: c.text, + border: `1px solid ${c.line}`, + borderRadius: "var(--w-r-sm)", + fontSize: t.sm, +}; + +function Label({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} + +/** What deleting a context would destroy, in the agent's own words. */ +interface DeletePreview { + id: string; + keys: string[]; + webvhDids: string[]; +} + +function CreateContext({ + parties, + parent, + authority, + onCreated, +}: { + parties: Parties; + parent: ContextSelection; + authority: Authority | null; + onCreated: () => void; +}) { + const [id, setId] = useState(""); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [pending, setPending] = useState(null); + + // `contexts/create` is super-admin at the agent — it also applies a finer + // check on the parent. Disabled-with-a-reason rather than hidden: an operator + // who cannot create needs to know that is the rule, not that the console + // forgot the feature. + const denied = authority && !hasRole(authority, "admin", "super-admin") + ? "Creating a context needs the admin role at this agent." + : null; + + const submit = useCallback(async () => { + setBusy(true); + setError(null); + setPending(null); + const ok = await runMutation( + async () => { + await contextsCreate(managerSender, { + ...parties, + id: id.trim(), + ...(name.trim() ? { name: name.trim() } : {}), + ...(description.trim() ? { description: description.trim() } : {}), + ...(parent ? { parent } : {}), + }); + }, + { onConsent: setPending, onError: setError }, + ); + setBusy(false); + if (ok) { + setId(""); + setName(""); + setDescription(""); + onCreated(); + } + }, [parties, id, name, description, parent, onCreated]); + + return ( + + Nested under {parent}. A context is a + sealed compartment: the keys and DIDs inside it are isolated from every other one, so + a compromise stops at its edge. + + ) : ( + <> + Top-level. Select a context in the tree first to nest inside it. A context is a sealed + compartment: the keys and DIDs inside it are isolated from every other one, so a + compromise stops at its edge. + + ) + } + > +
+ + + + + {error && {error}} + {pending && } + +
+ +
+ {denied && {denied}} +
+
+ ); +} + +function EditContext({ + parties, + record, + authority, + onChanged, +}: { + parties: Parties; + record: ContextRecord; + authority: Authority | null; + onChanged: () => void; +}) { + const [name, setName] = useState(record.name ?? ""); + const [description, setDescription] = useState(record.description ?? ""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [pending, setPending] = useState(null); + + const denied = authority && !hasRole(authority, "admin", "super-admin", "operator") + ? "Editing a context needs an administrative role at this agent." + : null; + + const save = useCallback(async () => { + setBusy(true); + setError(null); + setPending(null); + const ok = await runMutation( + async () => { + // `policy` is deliberately not sent. `contextsUpdate` replaces it whole + // rather than merging, so writing it from a form that never read it + // would silently drop every constraint the form does not know about. + await contextsUpdate(managerSender, { + ...parties, + id: record.id, + name: name.trim(), + description: description.trim(), + }); + }, + { onConsent: setPending, onError: setError }, + ); + setBusy(false); + if (ok) onChanged(); + }, [parties, record.id, name, description, onChanged]); + + const dirty = name !== (record.name ?? "") || description !== (record.description ?? ""); + + return ( + {record.basePath}}> +
+
Id
+
{record.id}
+
DID
+
+ {record.did ? : none bound} +
+
Created
+
{new Date(record.createdAt).toLocaleString()}
+
Updated
+
{new Date(record.updatedAt).toLocaleString()}
+
+ +
+ + + + {error && {error}} + {pending && } + +
+ +
+
+
+ ); +} + +function DeleteContext({ + parties, + record, + authority, + onDeleted, +}: { + parties: Parties; + record: ContextRecord; + authority: Authority | null; + onDeleted: () => void; +}) { + const denied = authority && !hasRole(authority, "admin", "super-admin") + ? "Deleting a context needs the admin role at this agent." + : null; + + return ( + + + label="Delete context" + disabledReason={denied} + preview={() => contextPreviewDelete(managerSender, { ...parties, id: record.id })} + needsForce={(p) => p.keys.length > 0 || p.webvhDids.length > 0} + forceLabel="Delete anyway, destroying the keys and DIDs listed above" + renderPreview={(p) => ( + <> + + Deleting {record.name || record.id} is irreversible. + + {p.keys.length === 0 && p.webvhDids.length === 0 ? ( + Your agent reports it holds no keys and no DIDs. + ) : ( + <> + {p.keys.length > 0 && ( +
+
+ {p.keys.length} key{p.keys.length === 1 ? "" : "s"} destroyed: +
+
    + {p.keys.map((k) => ( +
  • {k}
  • + ))} +
+
+ )} + {p.webvhDids.length > 0 && ( +
+
+ {p.webvhDids.length} DID{p.webvhDids.length === 1 ? "" : "s"} destroyed: +
+
    + {p.webvhDids.map((d) => ( +
  • + +
  • + ))} +
+
+ )} + + )} + + )} + commit={async (force) => { + await contextDelete(managerSender, { ...parties, id: record.id, force }); + }} + onDone={onDeleted} + /> +
+ ); +} + +export function ContextsPane({ + parties, + authority, + records, + selected, + onChanged, +}: { + parties: Parties; + authority: Authority | null; + records: ContextRecord[]; + selected: ContextSelection; + onChanged: () => void; +}) { + const record = selected ? records.find((r) => r.id === selected) : undefined; + + return ( +
+ {selected && !record && ( + + That context is no longer in your agent's list. It may have been deleted, or your access + to it revoked. + + )} + + {record && ( + <> + + + + )} + + +
+ ); +} diff --git a/packages/extension/src/manager/panes/dids.tsx b/packages/extension/src/manager/panes/dids.tsx new file mode 100644 index 0000000..efe573b --- /dev/null +++ b/packages/extension/src/manager/panes/dids.tsx @@ -0,0 +1,258 @@ +// DIDs — the `did:webvh` identifiers a context publishes. +// +// Scoped to the selected context like Keys, and for the same reason: +// `webvhDidList` takes a `contextId`, so a changed selection asks the agent a +// different question rather than filtering an answer it already gave. +// +// A DID here is a *published* identifier: its log lives on a hosting server and +// anyone can resolve it. That is why deletion is treated as the sharpest action +// in this console — see the confirm copy. + +import { useCallback, useState } from "react"; +import { + webvhDidCreate, + webvhDidDelete, + webvhDidList, + type WebvhDidRecord, +} from "@openvtc/pnm-core/webvh"; +import { Button, 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, 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"; + +const fieldStyle: React.CSSProperties = { + boxSizing: "border-box", + padding: "6px 9px", + background: c.ground, + color: c.text, + border: `1px solid ${c.line}`, + borderRadius: "var(--w-r-sm)", + fontSize: t.sm, +}; + +function CreateDid({ + parties, + contextId, + authority, + onCreated, +}: { + parties: Parties; + contextId: ContextSelection; + authority: Authority | null; + onCreated: () => void; +}) { + const [serverId, setServerId] = useState(""); + const [portable, setPortable] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [pending, setPending] = useState(null); + + 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." + : null; + + const submit = useCallback(async () => { + if (!contextId) return; + setBusy(true); + setError(null); + setPending(null); + const ok = await runMutation( + async () => { + await webvhDidCreate(managerSender, { + ...parties, + contextId, + portable, + ...(serverId.trim() ? { serverId: serverId.trim() } : {}), + }); + }, + { onConsent: setPending, onError: setError }, + ); + setBusy(false); + if (ok) { + setServerId(""); + onCreated(); + } + }, [parties, contextId, serverId, portable, 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." + ) + } + > +
+ + +
+ + {portable && ( + + A portable DID can be moved to another hosting domain later. That flexibility is decided + now and cannot be added afterwards — but it also means the DID's identifier does not pin + it to one host. + + )} + {error && {error}} + {pending && } + +
+ +
+ {denied && {denied}} +
+ ); +} + +export function DidsPane({ + parties, + authority, + contextId, +}: { + parties: Parties; + authority: Authority | null; + contextId: ContextSelection; +}) { + const list = useAsync( + () => + webvhDidList(managerSender, { + ...parties, + ...(contextId ? { contextId } : {}), + }), + [parties.holder.did, parties.service.did, contextId], + ); + + const denied = authority && !hasRole(authority, "admin", "super-admin") + ? "Deleting a DID needs the admin role at this agent." + : null; + + const columns: Column[] = [ + { key: "did", header: "DID", render: (d) => }, + { + key: "context", + header: "Context", + render: (d) => {d.contextId}, + }, + { + key: "server", + header: "Server", + render: (d) => {d.serverId}, + }, + { + key: "portable", + header: "Portable", + render: (d) => (d.portable ? portable : ), + }, + { + key: "log", + header: "Log entries", + render: (d) => {d.logEntryCount}, + }, + { + key: "created", + header: "Created", + render: (d) => ( + + {new Date(d.createdAt).toLocaleDateString()} + + ), + }, + { + key: "actions", + header: "", + render: (d) => ( +
+ + label="Delete" + disabledReason={denied} + preview={async () => d} + forceLabel="Delete anyway" + renderPreview={(p) => ( + <> + Deleting this DID cannot be undone. + + {p.did} + + + Its {p.logEntryCount} log{p.logEntryCount === 1 ? " entry" : " entries"} and the + keys behind them go with it. Anyone still resolving this DID — a relying party + holding a credential you issued, an ACL entry naming it — stops being able to + verify anything signed by it. + + + )} + commit={async () => { + await webvhDidDelete(managerSender, { ...parties, did: d.did }); + }} + onDone={list.reload} + /> +
+ ), + }, + ]; + + return ( +
+ + {list.error && } + {list.loading && !list.data && } + {list.data && ( +
d.did} + empty={ + contextId + ? `No DIDs in ${contextId}. Identifiers published from this context appear here.` + : "No DIDs you can reach. Published identifiers you administer appear here." + } + /> + )} + + + + + ); +} diff --git a/packages/extension/src/manager/panes/keys.tsx b/packages/extension/src/manager/panes/keys.tsx new file mode 100644 index 0000000..6a0beb2 --- /dev/null +++ b/packages/extension/src/manager/panes/keys.tsx @@ -0,0 +1,380 @@ +// Keys — scoped to the selected context. +// +// `keysList` takes a `contextId`, so this pane asks a different question when +// the tree selection changes rather than filtering a full list client-side. The +// difference matters: a caller's ACL may not reach every context, and asking +// per-context gets the agent's answer for the one being looked at instead of +// silently rendering a subset of a list that failed elsewhere. +// +// **Nothing here signs anything.** `keysSign` and `keysDeriveAndSign` exist in +// the admin module and are deliberately not surfaced: signing with an operator +// key is not an administration task, it is use, and a console that offers a +// "sign this" box turns an audit trail of key management into an oracle. + +import { useCallback, useState } from "react"; +import { + keysCreate, + keysList, + keysRename, + keysRevoke, + type KeyRecord, + type KeyStatus, +} from "@openvtc/pnm-core/admin"; +import { Button, 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, Truncated, 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"; + +const PAGE = 50; + +const fieldStyle: React.CSSProperties = { + boxSizing: "border-box", + padding: "6px 9px", + background: c.ground, + color: c.text, + border: `1px solid ${c.line}`, + borderRadius: "var(--w-r-sm)", + fontSize: t.sm, +}; + +function StatusPill({ status }: { status: KeyStatus }) { + return {status}; +} + +/** Where the key came from, which is the difference between recoverable and + * gone forever. `internal` keys are generated from the CSPRNG and derive from + * no seed — nothing reconstitutes them. */ +function OriginNote({ record }: { record: KeyRecord }) { + if (record.origin !== "internal") { + return {record.origin ?? "derived"}; + } + return ( + + internal · unrecoverable + + ); +} + +function CreateKey({ + parties, + contextId, + authority, + onCreated, +}: { + parties: Parties; + contextId: ContextSelection; + authority: Authority | null; + onCreated: () => void; +}) { + const [keyType, setKeyType] = useState<"ed25519" | "x25519" | "p256">("ed25519"); + const [label, setLabel] = useState(""); + const [keyId, setKeyId] = useState(""); + const [internal, setInternal] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [pending, setPending] = useState(null); + + const denied = authority && !hasRole(authority, "admin", "super-admin", "operator") + ? "Creating a key needs an administrative role at this agent." + : !contextId + ? "Select a context in the tree first — a key is minted into one." + : null; + + // `keyId` is optional for a derived key (the agent names it after the + // derivation path) but there is no path for an internal one, so the agent has + // nothing to name it after. Required here rather than discovered as a reject. + const needsKeyId = internal && !keyId.trim(); + + const submit = useCallback(async () => { + if (!contextId) return; + setBusy(true); + setError(null); + setPending(null); + const ok = await runMutation( + async () => { + await keysCreate(managerSender, { + ...parties, + keyType, + contextId, + internal, + ...(label.trim() ? { label: label.trim() } : {}), + ...(keyId.trim() ? { keyId: keyId.trim() } : {}), + }); + }, + { onConsent: setPending, onError: setError }, + ); + setBusy(false); + if (ok) { + setLabel(""); + setKeyId(""); + onCreated(); + } + }, [parties, contextId, keyType, label, keyId, internal, onCreated]); + + return ( + + Minted into {contextId}. Your agent + holds the private half and never returns it — only the public key comes back. + + ) : ( + "Select a context in the tree to mint a key into it." + ) + } + > +
+ + + + +
+ + {internal && ( + + An internal key derives from no seed. It cannot be re-derived, exported or recovered by + any means — losing the agent's keyspace loses this key and everything it authorises. + + )} + {error && {error}} + {pending && } + +
+ +
+ {denied && {denied}} +
+ ); +} + +function RenameKey({ + parties, + record, + onDone, +}: { + parties: Parties; + record: KeyRecord; + onDone: () => void; +}) { + const [open, setOpen] = useState(false); + const [next, setNext] = useState(record.keyId); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [pending, setPending] = useState(null); + + if (!open) { + return ( + + ); + } + + return ( +
+ setNext(e.target.value)} /> + {error && {error}} + {pending && } +
+ + +
+
+ ); +} + +export function KeysPane({ + parties, + authority, + contextId, +}: { + parties: Parties; + authority: Authority | null; + contextId: ContextSelection; +}) { + const [limit, setLimit] = useState(PAGE); + + const list = useAsync( + () => + keysList(managerSender, { + ...parties, + limit, + ...(contextId ? { contextId } : {}), + }), + [parties.holder.did, parties.service.did, contextId, limit], + ); + + const revokeDenied = authority && !hasRole(authority, "admin", "super-admin", "operator") + ? "Revoking a key needs an administrative role at this agent." + : null; + + const columns: Column[] = [ + { + key: "keyId", + header: "Key", + render: (k) => ( +
+ {k.keyId} + {k.label && {k.label}} +
+ ), + }, + { key: "type", header: "Type", render: (k) => {k.keyType} }, + { key: "status", header: "Status", render: (k) => }, + { key: "origin", header: "Origin", render: (k) => }, + { + key: "path", + header: "Derivation", + render: (k) => ( + + {k.derivationPath ?? "—"} + + ), + }, + { + key: "created", + header: "Created", + render: (k) => ( + + {new Date(k.createdAt).toLocaleDateString()} + + ), + }, + { + key: "actions", + header: "", + render: (k) => + k.status === "revoked" ? ( + revoked + ) : ( +
+ + + label="Revoke" + disabledReason={revokeDenied} + // There is no `keys/preview-revoke`, so the preview is the record + // itself — read back from the agent so what the operator confirms + // against is the agent's current view, not a row that may have + // been stale since the list was fetched. + preview={async () => k} + renderPreview={(p) => ( + <> + Revoking {p.keyId} cannot be undone. + + Anything this key authorises stops working, and any signature made with it + from now on will not verify. Existing signatures are unaffected. + + + )} + commit={async () => { + await keysRevoke(managerSender, { ...parties, keyId: k.keyId }); + }} + onDone={list.reload} + /> +
+ ), + }, + ]; + + return ( +
+ + {list.error && } + {list.loading && !list.data && } + {list.data && ( + <> +
k.keyId} + empty={ + contextId + ? `No keys in ${contextId}. Keys minted into this context appear here.` + : "No keys you can reach. Keys you administer appear here." + } + /> + {list.data.total > list.data.keys.length && ( + setLimit((n) => n + PAGE)} + /> + )} + + )} + + + + + ); +} diff --git a/packages/extension/src/manager/panes/policy.tsx b/packages/extension/src/manager/panes/policy.tsx new file mode 100644 index 0000000..9912ca6 --- /dev/null +++ b/packages/extension/src/manager/panes/policy.tsx @@ -0,0 +1,334 @@ +// Policy — the Rego modules the agent evaluates before it acts. +// +// A power-user surface, and labelled as one. For "which tasks need a human" +// the answer is the Approvals pane; this is the layer underneath, where the +// rule is written rather than chosen. +// +// **Every write carries `expectedVersion`.** `PolicyModule` has a `version`, +// and both upsert and delete accept the version the editor was opened on. That +// is the difference between saving your edit and silently discarding somebody +// else's: without it, two operators editing the same module means last-write- +// wins, and the loser never finds out. + +import { useCallback, useState } from "react"; +import { + policyDelete, + policyList, + policyUpsert, + type PolicyModule, +} from "@openvtc/pnm-core/admin"; +import { Button, 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, Truncated, 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"; + +const fieldStyle: React.CSSProperties = { + boxSizing: "border-box", + padding: "6px 9px", + background: c.ground, + color: c.text, + border: `1px solid ${c.line}`, + borderRadius: "var(--w-r-sm)", + fontSize: t.sm, +}; + +const codeStyle: React.CSSProperties = { + ...fieldStyle, + fontFamily: font.mono, + fontSize: t.xs, + minHeight: 180, + width: "100%", + lineHeight: 1.5, + resize: "vertical", +}; + +function PolicyEditor({ + parties, + existing, + authority, + onSaved, + onCancel, +}: { + parties: Parties; + /** Absent for a new module. */ + existing?: PolicyModule; + authority: Authority | null; + onSaved: () => void; + onCancel?: () => void; +}) { + const [name, setName] = useState(existing?.name ?? ""); + const [description, setDescription] = useState(existing?.description ?? ""); + const [module, setModule] = useState(existing?.module ?? ""); + const [enabled, setEnabled] = useState(existing?.enabled ?? false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [pending, setPending] = useState(null); + + const denied = authority && !hasRole(authority, "admin", "super-admin") + ? "Editing policy needs the admin role at this agent." + : null; + + const save = useCallback(async () => { + setBusy(true); + setError(null); + setPending(null); + const ok = await runMutation( + async () => { + await policyUpsert(managerSender, { + ...parties, + ...(existing ? { id: existing.id, expectedVersion: existing.version } : {}), + name: name.trim(), + module, + enabled, + ...(description.trim() ? { description: description.trim() } : {}), + }); + }, + { onConsent: setPending, onError: setError }, + ); + setBusy(false); + if (ok) onSaved(); + }, [parties, existing, name, description, module, enabled, onSaved]); + + return ( + + Saving is compare-and-swapped against version{" "} + {existing.version} — if someone else has changed this module since + this editor opened, your save is refused rather than overwriting theirs. + + ) : ( + "A Rego module the agent evaluates before it acts. Written by hand; for the common " + + "case of 'this task needs a human', use the Approvals pane instead." + ) + } + > +
+
+ + +
+ +