diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index aaee75c..914fbe2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -66,19 +66,78 @@ 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" + + # A second, stricter guard — and the difference from the one above is the + # point. + # + # That guard is about *authority*: `admin/*` grants and revokes it, and + # the console is deliberately the one surface that holds it, so it names + # `manager.js` as an exception. + # + # These tasks are about *material*. `vta/seeds/export-mnemonic/1.0` + # returns a BIP-39 mnemonic — the seed every derived key in the agent + # comes from — and `list`/`rotate` are the rest of that family's surface. + # There is no browser context that should be able to ask for them, so this + # guard has **no exception**: not the console, not the wallet, nowhere in + # `dist/`. + # + # It exists because the alternative is an omission, and an omission is + # indistinguishable from not having got to it yet. Someone reasonable + # could add a seeds pane next year and no one would know it was refused on + # purpose. This is what says so. + # + # `vault/release/0.1` is deliberately NOT here: it releases a secret to a + # site the human just approved, which is the wallet's whole job. + - name: Assert no key-material surface ships at all + run: | + for task in 'vta/seeds/list/1.0' 'vta/seeds/rotate/1.0' 'vta/seeds/export-mnemonic/1.0'; do + found=$(grep -rlF "$task" packages/extension/dist/ || true) + if [ -n "$found" ]; then + echo "::error::$found contains $task — this family returns key material and must not ship in any extension bundle, the console included. See CLAUDE.md." + exit 1 + fi + done + echo "OK: no key-material task URIs anywhere in dist/" + + # 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..3f20fb1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -15,7 +15,8 @@ interaction code: - **`vti-stack-development-guide.md`** — binding rules (R-numbers below); paste its pre-merge checklist into PRs. - **`vti-networking-remediation-plan.md`** — deliverable **D8** covers this - repo (with vti-didcomm-js and pnm-relay). + repo (with vti-didcomm-js; `pnm-relay` was the third and no longer exists — + see R4.1). - **`vti-architectural-direction.md`** — design-level rationale. Rules that bite hardest here: @@ -54,9 +55,22 @@ Rules that bite hardest here: network helper here takes an optional `fetch` for testability, so a literal `grep "fetch("` finds almost nothing — the real calls are spelled `f(...)`, `fetchFn(...)`, `this.fetchImpl(...)`. -- **R4.1 — shared code with pnm-relay and vti-didcomm-js is a liability until - extracted**: the relay never received this repo's body-first error-parsing - fix. Land contract/transport fixes in all three or extract the shared core. +- **R4.1 — the shared core is extracted; keep it that way.** This rule used to + read "shared code with pnm-relay and vti-didcomm-js is a liability until + extracted: the relay never received this repo's body-first error-parsing + fix". That is done and the note had gone stale: **`pnm-relay` no longer + exists.** Its `rest-channel.ts` / `request-task.ts` were consolidated into + `@openvtc/pnm-core` — the copy `pnm-extension` and `pnm-pwa` both consume, + which carries the body-first parse (`decodeTrustTaskHttpAck` reads the body, + then builds with `errorFromBody`; `errorFromResponse` appears nowhere) and the + `ConsentRequired` union. Nothing depends on `@openvtc/pnm-relay`, and + `rp-sdk-js` is a separate server-side SIOPv2 verifier, not its successor. + (`vti-networking-remediation-plan.md` F5, resolved by consolidation.) + + What survives is the *rule*, not the defect: `vti-didcomm-js` is still a + separate implementation of the same wire contract, so a transport or + error-shape fix has to land in both. A third copy is what R4.1 exists to + prevent — do not reintroduce one. ## How persist-before-ack is held (R1.6) @@ -128,6 +142,108 @@ 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. + +## Key material never reaches a browser, and that is enforced + +`vta/seeds/*` — `list`, `rotate`, `export-mnemonic` — is the one task family +this extension refuses outright. `export-mnemonic` returns a BIP-39 mnemonic: +the seed every derived key in the agent comes from, and the one secret whose +disclosure loses everything at once. `list` and `rotate` are the rest of that +family's surface. + +**A second CI guard bans all three from anywhere in `dist/`, with no +exception.** That is the difference from the admin guard above, and the +difference is the point: `admin/*` is *authority*, which the console is meant to +hold, so that guard names `manager.js` as its one permitted file. These return +*material*, and no browser context should be able to ask for them — not the +console, not the wallet, nowhere. + +**Why a guard rather than simply not building it.** Not building a seeds pane is +indistinguishable from not having got round to one. Someone reasonable adds it +next year, nothing objects, and the refusal was never recorded anywhere a person +would look. The guard is what makes the decision legible. + +**Verified non-vacuous — and the way it is verified matters.** A seeds URI +merely *present* in console source is not enough: Rollup tree-shakes an +unreferenced export, the string never reaches `dist/`, and the guard correctly +stays silent. That is the guard being right, not weak — it asserts what +*ships* — but it means a probe that adds an unused `export const` proves +nothing and reads like a hole. To re-verify, put the URI somewhere the console +actually renders (a nav `label`, say), rebuild, and watch `manager.js` trip it. + +`packages/core` has no seeds module and must not gain one. The guard catches +that too — a core function would be bundled into `manager.js` and grep would +find it there. + +**`vault/release/0.1` is deliberately not on the list.** It releases a secret to +a site the human has just approved, which is the wallet's entire job. The line +is not "touches a secret"; it is "hands over material the holder cannot revoke, +to a surface that cannot contain it". + +**What breaks it:** adding a seeds client to `packages/core`; relaxing the guard +to allow `manager.js` "for symmetry" with the admin one; or reading this as +advice rather than a refusal. + ## Advertisement is not availability A VTA's DID document says what it *offers*. `buildVtaSession` skips a channel diff --git a/package-lock.json b/package-lock.json index 880320f..ec110d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,9 @@ "packages/demo-rp", "packages/reviewer-demo" ], + "dependencies": { + "@openvtc/trust-tasks": "^0.16.8" + }, "engines": { "node": ">=24" } @@ -2301,9 +2304,9 @@ "link": true }, "node_modules/@openvtc/trust-tasks": { - "version": "0.16.3", - "resolved": "https://registry.npmjs.org/@openvtc/trust-tasks/-/trust-tasks-0.16.3.tgz", - "integrity": "sha512-dEWebVgAEvjc7rqfAkAT96sDiVSvTSxv1HGfrCI5XxYk7DfNNHToNGZeppAseMJM2/nuvLinDyhXoQis6XgbTA==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@openvtc/trust-tasks/-/trust-tasks-0.16.8.tgz", + "integrity": "sha512-wzwfEcylO91Tr5Cb9UCMqrMOpw1IVuspKHXAGYeOkPQi4LVurv7XH9/NHfRIK9dl7cqpucZ5kmjJRKCI8bD+Gw==", "license": "Apache-2.0" }, "node_modules/@openvtc/vti-didcomm-js": { @@ -4688,9 +4691,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "dev": true, "funding": [ { @@ -7806,7 +7809,7 @@ "dependencies": { "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", - "@openvtc/trust-tasks": "^0.16.3", + "@openvtc/trust-tasks": "^0.16.8", "@openvtc/vti-didcomm-js": "^0.7.0", "@openvtc/vti-tsp-js": "^0.2.0", "@scure/base": "^2.2.0", diff --git a/package.json b/package.json index a5819ce..3801843 100644 --- a/package.json +++ b/package.json @@ -26,5 +26,8 @@ "overrides": { "uuid": "^11.1.1", "esbuild": "^0.28.1" + }, + "dependencies": { + "@openvtc/trust-tasks": "^0.16.8" } } diff --git a/packages/core/package.json b/packages/core/package.json index a087975..3c6f2a8 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -120,7 +120,7 @@ "dependencies": { "@cfworker/json-schema": "^4.1.1", "@noble/curves": "^2.4.0", - "@openvtc/trust-tasks": "^0.16.3", + "@openvtc/trust-tasks": "^0.16.8", "@openvtc/vti-didcomm-js": "^0.7.0", "@openvtc/vti-tsp-js": "^0.2.0", "@scure/base": "^2.2.0", 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/backup.ts b/packages/core/src/admin/backup.ts new file mode 100644 index 0000000..a17c3bd --- /dev/null +++ b/packages/core/src/admin/backup.ts @@ -0,0 +1,104 @@ +// `vta/backup/*` — and the deliberate absence of most of it. +// +// The agent dispatches five verbs: initiate-export, complete-export, +// initiate-import, finalize-import, abort. **This module exposes one.** +// +// ## Why export and import are not here +// +// `initiate-export` and `finalize-import` both carry a `password` member: the +// key-derivation input that protects, or unlocks, a complete copy of the agent +// — every key, ACL and trust context it holds. It travels *inbound*, chosen by +// the caller at the moment of asking. +// +// The specification classifies it `ingests: secret` and annotates it +// `writeOnly`, and it says the part that decides this module's shape: the +// password's exposure is settled by **where it is typed**. A browser form is +// reachable by autofill, by a password manager, by any other extension with +// host access, and by screen capture — none of which this wallet controls, and +// none of which a step-up ceremony reaches. A passkey prompt proves a human is +// present for the action; it proves nothing about the field, which was filled +// in before the prompt appeared. +// +// So this is not a "not yet". Adding an export pane would be a decision to +// collect the agent's master password in the least defensible place available, +// and the CLI already collects it in a better one. Operators export and import +// there. +// +// ## Why `abort` is +// +// It carries no secret — one opaque handle in, a boolean out — and it is the +// only way to close a window that is otherwise open until it expires. An +// operator who realises mid-export that the download went somewhere it should +// not have wants the bundle dead now, and the alternative is waiting out the +// slot with a fetchable copy of the agent live at a known address. +// +// It also unwedges: an agent caps how many bundles one operator may hold open, +// so an abandoned bundle costs a slot until expiry. +// +// ## Why there is no bundle list to abort *from* +// +// There is no enumerate verb in the family — the agent offers no way to ask +// what is in flight. So the caller supplies the id the CLI printed. A picker +// would be nicer and cannot be built without a task that does not exist; the +// UI says so rather than implying the console lost it. + +import type { TaskParty, TrustTaskSender } from "../vta/channel.js"; +import { buildTrustTask } from "../vta/trust-task.js"; + +import { + TYPE_URI as BACKUP_ABORT, + RESPONSE_TYPE_URI as BACKUP_ABORT_RESPONSE, + type VTABackupAbortPayload, + type VTABackupAbortResponsePayload, +} from "@openvtc/trust-tasks/vta/backup/abort/1.0/payload"; + +/** Issued by an operator identity, to the agent holding the bundle. */ +export interface BackupCallerParams { + /** Envelope `issuer`. Must be the identity that initiated the bundle — + * the agent checks, and answers a mismatch as not-found rather than as a + * refusal, so that a stranger cannot learn a bundle exists by guessing. */ + holder: TaskParty; + /** The agent — envelope `recipient`. */ + service: TaskParty; +} + +export interface BackupAbortParams extends BackupCallerParams { + /** Handle from an initiate-export or initiate-import descriptor. Either kind + * is accepted; the agent already knows which it holds. */ + bundleId: string; +} + +/** What the agent did, which is not always what was asked. */ +export interface BackupAbortResult { + bundleId: string; + /** `false` means the bundle was already terminal — completed, expired, or + * aborted by an earlier attempt. A success, not a failure: abort is + * idempotent precisely because the situation it exists for (a dropped + * connection, an operator unsure whether the cancel landed) is the one that + * produces duplicates. Callers should render it as "already closed" rather + * than as an error. */ + aborted: boolean; +} + +/** + * Cancel an in-flight export or import bundle and discard its staged bytes. + * + * Destructive and irreversible for the bundle: on the export side the + * encrypted copy is deleted and cannot be re-minted — a new export re-serialises + * the agent and produces different bytes. + */ +export async function backupAbort( + sender: TrustTaskSender, + params: BackupAbortParams, +): Promise { + const payload: VTABackupAbortPayload = { bundleId: params.bundleId }; + const envelope = buildTrustTask(BACKUP_ABORT, payload, { + issuer: params.holder.did, + recipient: params.service.did, + }); + const res = await sender.send(envelope, { + expectedResponseType: BACKUP_ABORT_RESPONSE, + operationLabel: "vta/backup/abort/1.0", + }); + return { bundleId: res.bundleId, aborted: res.aborted }; +} 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..be537f4 100644 --- a/packages/core/src/admin/contexts.ts +++ b/packages/core/src/admin/contexts.ts @@ -10,28 +10,27 @@ // 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"; -const TASK_CONTEXTS_PREVIEW_DELETE = - "https://trusttasks.org/spec/vta/contexts/preview-delete/1.0"; +import { + TYPE_URI as TASK_CONTEXTS_DELETE, + type VTAContextsDeleteResponsePayload, +} from "@openvtc/trust-tasks/vta/contexts/delete/1.0/payload"; +import { TYPE_URI as TASK_CONTEXTS_PREVIEW_DELETE } from "@openvtc/trust-tasks/vta/contexts/preview-delete/1.0/payload"; 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. */ force?: boolean; } -export interface ContextDeleteResult { - id: string; - deleted: boolean; -} +/** The delete response, from the binding. The hand-written copy omitted + * `ext`, which SPEC §4.5.1 lets any agent send. */ +export type ContextDeleteResult = VTAContextsDeleteResponsePayload; /** * What deleting this context would destroy. diff --git a/packages/core/src/admin/credentials.ts b/packages/core/src/admin/credentials.ts index 62b9286..d43b576 100644 --- a/packages/core/src/admin/credentials.ts +++ b/packages/core/src/admin/credentials.ts @@ -11,11 +11,17 @@ // 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 { + TYPE_URI as CREDENTIALS_LIST, + RESPONSE_TYPE_URI as CREDENTIALS_LIST_RESPONSE, + type VTACredentialsListResponsePayload, + type IssuedCredentialSummary, + type IssuedCredentialStatus, +} from "@openvtc/trust-tasks/vta/credentials/list/0.1/payload"; + import { TYPE_URI as CREDENTIALS_ISSUE, RESPONSE_TYPE_URI as CREDENTIALS_ISSUE_RESPONSE, @@ -32,9 +38,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 { @@ -128,3 +134,82 @@ export async function revokeCredential( operationLabel: "vta/credentials/revoke/0.1", }); } + +export type { IssuedCredentialSummary, IssuedCredentialStatus }; + +export interface ListCredentialsParams extends CredentialIssuerCallerParams { + /** + * Only credentials issued to this DID. + * + * Named `holderDid` rather than `holder` — matching {@link issueCredential} — + * because `holder` on the caller params is the envelope's *issuer*, this + * library's own identity. Two different parties would otherwise share one + * member name on the same object. + */ + holderDid?: string; + /** Only credentials carrying this type tag beyond `VerifiableCredential`. */ + credentialType?: string; + /** Only credentials in this state. */ + status?: IssuedCredentialStatus; + /** Maximum records to return. The agent caps this. */ + pageSize?: number; + /** Continue a previous page. Opaque — never construct or parse one. */ + cursor?: string; +} + +export interface ListCredentialsResult { + credentials: IssuedCredentialSummary[]; + /** The agent stopped early. **Check this before drawing conclusions** — a + * truncated page is not a complete account of what was issued, and reading + * "nothing else was issued" off one is the mistake the member exists to + * prevent. */ + truncated: boolean; + /** Pass as `cursor` to continue. Absent on the last page, so a caller stops + * on its absence rather than needing an empty page to learn it is done. */ + cursor?: string; +} + +/** + * What this agent has issued, as metadata. + * + * **No credential bodies.** `vault/list/0.1` states the rule this family + * follows — list enumerates, release uses — and an issuer that needs a body + * minted it and got it back from {@link issueCredential}. A caller reaching for + * this to populate claim data has mistaken it for a read of the credentials + * themselves; there is no such task. + * + * `status` is derived by the agent when it answers, never stored, and + * `revoked` takes precedence over `expired` — a credential revoked before its + * window closed is revoked, and reading it as merely expired hides that + * somebody acted. Do not cache the result: a cached page reports a credential + * as active after it has been revoked. + * + * Unlike the holder-side `credVaultQuery`, an unfiltered call is answered. The + * caller here is the issuer reading a record of its own past actions rather + * than a delegate reading someone's private store. + */ +export async function listCredentials( + sender: TrustTaskSender, + params: ListCredentialsParams, +): Promise { + const envelope = buildTrustTask( + CREDENTIALS_LIST, + { + ...(params.holderDid ? { holder: params.holderDid } : {}), + ...(params.credentialType ? { credentialType: params.credentialType } : {}), + ...(params.status ? { status: params.status } : {}), + ...(params.pageSize !== undefined ? { pageSize: params.pageSize } : {}), + ...(params.cursor ? { cursor: params.cursor } : {}), + }, + { issuer: params.holder.did, recipient: params.service.did }, + ); + const res = await sender.send(envelope, { + expectedResponseType: CREDENTIALS_LIST_RESPONSE, + operationLabel: "vta/credentials/list/0.1", + }); + return { + credentials: res.credentials ?? [], + truncated: res.truncated ?? false, + ...(res.cursor ? { cursor: res.cursor } : {}), + }; +} 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/index.ts b/packages/core/src/admin/index.ts index 654ce94..786e5bd 100644 --- a/packages/core/src/admin/index.ts +++ b/packages/core/src/admin/index.ts @@ -22,3 +22,4 @@ export * from "./consent.js"; export * from "./contexts.js"; export * from "./services.js"; export * from "./credentials.js"; +export * from "./backup.js"; 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..f391845 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 { @@ -64,15 +62,21 @@ import { type VTAServicesDrainCancelPayload, type VTAServicesDrainCancelResponsePayload, } from "@openvtc/trust-tasks/vta/services/drain/cancel/1.0/payload"; +import { + TYPE_URI as MANAGEMENT_RELOAD, + RESPONSE_TYPE_URI as MANAGEMENT_RELOAD_RESPONSE, + type VTAManagementReloadServicesPayload, + type VTAManagementReloadServicesResponsePayload, +} from "@openvtc/trust-tasks/vta/management/reload-services/1.0/payload"; 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`. */ @@ -263,3 +267,57 @@ export async function serviceDrainCancel( return call(sender, params, SERVICES_DRAIN_CANCEL, SERVICES_DRAIN_CANCEL_RESPONSE, "vta/services/drain/cancel/1.0", payload); } + +/** + * Re-read the agent's service configuration and restart its transports. + * + * Takes no parameters, and the specification argues for the emptiness rather + * than leaving it to look unfinished: every member this could carry — a list of + * services to reload selectively, inline configuration, a flag to skip + * validation — turns "apply what is written down" into "apply what this + * document says", which would let a caller put an agent into a state its own + * configuration does not describe. So a reload is all-or-nothing, and the + * agent's config stays the single account of how the agent runs. + * + * ## Two outcomes that are not failures + * + * **No response.** A successful reload drops the very transport the response + * would travel on, so the connection may simply close. That is not an error and + * **must not be retried as one** — the reload has very likely succeeded. The + * reliable check is external to the call: reconnect and see whether the agent + * answers. + * + * **`reloadFailed`.** The agent read its configuration and could not bring + * services up on it. What state it is left in is implementation-specific, and + * the code deliberately does not assert one: it may hold the previous config + * and stay up, come up partially, or not come up at all. Do not report to an + * operator that the agent is still serving. + * + * ## What it costs + * + * Every open session drops, including this wallet's own inbound sessions. The + * cost falls on every counterparty of the agent, none of whom asked — which is + * why the console puts it behind a step-up even though the task creates, + * deletes and discloses nothing. + */ +export async function reloadServices( + sender: TrustTaskSender, + params: ServicesCallerParams, +): Promise<{ status: string }> { + const payload: VTAManagementReloadServicesPayload = {}; + const res = await call< + VTAManagementReloadServicesPayload, + VTAManagementReloadServicesResponsePayload + >( + sender, + params, + MANAGEMENT_RELOAD, + MANAGEMENT_RELOAD_RESPONSE, + "vta/management/reload-services/1.0", + payload, + ); + // Advisory free text — shown to a human, never branched on. The reliable + // signal is whether the agent answers afterwards, not what it said before + // restarting. + return { status: res.status }; +} 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/app-state/records.ts b/packages/core/src/app-state/records.ts index 87bd273..beb9f7b 100644 --- a/packages/core/src/app-state/records.ts +++ b/packages/core/src/app-state/records.ts @@ -15,9 +15,7 @@ // is the isolation boundary** — not a filter, and not a convenience. Two // contexts holding the same namespace and key hold two unrelated records. -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,9 +61,9 @@ export type { AppStateRecord }; /** Every `vta/app-state/*` call is issued by an identity, to an agent. */ export interface AppStateCallerParams { /** Envelope `issuer` — the caller's DIDComm identity. */ - holder: Identity; + holder: TaskParty; /** The agent — envelope `recipient`. */ - service: RemoteDidcommEndpoint; + service: TaskParty; /** The VTA context the record is scoped to. The isolation boundary: the same * `(namespace, key)` in another context is a different record. */ contextId: string; diff --git a/packages/core/src/device/register-gateway.ts b/packages/core/src/device/register-gateway.ts index b768d06..63751b7 100644 --- a/packages/core/src/device/register-gateway.ts +++ b/packages/core/src/device/register-gateway.ts @@ -19,19 +19,24 @@ import { withFetchTimeout } from "../http/timeout-fetch.js"; // push/register/0.2 — the payload is field-identical to 0.1 (no enum values), // so this is a pure version-string bump. The gateway accepts both 0.1 and 0.2 // and mirrors the request version into the `#response`. -const TASK_PUSH_REGISTER = "https://trusttasks.org/spec/push/register/0.2"; -const TASK_PUSH_REGISTER_RESPONSE = - "https://trusttasks.org/spec/push/register/0.2#response"; +import { + TYPE_URI as TASK_PUSH_REGISTER, + RESPONSE_TYPE_URI as TASK_PUSH_REGISTER_RESPONSE, + type WebPush, +} from "@openvtc/trust-tasks/push/register/0.2/payload"; /** A device's platform push channel — tagged union over `platform`. Only the * Web Push variant is wired today (self-hostable, no Apple/Google account). */ -export type PushRegistration = { - platform: "webpush"; - /** RFC 8030 Web Push subscription endpoint. */ - endpoint: string; - /** RFC 8291 encryption keys. */ - keys: { p256dh: string; auth: string }; -}; +/** What this wallet registers: a Web Push subscription. + * + * A deliberate NARROWING of the schema's `PushRegistration`, which is + * `Apns | Fcm | WebPush` — a browser extension cannot produce the other two. + * Taken as the generated `WebPush` variant rather than restated, so the + * narrowing is the only thing this line says: the members still come from the + * schema, and widening the union later does not silently leave this behind. + */ +export type PushRegistration = WebPush; + export interface RegisterPushChannelOptions { /** Push gateway base URL (the HTTPS transport — `POST {gatewayUrl}/trust-tasks`). */ diff --git a/packages/core/src/device/set-wake.ts b/packages/core/src/device/set-wake.ts index a61ddde..89c09c6 100644 --- a/packages/core/src/device/set-wake.ts +++ b/packages/core/src/device/set-wake.ts @@ -18,29 +18,23 @@ import type { RemoteDidcommEndpoint } from "../vta/didcomm.js"; import { RestChannel, type RestChannelOptions } from "../vta/rest-channel.js"; import { buildTrustTask } from "../vta/trust-task.js"; -const TASK_DEVICE_SET_WAKE = "https://trusttasks.org/spec/device/set-wake/0.2"; -const TASK_DEVICE_SET_WAKE_RESPONSE = - "https://trusttasks.org/spec/device/set-wake/0.2#response"; +import { + TYPE_URI as TASK_DEVICE_SET_WAKE, + RESPONSE_TYPE_URI as TASK_DEVICE_SET_WAKE_RESPONSE, + type WakeHandle, + type WakeTriggerPolicy, + type DeviceSetWakeResponsePayload, +} from "@openvtc/trust-tasks/device/set-wake/0.2/payload"; -/** The opaque gateway-issued handle — gateway address + handle, no token. */ -export interface WakeHandle { - /** Gateway that issued + acts on this handle (DID or https URL). */ - gateway: string; - /** Opaque gateway-issued channel identifier (reveals no token). */ - handle: string; -} +/** Re-exported from the binding rather than restated here: both were declared + * by hand and were byte-identical to the schema's, which is the state just + * before a divergence nobody notices. */ +export type { WakeHandle, WakeTriggerPolicy }; -/** The VTA's effective allowlist, as provisioned to the gateway. */ -export interface WakeTriggerPolicy { - allowedTriggers: string[]; -} +/** The set-wake response, from the binding. The hand-written copy omitted + * `ext`, which SPEC §4.5.1 lets any agent send. */ +export type DeviceSetWakeResponse = DeviceSetWakeResponsePayload; -export interface DeviceSetWakeResponse { - /** Whether the device now has a usable wake channel. */ - pushCapable: boolean; - /** The effective allowlist the VTA computed + provisioned (absent on clear). */ - triggerPolicy?: WakeTriggerPolicy; -} export interface DeviceSetWakeParams { /** The wallet's holder DIDComm identity (envelope `issuer`). */ diff --git a/packages/core/src/inbound/task-consent.ts b/packages/core/src/inbound/task-consent.ts index 8f443da..076b75b 100644 --- a/packages/core/src/inbound/task-consent.ts +++ b/packages/core/src/inbound/task-consent.ts @@ -53,17 +53,23 @@ import { type PayloadValidator, } from "../trust-tasks/validate.js"; -import { PAYLOAD_SCHEMA } from "@openvtc/trust-tasks/task-consent/request/0.1/payload"; -import { RESPONSE_PAYLOAD_SCHEMA as DECISION_RESPONSE_SCHEMA } from "@openvtc/trust-tasks/task-consent/decision/0.1/payload"; +import { + PAYLOAD_SCHEMA, + TYPE_URI as TASK_CONSENT_REQUEST_TYPE_URI, + type Exposure, + type StatePin, +} from "@openvtc/trust-tasks/task-consent/request/0.1/payload"; +import { + RESPONSE_PAYLOAD_SCHEMA as DECISION_RESPONSE_SCHEMA, + TYPE_URI as TASK_CONSENT_DECISION_TYPE_URI, +} from "@openvtc/trust-tasks/task-consent/decision/0.1/payload"; +import { TYPE_URI as TASK_CONSENT_GRANTED_TYPE_URI } from "@openvtc/trust-tasks/task-consent/granted/0.1/payload"; import type { SigningIdentity } from "../siop/self-issued.js"; -export const TASK_CONSENT_REQUEST_TYPE = - "https://trusttasks.org/spec/task-consent/request/0.1"; -export const TASK_CONSENT_DECISION_TYPE = - "https://trusttasks.org/spec/task-consent/decision/0.1"; +export const TASK_CONSENT_REQUEST_TYPE = TASK_CONSENT_REQUEST_TYPE_URI; +export const TASK_CONSENT_DECISION_TYPE = TASK_CONSENT_DECISION_TYPE_URI; /** VTA → requester: an approval landed and a grant is ready — re-submit now. */ -export const TASK_CONSENT_GRANTED_TYPE = - "https://trusttasks.org/spec/task-consent/granted/0.1"; +export const TASK_CONSENT_GRANTED_TYPE = TASK_CONSENT_GRANTED_TYPE_URI; /** The executor's acknowledgement of a decision this device sent. */ export const TASK_CONSENT_DECISION_RESPONSE_TYPE = `${TASK_CONSENT_DECISION_TYPE}#response`; @@ -264,16 +270,13 @@ export function parseTaskConsentGranted( /** SPEC §7.3 item 13 — the integrity effect of executing the task. */ export type SideEffectLevel = "none" | "mutating" | "destructive"; -/** SPEC §7.3 item 14 — what leaves the executor, and whose authority is used. */ -export interface Exposure { - discloses: "none" | "metadata" | "secret"; - actsAsSubject: boolean; -} /** * One consequence of executing the task, authored by the VTA by dry-running the * handler it is about to invoke. */ +export type { Exposure, StatePin }; + export interface ConsentEffect { /** Machine discriminator. The set is OPEN — handlers evolve faster than this * type, so a surface MUST tolerate a kind it does not recognise. */ @@ -287,11 +290,6 @@ export interface ConsentEffect { detail?: Record; } -/** The prior state the effects were computed against. */ -export interface StatePin { - resource: string; - version: string; -} /** Payload of an inbound `task-consent/request/0.1` (VTA → approver). */ export interface TaskConsentRequestPayload { diff --git a/packages/core/src/onboarding/swap.ts b/packages/core/src/onboarding/swap.ts index 6fe134a..d46e091 100644 --- a/packages/core/src/onboarding/swap.ts +++ b/packages/core/src/onboarding/swap.ts @@ -24,19 +24,29 @@ import { RestChannel } from "../vta/rest-channel.js"; import type { DidcommMessageBridge } from "../vta/transport.js"; import { buildTrustTask } from "../vta/trust-task.js"; -const ACL_SWAP_KEY = "https://trusttasks.org/spec/acl/swap-key/0.1"; -const ACL_SWAP_KEY_RESPONSE = "https://trusttasks.org/spec/acl/swap-key/0.1#response"; +import { + TYPE_URI as ACL_SWAP_KEY, + RESPONSE_TYPE_URI as ACL_SWAP_KEY_RESPONSE, + type ACLSwapKeyResponsePayload, + type AclEntry, +} from "@openvtc/trust-tasks/acl/swap-key/0.1/payload"; + +/** The swap-key response, as the registry declares it: the realized ACL entry + * plus the DID that was swapped out. + * + * This was declared by hand as a FLAT entry — `did`, `role`, `allowedContexts`, + * `createdAt: number` — and was wrong in three ways at once. The agent wraps + * the entry (`{ entry, previousSubject }`, VTI #857), and the entry itself + * names `subject` not `did`, `scopes` not `allowedContexts`, and dates as + * RFC 3339 strings not numbers. Every field a caller read came back + * `undefined`. + * + * Nothing caught it because `sender.send()` is an unchecked cast and the + * only test built its fixture from this type rather than from the schema — so + * the test asserted the drift and would have gone on doing so. */ +export type AclSwapResult = ACLSwapKeyResponsePayload; +export type { AclEntry }; -/** The ACL entry created for the new DID (the swap-key result body). */ -export interface AclSwapResult { - did: string; - role: string; - label?: string | null; - allowedContexts: string[]; - createdAt: number; - createdBy: string; - expiresAt?: number | null; -} export interface SwapAclParams { /** The OLD DID (operator-granted ephemeral). Its DID is `currentSubject` and diff --git a/packages/core/src/rp-login/didcomm.ts b/packages/core/src/rp-login/didcomm.ts index 2750d36..3156d10 100644 --- a/packages/core/src/rp-login/didcomm.ts +++ b/packages/core/src/rp-login/didcomm.ts @@ -27,8 +27,10 @@ import type { DidcommMessageBridge } from "../vta/transport.js"; // // Matched with `===` against the spelling the RP declares today; no // both-spellings fold, per this repo's rule on compatibility arms. -const MSG_AUTHENTICATE = "https://trusttasks.org/spec/auth/authenticate/0.1"; -const MSG_AUTH_RESPONSE = "https://trusttasks.org/spec/auth/authenticate/0.1#response"; +import { + TYPE_URI as MSG_AUTHENTICATE, + RESPONSE_TYPE_URI as MSG_AUTH_RESPONSE, +} from "@openvtc/trust-tasks/auth/authenticate/0.1/payload"; const DEFAULT_TIMEOUT_MS = 30_000; diff --git a/packages/core/src/rp-login/step-up.ts b/packages/core/src/rp-login/step-up.ts index 72527c4..1789e37 100644 --- a/packages/core/src/rp-login/step-up.ts +++ b/packages/core/src/rp-login/step-up.ts @@ -25,13 +25,15 @@ import { withFetchTimeout } from "../http/timeout-fetch.js"; // Canonical step-up approval spec from trusttasks-tf. The proof on the // approve-response is what the RP verifies to elevate the session's acr. -const MSG_APPROVE_RESPONSE = "https://trusttasks.org/spec/auth/step-up/approve-response/0.2"; +import { TYPE_URI as MSG_APPROVE_RESPONSE } from "@openvtc/trust-tasks/auth/step-up/approve-response/0.2/payload"; +import { TYPE_URI as APPROVE_REQUEST_0_2 } from "@openvtc/trust-tasks/auth/step-up/approve-request/0.2/payload"; +import { TYPE_URI as APPROVE_REQUEST_0_1 } from "@openvtc/trust-tasks/auth/step-up/approve-request/0.1/payload"; /** The RP→approver request halves this wallet accepts. 0.2 is what the * did-hosting control plane mints on `start`; 0.1 is the VTA-pushed flavor * (same required payload members) — both are gated identically. */ export const STEP_UP_APPROVE_REQUEST_TYPES = [ - "https://trusttasks.org/spec/auth/step-up/approve-request/0.2", - "https://trusttasks.org/spec/auth/step-up/approve-request/0.1", + APPROVE_REQUEST_0_2, + APPROVE_REQUEST_0_1, ] as const; /** The RP's `approve-request/0.2` payload, verified out of the signed diff --git a/packages/core/src/siop/login-client.ts b/packages/core/src/siop/login-client.ts index 7a21a02..7bb039a 100644 --- a/packages/core/src/siop/login-client.ts +++ b/packages/core/src/siop/login-client.ts @@ -11,7 +11,7 @@ import { withFetchTimeout } from "../http/timeout-fetch.js"; /** The canonical authenticate Trust-Task type from trusttasks-tf. * did-hosting + VTA + VTC all dispatch on this same URI. */ -const TASK_AUTH_AUTHENTICATE = "https://trusttasks.org/spec/auth/authenticate/0.1"; +import { TYPE_URI as TASK_AUTH_AUTHENTICATE } from "@openvtc/trust-tasks/auth/authenticate/0.1/payload"; export interface SiopLoginResult { accessToken: string; diff --git a/packages/core/src/vault/credentials.ts b/packages/core/src/vault/credentials.ts new file mode 100644 index 0000000..4953b9a --- /dev/null +++ b/packages/core/src/vault/credentials.ts @@ -0,0 +1,340 @@ +// The credential vault — the W3C credentials a holder **holds**. +// +// `vault/credentials/*`: invitations, memberships, roles. It shares the `vault` +// slug and the agent's `vault` keyspace with the password-manager surface in +// this directory, and is otherwise a different thing — the two use disjoint key +// namespaces, and a credential body is a presentable VC rather than a raw +// secret, so nothing here builds or opens a sealed envelope. +// +// Distinct again from `../credentials/`, which is the *exchange* protocol +// (offer, request, present), and from `../admin/credentials.ts`, which is the +// agent as an **issuer**. This module is the holder's own store. +// +// ## Query enumerates; get discloses +// +// `credVaultQuery` returns body-free descriptors and `credVaultGet` returns the +// credential itself, and they are separate calls on purpose: a consumer can +// browse its own vault continuously while the far narrower act of reading a +// credential's contents stays a separate, separately-recorded request. A caller +// that finds itself calling `credVaultGet` in a loop to populate a list has +// mistaken the two. +// +// **`credVaultQuery` refuses an unconstrained filter**, and so must callers. +// An empty filter returns the shape of the holder's whole life — every +// community, every role, every issuer — so the agent rejects it rather than +// answering. `includeArchived` and `includeDeleted` are modifiers and do not +// satisfy the requirement; `{ includeDeleted: true }` alone is an enumeration +// wearing a flag. {@link isRunnableCredentialQuery} is the local check, so a +// caller can disable a control rather than discover this from a rejection. +// +// Wire types come from `@openvtc/trust-tasks`, generated from the same JSON +// Schemas the agent is generated from. Specified in +// `dtgwg-trust-tasks-tf#338`, which wrote the family down from the +// implementation that had been dispatching it unspecified. + +import type { TaskParty, TrustTaskSender } from "../vta/channel.js"; +import { buildTrustTask } from "../vta/trust-task.js"; + +import { + TYPE_URI as CRED_QUERY, + RESPONSE_TYPE_URI as CRED_QUERY_RESPONSE, + type VaultCredentialsQueryResponsePayload, + type CredentialDescriptor, + type CredentialStatus, +} from "@openvtc/trust-tasks/vault/credentials/query/0.1/payload"; +import { + TYPE_URI as CRED_GET, + RESPONSE_TYPE_URI as CRED_GET_RESPONSE, + type VaultCredentialsGetResponsePayload, +} from "@openvtc/trust-tasks/vault/credentials/get/0.1/payload"; +import { + TYPE_URI as CRED_RECEIVE, + RESPONSE_TYPE_URI as CRED_RECEIVE_RESPONSE, + type VaultCredentialsReceiveResponsePayload, +} from "@openvtc/trust-tasks/vault/credentials/receive/0.1/payload"; +import { + TYPE_URI as CRED_ARCHIVE, + RESPONSE_TYPE_URI as CRED_ARCHIVE_RESPONSE, +} from "@openvtc/trust-tasks/vault/credentials/archive/0.1/payload"; +import { + TYPE_URI as CRED_UNARCHIVE, + RESPONSE_TYPE_URI as CRED_UNARCHIVE_RESPONSE, +} from "@openvtc/trust-tasks/vault/credentials/unarchive/0.1/payload"; +import { + TYPE_URI as CRED_DELETE, + RESPONSE_TYPE_URI as CRED_DELETE_RESPONSE, +} from "@openvtc/trust-tasks/vault/credentials/delete/0.1/payload"; +import { + TYPE_URI as CRED_RESTORE, + RESPONSE_TYPE_URI as CRED_RESTORE_RESPONSE, +} from "@openvtc/trust-tasks/vault/credentials/restore/0.1/payload"; +import { + TYPE_URI as CRED_PURGE, + RESPONSE_TYPE_URI as CRED_PURGE_RESPONSE, +} from "@openvtc/trust-tasks/vault/credentials/purge/0.1/payload"; + +export type { CredentialDescriptor, CredentialStatus }; + +/** Archival state. **Orthogonal to validity** — a credential can be `valid` + * and `archived`, or `revoked` and `active`. A caller that collapses the two + * axes mis-renders its own vault: "can I present this?" needs both, and only + * an `active` one may be presented. */ +export type CredentialLifecycle = "active" | "archived" | "deleted"; + +export interface CredVaultCallerParams { + /** Envelope `issuer`. */ + holder: TaskParty; + /** The agent — envelope `recipient`. */ + service: TaskParty; +} + +/** The indexed fields a query may constrain on. At least one is REQUIRED. */ +export interface CredentialFilter { + /** Match credentials carrying this VC `type` tag. */ + type?: string; + /** Match credentials held for this community or context DID. */ + communityDid?: string; + /** Match credentials from this issuer DID. */ + issuerDid?: string; + /** Match the agent's semantic classification — `invite`, `membership`, + * `role`, `endorsement`, `personhood`, or an agent-defined token. */ + purpose?: string; + /** Match the validity dimension. */ + status?: CredentialStatus; +} + +export interface CredVaultQueryParams extends CredVaultCallerParams, CredentialFilter { + /** Also return archived credentials. A **modifier, not a filter** — it does + * not satisfy the at-least-one-filter requirement. */ + includeArchived?: boolean; + /** Also return soft-deleted tombstones, so a trash view can offer restore or + * purge. Same modifier semantics. */ + includeDeleted?: boolean; +} + +/** + * Whether this filter is one the agent will run. + * + * Exported so a caller can decide *before* sending — a search box that knows + * an empty query is refused can stay disabled and say why, rather than firing a + * request that comes back as an error the user has to interpret. + * + * The modifiers are deliberately not counted. They widen what a filter matches; + * they do not constrain anything. + */ +export function isRunnableCredentialQuery(filter: CredentialFilter): boolean { + return Boolean( + filter.type || filter.communityDid || filter.issuerDid || filter.purpose || filter.status, + ); +} + +/** + * Search stored credentials. Returns **body-free** descriptors. + * + * Throws before sending when the filter constrains nothing — the agent would + * refuse it as an enumeration, and failing here names the reason at the call + * site instead of surfacing a `filterRequired` from three layers down. + */ +export async function credVaultQuery( + sender: TrustTaskSender, + params: CredVaultQueryParams, +): Promise { + if (!isRunnableCredentialQuery(params)) { + throw new Error( + "vault/credentials/query needs at least one of type, communityDid, issuerDid, purpose " + + "or status. An unconstrained query enumerates the whole vault and the agent refuses " + + "it; includeArchived and includeDeleted are modifiers and do not count.", + ); + } + const envelope = buildTrustTask( + CRED_QUERY, + { + ...(params.type ? { type: params.type } : {}), + ...(params.communityDid ? { communityDid: params.communityDid } : {}), + ...(params.issuerDid ? { issuerDid: params.issuerDid } : {}), + ...(params.purpose ? { purpose: params.purpose } : {}), + ...(params.status ? { status: params.status } : {}), + ...(params.includeArchived ? { includeArchived: true } : {}), + ...(params.includeDeleted ? { includeDeleted: true } : {}), + }, + { issuer: params.holder.did, recipient: params.service.did }, + ); + const res = await sender.send(envelope, { + expectedResponseType: CRED_QUERY_RESPONSE, + operationLabel: "vault/credentials/query/0.1", + }); + return res.credentials ?? []; +} + +export interface CredVaultGetParams extends CredVaultCallerParams { + /** Handle from a {@link CredentialDescriptor}. Opaque — never derive one. */ + id: string; +} + +/** + * Fetch one credential's full body, for presentation. + * + * The only call in this module that returns credential contents. An archived or + * soft-deleted credential is not returned: those states mean "not for use", and + * a body handed back is a body that can be presented. + * + * Hold the result no longer than the presentation it was fetched for. The agent + * remains the record — only it sees a later revocation or lifecycle change, and + * a cached body outlives both. + */ +export async function credVaultGet( + sender: TrustTaskSender, + params: CredVaultGetParams, +): Promise> { + const envelope = buildTrustTask( + CRED_GET, + { id: params.id }, + { issuer: params.holder.did, recipient: params.service.did }, + ); + const res = await sender.send(envelope, { + expectedResponseType: CRED_GET_RESPONSE, + operationLabel: "vault/credentials/get/0.1", + }); + return res.credential as Record; +} + +export interface CredVaultReceiveParams extends CredVaultCallerParams { + /** The verifiable credential. */ + credential: Record; + /** Handle to store under. Absent, the agent derives one from the + * credential's own `id`. Supplying it makes a retry after an ambiguous + * failure replace rather than duplicate. */ + id?: string; + /** Context to hold it in. Absent, the caller's own. */ + contextId?: string; + /** Credential format, where the body does not make it evident. */ + format?: string; +} + +/** + * Verify and store a received credential. + * + * The agent verifies the proof against the issuer key resolved from its DID + * **before** storing, and stores nothing that fails. There is no `purpose` + * parameter on purpose: the agent derives the classification from the + * credential's type tags, so a caller cannot file a credential under a + * classification its contents do not support. + */ +export async function credVaultReceive( + sender: TrustTaskSender, + params: CredVaultReceiveParams, +): Promise { + const envelope = buildTrustTask( + CRED_RECEIVE, + { + credential: params.credential, + ...(params.id ? { id: params.id } : {}), + ...(params.contextId ? { contextId: params.contextId } : {}), + ...(params.format ? { format: params.format } : {}), + }, + { issuer: params.holder.did, recipient: params.service.did }, + ); + return sender.send(envelope, { + expectedResponseType: CRED_RECEIVE_RESPONSE, + operationLabel: "vault/credentials/receive/0.1", + }); +} + +/** What a lifecycle transition reports back. `lifecycle` is the state *after* + * it — echoed rather than inferred from the verb that was called. */ +export interface CredVaultLifecycleResult { + id: string; + lifecycle: CredentialLifecycle; + /** Restore deadline. Present after a default `delete`; **absent after a + * forced one**, and that absence is how a caller knows nothing can be + * restored. */ + graceUntil?: string; +} + +export interface CredVaultLifecycleParams extends CredVaultCallerParams { + id: string; + /** Recorded with the transition. Must not carry credential contents — it + * lands in a trail read by people entitled to know a credential changed + * state without being entitled to know what it said. */ + reason?: string; +} + +function lifecycleCall( + sender: TrustTaskSender, + params: CredVaultLifecycleParams, + type: string, + responseType: string, + label: string, + extra: Record = {}, +): Promise { + const envelope = buildTrustTask( + type, + { id: params.id, ...(params.reason ? { reason: params.reason } : {}), ...extra }, + { issuer: params.holder.did, recipient: params.service.did }, + ); + return sender.send(envelope, { + expectedResponseType: responseType, + operationLabel: label, + }); +} + +/** Hide from default query results and refuse for presentation. Reversible + * with {@link credVaultUnarchive}; the credential is untouched. */ +export function credVaultArchive( + sender: TrustTaskSender, + params: CredVaultLifecycleParams, +): Promise { + return lifecycleCall( + sender, params, CRED_ARCHIVE, CRED_ARCHIVE_RESPONSE, "vault/credentials/archive/0.1"); +} + +/** Return an archived credential to active. Refuses a soft-deleted one — that + * comes back through {@link credVaultRestore}, which has a deadline. */ +export function credVaultUnarchive( + sender: TrustTaskSender, + params: CredVaultLifecycleParams, +): Promise { + return lifecycleCall( + sender, params, CRED_UNARCHIVE, CRED_UNARCHIVE_RESPONSE, "vault/credentials/unarchive/0.1"); +} + +export interface CredVaultDeleteParams extends CredVaultLifecycleParams { + /** + * Erase immediately instead of tombstoning. **Irrecoverable** — no grace + * window, no restore, and the result carries no `graceUntil`. + * + * The default path exists because a credential cannot be re-obtained by + * asking nicely: re-issuance means going back to the issuer, and for an + * invitation or a one-time membership that may not be possible at all. + */ + force?: boolean; +} + +/** Move to a recoverable tombstone, or erase outright with `force`. */ +export function credVaultDelete( + sender: TrustTaskSender, + params: CredVaultDeleteParams, +): Promise { + return lifecycleCall( + sender, params, CRED_DELETE, CRED_DELETE_RESPONSE, "vault/credentials/delete/0.1", + params.force ? { force: true } : {}); +} + +/** Return a soft-deleted credential to active, while its grace window lasts. + * After `graceUntil` the agent has erased it and there is nothing to restore. */ +export function credVaultRestore( + sender: TrustTaskSender, + params: CredVaultLifecycleParams, +): Promise { + return lifecycleCall( + sender, params, CRED_RESTORE, CRED_RESTORE_RESPONSE, "vault/credentials/restore/0.1"); +} + +/** Erase immediately and irrecoverably. No tombstone, no grace window. */ +export function credVaultPurge( + sender: TrustTaskSender, + params: CredVaultLifecycleParams, +): Promise { + return lifecycleCall( + sender, params, CRED_PURGE, CRED_PURGE_RESPONSE, "vault/credentials/purge/0.1"); +} diff --git a/packages/core/src/vault/index.ts b/packages/core/src/vault/index.ts index 74bc0cf..ca449fc 100644 --- a/packages/core/src/vault/index.ts +++ b/packages/core/src/vault/index.ts @@ -5,5 +5,9 @@ export * from "./delete.js"; export * from "./release.js"; export * from "./proxy-login.js"; export * from "./sign-trust-task.js"; +// The credential vault — the credentials a holder *holds*. Shares this slug +// and the agent's keyspace with the secrets surface above, and is otherwise a +// different store; see the module header. +export * from "./credentials.js"; export * from "./task-signer.js"; export type { VtaAuthInputs } from "../vta/auth.js"; diff --git a/packages/core/src/vta/auth.ts b/packages/core/src/vta/auth.ts index d24581f..d745ac2 100644 --- a/packages/core/src/vta/auth.ts +++ b/packages/core/src/vta/auth.ts @@ -30,7 +30,7 @@ import { packAuthcrypt, type Identity } from "../didcomm/index.js"; import type { RemoteDidcommEndpoint } from "./didcomm.js"; import { withFetchTimeout } from "../http/timeout-fetch.js"; -const VTA_AUTHENTICATE = "https://trusttasks.org/spec/auth/authenticate/0.1"; +import { TYPE_URI as VTA_AUTHENTICATE } from "@openvtc/trust-tasks/auth/authenticate/0.1/payload"; /** Reuse window — kept well under the server's ~15-minute token TTL so a * cached token can't be served past expiry (clock-skew safety margin). */ 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..918c6f7 100644 --- a/packages/core/src/vta/contexts.ts +++ b/packages/core/src/vta/contexts.ts @@ -1,103 +1,79 @@ -// Contexts — list + create, as dispatcher trust-tasks. +// Contexts — the `vta/contexts/*` family, as dispatcher trust-tasks. // // The popup's AddEntryForm fetches the operator's accessible contexts (to // populate the context picker) and can create a new context inline. Both run // as canonical trust-tasks over a TrustTaskChannel/VtaSession, so they work on // a DIDComm-only VTA as well as REST. // -// list → https://trusttasks.org/spec/vta/contexts/list/1.0 (payload {}) -// create → https://trusttasks.org/spec/vta/contexts/create/1.0 (super-admin) -// -// Wire shapes mirror `vta-sdk::protocols::context_management::{list,create}`: -// snake_case fields, `CreateContextResultBody` as the record. The VTA also -// exposes a bespoke `GET/POST /contexts` REST route (now deprecated) — the -// trust-task dispatcher form is the canonical one. +// **Every URI and the record shape come from `@openvtc/trust-tasks`.** They +// used to be hand-written here, alongside a hand-declared `ContextRecord` and a +// header claiming the wire was snake_case. None of that was true any more, and +// two of them were wrong in a way nothing would have caught: the hand-written +// record typed `did` and `description` as `string | null`, where the published +// schema makes them OPTIONAL — an agent omits them, so a caller testing +// `=== null` never matches, and TypeScript agrees with the caller. Taking the +// type from the binding means the schema is the only place that shape is +// stated. -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"; -const TASK_CONTEXTS_LIST = "https://trusttasks.org/spec/vta/contexts/list/1.0"; -const TASK_CONTEXTS_LIST_RESPONSE = `${TASK_CONTEXTS_LIST}#response`; -const TASK_CONTEXTS_CREATE = "https://trusttasks.org/spec/vta/contexts/create/1.0"; -const TASK_CONTEXTS_GET = "https://trusttasks.org/spec/vta/contexts/get/1.0"; -const TASK_CONTEXTS_GET_RESPONSE = `${TASK_CONTEXTS_GET}#response`; -const TASK_CONTEXTS_UPDATE = "https://trusttasks.org/spec/vta/contexts/update/1.0"; -const TASK_CONTEXTS_UPDATE_RESPONSE = `${TASK_CONTEXTS_UPDATE}#response`; -const TASK_CONTEXTS_UPDATE_DID = "https://trusttasks.org/spec/vta/contexts/update-did/1.0"; -const TASK_CONTEXTS_UPDATE_DID_RESPONSE = `${TASK_CONTEXTS_UPDATE_DID}#response`; -const TASK_CONTEXTS_CREATE_RESPONSE = `${TASK_CONTEXTS_CREATE}#response`; +import { + TYPE_URI as TASK_CONTEXTS_LIST, + RESPONSE_TYPE_URI as TASK_CONTEXTS_LIST_RESPONSE, + type ContextRecord, +} from "@openvtc/trust-tasks/vta/contexts/list/1.0/payload"; +import { + TYPE_URI as TASK_CONTEXTS_CREATE, + RESPONSE_TYPE_URI as TASK_CONTEXTS_CREATE_RESPONSE, +} from "@openvtc/trust-tasks/vta/contexts/create/1.0/payload"; +import { + TYPE_URI as TASK_CONTEXTS_GET, + RESPONSE_TYPE_URI as TASK_CONTEXTS_GET_RESPONSE, +} from "@openvtc/trust-tasks/vta/contexts/get/1.0/payload"; +import { + TYPE_URI as TASK_CONTEXTS_UPDATE, + RESPONSE_TYPE_URI as TASK_CONTEXTS_UPDATE_RESPONSE, +} from "@openvtc/trust-tasks/vta/contexts/update/1.0/payload"; +import { + TYPE_URI as TASK_CONTEXTS_UPDATE_DID, + RESPONSE_TYPE_URI as TASK_CONTEXTS_UPDATE_DID_RESPONSE, +} from "@openvtc/trust-tasks/vta/contexts/update-did/1.0/payload"; -/** One context record — mirrors `CreateContextResultBody` (the shape returned - * by both list and create). snake_case on the wire. */ -export interface ContextRecord { - id: string; - name: string; - did: string | null; - description: string | null; - /** Parent context id, or absent for a top-level context. */ - parent?: string; - /** Resolved path from the root context — derived by the VTA, not settable. */ - basePath: string; - createdAt: string; - updatedAt: string; -} +/** One context record, as the registry declares it. Re-exported so callers + * need not know which task's binding it happens to live under. */ +export type { ContextRecord }; -/** - * Read a member that the agent may still spell in snake_case. - * - * SPEC §4.10 makes lowerCamelCase the wire contract, and the VTA now emits it — - * but an agent that has not taken that change yet still sends the old spelling, - * and this library talks to agents it does not control. Accepting both on read - * is Postel's other half; this library emits only the canonical form. - * - * Delete once no supported agent predates the fold. - */ -export function fold( - raw: Record, - pairs: readonly (readonly [string, string])[], -): Record { - const out: Record = { ...raw }; - for (const [camel, snake] of pairs) { - if (snake in out) { - if (!(camel in out)) out[camel] = out[snake]; - delete out[snake]; - } - } - return out; -} /** - * Accept either spelling of the record's members; hand back the canonical one. + * A context record as the agent sends it. * - * Only rewrites what is present: a record that carries neither spelling of a - * member keeps not carrying it, rather than gaining the key with `undefined`. + * This used to fold `base_path`/`created_at`/`updated_at` into the canonical + * spelling, on the reasoning that the library "talks to agents it does not + * control". That reasoning does not hold here and the fold was dead code: + * nothing is deployed, the wallet has never been published, and `ContextRecord` + * in `vta-sdk` carries `#[serde(rename_all = "camelCase")]` — so the agent + * *emits* camelCase and has done since the casing change. Its `alias` + * attributes are deserialize-only; they let it keep *accepting* the old + * spelling, which says nothing about what it sends. + * + * A fold kept past its cause is worse than none: it reads as a live constraint, + * and the next person maintaining this file has to work out whether some peer + * still needs it. SPEC §4.10 names one spelling — match it with `===`. */ -function normalizeContext(raw: Record): ContextRecord { - const out: Record = { ...raw }; - for (const [camel, snake] of [ - ["basePath", "base_path"], - ["createdAt", "created_at"], - ["updatedAt", "updated_at"], - ] as const) { - if (snake in out) { - if (!(camel in out)) out[camel] = out[snake]; - delete out[snake]; - } - } - return out as unknown as ContextRecord; +function asContextRecord(raw: Record): ContextRecord { + return raw as unknown as ContextRecord; } 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. @@ -117,12 +93,12 @@ export async function contextsList( expectedResponseType: TASK_CONTEXTS_LIST_RESPONSE, operationLabel: "contexts/list/1.0", }); - return (payload.contexts ?? []).map(normalizeContext); + return (payload.contexts ?? []).map(asContextRecord); } 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; @@ -155,19 +131,26 @@ export async function contextsCreate( expectedResponseType: TASK_CONTEXTS_CREATE_RESPONSE, operationLabel: "contexts/create/1.0", }); - return normalizeContext(created); + return asContextRecord(created); } /** @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; } @@ -194,12 +177,12 @@ export async function contextsGet( expectedResponseType: TASK_CONTEXTS_GET_RESPONSE, operationLabel: "vta/contexts/get/1.0", }); - return normalizeContext(payload); + return asContextRecord(payload); } 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. */ @@ -235,12 +218,12 @@ export async function contextsUpdate( expectedResponseType: TASK_CONTEXTS_UPDATE_RESPONSE, operationLabel: "vta/contexts/update/1.0", }); - return normalizeContext(payload); + return asContextRecord(payload); } 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. */ @@ -261,7 +244,7 @@ export async function contextsUpdateDid( expectedResponseType: TASK_CONTEXTS_UPDATE_DID_RESPONSE, operationLabel: "vta/contexts/update-did/1.0", }); - return normalizeContext(payload); + return asContextRecord(payload); } export function vtaListContexts(opts: VtaListContextsOptions): Promise { @@ -269,8 +252,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/vta/list-dids.ts b/packages/core/src/vta/list-dids.ts index 7a1837b..b753ca4 100644 --- a/packages/core/src/vta/list-dids.ts +++ b/packages/core/src/vta/list-dids.ts @@ -20,27 +20,23 @@ import type { TrustTaskSender } from "./channel.js"; import type { RemoteDidcommEndpoint } from "./didcomm.js"; import { RestChannel, type RestChannelOptions } from "./rest-channel.js"; import { buildTrustTask } from "./trust-task.js"; -import { fold } from "./contexts.js"; -const TASK_WEBVH_DIDS_LIST_1_0 = "https://trusttasks.org/spec/vta/webvh/dids/list/1.0"; +import { + TYPE_URI as TASK_WEBVH_DIDS_LIST_1_0, + type WebvhDidRecord, +} from "@openvtc/trust-tasks/vta/webvh/dids/list/1.0/payload"; const TASK_WEBVH_DIDS_LIST_1_0_RESPONSE = `${TASK_WEBVH_DIDS_LIST_1_0}#response`; -/** One webvh DID record as returned by `vta/webvh/dids/list/1.0`. - * Mirrors `vta-sdk::webvh::WebvhDidRecord` — **snake_case** on the - * wire. Only the fields the wallet consumes are typed; the VTA also - * sends `mnemonic`, `scid`, `log_entry_count`, timestamps, etc. which - * we ignore here. */ -export interface WebvhDidRecord { - /** The hosted DID (`did:webvh:…`). The persona a did-self-issued - * entry acts AS — becomes the SIOP `iss`/`sub`. */ - did: string; - /** Context this DID belongs to (matches a `ContextRecord.id`). */ - contextId: string; - /** Hosting server the DID is registered with. */ - serverId?: string; - /** Whether the DID is portable across hosting servers. */ - portable?: boolean; -} +/** A hosted DID as the registry declares it. + * + * Taken from the binding rather than declared here. The hand-written version + * this replaces had drifted in both directions at once: it marked `serverId` + * and `portable` OPTIONAL where the schema makes them required, and it omitted + * seven members the agent actually sends (`mnemonic`, `scid`, `logEntryCount`, + * `preRotationCount`, `nextFragmentId`, `createdAt`, `updatedAt`). Neither + * kind of drift announces itself: the first invites guards that can never + * fire, the second hides data a caller would have used. */ +export type { WebvhDidRecord }; interface ListDidsResultBody { dids?: WebvhDidRecord[]; @@ -84,16 +80,13 @@ export async function vtaListDids( expectedResponseType: TASK_WEBVH_DIDS_LIST_1_0_RESPONSE, operationLabel: "webvh/dids/list", }); - // Accept either spelling while agents migrate to the canonical casing - // (SPEC §4.10). Changing the type alone would leave `contextId` undefined - // against an agent that has not taken the fold. - return (result.dids ?? []).map( - (d) => - fold(d as unknown as Record, [ - ["contextId", "context_id"], - ["serverId", "server_id"], - ]) as unknown as WebvhDidRecord, - ); + // No casing fold. `WebvhDidRecord` in `vta-sdk` carries + // `#[serde(rename_all = "camelCase")]`, so the agent emits `contextId` and + // `serverId`; its `alias` attributes are deserialize-only and say nothing + // about what it sends. Nothing is deployed, so "an agent that has not taken + // the fold" names no peer that exists — and a fold kept past its cause reads + // as a live constraint to whoever maintains this next. + return (result.dids ?? []) as unknown as WebvhDidRecord[]; } /** @deprecated Use {@link vtaListDids} with a channel from a `VtaSession`. 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/core/task-surface.json b/packages/core/task-surface.json index 9deb470..535ab34 100644 --- a/packages/core/task-surface.json +++ b/packages/core/task-surface.json @@ -2,7 +2,7 @@ "$comment": "Generated by scripts/sync-task-surface.mjs from a vta-sdk checkout. Do not hand-edit: re-run the script. Checked by tests/task-surface.mjs.", "source": { "crate": "vta-sdk", - "version": "0.30.0", + "version": "0.32.3", "scanned": "vta-sdk/src/**/*.rs" }, "tasks": [ @@ -416,10 +416,16 @@ "uri": "https://trusttasks.org/spec/provision/integration/0.1" }, { - "uri": "https://trusttasks.org/spec/provision/integration/0.2" + "uri": "https://trusttasks.org/spec/provision/integration/0.2", + "consts": [ + "V0_2" + ] }, { - "uri": "https://trusttasks.org/spec/provision/integration/0.3" + "uri": "https://trusttasks.org/spec/provision/integration/0.3", + "consts": [ + "V0_3" + ] }, { "uri": "https://trusttasks.org/spec/push/wake/0.2" @@ -672,6 +678,9 @@ { "uri": "https://trusttasks.org/spec/vta/credentials/issue/0.2" }, + { + "uri": "https://trusttasks.org/spec/vta/credentials/list/0.1" + }, { "uri": "https://trusttasks.org/spec/vta/credentials/revoke/0.1" }, diff --git a/packages/core/tests/schema-types-are-not-restated.mjs b/packages/core/tests/schema-types-are-not-restated.mjs new file mode 100644 index 0000000..24c0aab --- /dev/null +++ b/packages/core/tests/schema-types-are-not-restated.mjs @@ -0,0 +1,135 @@ +// A type the registry declares must not be declared here as well. +// +// ## What went wrong +// +// `ContextRecord` and `WebvhDidRecord` were written out by hand in this +// library, alongside the generated ones in `@openvtc/trust-tasks`. Both had +// drifted, in opposite directions, and neither drift could fail anything: +// +// * `ContextRecord.did` was typed `string | null`. The schema makes it +// OPTIONAL — a conforming agent omits the member — so a caller guarding +// with `=== null` never matches, and TypeScript agrees with the caller. +// * `WebvhDidRecord` marked `serverId` and `portable` optional where the +// schema makes them required, and omitted seven members the agent sends +// (`mnemonic`, `scid`, `logEntryCount`, `createdAt`, …). Data the caller +// could have used simply was not visible. +// +// Nothing else in this repo would have caught either. `task-surface.mjs` checks +// that the URIs this library names exist and are current; it says nothing about +// payload SHAPES. The type is the one part of a Trust Task that is checked +// against a copy rather than against the schema. +// +// ## The rule +// +// If `@openvtc/trust-tasks` exports a shared component type, this library uses +// it — it does not restate it. Four more (`WakeHandle`, `WakeTriggerPolicy`, +// `Exposure`, `StatePin`) were byte-identical to the generated ones when this +// test was written, which is not reassuring: identical is the state a type is +// in immediately before it diverges, and a duplicate that has not drifted yet +// is a duplicate that will. +// +// ## The exception, and why it is narrow +// +// A local type MAY narrow a generated one where the browser knows more than the +// schema can say — see `NARROWING`. It must say so, and it must be a +// restriction, never an addition: the schema is the wider contract and a local +// type that ADDS a member is asserting something the agent never promised. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync, readdirSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +const SRC = fileURLToPath(new URL("../src", import.meta.url)); +// Located from a real subpath, resolved through ESM. +// +// Two traps here, both worth naming. The package's `exports` map does not +// expose its own `package.json`, so the usual `require.resolve(pkg + +// "/package.json")` fails; and its subpaths are a bare `./*` pattern, which +// CJS `require.resolve` will not match even though `import` does. So this uses +// `import.meta.resolve` — the same resolver the source files use. +const COMPONENTS = join( + dirname(fileURLToPath(import.meta.resolve("@openvtc/trust-tasks/vta/contexts/list/1.0/payload"))), + "../../../../_shared/components.d.ts", +); + +/** + * Types this library deliberately restates, each with the reason. + * + * An entry is a claim that the local declaration says something TRUER for a + * browser than the schema can — not that importing it would be inconvenient. + */ +const NARROWING = { + PasskeyVerificationMethod: + "narrows `webauthnTransports` from the schema's `string[]` to the DOM's " + + "`AuthenticatorTransport` union. The schema cannot name a browser type, and " + + "a caller passing these to `navigator.credentials` wants the narrow one.", +}; + +const files = (dir) => + readdirSync(dir, { withFileTypes: true }).flatMap((e) => + e.isDirectory() ? files(join(dir, e.name)) : e.name.endsWith(".ts") ? [join(dir, e.name)] : [], + ); + +const generated = new Set( + [...readFileSync(COMPONENTS, "utf8").matchAll(/export (?:interface|type) ([A-Za-z0-9_]+)/g)].map( + (m) => m[1], + ), +); + +test("the generated component list is real, so none of this passes vacuously", () => { + assert.ok( + generated.size > 100, + `found only ${generated.size} generated component types — the path to ` + + `components.d.ts is stale and this test now checks nothing`, + ); + assert.ok(generated.has("ContextRecord"), "components.d.ts does not look like the right file"); +}); + +// `export type X = SomeIdentifier;` is the *fix* pattern, not the defect: it +// aliases the generated type, so the shape still has one source. Only an inline +// body — `= {`, `= "a" | "b"`, `= Foo[]` — restates it. This distinction was +// missing when the test was first written, and it hid two: `KeyRecord` (a +// correct alias) and `PushRegistration` (an inline restatement of a union the +// schema declares as `Apns | Fcm | WebPush`). +const ALIAS_TO_IDENTIFIER = /^\s*[A-Za-z0-9_.]+(<[^;]*>)?\s*;/; + +test("no schema component type is restated by hand", () => { + const restated = []; + for (const file of files(SRC)) { + const rel = file.slice(SRC.length + 1); + const src = readFileSync(file, "utf8"); + + for (const m of src.matchAll(/export interface ([A-Za-z0-9_]+)/g)) { + if (generated.has(m[1]) && !(m[1] in NARROWING)) restated.push(`${m[1]} (${rel})`); + } + for (const m of src.matchAll(/export type ([A-Za-z0-9_]+)\s*=/g)) { + if (!generated.has(m[1]) || m[1] in NARROWING) continue; + const rhs = src.slice(m.index + m[0].length); + if (!ALIAS_TO_IDENTIFIER.test(rhs)) restated.push(`${m[1]} (${rel})`); + } + } + + assert.deepEqual( + restated.sort(), + [], + `these types are declared here and also generated from a published schema:\n ` + + `${restated.join("\n ")}\n\n` + + `Import the generated one instead — a hand-written copy drifts silently, ` + + `because nothing compares the two. Aliasing it (\`export type X = Generated;\`) ` + + `is fine and is how the narrowing cases are written. If the local type ` + + `deliberately NARROWS the generated one for a browser caller, add it to ` + + `NARROWING with the reason.`, + ); +}); + +test("every NARROWING entry still names a generated type", () => { + const stale = Object.keys(NARROWING).filter((n) => !generated.has(n)); + assert.deepEqual( + stale, + [], + `these NARROWING entries no longer name a generated component type — the ` + + `schema moved, so the exception is either unnecessary or now wrong: ${stale.join(", ")}`, + ); +}); diff --git a/packages/core/tests/task-surface.mjs b/packages/core/tests/task-surface.mjs index f7aa593..ef70526 100644 --- a/packages/core/tests/task-surface.mjs +++ b/packages/core/tests/task-surface.mjs @@ -178,21 +178,52 @@ test("coverage against the agent's surface is recorded, not discovered", () => { [...REFERENCED.keys()].map(family).filter((f) => canonicalFamilies.has(f)), ); - // 152 of 177 as of vta-sdk 0.29.0. It was 130 until the specced-but- + // 161 of 178 as of vta-sdk 0.32.3. It was 130 until the specced-but- // unimplemented gap was closed in one pass: `trust-task-discovery/0.1`, // `acl/update/0.1`, `vta/webvh/servers/retire-orphan/0.1`, // `vtc/members/removal-notice/0.1`, `vta/app-state/*` (6), `vta/services/*` // (8), `vta/credentials/{issue,revoke}/0.1`, and // `auth/passkey/login/{start,finish}/0.2`. // - // **All 25 still outstanding are unspecced** — no schema in the registry, so - // no binding in @openvtc/trust-tasks to implement against: `vault/*`'s - // archive/restore/purge/unarchive and its whole `credentials` sub-family, - // `vta/backup/*`, `vta/attestation/*`, `vta/seeds/*`, - // `vta/audit/*-retention`, and `vta/management/reload-services`. That makes - // this number, for the first time, a statement about upstream rather than - // about this repo: it moves when a spec lands, not when someone here finds - // time. + // 160 -> 161 is `vta/credentials/list/0.1`, and the canonical total moved + // with it (177 -> 178) because the task did not exist on either side before. + // Specified at trustoverip/dtgwg-trust-tasks-tf#342 and implemented at + // OpenVTC/verifiable-trust-infrastructure#1235, in response to a gap this + // console surfaced: `revoke` is keyed on a `credentialId` that `issue` + // returns exactly once, so an issuer that had not recorded it could not ask. + // Unlike the eight below, this is not a family that moved off the unspecced + // list — it is new. + // + // 152 -> 160 is the whole `vault/credentials/*` sub-family — receive, query, + // get, archive, unarchive, delete, restore, purge — which moved here from + // the unspecced list rather than from a backlog. The agent had been + // dispatching all eight with no schema in the registry; specifying them + // (trustoverip/dtgwg-trust-tasks-tf#338, shipped in @openvtc/trust-tasks + // 0.16.4) is what produced bindings to implement against. + // + // 161 -> 163 is `vta/backup/abort` and `vta/management/reload-services`, + // specced at trustoverip/dtgwg-trust-tasks-tf#347 and shipped in + // @openvtc/trust-tasks 0.16.8. Same shape as the eight before them: the + // agent was already dispatching both with no schema in the registry. + // + // **`vta/backup/*` is now specced in full and deliberately implemented in + // part**, which makes it the first family whose absence is a decision rather + // than a gap upstream. All five verbs have bindings; this library exposes + // `abort` alone. `initiate-export` and `finalize-import` carry a `password` + // — the key to a complete copy of the agent, travelling inbound — and a + // browser is the wrong place to collect it, for reasons `admin/backup.ts` + // sets out at length. Do not "finish" the family to make this number + // rounder; the four that are missing are missing on purpose. + // + // **The other 15 outstanding are unspecced** — no schema in the registry, so + // no binding in @openvtc/trust-tasks to implement against: `vault/*`'s own + // archive/restore/purge/unarchive (the *secrets* lifecycle, distinct from + // the credential one above), `vta/attestation/*`, `vta/seeds/*` and + // `vta/audit/*-retention`. + // + // `vta/seeds/*` is a third category again, and will never move: it returns + // key material, and CI bans its URIs from every extension bundle including + // the console. A spec landing upstream would not change that. // // **Nothing is behind any more.** Every implemented family names the newest // version `vta-sdk` publishes: `vault/{list,get,upsert}` and @@ -203,7 +234,7 @@ test("coverage against the agent's surface is recorded, not discovered", () => { // the agent does not name, rather than as a deprecation warning. That is the // expected shape of a cutover here: nothing is deployed, so neither side // keeps an old version alive. - const expected = 152; + const expected = 163; assert.equal( implemented.size, expected, diff --git a/packages/core/tests/vault.ops.mjs b/packages/core/tests/vault.ops.mjs index dc87a5e..9e79572 100644 --- a/packages/core/tests/vault.ops.mjs +++ b/packages/core/tests/vault.ops.mjs @@ -76,13 +76,13 @@ test("vaultSignTrustTask forwards the unsigned envelope and returns signedEnvelo }); test("vtaListDids scopes by context and unwraps dids[]", async () => { - const ch = captureChannel({ dids: [{ did: "did:webvh:a", context_id: "work" }] }); + const ch = captureChannel({ dids: [{ did: "did:webvh:a", contextId: "work" }] }); const res = await vtaListDids(ch, { holder, service, contextId: "work" }); assert.equal(ch.sent[0].envelope.type, "https://trusttasks.org/spec/vta/webvh/dids/list/1.0"); - // camelCase: the schema names `contextId` and sets additionalProperties:false, - // so `context_id` was malformed rather than an accepted synonym. This - // assertion pinned the drift in place — the reply is still snake_case above, - // because the READ path deliberately folds both while agents migrate. + // camelCase both ways: the schema names `contextId` and sets + // additionalProperties:false, so `context_id` was malformed rather than an + // accepted synonym — and the agent emits the canonical spelling too, so the + // read path no longer folds. assert.deepEqual(ch.sent[0].envelope.payload, { contextId: "work" }); assert.deepEqual(res, [{ did: "did:webvh:a", contextId: "work" }]); }); @@ -108,12 +108,21 @@ test("contextsCreate defaults name to id and forwards description/parent", async test("swapAcl sends acl/swap-key with currentSubject/newSubject/linkProof (ephemeral issuer)", async () => { const holderSigning = generateSigningIdentity(); + // The response shape the AGENT sends (VTI #857): the realized entry wrapped, + // plus the DID swapped out. This fixture used to be a flat entry with `did`, + // `allowedContexts` and a numeric `createdAt` — built from the client's own + // hand-written type rather than from the schema, so it agreed with the drift + // instead of catching it. Nothing read it either, which is how a response + // type can be wrong in three ways and stay green. const ch = captureChannel({ - did: holderSigning.did, - role: "admin", - allowedContexts: [], - createdAt: 1, - createdBy: "did:web:vta.example", + entry: { + subject: holderSigning.did, + role: "admin", + scopes: [], + createdAt: "2026-01-01T00:00:00Z", + createdBy: "did:web:vta.example", + }, + previousSubject: "did:key:zEphemeral", }); const res = await swapAcl(ch, { ephemeralDid: "did:key:zEphemeral", @@ -126,12 +135,18 @@ test("swapAcl sends acl/swap-key with currentSubject/newSubject/linkProof (ephem assert.equal(envelope.recipient, "did:web:vta.example"); assert.equal(envelope.payload.currentSubject, "did:key:zEphemeral"); assert.equal(envelope.payload.newSubject, holderSigning.did); + + // Assert the RESPONSE too. The absence of this is what let the return type + // drift: `sender.send()` is an unchecked cast, so a wrong `T` costs + // nothing until a caller reads a field and gets `undefined`. + assert.equal(res.entry.subject, holderSigning.did); + assert.equal(res.entry.role, "admin"); + assert.equal(res.previousSubject, "did:key:zEphemeral"); assert.ok( typeof envelope.payload.linkProof === "string" && envelope.payload.linkProof.length > 0, "linkProof VP-JWT is present", ); assert.equal(opts.expectedResponseType, "https://trusttasks.org/spec/acl/swap-key/0.1#response"); - assert.equal(res.did, holderSigning.did); }); test("ops accept a VtaSession (not just a raw channel) and route through it", async () => { @@ -154,30 +169,6 @@ test("setDeviceWake sets the handle; omitting it clears", async () => { assert.deepEqual(ch2.sent[0].envelope.payload, {}); // clear }); -test("a context record still arrives from an agent that predates the casing fold", async () => { - // SPEC §4.10 makes lowerCamelCase the wire contract and the VTA now emits it, - // but this library talks to agents it does not control. The old spelling is - // accepted on read and normalised away — a caller never sees both. - const ch = captureChannel({ - contexts: [ - { id: "work", name: "Work", base_path: "/work", created_at: "t1", updated_at: "t2" }, - ], - }); - const [ctx] = await contextsList(ch, { holder, service }); - assert.equal(ctx.basePath, "/work"); - assert.equal(ctx.createdAt, "t1"); - assert.equal(ctx.updatedAt, "t2"); - assert.ok(!("base_path" in ctx), "the pre-fold spelling must not survive into the result"); -}); - -test("a webvh DID record likewise", async () => { - const ch = captureChannel({ dids: [{ did: "did:webvh:a", context_id: "work", server_id: "prod" }] }); - const [d] = await vtaListDids(ch, { holder, service }); - assert.equal(d.contextId, "work"); - assert.equal(d.serverId, "prod"); - assert.ok(!("context_id" in d)); -}); - test("the canonical spelling is passed through untouched", async () => { const ch = captureChannel({ contexts: [{ id: "work", name: "Work", basePath: "/work" }] }); const [ctx] = await contextsList(ch, { holder, service }); diff --git a/packages/core/tests/vta.list-dids-casing.mjs b/packages/core/tests/vta.list-dids-casing.mjs index 5073dbe..2c29781 100644 --- a/packages/core/tests/vta.list-dids-casing.mjs +++ b/packages/core/tests/vta.list-dids-casing.mjs @@ -47,12 +47,14 @@ test("no filter sends an empty payload rather than an absent key set to undefine assert.deepEqual(ch.sent[0].envelope.payload, {}); }); -test("the read path still folds a legacy record, because agents migrate later", async () => { - // Emitting the canonical spelling and accepting both are separate moves. An - // agent that has not taken the fold still answers `context_id`, and dropping - // the fold here would leave `contextId` undefined against it. +test("the read path passes the canonical spelling straight through", async () => { + // The fold this replaced accepted `context_id`/`server_id` too, justified by + // "an agent that has not taken the fold". No such agent exists: nothing is + // deployed, and `WebvhDidRecord` in `vta-sdk` is `rename_all = "camelCase"`, + // so the agent emits the canonical spelling. Its `alias` attributes are + // deserialize-only — they govern what it accepts, not what it sends. const ch = recorder({ - dids: [{ did: "did:webvh:QmA:h.example", context_id: "personal", server_id: "prod" }], + dids: [{ did: "did:webvh:QmA:h.example", contextId: "personal", serverId: "prod" }], }); const [rec] = await vtaListDids(ch, { holder: HOLDER, service: SERVICE }); assert.equal(rec.contextId, "personal"); 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/active-vta.ts b/packages/extension/src/active-vta.ts index 7c73e12..6d07f86 100644 --- a/packages/extension/src/active-vta.ts +++ b/packages/extension/src/active-vta.ts @@ -32,6 +32,44 @@ export function parseActiveVtaDid(raw: unknown): string | null { } } +/** + * Make `vtaDid` the active agent, if the wallet has onboarded it. + * + * Writes the same `pnm-connection/v3` envelope the readers above parse, which + * is zustand's persisted blob. Read-modify-write rather than a blind set: the + * envelope carries the whole connection map, and replacing it wholesale from a + * partial view would forget every other VTA — the same class of bug the + * per-agent inbox map exists to prevent (see CLAUDE.md). + * + * Refuses a DID the wallet does not hold, matching `activateVta` in the store: + * activating an agent that was never onboarded would leave the console pointed + * at something it has no holder identity for. + * + * Returns whether the switch happened, so a caller can tell "done" from "that + * agent is not on this device" without inspecting storage itself. + */ +export async function setActiveVtaDid(vtaDid: string): Promise { + const KEY = "pnm-connection/v3"; + const stored = await chrome.storage.local.get(KEY); + const raw = stored[KEY]; + if (typeof raw !== "string") return false; + + let parsed: { state?: { connections?: { activeVtaDid?: string | null; vtas?: Record } } }; + try { + parsed = JSON.parse(raw); + } catch { + return false; + } + + const connections = parsed.state?.connections; + if (!connections?.vtas || !(vtaDid in connections.vtas)) return false; + if (connections.activeVtaDid === vtaDid) return true; + + connections.activeVtaDid = vtaDid; + await chrome.storage.local.set({ [KEY]: JSON.stringify(parsed) }); + return true; +} + /** Enumerate every VTA the wallet has onboarded — keys of the * persisted `vtas` map regardless of which one is active. Background * uses this to drive the multi-listener inbound reconcile. Returns 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..43675fd 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,72 @@ 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(); + // No `origin`. There is no proposing page — the operator is acting directly, + // the same position a CLI is in — and `requestTask` is explicit that a caller + // with no attested origin should omit it rather than invent one. Inventing + // this extension's own put an `ext` member on every payload, which some agent + // payload structs reject outright. + return (await chrome.runtime.sendMessage({ + target: OFFSCREEN_TARGET, + type: OFFSCREEN_REQUEST_TASK, + vtaDid: active.conn.vtaDid, + restBaseUrl: active.conn.restBaseUrl, + 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 +2732,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..be512a6 100644 --- a/packages/extension/src/bridge-protocol.ts +++ b/packages/extension/src/bridge-protocol.ts @@ -1743,10 +1743,57 @@ export interface OffscreenRequestTaskRequest { type: typeof OFFSCREEN_REQUEST_TASK; vtaDid: string; restBaseUrl: string; - origin: string; + /** + * The origin the browser attributed to the proposing page, when there is one. + * + * **Absent for the management console, deliberately.** `requestTask` stamps + * this into `payload.ext["openvtc.origin"]` so the origin a human approves is + * bound to the payload that executes — which is essential when a *page* + * proposed the task and meaningless when the operator is driving their own + * console. `requestTask`'s own contract says a caller with no attested origin + * should omit it rather than invent one, and inventing one here was not free: + * it put an `ext` member on every payload, and an agent whose struct has + * drifted from its schema rejects the whole request as malformed. + * + * The page path is unaffected — `background.ts` still refuses a page-facing + * message that carries no browser-attested origin, which is where that + * requirement is enforced. + */ + origin?: string; + 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..41dcc5b --- /dev/null +++ b/packages/extension/src/manager-theme.css @@ -0,0 +1,73 @@ +/* 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 + * data (teal) what is stored here, and who put it there + * 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; + --m-act-data: #0e6f78; + --m-act-data-soft: #e2f2f4; + + /* 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-act-data: #4dc4d0; + --m-act-data-soft: #0e2326; + + --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..901fa6f --- /dev/null +++ b/packages/extension/src/manager/context-column.tsx @@ -0,0 +1,218 @@ +// 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 { contextLabel } from "./format.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; + // The id is what joins this tree to every table beside it, so it is shown + // whenever it differs from the label — never dropped in favour of the label. + const label = node.record + ? contextLabel(node.record) + : { primary: node.name, id: undefined }; + + 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..6ecc9f3 --- /dev/null +++ b/packages/extension/src/manager/destructive.tsx @@ -0,0 +1,274 @@ +// 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 ( + // `maxWidth` because this often renders inside a table cell. + // + // The agent's refusals are prose, and some are long — a DID delete that + // is still depended on comes back naming the dependant and the command + // to fix it. Unconstrained, that text sets the column's width, the + // browser widens the cell to fit, and every other column in the table + // collapses: the DID column ends up a few characters wide, wrapping + // mid-identifier. The message is the most useful thing on screen at that + // moment, and it was wrecking the row that gave it context. +
+ {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/format.ts b/packages/extension/src/manager/format.ts new file mode 100644 index 0000000..abebbf3 --- /dev/null +++ b/packages/extension/src/manager/format.ts @@ -0,0 +1,88 @@ +// Rendering helpers that refuse to state more than the agent said. +// +// Both of these exist because of the same failure, seen against a live agent: +// a value the console did not have was rendered as a confident, wrong one. + +import type { ContextRecord } from "@openvtc/pnm-core"; + +/** + * Anything at or before this is treated as "no timestamp", not as a date. + * + * `new Date(null)`, `new Date(0)` and `new Date(undefined as never)` all land + * on or near the epoch, and `toLocaleString()` renders that as + * "01/01/1970, 01:00:00" — which reads as a real answer and is not one. A + * session whose expiry the agent did not send is not a session that expired + * fifty-six years ago, and the difference matters on a banner whose whole job + * is to say how long your authority lasts. + * + * A year is generous: no real timestamp in this system predates the protocol. + */ +const NOT_A_TIMESTAMP = Date.UTC(1971, 0, 1); + +/** Parse to a Date, or null when the value cannot be one. */ +function parseInstant(value: string | number | null | undefined): Date | null { + if (value === null || value === undefined || value === "") return null; + const d = new Date(value); + const ms = d.getTime(); + if (Number.isNaN(ms) || ms <= NOT_A_TIMESTAMP) return null; + return d; +} + +/** Date and time, or `fallback` when the agent gave nothing usable. */ +export function formatInstant( + value: string | number | null | undefined, + fallback = "unknown", +): string { + return parseInstant(value)?.toLocaleString() ?? fallback; +} + +/** Date only, or `fallback`. */ +export function formatDate( + value: string | number | null | undefined, + fallback = "unknown", +): string { + return parseInstant(value)?.toLocaleDateString() ?? fallback; +} + +/** Whether an instant has passed. `false` when there is no usable instant — + * "we don't know" must never render as "expired". */ +export function isPast(value: string | number | null | undefined): boolean { + const d = parseInstant(value); + return d !== null && d.getTime() < Date.now(); +} + +/** + * How a context is named everywhere in this console. + * + * The tree used to render `name` ("Verifiable Trust Agent") while every table + * column and pane title rendered `id` ("vta"), so the same context had two + * names on one screen and nothing connecting them. An operator selecting + * "Verifiable Trust Agent" and reading "Audit for vta" has to guess those are + * the same thing — and when the ids are `vta`, `vtc` and `webvh`, guessing is + * exactly what they should not be doing. + * + * So both are shown, always, in the same order: the operator's own label reads + * first because that is what they navigate by, and the `id` follows in + * monospace because that is the vocabulary the agent's own records carry — a + * key's `contextId`, a DID's `contextId`, an ACL scope. The id is what joins + * this tree to every table beside it, so it is never the half that gets + * dropped. + */ +export interface ContextLabel { + /** What to read first. The operator's label, or the id when there is none. */ + primary: string; + /** The agent's own identifier, when it differs from `primary`. Monospace. */ + id?: string; +} + +export function contextLabel(record: ContextRecord): ContextLabel { + const name = record.name?.trim(); + return name && name !== record.id ? { primary: name, id: record.id } : { primary: record.id }; +} + +/** One-line form for a heading: `Verifiable Trust Agent (vta)`. */ +export function contextHeading(record: ContextRecord | undefined, id: string): string { + if (!record) return id; + const label = contextLabel(record); + return label.id ? `${label.primary} (${label.id})` : label.primary; +} diff --git a/packages/extension/src/manager/panes/access.tsx b/packages/extension/src/manager/panes/access.tsx new file mode 100644 index 0000000..ee8869b --- /dev/null +++ b/packages/extension/src/manager/panes/access.tsx @@ -0,0 +1,370 @@ +// 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 { formatDate, isPast } from "../format.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 past = isPast(entry.expiresAt); + return ( + + {formatDate(entry.expiresAt)} + {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, + contextHeading, +}: { + parties: Parties; + authority: Authority | null; + contextId: ContextSelection; + /** How the selected context is named in the tree, so heading and navigation + * agree. See `contextLabel` in `format.ts`. */ + contextHeading?: string | undefined; +}) { + 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/app-state.tsx b/packages/extension/src/manager/panes/app-state.tsx new file mode 100644 index 0000000..b9aa22b --- /dev/null +++ b/packages/extension/src/manager/panes/app-state.tsx @@ -0,0 +1,485 @@ +// App state — where applications keep their metadata in the agent. +// +// `vta/app-state/*`: durable key/value records the *agent* holds, as opposed to +// the wallet's own `KVStore`, which is this browser profile's state and is +// invisible to a phone or a second laptop signed in as the same holder. This is +// the only place an application can keep something that has to be true on every +// device — so it is also the place where a wrong or stale record follows the +// user everywhere. +// +// ## Why this pane demands a context, rather than defaulting to all of them +// +// Every other context-scoped pane treats `contextId` as a filter: omit it and +// the agent answers for everything the caller can reach. App-state does not +// work that way — `contextId` is part of the *address*. A record is +// `(contextId, namespace, key)`, and two contexts holding the same namespace +// and key hold two unrelated records. +// +// So there is no "all contexts" answer to give, and inventing one by fanning +// out across contexts would produce a list in which identical-looking rows are +// different records. The pane asks for a context instead. +// +// ## Two things the agent tracks that this pane must not flatten +// +// Records are **versioned**, and every write can carry `expectedVersion` — so +// an edit compare-and-swaps against what was read rather than overwriting +// whatever arrived since. And deletes are **soft**: `includeDeleted` reveals +// tombstones, which is the difference between "no application ever wrote this" +// and "something deleted it". + +import { useCallback, useState } from "react"; +import { + appStateDelete, + appStateList, + appStatePut, + type AppStateRecord, +} from "@openvtc/pnm-core"; +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 { formatInstant } from "../format.js"; +import { hasRole, type Authority, type Parties } from "../use-vta.js"; +import type { ContextSelection } from "../context-column.js"; + +const PAGE = 100; + +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: 140, + width: "100%", + lineHeight: 1.5, + resize: "vertical", +}; + +/** The record's value, rendered as the agent stored it. */ +function Value({ record }: { record: AppStateRecord }) { + if (record.value === undefined) { + // `appStateList` can be asked for keys without values, and a tombstone has + // none. Three different facts — not fetched, deleted, stored empty — that + // an empty cell would collapse into one. + return ( + {record.deleted ? "deleted" : "not fetched"} + ); + } + const text = JSON.stringify(record.value, null, 2); + return ( +
+      {text}
+    
+ ); +} + +function WriteRecord({ + parties, + contextId, + authority, + existing, + onDone, + onCancel, +}: { + parties: Parties; + contextId: string; + authority: Authority | null; + existing?: AppStateRecord; + onDone: () => void; + onCancel?: () => void; +}) { + const [namespace, setNamespace] = useState(existing?.namespace ?? ""); + const [key, setKey] = useState(existing?.key ?? ""); + const [value, setValue] = useState(() => { + return existing?.value === undefined ? "{}" : JSON.stringify(existing.value, null, 2); + }); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [pending, setPending] = useState(null); + + const denied = authority && !hasRole(authority, "admin", "super-admin", "operator") + ? "Writing app state needs an administrative role at this agent." + : null; + + const save = useCallback(async () => { + setBusy(true); + setError(null); + setPending(null); + // The schema types `value` as a JSON object, so anything else is refused + // here rather than sent and rejected. Reporting "expected an object" beside + // the box beats the agent's parse error arriving with no cursor in it. + let parsed: Record; + try { + const candidate: unknown = JSON.parse(value); + if (candidate === null || typeof candidate !== "object" || Array.isArray(candidate)) { + setError("A record's value must be a JSON object — an array or a bare value is not one."); + setBusy(false); + return; + } + parsed = candidate as Record; + } catch { + setError("That is not valid JSON. A record's value must be a JSON object."); + setBusy(false); + return; + } + const ok = await runMutation( + async () => { + await appStatePut(managerSender, { + ...parties, + contextId, + namespace: namespace.trim(), + key: key.trim(), + value: parsed, + // Compare-and-swap against the version this editor opened on, so an + // application that wrote since is not silently overwritten. + ...(existing ? { expectedVersion: existing.version } : {}), + }); + }, + { onConsent: setPending, onError: setError }, + ); + setBusy(false); + if (ok) onDone(); + }, [parties, contextId, namespace, key, value, existing, onDone]); + + return ( + + Saving is compare-and-swapped against version{" "} + {existing.version} — if an application has written + since this editor opened, your save is refused rather than overwriting it. + + ) : ( + <> + Written into {contextId}. Applications + read this on every device the holder uses, so a wrong value here follows them + everywhere. + + ) + } + > +
+
+ + +
+ {existing && ( + + Namespace and key form the record's address and cannot be changed — write a new + record and delete this one instead. + + )} + +