Skip to content

feat(core)!: turn pnm-core into a standalone library, and make the extension reviewable - #121

Merged
stormer78 merged 15 commits into
mainfrom
feat/core-library-and-store-readiness
Aug 19, 2026
Merged

feat(core)!: turn pnm-core into a standalone library, and make the extension reviewable#121
stormer78 merged 15 commits into
mainfrom
feat/core-library-and-store-readiness

Conversation

@stormer78

Copy link
Copy Markdown
Contributor

What

Two threads that turned out to be the same work.

1. @openvtc/pnm-core becomes a library rather than the extension's inner
half.
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-core into
its 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 cookies permission,
src/cookie-scope.ts, handleInjectCookies, and the password-POST auto-login
UI. doVaultProxyLogin now drops any cookie jar a VTA returns before it crosses
the bridge, so a rolled-back VTA cannot reintroduce the behaviour.

vault/proxy-login survives for the SIOP id_token path only, which installs
nothing. CI asserts both that the permission is absent and that no
chrome.cookies call reaches the shipped bundle.

Library work

  • Layering, enforced. util/httpdid/didcomm/webauthnsiop
    vta/trust-tasksstore/vault/device/provision/rp-login/
    onboardinginbound. tests/package.module-boundaries.mjs fails the
    build on a violation; its KNOWN_EXCEPTIONS list may only shrink. Breaking
    the vtavault cycle is why getVtaBearer and friends moved to
    vta/auth.ts.
  • Entry points are import-tested in plain Node with no DOM, because a
    browser global reaching a shared module is the failure that only appears
    after someone installs the package into a server.
  • Admin surface on generated bindingsacl, keys, policy, sessions,
    devices, observability, consent, did-templates, memory, contexts,
    plus new credentials, vtc and did-hosting entry points.
  • A one-way notify primitive across all three transports. The
    credential-exchange threaded steps define no response, so awaiting an ack
    the spec says may never come is wrong. TSP refuses with
    e.client.unsupported rather than silently falling back.
  • task-surface.json + a conformance harness pinning how much of the Rust
    task 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:key per installation and the VTA has no
self-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 pnm grant 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.json is a template, not the manifest — no version, no key; the
real one is assembled into dist/ by a vite plugin. dist/ gets key so
unpacked 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) and
minimum_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 previous
spelling on intake. ContextRecord and list-dids needed a runtime fold
rather than a type change, because the extension reads d.contextId at
runtime.

Test

Lint (tsc -b) clean. 504 tests passing, 0 failing across the workspace.

Checklist (stack guide §9)

  • No new bare fetch(); timeouts applied where fetch is injected (R1.2)
  • No lock held across a network await (R1.3)
  • No local state committed before its remote effect (R2.1)
  • Every retry bounded + backed off (R1.4)
  • Accept/poll/listen loops survive transient errors (R1.5)
  • Acks/deletes happen only after durable handoff (R1.6) — still violated,
    and not fixable from this repo.
    vti-didcomm-js acks an inbound frame
    before dispatching it, and the wallet persists only the message id. A
    task-consent/request lost between ack and decision is gone for good.
    Fixing it needs a persist hook in vti-didcomm-js or explicit
    acknowledgeMessages, which is a contract change touching pnm-relay
    too (R4.1). This PR does not paper over it.
  • New/changed wire types: camelCase, schema registered, consumers updated (R3.*)
  • Config absence = most restrictive (R5.*)
  • Logs/status claim only what was verified (R6.*)
  • Deviations flagged with rule numbers — see R1.6 above

…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
stormer78 force-pushed the feat/core-library-and-store-readiness branch from 267dea1 to 90a5530 Compare August 19, 2026 07:21
`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>
@stormer78
stormer78 merged commit eeb4bce into main Aug 19, 2026
3 checks passed
@stormer78
stormer78 deleted the feat/core-library-and-store-readiness branch August 19, 2026 08:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant