feat: VTA management console, proof-of-presence gating, and a registry-derived core - #157
Merged
Conversation
…text tree (#155) * feat(console): a VTA management console in the wallet, on the context tree Everything an operator does *to* their agent — contexts, keys, ACL, approvals, policy, services — has only ever been reachable from the `pnm` CLI, which means from a laptop with a keyring. The client library for it already existed: `@openvtc/pnm-core/admin` covers all of the CLI's command tree bar backup, bootstrap and auth-credential, and its doc comments were written for a console it did not yet have. This is the spine of that console, plus Contexts end to end. Trust contexts are the navigation, not a section. `ContextRecord` already carries `parent`/`basePath` and `contextId` is a filter parameter on `keysList`, `aclList` and `auditList` — so the tree is a persistent column and the selection scopes every pane to its right. Two properties are load-bearing: The console holds no key material. It composes typed documents with the `admin/*` helpers and the offscreen document mints and signs them, so `RUNTIME_MANAGER_TASK` carries only `type` and `payload` — the rule that the device mints the envelope does not soften because the composer is an extension page. Reusing `OFFSCREEN_REQUEST_TASK` inherits transport selection, health recording and the approver ceremony; `offscreen.ts` is unchanged. This is also why `admin/*` now types its envelope parties as `TaskParty` (just a DID) rather than `Identity`: only `.did` was ever read, and a surface typed on `Identity` can only be called from somewhere holding a private key. The wallet still ships no operator authority. The CI guard is narrowed rather than dropped — admin task URIs are banned everywhere in `dist/` except `manager.js`, which is its own vite build with `codeSplitting: false` so Rollup cannot hoist a shared chunk into a wallet surface. A second assertion fails if it ever emits more than one chunk. The relay is gated on `sender.url`, since every content script carries this extension's id. It does not prompt per call — the operator is the caller, and twelve dialogs to draw one screen is dismissal, not consent; the agent's ACL and policy engine remain the authority, and a `requireConsent` renders as a match-code ceremony rather than a red error string. Signed-off-by: Glenn Gore <glenn.g@affinidi.com> * feat(console): keys, DIDs, access, approvals, policy, transports and audit Fills out the console's remaining eight panes on the spine landed in the previous commit. Every one is scoped by the persistent context selection where the underlying task takes a `contextId`, so choosing `work/eng` once scopes Keys, DIDs, Access, Policy and Audit together. Shared vocabulary rather than nine copies of it: `table.tsx` separates the three states a list actually has — being fetched, refused, and answered with nothing — because written per-pane they collapse and an operator reads "you have no keys" off a permission error. `use-async.ts` keeps `data` null until the agent answers for the same reason. Where the agent draws a distinction, the pane draws it too: - Keys shows `internal` origin as unrecoverable, because such a key derives from no seed and nothing reconstitutes it. `keysSign` is deliberately not surfaced — signing is use, not administration, and a console that offers a "sign this" box turns key management into an oracle. - Access renders an entry with no scopes as "everywhere" and no expiry as "never", both in the caution colour. `aclChangeRole` carries `fromRole` from the row it was opened on, so a role someone else changed in between rejects instead of silently overwriting them. - Policy carries `expectedVersion` on upsert *and* delete — without it two operators editing one module is last-write-wins and the loser never finds out. - Transports pairs advertised state against observed, and presents `unknown` as "not observed" rather than a failure; a constructed REST channel is not evidence of anything. - Audit renders `truncated` above the table, not below: a warning under a long list is a warning nobody reads, and concluding "nothing else happened" from a partial page is the failure an audit trail exists to prevent. - Sessions is headed "your sessions" — the task returns only the caller's, and the agent's all-sessions route is deliberately not wired here. `webvh/dids.ts` takes the same `TaskParty` widening as `admin/*`: it too read only `.did` off its envelope parties. Verified by rendering the built bundle against stubbed extension APIs in Chrome, light and dark. Admin surface remains confined to manager.js. Signed-off-by: Glenn Gore <glenn.g@affinidi.com> * refactor(console): one import path for ConsentRequiredError carrier.ts defines it; sender.ts re-exported it, so half the panes reached for one path and half the other. Two routes to one symbol invite the question of whether they could ever be different classes — which matters here, because every pane distinguishes this class from Error by identity to decide between rendering a consent ceremony and rendering a red string. Signed-off-by: Glenn Gore <glenn.g@affinidi.com> --------- Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
First run against a real VTA. Every one of these was invisible against
stubbed data.
**The context column showed where it filters nothing.** Transports and
Sessions take no `contextId` at all, so selecting a context there looked
like it did something and did nothing — worse than clutter, because it
implies the list on screen was scoped when it never was. Each section now
declares `contextScoped`, and the column and its grid track appear only where
that is true. A new pane has to answer the question, which is the point.
**The console invented an origin, and it broke two panes.** `requestTask`
stamps `payload.ext["openvtc.origin"]` when given an origin, and its contract
says a caller without an attested one should omit it rather than invent one —
the console has no proposing page. Stamping this extension's own put an `ext`
member on every payload, and the agent rejected `acl/list` and `policy/list`
outright with "unknown field `ext`", taking out Access and Policy entirely.
The page path is untouched and still carries its attested origin; both halves
are now pinned by tests.
Worth reporting upstream separately: `ACLListPayload` and `PolicyListPayload`
both declare `ext` in the generated bindings, so those Rust structs have
drifted from the schemas they are generated alongside. SPEC §4.5.1 makes `ext`
legal on any payload. The console no longer depends on that being fixed.
**"SESSION EXPIRES 01/01/1970."** The agent sent no `expiresAt`, and
`new Date(undefined)` renders as the epoch — which reads as a real answer. A
session whose expiry is unknown is not one that expired in 1970, and that
distinction is the entire job of that banner field. Every timestamp now goes
through `formatInstant`/`formatDate`, which say "unknown" rather than
guessing, and `isPast` never reports an absent date as expired.
**One context had two names on one screen.** The tree rendered `name`
("Verifiable Trust Agent") while every table column and pane title rendered
`id` ("vta"), with nothing connecting them — and with ids like `vta`, `vtc`
and `webvh`, connecting them by eye is exactly what an operator should not be
doing. Both are shown now, everywhere, in one order: the label first because
that is what you navigate by, the id beneath in monospace because that is
what joins the tree to every table beside it.
**Audit rows open.** A row shows what fits; what does not fit is the part
answering "why" — `detail` is free-form per action, `actor`/`target` are full
DID URLs a column truncates, and `prevHash`/`entryHash` are the only evidence
of chaining there is. Rows now expand to the entry as the agent sent it. The
CHAINED column is gone: against the live agent every row read "no", and a
column that always says the same thing carries no information while costing
the width a DID needed — it is one note above the table instead. Non-DID
actors (`internal:webvh-rest-auth`) are no longer styled as DIDs.
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Adds a fourth act to the rail. The deck's three answer "who am I", "how do bytes move" and "who may act"; none of them answers "what is stored here", which is the question these three share. **App state** (`vta/app-state/*`) is where applications keep metadata in the agent — the openvtc install record, sync cursors, per-app preferences. Unlike the wallet's own storage these follow the holder to every device, so a wrong value here follows them too. Two things the pane refuses to flatten: writes carry `expectedVersion`, so an edit compare-and-swaps against what was read rather than overwriting whatever an application wrote since; and deletes are soft, so `includeDeleted` distinguishes "nothing ever wrote this" from "something removed it". **Memory** (`vta/memory/*`) is what the agent recalls between conversations. Read, write and forget, as asked — with the write path stating plainly that a hand-written memory is stored identically to one the agent formed itself, so nothing downstream can tell them apart. Both of those **require** a context and say so rather than showing an empty list. `contextId` is part of the record's address in these families, not a filter over it: `(context, namespace, key)` names one record, and two contexts holding the same namespace and key hold unrelated records. Fanning out to build an agent-wide view would produce a list where identical-looking rows are different records. **Credentials** is issuer-side only, and the pane leads with why. The `vta/credentials` family declares `issue` and `revoke` and nothing else — no list — so this console cannot show what has been issued, and a blank table would read as "you have issued nothing", which is a claim it has no basis for. It says so and points at the audit trail. The issued id is surfaced prominently on success for the same reason: it is the only handle revocation has, and there is no way to look it up later. Revocation likewise cannot preview, so its confirm step states what could not be checked instead of dressing a guess up as one. Holder-side credentials are deliberately absent. They live in `vault/credentials/*`, which ships no TypeScript bindings in `@openvtc/trust-tasks`; transcribing those shapes by hand is the copy that drifts this repo already removed once, so that half waits for the bindings rather than being approximated. The pane says that too, rather than leaving a gap the reader has to explain to themselves. `app-state/records.ts` takes the same `TaskParty` widening as `admin/*`. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Closes the half the credentials pane has been carrying a placeholder for. `vault/credentials/*` had no specification, so no bindings, so nothing to implement against without hand-transcribing shapes out of someone's service code. trustoverip/dtgwg-trust-tasks-tf#338 specified the family from that implementation and shipped in @openvtc/trust-tasks 0.16.4; this consumes it. `vault/credentials.ts` in core is the client — query, get, receive, and the five lifecycle verbs. It sits beside the password-manager vault because they share the wire slug and the agent's keyspace, and the module header says why they are otherwise different things (disjoint namespaces, a presentable VC rather than a raw secret, no sealed envelope). Two properties the pane renders rather than flattens: **Query refuses an unconstrained filter**, because the answer would be the shape of the holder's whole life — every community, every role, every issuer. `isRunnableCredentialQuery` is exported so a caller can check locally: the Search control stays disabled and explains itself instead of firing a request whose refusal the operator would have to interpret. The two tick-boxes widen a search rather than narrowing one, so they deliberately do not count. **Validity and lifecycle are orthogonal**, so they get separate columns. Against representative data that is immediately load-bearing: an invitation that is expired but active, a role that is valid but archived, a membership that is revoked and in the trash with its restore deadline shown. A single "status" column would have been wrong about three of the four, and the available actions follow lifecycle — archive, unarchive, restore or purge — rather than validity. Delete previews as recoverable and says what recovery would cost (going back to the issuer, which for an invitation may not be possible at all); purge previews as the thing with no way back, and is offered only on a row that is already a tombstone. `credVaultGet` is in core and deliberately not wired to a button. It is the only call that returns credential contents, and a console has no reason to fetch a body it would only display — presentation is use, not administration, the same line `keysSign` sits on the far side of. The task-surface ratchet moves 152 -> 160. Its comment listed this family as unspecced; that is now the eight that moved, and the remaining 17 are named. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
The credentials pane opened with a notice rather than a table:
There is no list of issued credentials, and this is not an empty one.
That was true, and building the empty table would have been the easy lie —
it reads as "you have issued nothing" and is wrong the moment anything has
been. The task now exists, so the notice becomes what it was standing in for.
The chain that got here, in order: the gap was filed
(trust-tasks-tf#337), specified (trust-tasks-tf#342), implemented in the
agent (verifiable-trust-infrastructure#1235), released
(@openvtc/trust-tasks 0.16.6, vta-sdk 0.32.3), and is now consumed here.
The console kept saying "there is no list" for all of it, which was the
honest thing to say while it was true.
`listCredentials` in core admin returns metadata only — there is no task that
reads a credential body back, and this console does not pretend otherwise.
Its `holderDid` is named for the *subject* rather than `holder`, matching
`issueCredential`, because `holder` on the caller params is the envelope's
issuer: two different parties would otherwise share one member name on one
object.
The pane renders two things the shape makes easy to lose. `truncated` goes
*above* the table, because it changes what the list means — anything concluded
from a partial page, including that a credential was never issued, may be
wrong — and a caution under a long list is one nobody reads. And `status` is
derived by the agent at read time with `revoked` beating `expired`, so it is
painted as received and never recomputed or cached here.
Revoking is now a row action that hands its id to the form below. The id is an
opaque UUID, and asking an operator to copy one by eye immediately before an
irreversible action is how the wrong credential gets revoked.
Two pieces of copy that the new list made false are corrected rather than left
standing: the revoke panel no longer says there is nothing to pick from, and
its confirm no longer claims the console can show nothing about the credential
— it can show who holds it and when it was issued, and still cannot show what
it claims. That last limit is real and stays stated.
Task-surface ratchet 160 -> 161, canonical total 177 -> 178. Unlike the eight
before it, this family did not move off the unspecced list — the task did not
exist on either side until this month.
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
`vta/seeds/export-mnemonic/1.0` 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. A second guard bans all three from anywhere in `dist/`, with **no exception**. That is the difference from the admin guard beside it: `admin/*` is authority, which the console is meant to hold, so that guard permits `manager.js`. These return material, and no browser context should be able to ask for them. The guard exists rather than simply not building the pane, because not building it is indistinguishable from not having got round to it. Someone reasonable adds a seeds view next year, nothing objects, and the refusal was never recorded anywhere a person would look. Verified non-vacuous the only way that means anything: a seeds URI imported into the console, rebuilt, and the guard caught it in `manager.js` — the file the admin guard would have let through. Reverted. `vault/release/0.1` is deliberately not listed. It releases a secret to a site the human just approved, which is the wallet's whole job. The line is not "touches a secret" but "hands over material the holder cannot revoke, to a surface that cannot contain it". Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…s way fails The section claimed the guard was "verified non-vacuous by importing a seeds URI into the console and watching it fail". Re-running that verification is how I found the sentence is misleading. An unused `export const SEEDS_PROBE = "vta/seeds/export-mnemonic/1.0"` in `shell.tsx` does **not** trip the guard. Rollup tree-shakes it, the string never reaches `dist/`, and the guard stays silent — correctly, since it asserts what ships rather than what is typed. But someone re-checking the refusal reaches for exactly that probe, sees nothing happen, and concludes the guard is a no-op. So the note now says what actually works: put the URI somewhere the console renders. Verified with a nav `label`, which trips `manager.js` as intended, and reverted. Both dist guards re-run against a cold `npm run clean && npm run build`; the admin one confirmed non-vacuous too (`acl/grant/0.1` really is present in `manager.js`, so its exclusion clause is doing work rather than matching nothing). Full suite green: 720 tests across the four workspaces. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
`requirePresence` runs a WebAuthn assertion and throws if it does not complete. Backup abort and reload-services will sit behind it. Three things it deliberately is not, each written into the module because each is what someone would otherwise assume. **It is not an authorization of the operation.** Nothing verifies this assertion against the Trust Task that follows: the agent never sees it, the document carries no trace of it, and the trail at the far end records an ordinary request. What it buys is that an unattended console cannot be used by whoever walks past, and that an irreversible action is deliberate rather than one click deep. Worth having; not a second factor on the agent, and the header says so rather than leaving a reader to assume the stronger claim. **It does not reuse the PRF ceremony.** `runPrfUnlockCeremony` returns PRF output — the AES key root, as sensitive as the holder seed. A presence check needs none of it, and a primitive that hands back key material to answer "is someone there?" will eventually have that output stored by a caller who only wanted the boolean. Same assertion, no `prf` extension, returns `void`. Nothing to leak because nothing is returned. **It never consults unlock state.** A step-up a prior unlock satisfies is not a step-up — the wallet may have been unlocked hours ago, and reusing that means the gate stands open for as long as the console does. Fresh challenge, fresh assertion, every call. This is the first thing that breaks if someone later adds a cache because the prompt is annoying. The challenge is random rather than bound to the action, and that is a choice rather than a shortcut: binding buys something only where a verifier checks it against the request, and there is no verifier here. Calling it "for this bundle" when nothing enforces that would be the overstatement the rest of the module avoids. A dismissal and a timeout both raise `NotAllowedError` with no way to distinguish them — by design, so a site cannot learn that a user refused. Both surface as `cancelled`, which is the reading a caller acts on: do nothing, say nothing. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…torage is blocked Seven tests, all guarding the same failure mode: a step-up that quietly stops being one. It never breaks loudly — the prompt just stops appearing, or appears and is not waited for, and the console looks exactly as it did. Two of them are the ones that matter. **A second call must run a second assertion with a different challenge**, because a step-up an earlier one satisfies is not a step-up; and **the module must not name unlock or session state**, checked against the source, because a cache added later would leave every runtime assertion here passing on its first call. That source check strips comments first. The module *discusses* the PRF ceremony at length, explaining why it deliberately does not reuse it, and a check matching prose would fail on the very paragraph documenting the refusal — teaching the next person to delete the explanation to get green. Writing the tests found a real defect in the module. `IndexedDBKVStore.get` **throws** where IndexedDB is unavailable rather than returning nothing — a private window, or a browser set to block site data. Looking up which credential to name is an optimisation, so letting that propagate turned "we could not find your credential" into "you cannot approve this action", which is the wrong failure by a long way. Now caught, falling back to a discoverable credential. 179 → 186 tests in the extension workspace; 727 across all four. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…ence Both land on `@openvtc/trust-tasks` 0.16.8, which carries the six specs from trustoverip/dtgwg-trust-tasks-tf#347. ## Backup is one verb out of five, and the pane says why `initiate-export` and `finalize-import` both carry a `password` — the key that protects, or unlocks, a complete copy of the agent. It travels *inbound*, typed by the operator, and a browser form is reachable by autofill, by a password manager, by any other extension with host access, and by anything recording the screen. A step-up does not help: it proves a human is present for the action, and the password was typed before the prompt appeared. So the wallet does not ask for it at all. The CLI collects it in a terminal, which is not perfect and is much smaller. That reasoning is on the surface, not just in a comment, because an empty pane is indistinguishable from an unfinished one — someone would reasonably "complete" the family next year. The same guard is in `admin/backup.ts` and in the coverage ratchet's prose, which now records `vta/backup/*` as the first family whose absence is a decision rather than a gap upstream. `abort` is here because it carries no secret and is the only way to close a window that is otherwise open until it expires, with a fetchable copy of the agent live at a known address. There is no verb that lists in-flight bundles, so the operator pastes the id the CLI printed — the pane says that rather than implying the console lost a picker. ## Two non-failures that would otherwise read as faults **`aborted: false`** is rendered as "already closed", not an error. 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. **A dropped connection on reload** is the expected shape of *success*: a reload restarts the transport the response would have travelled on. Reporting it as an error would send an operator hunting for a fault that is the agent coming back, and invite a retry that restarts it again. ## Why reload-services is gated at all It creates, deletes and discloses nothing, which invites a lower bar than it deserves. What it spends is availability, and that falls on every counterparty of the agent rather than on the operator who clicked — including this wallet's own inbound sessions, so approval requests wait at the mediator while it restarts. The copy says so. Both guards re-run against a cold `npm run clean && npm run build`: admin URIs still confined to `manager.js`, no key-material URI anywhere, console still a single chunk, and the new `vta/backup/*` URIs land only in `manager.js`. 727 tests green. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
… blank Three lists describe the console's navigation — the `SectionId` union, the `ACTS` nav data, and the `switch` in `renderPane` — and nothing forced them to agree. The union makes the switch exhaustive over ids that *exist*, so a case cannot name a section that was never declared. The reverse has no guard at all: an id in the union and in the nav, with no case, compiles clean. The failure is silent, which is what makes it worth a test. The section appears in the sidebar, the operator clicks it, and the pane renders nothing — no error, no console message, no failed request. A blank column reads as "this agent has none of those" rather than as a bug. It is the same shape as the panes that died on `unknown field 'ext'`, only quieter, because at least those said something. Noticed while adding the Backup section, which is exactly the change that would introduce it. Five assertions: nav ⊆ cases, cases ⊆ nav (a pane nothing routes to is code that reads as live and is not), all three lists name the same set, no duplicate ids, and a floor so none of it passes vacuously. Proved non-vacuous rather than assumed — the lesson from the seeds guard, where the obvious probe was tree-shaken away and proved nothing. Adding a `probe` section to the union and the nav with no case fails two of the five; reverted. 186 → 191 tests in the extension workspace. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…d casing folds Answering "are we on the latest trust-tasks, and can the hand-written tasks go?" — we are (0.16.8 everywhere), and two categories could go. ## Two compat folds for peers that do not exist `contexts.ts` folded `base_path`/`created_at`/`updated_at`, and `list-dids.ts` folded `context_id`/`server_id`, both justified in comments as "agents migrate later" and "this library talks to agents it does not control". CLAUDE.md forbids exactly this: nothing is deployed, so "an older agent is still a supported peer" names nobody. And it was checkable — `ContextRecord` and `WebvhDidRecord` in `vta-sdk` both carry `#[serde(rename_all = "camelCase")]`, so the agent *emits* the canonical spelling. Their `alias` attributes are deserialize-only: they govern what the agent accepts, which says nothing about what it sends. Two tests pinned the folds in place, one of them titled "still folds a legacy record, because agents migrate later". Both now assert the canonical spelling instead. ## Two hand-written types that had already drifted Worse, and the reason this is more than tidying. `ContextRecord` and `WebvhDidRecord` were declared by hand beside the generated ones, and had drifted in opposite directions: - `ContextRecord.did` was `string | null`. The schema makes it OPTIONAL — an agent omits it — so a caller guarding `=== 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`, `preRotationCount`, `nextFragmentId`, `createdAt`, `updatedAt`). Data a caller could have used was simply invisible. Neither could fail anything. `task-surface.mjs` checks that the URIs are real and current; it says nothing about payload shapes, and the type is the one part of a Trust Task checked against a copy rather than a schema. Both now come from the binding, along with the five `vta/contexts/*` URIs, `vta/webvh/dids/list`, and `task-consent/request` — plus `WakeHandle`, `WakeTriggerPolicy`, `Exposure` and `StatePin`, which were byte-identical to the generated ones. Identical is not reassuring: it is the state a type is in immediately before it diverges. ## The guard `schema-types-are-not-restated.mjs` fails if any `export interface` here shares a name with a generated `_shared/components` type. `NARROWING` holds the one deliberate exception — `PasskeyVerificationMethod` narrows `webauthnTransports` from the schema's `string[]` to the DOM's `AuthenticatorTransport`, which the schema cannot name and a caller passing these to `navigator.credentials` wants. Proved non-vacuous: restating `ContextRecord` fails it by name and file. 479 -> 482 tests (two fold tests removed, three added). Cold clean/lint/build/test green; both dist guards re-run. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Continuing the sweep for hand-written task types, and this one is a live
defect rather than tidying.
`AclSwapResult` was declared here as a flat ACL entry — `did`, `role`,
`allowedContexts`, `createdAt: number`. The agent sends none of that. VTI
#857 made `acl/swap-key/0.1` respond `{ entry, previousSubject }`, and the
entry itself names `subject` not `did`, `scopes` not `allowedContexts`, and
dates as RFC 3339 strings not numbers. Wrong at three levels at once:
wrapping, member names, and types.
So every field a caller read came back `undefined`. It has not bitten
because nothing in the extension calls `swapAcl` yet — the next caller
would have been the one to find out.
**Why nothing caught it.** `sender.send<T>()` is an unchecked cast, so a
wrong `T` costs nothing at compile time. And the one test built its
response fixture from this very type rather than from the schema, then
never read it: it asserted the request and stopped. A fixture derived from
the client's assumption cannot contradict the client. Rebuilt from the
schema, the test's own surviving line `assert.equal(res.did, …)` failed —
which is the drift, demonstrated.
The test now asserts the response too, because the absence of that is what
let the type rot.
Also taken from the binding, both missing only `ext` (SPEC §4.5.1, which
any agent may send): `DeviceSetWakeResponse` and `ContextDeleteResult`,
plus the `vta/contexts/delete` and `acl/swap-key` URIs.
`*Params` and `*Options` types stay local on purpose — they are
caller-facing argument shapes that bundle parties, payload fields and
channel options, not wire types, and no schema describes them.
Cold clean/lint/build/test green; 482 core tests; both dist guards re-run.
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…iding
`schema-types-are-not-restated.mjs` matched `export interface` only. A
schema component restated as `export type X = { … }` walked straight past
it — which is how `PushRegistration` survived the sweep that added the
guard.
The schema declares it `Apns | Fcm | WebPush`. This library restated it as
an inline object modelling webpush alone. The narrowing is right — a
browser extension cannot produce an APNs or FCM registration — but writing
it out by hand states the members again, so a change to the WebPush variant
leaves this behind silently. Now `export type PushRegistration = WebPush`,
taking the generated variant: the narrowing is the only thing the line
says.
The guard now reads `export type` too, and distinguishes the two cases,
which is the part worth getting right:
export type X = Generated; // an alias — one source, fine
export type X = { … }; // a restatement — a second source
`ALIAS_TO_IDENTIFIER` draws that line. Without it the check would have
flagged `KeyRecord`, which correctly aliases `SpecKeyRecord`, and the
obvious way to silence it would have been to add a NARROWING entry for a
type that is not narrowed at all.
Proved both directions: restating `PushRegistration` inline fails the
guard by name; aliasing it passes, and `KeyRecord` still passes untouched.
Cold clean/lint/build/test green; 482 core tests.
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
GitHub flagged this on push. Four advisories against `fast-uri` 3.1.5, one rated high: host confusion via skipped IDN canonicalization, plus SSRF via malformed IPv6 normalization, repeated hostname percent-decoding, and percent-encoded scheme normalization. **Build tooling only, and worth saying so rather than leaving it to be assumed.** The path is `packages/pwa -> vite-plugin-pwa -> workbox-build -> ajv -> fast-uri`, and `fast-uri` appears nowhere in `packages/extension/dist/` — the wallet does not ship it, so no shipped surface parses a URI with it. That bounds the exposure to whoever runs the build; it does not make it a non-issue. Lockfile only: a patch bump inside the 3.x line, no manifest change and no transitive resolution moved. `npm audit` now reports zero. Cold clean/lint/build/test green. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…he stale R4.1
Closes the two gaps left from the type sweep.
## No hand-written task URI survives in `packages/core/src`
The last ten were `auth/authenticate/0.1` (three call sites),
`auth/step-up/approve-{request,response}`, `task-consent/{decision,granted}`
and `vta/contexts/preview-delete`. All now import `TYPE_URI` /
`RESPONSE_TYPE_URI` from their binding.
Lower stakes than the types were — `task-surface.mjs` already validates
every URI against the agent's live surface and fails on a deprecated
version, so a wrong one could not hide. The value is that there is now one
answer to "which version does this library speak", and it is the
registry's.
`STEP_UP_APPROVE_REQUEST_TYPES` keeps both 0.1 and 0.2, now as imported
constants. That is not a compat fold: the VTA pushes 0.1 over DIDComm
(`messaging/handlers.rs`) while the did-hosting control plane mints 0.2 —
two live producers, checked rather than assumed.
The three `trust-task-error` constants stay written out, as their own
comment already explains: they are historical markers, matching is done on
the slug across every 0.x, and the current version is re-exported from the
package runtime.
## R4.1 named a repo that no longer exists
The rule 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". Both halves were out of date.
`pnm-relay` is gone. 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 and the
`ConsentRequired` union. Verified in code, not taken from the plan:
`decodeTrustTaskHttpAck` reads the body and builds with `errorFromBody`,
and `errorFromResponse` appears nowhere in the module. Nothing depends on
`@openvtc/pnm-relay`; `rp-sdk-js` is a separate server-side SIOPv2
verifier, not its successor. (D8/F5, resolved by consolidation.)
What survives is the rule rather than the defect, so the note now says
that: `vti-didcomm-js` is still a second implementation of the same wire
contract, and a transport or error-shape fix has to land in both. A third
copy is what R4.1 exists to prevent.
Cold clean/lint/build/test green; 733 tests; all three dist guards re-run.
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…he agent The console already had an entry point on the options page and none in the popup, which is the wrong way round. The popup is the surface that tells you which VTA you are connected to, your role and your transports — so it is where "and now go administer it" belongs. Reaching it otherwise meant knowing to open Settings first. Placed after Role/Transports and before Lock wallet: it is an action about the agent, and the two below it are teardown. **A tab, not a pane**, and that is the point rather than a cost. Opening it closes the popup — but the console is a working surface with a context tree and thirteen panes, and a 400px popup that vanishes when it loses focus is no place to destroy a context from. It also ships as its own bundle, which is what keeps `@openvtc/pnm-core/admin` out of every wallet surface. **Not role-gated.** Several panes are readable at any role, and the console already refuses what the caller may not do — with the reason on the control, rather than by hiding it. Gating here would hide the readable half from everyone below admin. This adds no admin code to the popup: it opens a URL. Verified rather than assumed — the CI guard that confines `admin/*` task URIs to `manager.js` still passes against a fresh build, as does the key-material guard, and the console is still a single chunk. 733 tests green. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
The pane argued why export and import are not in the browser and then left
the operator to go find `--help`. It already knew the answer; it just was
not saying it.
Four commands, taken from `pnm-cli/src/cli.rs` rather than remembered:
pnm backup export
pnm backup export --include-audit
pnm backup import <file> --preview
pnm backup import <file>
Two things are called out because a flag list does not convey them.
`--preview` is a real rehearsal — decrypt, validate, report, change
nothing — and it is worth running first, because the common mistake is not
a wrong password (which fails loudly) but the right password on the wrong
bundle. And `--include-audit` widens the bundle beyond the agent's own
state to its dealings with counterparties who were never party to the
export, which is why it is off by default.
The third detail strengthens the argument the pane was already making.
There is **no `--password` flag** — verified, the CLI defines none; it is
prompted, with confirmation, minimum 15 characters. So "the terminal is a
smaller surface" stops being a hand-wave: the password never enters shell
history or the process list. That is a concrete property a browser field
cannot offer, and it belongs next to the claim it supports.
Both dist guards re-run against a fresh build — the pane gained text, not
imports. 733 tests green.
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…agent Reload-services was under Transports and read wrong there, which was the tell. Two facts say it belongs elsewhere: The task is `vta/management/reload-services`. It re-reads the agent's entire configuration; the transports going down is what you *observe*, not what it is. And Transports is per-transport CRUD — enable this one, drain that one — so an agent-wide restart sits in that list looking like another row of it, which is exactly what it is not. Backup was under Wire & execution for want of anywhere better, and was never really "wire" either. So: one pane, `Maintenance`, holding both. Everything else in this console acts on something the agent *holds* — a context, a key, a credential, an ACL entry. These two act on the agent. That is the grouping. It also happens to be the step-up boundary: these are the only two controls gated on proof of presence, precisely because they are the two that act on the whole agent. That is not a coincidence to design around — it is the same fact showing up twice, and a pane that matches it will keep matching it as things are added. Reload keeps its own role gate (admin), separate from the backup one, since the two answer to different authority even in the same pane. `services.tsx` shed its now-dead `reloadServices`, `requirePresence` and `StepUpError` imports — tsc did not flag them, and a live import of `reloadServices` in the Transports pane would have left the old path reachable. Behaviour is unchanged: same task, same step-up, same copy. Confirmed working against a live agent before the move. `manager-sections.test.mts` verified the rename end to end — the union, the nav and the switch still name one set. Both dist guards re-run; the reload-services URI still appears in `manager.js` and nowhere else. 733 tests green. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…ould save it
Reported from testing: every context in the tree showed `agent-memory` in
the Name field — the first context's name — while the header, id, DID and
timestamps all showed the right record.
`EditContext` seeds its fields with `useState(record.name)`, which runs on
mount and never again. The detail panel is a single instance that swaps
records as the operator moves through the tree, so React reused it and the
form kept its first value.
**It reads as a display glitch and is not one.** That field is the input to
`contextsUpdate`. An operator pressing Save would have renamed the context
they were looking at to the name of one they were not — a write, from a
screen that showed them the correct record everywhere else. Nothing else on
that panel disagreed.
Fixed with `key={record.id}`, which makes selecting a different context a
different component and re-runs the initialisers.
## The guard found four more, and one of them was itself
`manager-form-state.test.mts` fails when a component seeding state from a
prop is rendered without a `key`. Running it turned up `WriteRecord`
(app state), `WriteMemory` and `PolicyEditor` — same pattern, all now keyed,
including their create call sites so create and edit cannot share an
instance either. Those three were safe only by accident of control flow:
the panes unmount the editor between edits, so nothing had gone wrong yet.
Keyed, they are safe by construction.
It also reported `DeleteContext`, which has no `useState` at all. The scope
detection stopped at `\nfunction ` and ran straight past a component whose
successor is declared `export function`, into a sibling's state. Fixed
rather than exempted — a guard that cries wolf is one people learn to skip,
and adding a meaningless `key` to silence it would have been worse.
`RenameKey` and `ChangeRole` are exempt with the reason recorded: `Table`
renders them per row under `rowKey`, so the key is already on the `<tr>`.
Proved non-vacuous: removing the new key reports `EditContext` by name.
191 -> 194 tests; 736 across the workspaces; dist guards re-run.
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…that wrecked its own table Three things from testing. ## The agent gets its name back The console header showed a raw `did:webvh:Qm…` and nothing else. It now renders the name through `useAgentNames`, which reads `alsoKnownAs` off the DID document and verifies it — never derived from the DID's host or path, because a derived name is a guess wearing an identity. No claim, no name: the DID stands alone, which is the honest fallback. Same hook the setup pane already uses. This banner is the line an operator reads to check they are administering the agent they think they are, so it is the right place for it. ## You can reassign a context's DID `contextsUpdateDid` was in `pnm-core` and surfaced nowhere. That left the operator somewhere absurd: deleting a DID a context depends on fails with the agent's own instruction — "reassign it first: `pnm contexts update test-vtc --did <new-did>`" — an instruction the console could carry out and did not offer. Being told the fix and handed no way to apply it is worse than not being told. Its own panel, not another field on the name/description form. Those are labels; this changes which identity the context presents to every counterparty, and the agent refuses to delete a DID while a context points at it. One Save button for both would make a rename and an identity change look like one decision. The button reads "Assign" or "Reassign" depending on whether there is a DID today, and a context with none says so — that is a state, not a fault. ## A refusal that destroyed the row explaining it `Destructive`'s error branch had no `maxWidth` while its preview branch has carried one all along. Inside a table cell, a long refusal — and the DID delete refusal is prose, naming the dependant and the fix — set the column's width, so every identifier column collapsed and DIDs wrapped every few characters. The most useful message on screen was wrecking the row that gave it meaning. `Table` already documents this exact failure at its `minWidth: 760` floor; 760 was not enough for this one. Error capped at 480 to match the preview, and the DID column given a `26ch` floor. The first attempt used `minmax(22ch, 1fr)`, which is a grid function and invalid as a table width — it would have been dropped silently and looked fixed. 736 tests; dist guards re-run. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Three from testing, plus one non-finding worth recording.
## "I have credentials but the pane says none"
The pane was right and useless. `vault/credentials/query` refuses a search
that constrains nothing, so the answer to "what do I hold?" was behind a
`type` or issuer DID the operator had to know in advance. A wallet holding
credentials looked identical to one holding none.
`status` counts as a filter, so one click gets there. Not a way round the
agent's rule — it is the rule satisfied with the narrowest thing that still
answers the question. The button is named for what it returns ("Show what
is currently valid"), because `valid` excludes expired and revoked and the
button must not imply otherwise; a line underneath says where those are.
## Reading a credential
`View` fetches the document by id through `credVaultGet` and renders it,
with a copy button.
Its own request, deliberately. The search returns metadata — the pane says
so — and rendering full documents from a list would quietly turn "show me
what I hold" into "read everything I hold". Copy hands over exactly what
the agent returned, canonically formatted, because that is the form a
verifier expects rather than a prettified approximation. A refused
clipboard says so instead of showing "Copied" over an unchanged one.
## Switching agent from the console
The console read the active VTA and offered no way to change it, so
switching meant going back to the popup. `setActiveVtaDid` writes the same
`pnm-connection/v3` envelope the readers parse, read-modify-write so a
partial view cannot forget every other agent — the failure the per-agent
inbox map exists to prevent. It refuses a DID the wallet never onboarded.
**Switching reloads the page**, deliberately. Thirteen panes each hold
fetched state; propagating a change of agent is thirteen chances to leave
one showing the previous agent's keys under the new agent's name. That is
not staleness, it is the console asserting something false about who you
are administering — on the surface whose whole job is to answer that. The
control only appears when there is somewhere to switch to.
## App state was not a bug
Its empty state already names the context it searched — "No application has
written anything into <id>". App state is per-context, so OpenVTC's records
are under the `openvtc` context. Left alone.
736 tests; all dist guards re-run.
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Asking an operator to copy a `did:webvh:Qm…` out of the DIDs pane and paste it here made them do a lookup the console could already do — and a mistyped identifier is not something anyone catches by reading it back. Now a select, scoped to this context's own DIDs, which is what a context acts as. `— none —` clears it, and says what that means rather than leaving an empty field to be interpreted. The current DID is always among the options, even when the agent no longer lists it under this context. Otherwise a DID assigned from elsewhere would vanish from the list and the select would fall to whatever happened to be first — turning "open the panel" into "silently propose a change". ## The type system caught the right thing on the way The obvious lister, `vtaListDids`, is typed on `Identity` — it wants key material — and refused to compile here. That is the guard working, not an inconvenience: this console holds no private key, which is why `admin/*` and `vta/contexts.ts` type their parties as `TaskParty` (CLAUDE.md, "the console holds no key material"). Weakening the signature to get a list would have quietly undone the property the whole surface is built on. `webvhDidList` is the `TaskParty`-typed one the DIDs pane already uses, and it was the correct call all along. 736 tests; dist guards re-run. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…the table Reported from testing: "I missed that it was rendering under the list." That is the whole defect. The panel appeared after the table, so clicking View on the third row put the answer below the fold and the table looked like it had done nothing. A detail panel the operator does not notice is a detail panel that does not exist. `Table` gains an `expanded` prop: detail rendered in the flow, directly beneath its own row, spanning the columns. The row drops its bottom rule when a detail follows — the row and its detail are one thing, and a line between them reads as two. The button toggles now and says `Hide` when open, because a control that only ever opens leaves the operator hunting for a close button that is somewhere else on screen. Not a new mechanism: the Audit pane already does exactly this, hand-rolling its own `<table>` and `colSpan` row because the shared component could not. Putting it in `Table` means the next pane that needs it does not hand-roll a third one, and Audit has somewhere to converge on when it is next touched. 736 tests; dist guards re-run. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
25 commits. The console itself, the two controls that act on the whole agent, and a sweep that replaced hand-written task types with the registry's.
This branch has never run CI — the workflow triggers on pull requests and pushes to
mainonly. Everything below was verified locally (lint, 736 tests, all three dist guards, coldnpm run clean && npm run build) and much of it against a live agent, but the Node 24/26 cold-install matrix runs here for the first time. That is the main reason this is a PR rather than a direct push.The console
Thirteen panes on the Trust Context tree: Contexts, Keys, DIDs · Credentials, Memory, App state · Transports, Maintenance, Audit · Access, Approvals, Policy, Sessions.
It 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. That is why those surfaces type their parties asTaskPartyrather thanIdentity— and the type system enforced it during this work: an obvious DID lister refused to compile here because it wanted key material.CI keeps it structural:
admin/*task URIs are banned everywhere indist/exceptmanager.js, and the console is its own vite build withcodeSplitting: falseso there is no shared chunk to leak through.Proof of presence, and a refusal
requirePresenceruns a fresh WebAuthn assertion — no PRF extension, returnsvoid, never consults unlock state. A step-up an earlier unlock satisfies is not a step-up. Confirmed against a real authenticator: prompts every time, even unlocked.Behind it: Maintenance, holding backup-abort and reload-services — the two operations whose subject is the agent itself rather than anything it holds.
Backup offers
abortonly.initiate-exportandfinalize-importcarry apasswordinbound, and a browser field is reachable by autofill, password managers, co-resident extensions and screen capture; a step-up guards the action, not the field. The pane says so and prints the CLI commands instead. A second CI guard bansvta/seeds/*from every bundle including the console — those return key material, and no browser context should be able to ask.Types from the registry, not by hand
ContextRecord,WebvhDidRecord,AclSwapResultand others were declared locally beside the generated ones and had drifted.swapAclwas the live one: it typed the reply as a flat entry while the agent has sent{ entry, previousSubject }since VTI #857, so every field read came backundefined. Latent rather than live — nothing calls it yet — but the next caller would have found out.Two dead casing folds removed, verified against
rename_all = "camelCase"on the Rust side.schema-types-are-not-restated.mjsnow fails if any local type shadows a generated one.Guards added, each proved non-vacuous
contextsUpdateVerification
npm run clean && npm run lint && npm run build && npm testfrom cold — 736 tests. All three dist guards re-run by hand: admin URIs confined tomanager.js, no key-material URI anywhere, console a single chunk, MV3 worker free of dynamicimport(), Web Store zip clean ofkey/content_scripts/cookies.Live-agent testing confirmed: Backup renders, cancel behaves, reload-services restarts, the step-up prompts twice, contexts and DIDs populate, and the pane sweep.
After merge
@openvtc/pnm-corewants a minor bump (0.7.0) — exported types changed incompatibly. npm's 0.6.0 predates all of this.