npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@learncard/openid4vc-plugin

v0.1.0

Published

OpenID for Verifiable Credentials (OID4VCI + OID4VP + SIOPv2) holder support for LearnCard

Readme

@learncard/openid4vc-plugin

OpenID for Verifiable Credentials holder-side support for LearnCard:

  • OID4VCI — accept credential offers, exchange pre-authorized / authorization codes for access tokens, request credentials from issuers.
  • OID4VP — parse Authorization Requests, match held credentials against Presentation Definitions (DIF PEX v2), build and sign VP tokens.
  • SIOPv2 — issue self-issued ID tokens for holder authentication.

Status

Holder surface feature-complete. Every slice that touches the wire — offer intake, both VCI flows, OID4VP with PEX and DCQL, JARM-encrypted responses, SIOPv2 — is implemented and verified live against walt.id, Sphereon, and Animo Paradym. The remaining work is wallet-app UI integration (Slice 10) and CI infrastructure (Slice 11).

OID4VCI — issuance side

| Capability | Status | |---|---| | Credential Offer URI parsing (by-value + by-reference) | ✅ Slice 1 | | Draft 11 → Draft 13 normalization | ✅ Slice 1 | | Issuer + authorization-server metadata fetching | ✅ Slice 2 | | Pre-authorized code flow (jwt_vc_json) | ✅ Slice 2 | | Proof-of-possession JWT (EdDSA, default signer from host LearnCard) | ✅ Slice 2 | | Wallet index / LearnCloud integration (one-call acceptAndStoreCredentialOffer) | ✅ Slice 3 | | JWT VC → W3C VC reconstruction (VCDM §6.3.1) with raw JWT preserved under proof.jwt | ✅ Slice 3 | | Partial-failure reporting (one bad credential doesn't abort a batch) | ✅ Slice 3 | | Authorization code flow + PKCE (beginCredentialOfferAuthCode / completeCredentialOfferAuthCode) | ✅ Slice 4 | | ldp_vc issuance format | ⏳ Slice 5 — jwt_vc_json covers every issuer we test against; revisit when a partner needs LD-proof issuance. |

OID4VP — presentation side

| Capability | Status | |---|---| | OID4VP Authorization Request URI parsing (inline + presentation_definition_uri) | ✅ Slice 6 | | DIF PEX v2 matcher (JSONPath subset + JSON Schema filter subset) | ✅ Slice 6 | | Candidate selection + submission_requirements (all / pick / from_nested) | ✅ Slice 6 | | Presentation Submission descriptor_map builder | ✅ Slice 6 | | Format designation matching (jwt_vc_json / ldp_vc / ldp) | ✅ Slice 6 | | VP construction (buildPresentation): unsigned VP + PresentationSubmission | ✅ Slice 7a | | VP signing (signPresentation): jwt_vp_json (JWS) + ldp_vp (LD proof via host) | ✅ Slice 7b | | direct_post VP response (submitPresentation) | ✅ Slice 7c | | End-to-end presentCredentials() verified against walt.id, Sphereon, Animo Paradym | ✅ Slice 7 | | Signed Request Objects (request / request_uri JWS) — client_id_scheme=did (did:jwk, did:web) + x509_san_dns (trusted-roots or self-signed-dev) | ✅ Slice 7.5 | | DCQL query — W3C OID4VP Draft 22 alternative to PEX. Same prepareVerifiablePresentation / presentCredentials entry points; DCQL takes precedence when both dcql_query and presentation_definition are present. | ✅ | | JARM — encrypted direct_post.jwt responses (JWE A128CBC-HS256 / ECDH-ES). Selected automatically when the verifier requests response_mode=direct_post.jwt. | ✅ |

SIOPv2 + cross-cutting

| Capability | Status | |---|---| | SIOPv2 ID token (signIdToken) standalone + auto-bundled when response_type includes id_token | ✅ Slice 8 | | Browser-safe runtime (no node:crypto / node:zlib) — bundles cleanly into apps/learn-card-app | ✅ Slice 7.6 | | Auto-wired into every seed-based @learncard/init function | ✅ Slice 7.6 | | Bitstring Status List checking | Handled by @learncard/didkit-plugin. lc.invoke.verifyCredential(vc) automatically checks BitstringStatusListEntry / StatusList2021Entry / RevocationList2020 when the credential carries a credentialStatus field. | | Deep-link / QR entry points in learn-card-app | ⏳ Slice 9 | | UI adapter for consent + selection in learn-card-app / learn-card-base | ⏳ Slice 10 | | Self-hosted issuer + verifier in CI | ⏳ Slice 11 |

See the California RFP epic for full scope.

Spec versions

  • OID4VCI: Draft 13. Draft 11 offers are accepted and normalized on ingest.
  • OID4VP: Draft 22.
  • SIOPv2: final.
  • PEX: DIF Presentation Exchange v2.

Installation

pnpm add @learncard/openid4vc-plugin

Required peer plugins: @learncard/vc-plugin, @learncard/didkit-plugin.

Related plugins

Bitstring Status List revocation/suspension checking is performed inside @learncard/didkit-plugin as part of lc.invoke.verifyCredential(...). The plugin recognises BitstringStatusListEntry (W3C VC 2.0), StatusList2021Entry (legacy alias), and RevocationList2020.

Bundled by default with @learncard/init

Every seed-based initializer in @learncard/init (learnCardFromSeed, networkLearnCardFromSeed, didWebLearnCardFromSeed, didWebNetworkLearnCardFromSeed) automatically attaches this plugin to the resulting LearnCard. You do not need to call getOpenID4VCPlugin() yourself unless you're composing a custom LearnCard from @learncard/core. Pass plugin config through the init function's openid4vc option:

import { initLearnCard } from '@learncard/init';

const lc = await initLearnCard({
    seed: 'a'.repeat(64),
    openid4vc: {
        // Optional. Defaults are sensible for most callers.
        // fetchImpl: customFetch,
        // trustedX509Roots: [pemString],
    },
});

// All OpenID4VC methods are now available on `lc.invoke.*`.

Browser compatibility

As of lc-1794 the plugin's runtime surface is fully cross-platform. The previously node-only dependencies (node:crypto.X509Certificate, node:crypto.randomBytes, node:zlib.gunzipSync) have been swapped for Web Crypto, crypto.getRandomValues, @peculiar/x509, and fflate. The plugin can be bundled directly into a browser app without polyfills; the holder methods all run end-to-end in apps/learn-card-app.

Usage

import { initLearnCard } from '@learncard/init';

const lc = await initLearnCard({ seed: 'a'.repeat(64) });

// Slice 1 — parse a credential offer URI without hitting the network:
const parsed = lc.invoke.parseCredentialOffer(
    'openid-credential-offer://?credential_offer=...'
);

if (parsed.kind === 'by_value') {
    console.log('Issuer:', parsed.offer.credential_issuer);
    console.log('Credentials on offer:', parsed.offer.credential_configuration_ids);
}

// Slice 2 — accept the offer end-to-end (fetch metadata, exchange the
// pre-authorized code for a token, build a proof-of-possession JWT, and
// request the credential):
const result = await lc.invoke.acceptCredentialOffer(
    'openid-credential-offer://?credential_offer=...',
    { txCode: '1234' } // only required when the offer carries a tx_code
);

for (const entry of result.credentials) {
    console.log(entry.format, entry.credential); // e.g. 'jwt_vc_json', 'eyJ.vc.jwt'
}

Slice 3 — accept and store in one call

For the common case (accept an offer, persist the credentials, have them show up in the wallet UI), use acceptAndStoreCredentialOffer:

const result = await lc.invoke.acceptAndStoreCredentialOffer(offerUri, {
    txCode: '1234', // only if the offer requires it
    category: 'Achievement', // optional override; otherwise auto-derived from VC types
});

// All credentials issued by the offer:
result.credentials; // same as acceptCredentialOffer
// Credentials that were successfully persisted:
result.stored; // [{ uri, recordId, vc, configurationId, format }]
// Credentials that failed to normalize / upload / index:
result.failures; // [{ configurationId, format, error: VciError }]

What it does under the hood:

  1. Runs the pre-authorized code flow exactly like acceptCredentialOffer.
  2. Decodes each jwt_vc_json credential into a W3C VC per VCDM §6.3.1, preserving the raw JWT under proof.jwt so future verification can re-check the issuer signature.
  3. Uploads each VC via learnCard.store.LearnCloud.uploadEncrypted (default — encrypted at rest for the holder) or upload when encryption isn't available.
  4. Indexes each credential via learnCard.index.LearnCloud.add(record) so the wallet's credential list picks it up immediately.
  5. Catches per-credential errors so one bad credential doesn't discard a whole batch.

Storage overrides:

await lc.invoke.acceptAndStoreCredentialOffer(offerUri, {
    encrypt: false, // use plain `upload` instead of `uploadEncrypted`
    category: vc => (vc.type?.includes('IdentityCredential') ? 'ID' : 'Achievement'),
    title: vc => vc.name as string,
    imgUrl: 'https://issuer.example.com/logo.png',

    // Low-level escape hatches for custom backends or tests:
    upload: async vc => myCustomStore.put(vc),
    addToIndex: async record => myCustomIndex.insert(record),
});

Slice 4 — authorization code flow with PKCE

When an offer's grants carries authorization_code (with or without an issuer_state), the issuer wants the wallet to bounce the user through their authorization endpoint before issuing. The plugin splits this into two awaits with the user-redirect step in between:

// 1. Offer in hand. Build the authorize URL + persist a flow handle
//    (PKCE verifier, nonce, etc.). Open `authorizationUrl` in a popup
//    or top-level redirect.
const { authorizationUrl, flowHandle } = await lc.invoke.beginCredentialOfferAuthCode(
    offerUri,
    {
        redirectUri: 'https://wallet.example.com/callback',
        clientId: 'wallet.example.com',
        scope: ['openid'],            // optional
        // userHint: '[email protected]', // optional `login_hint`
    }
);

localStorage.setItem('vci.flow', JSON.stringify(flowHandle));
window.location.href = authorizationUrl;

// 2. After the issuer redirects back to `redirect_uri` with `?code=...&state=...`,
//    finish the exchange. Same return shape as `acceptCredentialOffer`.
const flowHandle = JSON.parse(localStorage.getItem('vci.flow')!);
const { code, state } = parseQuery(window.location.search);

const result = await lc.invoke.completeCredentialOfferAuthCode({
    flowHandle,
    code,
    state,
});

for (const entry of result.credentials) {
    console.log(entry.format, entry.credential);
}

flowHandle is a small JSON-safe object — you can persist it in localStorage, a cookie, or your wallet's session store. It carries the PKCE code_verifier, the offer, and the issuer/AS metadata snapshot so step 2 doesn't need to re-fetch.

Slice 8 — SIOPv2 ID tokens

Some verifiers want a self-issued ID token instead of (or alongside) a VP. presentCredentials does the right thing automatically when response_type includes id_token, but you can also drive it standalone:

const { idToken } = await lc.invoke.signIdToken(authorizationRequestUri, {
    // All optional — sensible defaults from the host LearnCard.
    // holder: 'did:jwk:eyJ...',
    // signer: customSigner,
    // additionalClaims: { name: 'Alice', email: '[email protected]' },
});

The ID token is a JWS over a SIOPv2 payload (iss = sub = holder DID, aud = client_id, nonce from the request). When the verifier asks for vp_token id_token, presentCredentials signs both, bundles the ID token into the direct_post, and returns it on the result for inspection.

Using your own signer

Callers using a non-Ed25519 key (HSM, secp256k1) should pass a custom signer:

import { ProofJwtSigner } from '@learncard/openid4vc-plugin';

const signer: ProofJwtSigner = {
    alg: 'ES256',
    kid: 'did:key:zDn...#zDn...',
    sign: async (header, payload) => myHsm.signCompactJws(header, payload),
};

await lc.invoke.acceptCredentialOffer(uri, { signer });

OID4VP — presenting credentials to a verifier

When a verifier scans/links you an openid4vp:// URI, the wallet needs to: (1) resolve the verifier's request (including verifying any signed Request Object), (2) figure out which held credentials satisfy the DIF PEX Presentation Definition, (3) let the user choose, (4) sign + POST a VP token. All four steps are wired up end-to-end via plugin.presentCredentials() (Slices 6 + 7 + 7.5).

Slice 6a — parse an Authorization Request without touching the network

const parsed = lc.invoke.parseAuthorizationRequest(
    'openid4vp://?client_id=https%3A%2F%2Fverifier.example.com&...'
);

if (parsed.kind === 'by_value') {
    console.log('Verifier:', parsed.request.client_id);
    console.log('Nonce:',    parsed.request.nonce);
    console.log('Return to:', parsed.request.response_uri);
}

Three discriminated variants:

  • by_value — every param was inline; the returned request is ready for matching.
  • by_reference_request_uri — the verifier delegated the request to a signed JWS fetched from request_uri. Slice 7.5 verifies the signature per client_id_scheme (see below) and inlines the claims.
  • by_reference_request_jwt — same thing but the JWS is embedded directly via the request param.

Slice 6b — resolve presentation_definition_uri over HTTP

// One call: parse + fetch out-of-band PD + return a fully-resolved request.
const request = await lc.invoke.resolveAuthorizationRequest(uri);
console.log(request.presentation_definition?.input_descriptors);

Slice 6c — match held credentials against the PD (the main method)

prepareVerifiablePresentation is the turnkey entry point: resolve the verifier's request, walk every input_descriptor, and return a preview object the UI can render directly.

import type { CandidateCredential } from '@learncard/openid4vc-plugin';

// Your wallet's credentials, in whatever shape they live in today.
const candidates: CandidateCredential[] = myRecords.map(r => ({
    credential: r.vc,              // JSON-LD object, or a JWT-VC string
    format: r.format,              // optional; inferred from shape when absent
    id: r.uri,                     // optional caller-side id for audit/UI
}));

const { request, selection } = await lc.invoke.prepareVerifiablePresentation(
    uri,
    candidates
);

if (!selection.canSatisfy) {
    // UI shows a "We couldn't satisfy this request" state.
    // `selection.reason` is a user-readable summary; per-descriptor
    // reasons live at `selection.descriptors[i].reason`.
    showError(selection.reason);
    return;
}

for (const descriptor of selection.descriptors) {
    console.log(`Descriptor ${descriptor.descriptorId}: ${descriptor.candidates.length} match(es)`);
    for (const match of descriptor.candidates) {
        console.log('  ', match.candidate.id, '→', match.candidate.format);
    }
}

Slice 6d — build a Presentation Submission

Once the user picks one credential per descriptor, build the DIF PEX v2 presentation_submission that will ride alongside the vp_token:

import { buildPresentationSubmission } from '@learncard/openid4vc-plugin';

const submission = buildPresentationSubmission(request.presentation_definition!, [
    {
        descriptorId: 'university-degree',
        format: 'ldp_vc',
        path: '$.verifiableCredential[0]',
    },
    {
        descriptorId: 'drivers-license',
        format: 'jwt_vc_json',
        path: '$.verifiableCredential[1]',
        pathNested: '$', // JWT VC is the whole value at that path
    },
]);

// submission.id, submission.definition_id, submission.descriptor_map[]

PEX subset supported

The matcher is spec-correct for the vast majority of Presentation Definitions seen in the wild. It covers:

  • JSONPath (field.path[]) — root ($), dotted ($.a.b.c), bracketed ($['foo bar']), numeric index ($.a[0]), wildcard ($.a[*], $.a.*), recursive descent ($..foo). Filter predicates ([?(@.x=='y')]) and array slicing ([0:2]) throw a descriptive error rather than silently missing.
  • JSON Schema filter (field.filter)type, const, enum, pattern, minimum / maximum / exclusiveMinimum / exclusiveMaximum, minLength / maxLength, contains, items, minItems / maxItems. Unknown keywords pass by default (lenient mode) so verifiers introducing new keywords don't block wallet upgrades.
  • submission_requirementsrule: 'all', rule: 'pick' with count / min / (soft) max, and nested from_nested groups.
  • Format designationsdescriptor.format takes precedence over pd.format; candidates are filtered before JSONPath runs. jwt_vc_json credentials are transparently base64url-decoded for matching (paths like $.credentialSubject.id work even when the raw credential is a compact JWS).

If your verifier lands a PEX feature we haven't modeled, open an issue with the Presentation Definition — the matcher layer is designed to swap for a full ajv-backed implementation without touching the plugin surface.

DCQL — OID4VP Draft 22 query language

DCQL (Digital Credentials Query Language) is OID4VP Draft 22's spec-native alternative to DIF PEX. When a verifier sends dcql_query instead of presentation_definition, the same entry points work — prepareVerifiablePresentation returns DCQL match results in selection.descriptors, and presentCredentials builds a DCQL vp_token (a record keyed by credential_query_id) instead of a PEX submission. DCQL takes precedence when a verifier sends both.

// Same call as before — no caller-side change needed for DCQL.
const { request, selection } = await lc.invoke.prepareVerifiablePresentation(
    uri,
    candidates
);

if (request.dcql_query) {
    // selection.descriptors[i].descriptorId === credential_query.id
    // selection.descriptors[i].candidates  === DCQL match candidates
}

JARM — encrypted response mode

When a verifier requests response_mode=direct_post.jwt, the wallet must encrypt the response body to the verifier's JWKS (advertised via client_metadata.jwks or client_metadata.jwks_uri). The plugin handles this automatically: submitPresentation (and therefore presentCredentials) inspects the resolved request, discovers the verifier's encryption key, and emits a JWE (alg=ECDH-ES, enc=A128CBC-HS256 by default) wrapping the standard direct_post payload. No caller-side change required — if a verifier asks for JARM, you get JARM.

Errors

VCI errors are thrown as CredentialOfferParseError / VciError; VP errors as VpError. Every error carries a stable code field so UI can map to friendly copy without string-matching messages.

CredentialOfferParseError.code

| code | Meaning | |---|---| | invalid_uri | URI is malformed, non-https reference, or network fetch failed | | missing_offer | Neither credential_offer nor credential_offer_uri present | | both_offer_and_uri | Both parameters present (spec violation) | | invalid_json | Offer payload or by-reference response was not valid JSON | | missing_issuer | Offer has no credential_issuer | | missing_credentials | Offer has no resolvable credential identifiers | | invalid_grants | grants object is malformed |

VpError.code

| code | Meaning | |---|---| | invalid_uri | Authorization Request URI is malformed or has no query string | | invalid_json | presentation_definition / client_metadata / resolved PD was not valid JSON | | missing_client_id | Authorization Request has no client_id | | missing_nonce | Authorization Request has no nonce (replay-protection required by spec) | | missing_response_type | Authorization Request has no response_type | | unsupported_response_type | response_type is neither vp_token nor id_token | | missing_response_target | Neither response_uri (Draft 22+) nor legacy redirect_uri is present | | missing_presentation_definition | vp_token request carries no presentation_definition, presentation_definition_uri, or scope | | invalid_presentation_definition | PD is structurally malformed (missing id, empty input_descriptors, descriptor without constraints) | | both_definition_and_uri | Both presentation_definition and presentation_definition_uri supplied (spec violation) | | presentation_definition_fetch_failed | Fetching presentation_definition_uri timed out, returned non-2xx, or disallowed by scheme |

Signed Request Objects surface a separate typed error, RequestObjectError, with these codes:

| code | Meaning | |---|---| | invalid_request_object | JWS couldn't be decoded, or the payload was missing required claims (client_id in particular) | | request_fetch_failed | Fetching request_uri failed or returned non-2xx | | missing_client_id_scheme | Neither URL params nor JWS claims supplied client_id_scheme | | unsupported_client_id_scheme | Scheme is one we don't yet implement (pre-registered, verifier_attestation), or a forbidden scheme (redirect_uri) was combined with a signed JWS | | client_id_mismatch | Outer URL client_id disagrees with signed client_id, OR (for x509) the leaf cert SAN doesn't cover the client_id host | | request_signature_invalid | JWS signature failed to verify against the resolved key | | request_signer_untrusted | DID resolver couldn't find the kid; or the x509 chain didn't root into trustedX509Roots | | did_resolution_failed | DID resolver couldn't produce a document (network error for did:web, unsupported method for others) |

Testing

Unit tests

pnpm --filter @learncard/openid4vc-plugin test

Driving a real issuer end-to-end — try-offer harness

scripts/try-offer.ts drives acceptCredentialOffer against any Draft 13 conformant issuer with an ephemeral did:jwk keypair. It skips the full LearnCard wiring (no wallet, no LearnCloud plugin required), so it's the fastest way to validate the flow against live infrastructure during development.

# from repo root
pnpm --filter @learncard/openid4vc-plugin try-offer "<offer-uri>"

# or from the plugin dir
cd packages/plugins/openid4vc
pnpm try-offer "<offer-uri>"

Flags:

| Flag | Purpose | |---|---| | --tx-code <code> | PIN the issuer delivered out-of-band (if the offer carries tx_code) | | --client-id <id> | Client identifier, if the authorization server requires one | | --only <id1,id2> | Comma-separated subset of credential_configuration_ids to request | | --save <path> | Write the issued credential(s) as JSON at <path> — single VC as an object, multiple as an array. Feed it straight into try-verify --credentials <path>. *.vc.json, my-vc*.json, and issued-credentials*.json are gitignored by default so real holder credentials never get committed. | | --verbose, -v | Log the full resolved offer and each credential's raw JWT | | --help, -h | Show usage |

Output on success: the decoded W3C VC for each issued credential plus the issuer's notification_id (if supplied). On failure: the VciError code, HTTP status, error body, and cause — so you can distinguish metadata_fetch_failed from token_request_failed from credential_request_failed at a glance.

Recipes for public test issuers

All of these expose pre-authorized-code offers, so the harness works as-is.

EUDI Reference Issuer — most spec-conformant. Good first target.

  1. Visit https://dev.issuer.eudiw.dev/.
  2. Pick a credential (PID, mDL, etc.), start the issuance flow.
  3. Instead of scanning the QR with a wallet app, copy the openid-credential-offer://... URI the page displays.
  4. Run: pnpm try-offer "openid-credential-offer://?credential_offer_uri=..."
  5. If prompted for a PIN, pass it via --tx-code.

WaltID Portal — friendliest UI.

  1. Visit https://portal.walt.id, sign in (free), go to Issuer.
  2. Issue a credential — the portal shows an offer URI / QR on the success page.
  3. Copy the openid-credential-offer://... URI and feed it to the harness.

Animo Funke — Credo-TS based, EUDI-compatible.

  1. Visit https://funke.animo.id, select a credential profile.
  2. Start the issuance flow and copy the offer URI.
  3. Run against the harness.

Running a local issuer

When you want deterministic iteration (same offer twice, offline, step-through debugging), run WaltID's issuer in Docker:

docker run --rm -p 7002:7002 -p 7003:7003 waltid/issuer-api:latest
# in another terminal, POST an offer config to http://localhost:7002/...
# then feed the returned offer URI to pnpm try-offer

What the harness does NOT cover

  • No wallet integration. Credentials are decoded and printed, not persisted. Once Slice 10 wires acceptAndStoreCredentialOffer into the wallet UI, use the learn-card-app instead for that part of the flow.
  • No signature verification. The harness trusts the issuer's response. Verifying the credential JWT against the issuer's published key is a separate concern handled by the VC plugin on read.
  • Pre-authorized code only. The harness drives the pre-authorized flow because it's headless. The plugin itself supports the authorization code flow via beginCredentialOfferAuthCode / completeCredentialOfferAuthCode (Slice 4); exercising it requires a real browser to bounce through the issuer's authorization endpoint, so it's covered by the wallet app's e2e tests rather than this harness.

Driving a real verifier end-to-end — try-verify harness

scripts/try-verify.ts is the OID4VP counterpart to try-offer: it takes a verifier's Authorization Request URI, resolves the Presentation Definition, runs the PEX matcher + selector against credentials you supply, and prints the exact presentation_submission the wallet would POST back. Slice 6's entire surface gets exercised in one command.

pnpm --filter @learncard/openid4vc-plugin try-verify \
    "<oid4vp-uri>" \
    --credentials ./my-vc.json

Flags:

| Flag | Purpose | |---|---| | --credentials <path> | JSON file containing a W3C VC object, a compact JWT-VC string, or an array of either | | --holder <path> | Sidecar written by try-offer --save — carries the holder did / kid / private JWK. Default: <credentials>.holder.json | | --submit | After PEX selection, sign the VP and POST it via direct_post | | --envelope <fmt> | Force jwt_vp_json or ldp_vp (default: inferred from PD + VC formats) | | --trusted-root <path> | PEM file with a trusted X.509 root — required for client_id_scheme=x509_san_dns verifiers. Repeatable. | | --unsafe-allow-self-signed | Dev-only: accept self-signed x509 chains. Disables the chain-to-trust-root check. Never production. | | --verbose, -v | Log the full resolved request + per-field JSONPath matches | | --help, -h | Show usage |

The harness drives the full Slice 7 pipeline when --submit is set: build → sign → POST. Without it, it stops after previewing the presentation_submission so you can inspect what would be sent.

Recipe: walt.id Verifier Portal (UI flow)

The quickest path to a real OID4VP URI.

  1. Visit https://portal.walt.id, sign in (free), open Verifier.

  2. Pick a credential template (e.g. OpenBadgeCredential, VerifiableDiploma) and click Create Verification.

  3. The portal shows a QR and a clickable openid4vp://... URL. Copy the URL.

  4. Run:

    pnpm try-verify "openid4vp://?..." --credentials ./my-vc.json --submit

    walt.id's default verifier flow uses client_id_scheme=redirect_uri with inline PD — no trust-anchor setup required. If you pick a flow that wraps the request in a signed JWS, the harness automatically verifies it via the configured DID resolver (did:jwk / did:web out of the box).

  5. If canSatisfy: ✓ YES, the harness signs a JWT-VP, POSTs to the verifier's response_uri, and prints the redirect_uri the walt.id portal hands back. If ✗ NO, each descriptor's reason field explains why (wrong format, missing type, filter mismatch).

Recipe: walt.id Verifier REST API (scripted flow)

When you want to iterate quickly on a specific Presentation Definition without the portal UI.

# 1. POST a PD to walt.id's hosted verifier to get a session URI.
curl -s -X POST https://verifier.demo.walt.id/openid4vc/verify \
     -H "Content-Type: application/json" \
     -H "authorizeBaseUrl: openid4vp://authorize" \
     -H "responseMode: direct_post" \
     --data '{
       "request_credentials": [
         { "input_descriptor": {
             "id": "UniversityDegree",
             "format": { "jwt_vc_json": { "alg": ["EdDSA","ES256"] } },
             "constraints": {
               "fields": [ { "path": ["$.type"],
                             "filter": { "type": "array",
                                         "contains": { "const": "UniversityDegreeCredential" } } } ]
             }
           } }
       ]
     }'
# → returns a plain openid4vp://... URI in the response body.

# 2. Run the harness against it.
pnpm try-verify "openid4vp://..." --credentials ./my-vc.json --submit

This path is perfect for regression-testing after we change the PEX matcher — you can keep a single PD template and rerun it after every selector change.

Recipe: Sphereon demo verifier

Sphereon's reference verifier (https://ssi.sphereon.com) emits both by-value URIs and signed Request Objects (client_id_scheme=did with did:web). Both work without any extra flags — Slice 7.5 resolves the did:web document over the harness's fetch and verifies the JWS before matching.

  1. Visit the Sphereon demo verifier, pick a flow (e.g. "Request University Degree").
  2. Copy the openid4vp:// URI.
  3. pnpm try-verify "openid4vp://..." --credentials ./my-vc.json.

Recipe: Animo Paradym

Animo's Paradym verifier playground (https://paradym.id) supports both ldp_vc and jwt_vc_json requests and is the second real-world verifier we smoke-tested against. Flow is identical to walt.id's.

Where to get a credential to feed it

The fastest loop is try-offer --savetry-verify --credentials:

# 1. Issue a credential from walt.id / Animo / EUDI and write it to disk.
pnpm try-offer "<offer-uri>" --save ./my-vc.json

# 2. Point a real verifier at the same file.
pnpm try-verify "<oid4vp-uri>" --credentials ./my-vc.json --submit

try-offer writes the credential in its normalized W3C VC shape (JWT-VCs keep their compact JWS under proof.jwt), which try-verify's inferCredentialFormat classifies correctly in both directions — so a single ./my-vc.json round-trips through matchers for either format.

The plugin's .gitignore excludes my-vc.json, my-vc*.json, *.vc.json, and issued-credentials*.json by default so a real credential dropped into this directory never gets committed. Use any of those names for your local testing file.

What the harness does NOT cover

  • No client_id_scheme=pre-registered / verifier_attestation. Both surface unsupported_client_id_scheme from the Slice 7.5 verifier. did (did:jwk + did:web built-in, others via custom didResolver) and x509_san_dns (with trustedX509Roots) are the supported schemes.

    JARM (encrypted direct_post.jwt) and DCQL queries are both supported in the plugin and exercised through the wallet's e2e suite, not via this harness.

License

MIT © Learning Economy Foundation