id-proto-debugger

The SD-JWT VC issuance workflow (vc-issuance-{0,1,2,3,4}.html)

Read this before changing any vc-issuance-* page, its bundle, or css/sd_jwt_vc.css. Two things here are counter-intuitive: the layout constraints are measured and mutation-tested, so a plausible CSS simplification is caught; and Credential History is a log of every attempt, of which the held credentials are only a navigable subset.

The workflow

The SD-JWT VC issuance workflow (vc-issuance-{0,1,2,3,4}.html) reuses the OIDC Authorization Code flow on oauth2_oidc_1.html / oauth2_oidc_2.html: the ?sdjwtvc=1 query parameter marks the flow active, and oauth2_oidc_2.html returns to step 2 once it has the tokens. Both debugger pages behave exactly as before without that parameter. Step 1’s authorization-server pane deliberately writes the same localStorage keys the debugger pages read.

That sharing runs both ways, and one of the two directions is a trap. oauth2_oidc_1.html’s metadata pane defaults to an OpenID Connect Discovery URL (https://localhost/oidc/.well-known, a placeholder it writes on a browser that has merely loaded the page) and stores it under oidc_discovery_endpoint — the same name step 1’s RFC 8414 pane uses. Read back unfiltered, that turns “Metadata Endpoint URL” on vc-issuance-1.html into an openid-configuration URL, or into a placeholder that resolves to nothing, for every browser that has visited the OAuth2 / OIDC workflow once — and it looks like a wrong default rather than like a value from another page. asMetadataUrlAtLoad() in client/src/vc_issuance_1.js is the filter: at load, a stored value is this pane’s own only if it carries /.well-known/oauth-authorization-server (the RFC 8414 section 3.1 issuer-path form included) or is the empty string a Clear leaves behind; anything else loses to rfc8414MetadataUrlDefault from the client env — https://localhost:8081/.well-known/oauth-authorization-server, the mock STS, on local.js. Nothing is written back, so the debugger page keeps its own URL. It is the same rule defaultAuthorizationServerUrl() already applied to what it found in the field after a credential-issuer retrieval.

Step 0 chooses which OID4VCI Appendix H use case to run. The list lives in one place — USE_CASES in client/src/sd_jwt_vc.js — and both the chooser’s cards and the badge every other page shows are generated from it, so they cannot disagree; setUseCase() redraws the badge itself, because an arriving Credential Offer changes the use case after the page has already been laid out. H.6 (wallet-initiated) is what the workflow has always done. H.1 (issuer-initiated) starts at the issuer’s own page, comes back to step 1 with a Credential Offer in credential_offer or credential_offer_uri, and carries the offer’s issuer_state into the authorization request (oauth2_oidc_1.js appends it as a custom authorization parameter). H.2 (cross-device) and H.3 (deferred) start at the issuer’s QR screen, arrive through step 1’s Receive a Credential Offer pane (the wallet is on another device, so nothing navigates it), and use the pre-authorized_code grant — no authorization request, so step 1 hands off straight to step 2, which owns the Token Request and the tx_code. H.3 adds the deferred pane: a 202 Credential Response with a transaction_id, polled at deferred_credential_endpoint until the credential arrives.

Step 4 (vc-issuance-4.html) refreshes a credential the wallet already holds — OID4VCI section 14.5, Refreshing Issued Credentials — in the two calls that mechanism is made of: grant_type=refresh_token at the token endpoint (RFC 6749 section 6, so nothing about it is OID4VCI-specific), then the same Credential Request step 2 makes, which is why both pages build it with vci_wallet.js. Section 14.3 is the reason the refresh half is optional rather than required: the Credential Endpoint may be asked again with an access token that is still valid, which is the only route left after the pre-authorized code grant (H.2/H.3), since that grant issues no refresh token.

Every page of the workflow opens with the same row of links to every steppartials/vc_steps.html, included at the top of the container, above the page title, with each page marking its own vc_step_N as current from its bundle. It is deliberately one row: ol.vc-steps is flex-wrap: nowrap with items that may shrink (flex: 1 1 0; min-width: 0), because with the wrapping it had before, adding step 4 pushed the fifth link onto a second line. Captions in the partial are kept short for the same reason, and stepLinksOnEveryPage() in tests/sd_jwt_vc_issuance.js asserts geometrically — all five items on one top edge, the row one item tall, above the title, no horizontal page scroll — on all five pages.

Layout trap on these panes. Everything they display is base64url with no break opportunity in it, and bootstrap.css sets code { white-space: nowrap } — so a long c_nonce or access token used to run off to the right of the pane, and in the Approve pane’s list it pushed the whole page sideways. sd_jwt_vc.css fixes it at the root: .dbg-pane code overrides the nowrap, .vc-token-table is table-layout: fixed (the cell decides the width, as the disclosure tables already did), and pre.vc-json / textarea.vc-token are both width: 100%; box-sizing: border-box so a <pre> and a <textarea> in one pane come out the same size. panesContainTheirContent() in tests/sd_jwt_vc_issuance.js plants an over-long value into every <code> on all four pages and fails if anything is wider than its pane or the page scrolls horizontally.

Step 1 is laid out for one screen, and the row of metadata panes is what pays for it. That page carries four discovery panes — Credential Issuer Metadata, Issuer DID Document, Authorization Server Metadata, DID Configuration — and each is a URL, a few buttons and a status, so stacked full-width they spent 1,234px on content that fits in a quarter of the width. They now sit in <div class="vc-pane-row">, a repeat(auto-fit, minmax(270px, 1fr)) grid with align-items: start, four across at 287px each on a desktop container and folding to 3+1 / 2+2 / one-per-line as the window narrows. The rest of the page was compressed the same way: each pane’s prose is folded into <details class="vc-hint"> (a <details> needs no script, which matters because these pages set script-src tightly), the page intro and the Configuration Parameters note likewise, the legends are short, the Return to home link is merged onto the title, and Receive a Credential Offer moved below the row. Empty page 6,569px → 2,920px; the four panes, the use-case badge, the collapse toggle and most of the offer pane are above an 839px fold.

Four things about that row are load-bearing, and three of them were regressions in the first cut:

stepOneFitsInOneRow() in tests/sd_jwt_vc_issuance.js covers all four, and it is mutation-tested: dropping the ceiling (row 23,339px), turning the grid back into a block (four lines), and removing the name-wrapping rule are each caught. Two rules the first cut added were caught by the same exercise as doing nothing and were removed — a table-layout: fixed and a word-break restated for the row’s document tables, which debugger.css’s .discovery_info_table block (max-width: 100% plus overflow-wrap: anywhere) already enforces, and a display: table restoring a display the stacking rule never took away. A rule that claims to hold a layout together while holding nothing is worse than no rule. Separately, note that vc-config-table is applied by vc_issuance_1.js and did_tools.js in generated markup and was for a while styled only via .vc-config-group table — defined in no stylesheet, which is precisely what checkStylesheetsLoaded() in tests/navigation.js fails a run over. Style the class the markup carries.

Step 4 also carries a Credential History pane, the counterpart of oauth2_oidc_2.html’s Token History — recording rather more than oauth2_oidc_2 does: every attempt, one row each, newest first. sd_jwt_vc.js holds the store; a row has a kind (issuance / token_refresh / credential_request / deferred_poll), an outcome (success / failed / deferred / pending / kept / discarded) and a detail saying what the issuer actually said. recordHistoryEntry() appends one row per attempt and returns its id; updateHistoryEntry() resolves it later (pendingkept or discarded), so a retry, a refusal and a discarded credential are all on record without any of them becoming a second row for the same call. Every row is numbered in # by the order the attempts were made — there is no unnumbered row — while Gen carries the generation number, which only the kept rows with a credential have: those are the generations (heldGenerations() numbers them, activeGeneration() says which is in hand) and ◀ Older / Newer ▶ / Oldest / Latest move over that subset (a log row is not a place to be, and says log only). The list is the newest HISTORY_LIMIT (100) attempts in a fixed-height scrolling box with a sticky header (#vc_history_table in sd_jwt_vc.css): a 100-row log would otherwise push the panes below it off the screen and the pane’s height would jump on every attempt. Trimming at HISTORY_LIMIT drops the oldest log rows first and only touches a generation when nothing else is left, so an audit trail never costs the ability to go back to a credential. HISTORY_INDEX holds the active row’s id, not an index, so a log row appearing between two generations cannot shift it. A credential the issuer has just returned shows up there immediately as a vc-history-pending row (outcome pending) marked not kept yet, with Keep/Discard on it — the pane has to react to the retrieval, not only to keeping, or it reads as broken; and keeping deliberately does not navigate (replaceCredential() calls reloadHeldCredential() in place and offers a Verify in step 3 button), because the pane the holder acted in is the pane that has to show what the action did. Activating a generation is a real state change, not a highlight: it writes CREDENTIAL/CREDENTIALS/CREDENTIAL_META and that generation’s holder key pair, because a credential whose cnf key the wallet no longer has cannot be presented at all — which is also why each entry stores its own key pair, and why Clear History writes an empty list rather than removing the key (hasCredentialHistory() distinguishes “cleared on purpose” from “never recorded”, and only the latter backfills the credential in hand as generation 1). A discarded refresh stays in the log as a discarded row — the attempt happened — but its credential and private key are stripped, because discarded has to mean discarded, and it is not a generation: the wallet never held it. Three things the page must get right, all of them wallet behaviour the spec leaves open: the refreshed credential is kept apart from the one in hand until the holder chooses (section 14.5 warns about a wallet holding two credentials of the same type and not knowing which is current); what the issuer changed is read off the two credentials rather than assumed, because the issuer decides whether to update the signature only or the claim values too — and the salts, Disclosures and digests are new either way, so the comparison is of claim values; and choosing to bind a new holder key must not overwrite HOLDER_PRIVATE_JWK until that credential is kept, or the credential still in hand loses the key it needs to be presented (the pending pair parks in REFRESHED_HOLDER_*, and the replaced credential’s pair in PREVIOUS_HOLDER_*).

Steps 3 and 4 each end with a pane offering the next thing to do with the credential — step 3 offers both Refresh It (step 4) and Present It, step 4 offers Present It. Two things about that hand-off are load-bearing. First, it copies nothing: the two workflows meet at the storage keys described under Key Implementation Notes in the repo-root CLAUDE.md and the presentation bundles read CREDENTIAL / HOLDER_PRIVATE_JWK directly, so the credential is already where the other workflow looks — a hand-off that duplicated it would be a second copy that goes stale, and presentationHandoff() in tests/sd_jwt_vc_issuance.js asserts the click adds no new key holding those bytes. What the offer owes the user is therefore not a transfer but an honest answer to will anything happen if I click this, which is presentationReadiness() in sd_jwt_vc.jsone implementation, because it has to reach the same verdict renderRequest() in vc_presentation_1.js will reach a page later. It keeps that function’s three-way distinction: no credential or an unparseable one blocks; a holder key that is missing while saving is on blocks, because it was never generated here, there is nothing to paste and step 1 refuses; a holder key absent by choice does not block, because presentation step 2 has a field for it. Collapsing those last two to “no key → blocked” is the plausible simplification and it strands the user two pages before the only field that fixes it, so the test asserts they come out differently.

Second, step 4 is the one page where “the credential” is ambiguous, and the offer has to say which one would go. It names the generation in hand (and whether it is the newest — going back to an older one is a supported thing to do there), and it explicitly disowns a refreshed credential that has been retrieved but not kept: that one is the newest thing on the screen and is not in storage at all, so it is not what a Verifier would see. The offer is rendered from renderHistory(), which is the pass every state change goes through — keeping, discarding, activating, stepping, clearing — but the call sits at the top of that function rather than the tail, because the empty-history early return is itself a state the offer must describe. Putting it at the tail leaves a fresh wallet with a blank, still-enabled offer, and it is invisible unless the test clears the history as well as the credential — which is exactly what the mutation testing caught.

Asking for a subset of the claims, and where that request actually goes

Step 1 carries a Claims to Request pane: one row per claim the chosen credential configuration says it can carry, every row checked, and clearing a row asks the issuer to leave that claim out. The rows are built from the issuer’s own metadata — credential_configurations_supported[<id>].claims, an array of claims description objects (OID4VCI Appendix A.2) — so the wallet asks in the vocabulary the issuer published rather than in one invented here, and an issuer that publishes no claims member gets a pane that says so rather than an empty table.

The member does not go where almost everybody looks for it first. OID4VCI 1.0 puts the optional claims member in the authorization_details entry of type openid_credential (section 5.1.1). The Credential Request has no such member: section 8.2 defines credential_identifier / credential_configuration_id, proofs and credential_response_encryption, and nothing else — the 1.1 editor’s draft still does. So the selection is made where the issuance is authorized, and by the time step 2 builds the Credential Request it has already been granted; what step 2 shows in its approval pane is what was asked for, not what the metadata offers. Adding a claims member to the Credential Request body instead would be ignored by a conforming issuer (“the Credential Issuer MUST ignore any unrecognized parameters”), which is the worst possible failure here: the credential comes back carrying everything and nothing says why.

That places two constraints on the pane, and both are visible in it:

Three decisions inside the pane that are easy to get wrong the other way:

Step 2 describes what was ASKED, not what was ticked, for the same reason: its approval pane reads the claims out of the token response’s granted authorization_details (RFC 9396 section 7), or — before a pre-authorized Token Request has been sent — out of the request it is about to make. Reading step 1’s stored selection instead is what made that pane agree with the lie above; the wire cannot.

The mock issuer honours the selection end to end — the authorization endpoint validates the paths against what it advertises, the granted details ride inside the access token, and the credential endpoint builds only the claims that were asked for — and refuses an unadvertised path, a repeated claim (Appendix A.3) and a malformed pointer with invalid_authorization_details. claimsSelection() and preAuthorizedClaimsRequest() in tests/sd_jwt_vc_issuance.js follow one selection from the pane through the authorization request, the token response and into the credential’s Disclosures, and read what the issuer advertises off the wire rather than assuming it: which claims that mock carries is /admin/vc configuration and it survives between tests. Both are mutation-tested — dropping the claims member from the client and dropping the filter from the issuer are each caught, the second by a message naming the claim that should not have been there.

Step 0 has to fit on one screen

vc-issuance-0.html is a chooser, and a chooser that scrolls cannot do its job — you cannot compare four options by scrolling between them. It did not start that way: four full-width cards stacked in a 1100px column came to 757px of a 1450px page against a 839px viewport (what a 1512x982 display leaves after the browser’s chrome), while all the horizontal space beyond the cards sat empty. Everything the page offers now ends 795px in, 44px above the fold — it was 808px until the shared VC Tools pane grew a second tool, which is the story at the end of this section.

Two changes got it there, and the split matters. .vc-usecases is a gridrepeat(auto-fit, minmax(400px, 1fr)), so two readable columns in the container and one on a narrow screen with no breakpoint — which is what uses the horizontal room. And the rest is a denser vertical rhythm scoped to .vc-fit on this page’s container: the return link rides on the title’s line, the intro drops the 1100px reading cap (a wider paragraph is fewer lines, and here height is the scarce thing), and the card padding, inner gaps and pane legend are each trimmed a few pixels. Scoped, because the other eight workflow pages are meant to be read top to bottom and may legitimately scroll — tightening .vc-title globally would be a decision about all of them.

Measurement notes, both of which flatter the result if got wrong: align-items: start on the grid, or every card in a row stretches to the tallest and the grid quietly gives the height back; and setRect() sets the outer window height, so the window is opened at 982 to get 839 inside it — measuring the window rather than the viewport overstates the space by ~143px, and headless has no toolbar to correct for.

chooserFitsOnOneScreen() in tests/sd_jwt_vc_issuance.js defends all of it: a grid rather than one card per row, columns still at least 380px wide (squeezing in more columns buys height only by making every card taller — measured: four across saved just 46px), everything above the fold, and no sideways scroll gained in exchange. Run against the pre-change build it reports “still one card per row; below the fold by 397px”.

The pane that breaks this page is the one that belongs to no step. The shared VC Tools pane (partials/vc_tools.html) sits at the foot of all nine workflow pages, and it grows: it gained a second tool on 2026-08-17 (the Certificate Authority & X.509 page), which put the page at 871px against the 839px viewport and failed that check by 32px — a page nobody edited, broken by a partial two directories away. Note what it does not look like: the assertion names step 0’s last pane, so the first thing it suggests is the chooser, which was fine.

The 32px came back from height that was never content, and the page now ends at 795px, 44px above the fold — deliberately more than one tools row, so the next tool does not repeat this:

Reclaimed How Worth
The pane legend’s line box .vc-fit .dbg-legend had its padding trimmed already, leaving one 14px word in a 40px line box 23px
The second tool’s description Shortened to roughly the DID tool’s length — this table’s row is as tall as its longest cell 19px
The title’s line box 24.5px of text in a 40px line box 9px
The tools rows’ leading .vc-fit #pane_vc_tools td at line-height: 1.3; 12.6px note text does not need 20px lines 3px now, ~11px per future row
Doubled whitespace The step row’s bottom margin and the card grid’s top margin were both spent on the same gap 12px

So a third tool costs about 33px of the 44 remaining, and a fourth needs another decision — the obvious next one being that this pane starts collapsed on .vc-fit, since its legend already toggles it. The trims are all scoped to .vc-fit (step 0 and step 1); only the shortened description is shared, and it is shorter on every page for the same reason. Checked against stepOneFitsInOneRow() as well, because that page carries the same class: its four metadata panes each lose the same 21px of legend, stay on one row, and the populated row measures 772px against its 1100px ceiling.

One thing it does not require is the footer above the fold: that is 200px of shared site furniture on every page in the project, so demanding it would be a constraint on the footer rather than on this page. Related and pre-existing: every VC workflow page overflows sideways by exactly 182px below about 1200px of width — the five-item steps row’s floor, present on untouched pages too and absent from oauth2_oidc_1.html and the landing page.