feat(core)!: turn pnm-core into a standalone library, and make the extension reviewable - #121
Merged
Merged
Conversation
…t the spec Three changes that turn `@openvtc/pnm-core` into something other people can build VTA-enabled apps and services on, ahead of it moving to its own repo. **Layering.** `vta` and `vault` were mutually dependent: the VTA's REST auth bootstrap lived in `vault/transport.ts`, so importing the VTA protocol pulled the entire vault surface in behind it. It moves to `vta/auth.ts`, where it belongs — nothing about it was ever vault-specific. `base64url` moves from `webauthn/` to `util/`, closing the edges that made `did` and `vta` depend on WebAuthn to encode bytes. Every module directory is now a published entry point (was three), and the package declares `sideEffects: false`, so a consumer can import the module it needs instead of the whole library. Two tests enforce this rather than describing it: one fails the build on a sideways or upward import, on any cycle, and on a stale entry in its own exceptions list; the other imports every advertised entry point in plain Node with no DOM, which is the failure that otherwise only shows up after someone installs this into a server. **`@openvtc/pnm-core/admin`.** `acl/*`, `keys/*`, `policy/*`, session introspection and context deletion, over any `TrustTaskSender`. Payload types, response types and task URIs come from `@openvtc/trust-tasks` — the generated bindings for the same JSON Schemas the agent's Rust is generated from — so this package owns only the call layer. Adopting them caught two mistakes an earlier hand-written version had already made: `acl/show`'s response entry is nullable, and `acl/revoke`'s `scopes` has `minItems: 1`. Deliberately not re-exported from the package root: it is operator surface, and a wallet has no business shipping it. **Conformance.** `task-surface.json` snapshots the agent's canonical task surface (285 tasks, from vta-sdk 0.25.0); a test checks that every task this library names exists, that none targets a deprecated version, and that coverage — 40 of 270 families — is a number that moves in a diff rather than something you discover by grepping. Refresh with `npm run tasks:sync`. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…es permission The wallet no longer writes anything into the browser on a site's behalf. `chrome.cookies.set` had exactly one caller: a "Sign in to <site>" button that wrote a VTA-issued SessionBlob's cookie jar into the user's browser for legacy relying parties — ordinary web apps with a server-side session and no notion of DIDs. That path, the scope checks guarding it (`cookie-scope.ts`), the vault's password-POST auto-login UI, and the `cookies` permission itself are all gone. The offscreen document now drops any cookie jar an agent returns before it can cross the bridge, so a jar the wallet cannot use never reaches the popup's memory. What survives is the modern half of `vault/proxy-login`: the VTA mints a SIOP id_token and the wallet displays the session rather than installing it. Vault password entries are browser-fill only. This is the permission a Web Store reviewer looks hardest at, and the one whose shape is indistinguishable from session hijacking without provably tight scoping. Removing it is worth more than any argument for keeping it: "the wallet writes nothing into your browser" is a claim a reviewer can check in one grep. Also fixes two things that would have failed the upload itself: - the manifest `description` was 135 characters against the Store's 132 limit. Chrome loads an over-long description unpacked without complaint, so nothing in the dev loop caught it. `assertStoreListingLimits` now fails the build instead, with tests. - `minimum_chrome_version: "116"` — WebAuthn PRF is the binding floor, and without it Chrome 109-115 users install a wallet that cannot unlock. BREAKING CHANGE: vault entries carrying a `loginConfig` are no longer usable from this UI; their proxy-login would return a session the wallet cannot consume. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Onboarding is a two-party handshake: the wallet mints an ephemeral `did:key` in the reviewer's own browser and shows them `pnm acl create --did … --role admin --expires 1h`, which someone with access to the agent has to run before Connect works. The ephemeral is per-installation, so it cannot be granted in advance, and the agent has no self-service enrolment. A Chrome Web Store reviewer has neither a shell on the agent nor a reason to trust one — without this, the extension cannot be tested by the only people whose testing decides whether it ships. The site stands in for that shell and doubles as the relying party they sign in to afterwards: - sign in with a reviewer key ID (the bootstrap — needed before a wallet exists), or with the wallet itself over SIOPv2 once onboarded. The SIOPv2 result is verified by spending the returned token against the agent's `/auth/sessions` rather than trusting the browser; - paste the command the wallet is showing and it runs the grant, for seven days rather than the one hour the command asks for, so it does not expire mid-review; - a walkthrough of what to test, a reset button that returns the agent to a known starting state, and a button that raises the consent prompt on its own (`requestTask`, which the wallet gates un-skippably) with instructions to click Deny first. Every screen says the demo is disposable and deleted on a stated date, because a reviewer putting their own data into it would be a worse outcome than a failed review. Pasted input is parsed for an anchored base58btc `did:key` and passed as argv, never through a shell; grants are capped per hour; sessions are `HttpOnly`/`SameSite=Lax` with a same-origin check on every write. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
The wallet's claim is now "it writes nothing into your browser on a site's behalf", so the documents that make that claim to a reviewer say it plainly: the `cookies` section is gone from the Web Store review notes, the README's cookie-injection section is replaced by what actually holds, and the permissions model describes two consequences of a site grant rather than three. Adds `docs/privacy-policy.md` — required for the listing, since the wallet handles authentication information — and dashboard-ready test instructions built around the demo site, with a pre-submission checklist whose first item is that the demo is actually up. CI gains three invariants, each covering a way a wrong build could pass a green run: the packaged manifest must not request `cookies`, the built bundle must not call `chrome.cookies`, and the extension bundle must contain no task URI from `@openvtc/pnm-core/admin` — agent administration reaching a wallet would mean someone imported it from the package root. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
`deviceList`, `deviceDisable`, `deviceWipe`, `auditList`, `configShow` and `configPatch` — the surface a management console needs before it can be trusted to change anything: what is enrolled, what happened, and under which settings. Thin wrappers now that the payload types come from `@openvtc/trust-tasks`, so what is worth saying is what the shapes imply: - `deviceWipe` takes `scope` and `reason` as required parameters, because the schema refuses a wipe without them — a wipe with no recorded reason is an audit gap, and that obstacle is deliberate. - `deviceList` omits disabled and wiped devices unless asked. A device that was taken away still exists, and `includeDisabled` decides whether an operator can see that it was. - `auditList` surfaces `truncated`. A partial audit page read as a complete account is the failure an audit trail exists to prevent. - `configPatch` returns `applied`, `pendingRestart` and `rejected` whole. A caller reading only the status code will tell an operator a setting is live when it is queued, or when the agent refused it outright. The device-side tasks (register, heartbeat, set-wake) stay in `device/`: they belong to whatever is *being* a device, which for this repo is the wallet. Coverage moves from 40 to 46 of 270 task families; CI's wallet-bundle guard gains `device/wipe` and `config/patch`. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
`didTemplateCreate/Get/List/Update/Delete/Render` and `memoryPut/List/Delete`, bringing admin coverage to 55 of 270 task families. Templates are the shape an agent stamps DIDs from. `didTemplateRender` performs the substitution the agent would perform and returns the resulting document **without creating anything** — the safe way to show an operator what is about to be published under their identity, and the reason to reach for it before create. `didTemplateUpdate` replaces rather than patches: anything omitted from the template is omitted from the stored record. The two families scope differently, and both are pinned by tests because "the same name in a different scope" is how a console shows an operator the wrong record. A template call omitting `contextId` addresses the global namespace, in which a name may also exist; memory has no global namespace at all and requires one. `memoryList` returns keys and never values, so enumerating memory does not spill its contents. Templates target 2.0 because that is the version `vta-sdk` declares. The bindings ship 1.0 as well, and a 1.0 import compiles perfectly and fails at the agent — the conformance test is what catches it. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
`CONST_DECL` matches every `const NAME: &str = "…"` in vta-sdk, and the snapshot recorded all of them — service names, DID examples, error codes, sealed-bundle delimiters. It also kept family prefixes (`…/spec/acl/`), which are building blocks rather than callable tasks. The effect was quiet but real: the snapshot claimed 285 tasks where there are 175, and the coverage check reported a denominator of 270 families rather than 161 — so the gap between this library and the agent looked substantially worse than it is. The checks that catch drift (unknown URI, deprecated version) were unaffected, since the junk entries were never matched against. Recording now requires the `https://trusttasks.org/spec/` prefix and a version suffix. Coverage reads 55 of 161. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
`consentList/Decision/Revoke/Request`, `consentApproverList/Set` and `agentPing`, bringing admin coverage to 62 of 161 task families. This is consent to *be messaged* — who may reach an agent, on which platform, in which conversation — and is a different thing from `task-consent/*`, the human-approval flow for privileged actions that arrives inbound. The naming is close enough to be worth the note at the top of the module. Three shapes carry meaning a caller can get backwards, so each has a test: - a recorded `deny` is a decision, and is surfaced rather than treated as an absence: a blocked counterparty must not look merely unruled-on; - a `rejected` decision is the agent refusing to record anything (a stale challenge, say), which is not a deny. Rendering the two the same tells a user the opposite of what happened in one of the cases; - `agentPing` reports `degraded` as well as `ok`, because an agent that answers is not necessarily an agent that is working. `consentApproverSet` decides who gets asked when consent is needed, which makes it as privileged as any grant — pointing it at the wrong DID hands that DID the decision. Worth showing an operator explicitly rather than folding into a settings save. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…ssing New entry point `@openvtc/pnm-core/credentials`: `pendingPresentations`, `approvePendingPresentation`, `denyPendingPresentation`. Separate from `admin/` because this is holder-side surface — a wallet reaches for it, so it must not sit behind the operator-only boundary. A deferred presentation carries the specific credentials **and claims** a verifier asked for, its stated purpose, and an expiry. The tests protect the claim list surviving intact, because "approve this presentation" without naming the fields that leave the wallet is consent to nothing in particular. `purpose` is written by the verifier about itself and is documented as a claim to display, not an explanation the holder's agent vouches for. **The threaded steps of an exchange are not wrapped, and that is a finding rather than an omission.** `offer → request → issue` and `query → present` define no response document. Per SPEC.md §8.6 a consumer may send a courtesy `trust-task-ok`, but "a producer MUST NOT rely on receiving one, and the absence of one carries no information". `TrustTaskChannel` exposes exactly one primitive, `send()`, which awaits a reply — so wrapping those steps would wait for something a conforming counterparty is entitled never to send: a hang, or a timeout reported as a failure when the message was delivered perfectly. Supporting them needs a one-way path on the channel and on all three transports. That is a deliberate change to the transport contract, not something to slip in while adding a family, so it is written down at the top of `credentials/exchange.ts` instead. Coverage 62 → 65 of 161. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…unblocks `TrustTaskNotifier.notify` — deliver a Trust-Task without awaiting a reply. Some tasks define no response document. `send` is the wrong call for them: it waits for something the counterparty is entitled never to send, so a message that arrived perfectly surfaces as a timeout. SPEC.md §8.6 is explicit that a courtesy `trust-task-ok` may come and that its absence carries no information, which means the only honest promise is "handed to the transport" — and that is exactly what `notify` promises, and no more. All three channels implement it: - **REST** posts and treats a 2xx as delivered without reading the body: an empty body, a `trust-task-ok`, or nothing at all are all the same answer. A non-2xx still has its body read once and its code preferred over the status (R3.7) — the agent rejecting a task is not delivery, and a caller that cannot tell those apart will report a refusal as sent. - **DIDComm** uses the bridge's existing fire-and-forget `send`, which already resolves on hand-off and tracks no acknowledgement. - **TSP** uses a new optional one-way `send` on `TspTransport`. When the transport has none it refuses with `e.client.unsupported` rather than falling back to `sendAndAwaitReply`: the fallback would look like it worked and then block until the timeout. `VtaSession.notify` treats that code the way `send` always has, so the task moves to a channel that can carry it — while a real failure still stops the chain, because falling onward on a network error would deliver the same message twice. With the primitive in place, the threaded credential-exchange steps are now callable: `credentialOffer`, `credentialRequest`, `credentialIssue`, `credentialQuery`, `credentialPresent`. They take a `TrustTaskNotifier` rather than a sender, so a caller cannot await an answer that was never promised — the counterparty's reply arrives later as the next task in the thread. Coverage 65 → 70 of 161. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
`keysImport`, `keysDeriveAndSign` and `keysDeriveAndSignDocument` finish the `keys/*` family; `vaultGet` fills the read hole in `vault/`. Coverage 70 → 74 of 161. `keysImport` takes exactly one of three carriers, enforced by its parameter type — the schema states the constraint as a `oneOf`, which does not survive into the generated TypeScript, so without this a caller could send two and let the agent choose. The three are not equivalent: `sealed` is an armored bundle only the agent can open, `jwe` is decryptable by it, and `multibase` is the **private key in cleartext**, which puts it in every proxy log and error report that touched the request. No type can enforce that, so it is said in the doc comment where a caller reads it. `keysDeriveAndSign` leaves no record behind — nothing lists the key and nothing can revoke it, and an audit answers "who signed this" by re-deriving from the path rather than looking it up. Worth knowing before reaching for it instead of `keysSign`. `vaultGet` returns an entry's metadata and never its secret: releasing a secret is its own task with its own gating, so reading a vault's contents and obtaining what is inside them stay separate authorities. `redactedFields` is surfaced rather than swallowed, because an entry rendered without saying parts were withheld reads as a complete record to whoever decides from it. It deliberately does not re-export `VaultEntry`. `vault/list.ts` exports a hand-written type of that name which predates the bindings, and the two disagree — the spec's `targets` is a non-empty tuple and its `AttachmentRef` has no `sha256`. Migrating the rest of `vault/` onto the generated types is the job `admin/` has already had done, and it changes a published surface, so it is not being slipped in here. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Finishes every task family that has both a published schema and a declaration in vta-sdk. Coverage 74 → 110 of 161; what remains unimplemented is, with two stated exceptions, the set with no schema to generate from. **`@openvtc/pnm-core/did-hosting`** (23 tasks) — a different counterparty from everything else here: a did:webvh hosting service, which publishes DID documents and serves their logs. A DID is addressed by its *mnemonic*, the service's handle for the record, which has nothing to do with the BIP-39 phrase `keys/*` means by that word; the collision is the specification's and the module says so. `reportHostedDidProblem` is one-way and takes a notifier — the first caller of the primitive added in the previous commit that is not a credential exchange. **`@openvtc/pnm-core/vtc`** — the member's side of a community: apply, track, hold the credential, leave. Eight tasks rather than the sixty the bindings ship, because the rest is the community's own administration plane. Wrapping it here would be building a VTC console inside a wallet library. **Device enrolment** — `registerDevice` and `deviceHeartbeat` in the device module, where the device's own side belongs; the operator's half stays in `admin/devices.ts`. A heartbeat's `queuedOperations` is how a remote wipe actually reaches a device, so a client that drops the array is a client that cannot be wiped. **`pushWake`** — `tokenUnregistered` means the subscription is dead: replace the handle, do not retry. **`auth/challenge` and `auth/refresh` as tasks** for TSP and DIDComm, where the sender is authenticated by the envelope and the REST bootstrap's chicken-and-egg does not arise. `vta/auth.ts` keeps that bootstrap, which cannot be a task. A refresh may rotate the refresh token. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
did-management/* manages DIDs at a hosting service — registering, disabling, deleting, rolling back other people's identities. Like the rest of the admin surface it must not be reachable from the wallet bundle, and the task URI is the tell. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…e the old spelling SPEC §4.10 makes lowerCamelCase the wire contract. Four hand-written types here mirrored the VTA's snake_case emission instead: `ContextRecord` (`base_path`/`created_at`/`updated_at`), `WebvhDidRecord` (`context_id`/`server_id`), `AclSwapResult` (`allowed_contexts` and friends), and the `webvh_dids` read in `admin/contexts.ts`. The VTA now emits camelCase (verifiable-trust-infrastructure `fix/wire-casing-camel`), so these follow — but this library talks to agents it does not control, and an agent that has not taken that change still sends the old spelling. Reads therefore accept either and normalise to the canonical one, so a caller never sees both; sends emit only the canonical form, which the agent accepts via its own aliases. Three tests pin that tolerance, including that the pre-fold spelling does not survive into a result. `list-dids` is the one that mattered most: changing only its type would have left `contextId` silently `undefined` against an un-upgraded agent, which the extension now reads. Its runtime normalises too. The other 73 snake_case members in this package are **correct and untouched**: DIDComm pickup and mediation members, OAuth/OIDC token fields, OID4VCI and OID4VP payloads, the sealed-transfer bundle format, `unpackMessage`'s library parameters, and the PWA's W3C web-app manifest. §4.10 requires externally owned names to be carried verbatim; re-casing them would be the same mistake in the other direction. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
stormer78
force-pushed
the
feat/core-library-and-store-readiness
branch
from
August 19, 2026 07:21
267dea1 to
90a5530
Compare
`fix(extension): resolve agent names from a stage 1 a browser can read (#120)` landed on main and touched the same import block this branch rewrites. The conflict is the two changes meeting head-on: main added `AGENT_NAME_UNREADABLE` to the `agent-name.js` import and still imports `cookie-scope.js` beside it, while this branch deletes `cookie-scope.ts` outright along with the password-site login that needed it. Resolution keeps main's `AGENT_NAME_UNREADABLE` — it is used at two call sites in `background.ts` — and drops the `cookie-scope.js` import, whose module no longer exists. Nothing else in the tree references `checkInjectableOrigin` or `cookieDomainScope`. 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.
What
Two threads that turned out to be the same work.
1.
@openvtc/pnm-corebecomes a library rather than the extension's innerhalf. Modules are layered with the layering enforced, every module directory
is a published entry point, and the VTA surface is broad enough to manage an
agent without the extension. This is the groundwork for pulling
pnm-coreintoits own repo.
2. The extension becomes something a Chrome Web Store reviewer can actually
review — the parts that were hard to justify are gone, and the parts a
reviewer must exercise now have somewhere to be exercised.
The breaking change
feat(extension)!: remove the legacy password-site login and the cookies permission.The VTA performed a password login on the user's behalf and the wallet injected
the returned cookie jar into the browser. That is indefensible in review, and
defending it was worse value than deleting it. Gone: the
cookiespermission,src/cookie-scope.ts,handleInjectCookies, and the password-POST auto-loginUI.
doVaultProxyLoginnow drops any cookie jar a VTA returns before it crossesthe bridge, so a rolled-back VTA cannot reintroduce the behaviour.
vault/proxy-loginsurvives for the SIOPid_tokenpath only, which installsnothing. CI asserts both that the permission is absent and that no
chrome.cookiescall reaches the shipped bundle.Library work
util/http→did/didcomm/webauthn→siop→vta/trust-tasks→store/vault/device/provision/rp-login/onboarding→inbound.tests/package.module-boundaries.mjsfails thebuild on a violation; its
KNOWN_EXCEPTIONSlist may only shrink. Breakingthe
vta⇄vaultcycle is whygetVtaBearerand friends moved tovta/auth.ts.browser global reaching a shared module is the failure that only appears
after someone installs the package into a server.
acl,keys,policy,sessions,devices,observability,consent,did-templates,memory,contexts,plus new
credentials,vtcanddid-hostingentry points.notifyprimitive across all three transports. Thecredential-exchangethreaded steps define no response, so awaiting an ackthe spec says may never come is wrong. TSP refuses with
e.client.unsupportedrather than silently falling back.task-surface.json+ a conformance harness pinning how much of the Rusttask surface the TS side implements (currently 110 of 161), so the gap is a
tracked number rather than a vibe.
Reviewer demo (
packages/reviewer-demo/)A reviewer gets an ephemeral
did:keyper installation and the VTA has noself-service enrolment, so without a grant broker there is no way for them to
exercise a single gated action. The demo site logs in with a reviewer key ID,
shows the demo VTA DID, accepts pasted
pnmgrant commands into a 7-day ACL,offers SIOPv2 login, and has a DTTE flow for exercising a consent pop-up. It
states plainly that it is a demo that auto-deletes in 7 days, and documents the
VTA reset that returns things to a known starting point.
Store packaging
manifest.jsonis a template, not the manifest — noversion, nokey; thereal one is assembled into
dist/by a vite plugin.dist/getskeysounpacked installs hold a stable ID; the Web Store zip omits it, because a new
item's upload is rejected if it carries one. Added
assertStoreListingLimits(description ≤132, name ≤75) andminimum_chrome_version: 116.Casing
fix(core): fold the wire types onto canonical lowerCamelCase, tolerate the old spelling— the plugin now emits SPEC §4.10 casing and accepts the previousspelling on intake.
ContextRecordandlist-didsneeded a runtimefoldrather than a type change, because the extension reads
d.contextIdatruntime.
Test
Lint (
tsc -b) clean. 504 tests passing, 0 failing across the workspace.Checklist (stack guide §9)
fetch(); timeouts applied wherefetchis injected (R1.2)and not fixable from this repo.
vti-didcomm-jsacks an inbound framebefore dispatching it, and the wallet persists only the message id. A
task-consent/requestlost between ack and decision is gone for good.Fixing it needs a persist hook in
vti-didcomm-jsor explicitacknowledgeMessages, which is a contract change touching pnm-relaytoo (R4.1). This PR does not paper over it.