Architecture
A Go + HTMX server that renders HTML for every interaction and treats the
backend layer as a single backend.Adapter interface. No SPA, no framework,
standard library net/http and html/template only.
Package layout
vctypes/ Shared domain types (DPG, Schema, Credential, โฆ).
Zero third-party deps. Everything else imports this.
backend/ Adapter interface + request/response shapes + the
context helper for holder-DPG routing.
Depends only on vctypes and stdlib.
internal/adapters/ Concrete implementations of Adapter:
registry/ Fan-out adapter: routes to the right per-DPG
impl based on the DPG selected in the request.
waltid/ Real walt.id issuer-api / wallet-api / verifier-api client.
injicertify/ Real Inji Certify client; supports both pre-auth and
auth-code flows.
injiweb/ Thin stub โ Inji Web is browser-hosted; the adapter
exposes the redirect URL and capability claims.
injiverify/ Real Inji Verify client for OID4VP + direct verify.
libretranslate/ Translator client with on-disk cache.
factory/ Builds the right adapter from a config/backends.json entry.
internal/auth/ OIDC provider registry (Keycloak, WSO2IS) + discovery +
PKCE authorize URL + token exchange.
internal/handlers/ HTTP handlers. Depend on backend + vctypes. Never import
adapters directly โ they go through the Registry.
inji_proxy.go hosts the did:web and credential-forwarding
endpoints certify-nginx routes back to us.
i18n_postprocess.go wraps render output with a text-node
walker that translates anything not explicitly marked.
internal/httpx/ Tiny HTTP client with bearer-token context injection.
cmd/server/ main.go + adapter wiring + auth wiring + i18n wiring +
template loading. The only place where adapter types
are named explicitly.
templates/ html/template files. layout.html wraps every page;
fragments/ hold the HTMX-swappable pieces.
static/ CSS + a small JS file + the jsQR scanner.
e2e/ Headless Chromium tests (puppeteer-core) per DPG flow.
deploy/ compose override + per-service config overrides.
deploy.sh Single-entrypoint orchestrator.
Dependency graph
handlers โโโ backend โโโ vctypes
โ โ
โ โ
cmd/server โโโดโโโ registry โโโ waltid / injicertify / injiweb / injiverify
โ
โโโโ libretranslate
internal/handlers only knows about the Adapter interface. Swapping every
concrete adapter in cmd/server/adapter.go doesn't move a byte of handler
code.
The Adapter interface
One ~30-method interface in backend/adapter.go covers:
- List capabilities (
ListIssuerDpgs,ListHolderDpgs,ListVerifierDpgs) - Schema browsing + custom-schema lifecycle
- Prefill (MOSIP Identity Plugin, walt.id demo account, โฆ)
- Issuance (
IssueToWallet,IssueAsPDF,IssueBulk) - Wallet operations (
ParseOffer,ClaimCredential,ListWalletCredentials) - Presentation (
PresentCredential,RequestPresentation,FetchPresentationResult) - Direct verification (
VerifyDirect) - Example offers (
ListExampleOffers,BootstrapOffers)
Most methods carry a ...Dpg field in the request struct. The four that
don't (ParseOffer, ClaimCredential, ListWalletCredentials,
PresentCredential's context call path) use
backend.WithHolderDpg(ctx, vendor) โ the Registry reads it back via
backend.HolderDpgFromContext(ctx) to route the call.
Registry fan-out
internal/adapters/registry is the adapter the handlers actually hold. It
keeps per-role maps (issuers, holders, verifiers) keyed by vendor
display name, and for each request dispatches to the matching concrete
adapter. Unknown DPGs return backend.ErrUnknownDPG; handlers show a
toast rather than crashing.
When a scenario has a single holder registered, currentHolder falls
through to a shortcut โ so scenario=waltid works even if a handler forgets
to wrap the context. all forces callers to be explicit.
HTMX pattern
Every interactive control is hx-get or hx-post that swaps an HTML
fragment back into the page. Key conventions:
- Page loads render
{{template "layout" .}}which dispatches to the
page-specificcontent_Xtemplate viaPageData.ContentTemplate. - HTMX boost requests (
HX-Target: main) skip the layout and render
just the content template. - Fragment responses use
H.renderFragment(w, r, name, data)โ no
layout, just the named sub-template. - Toasts are triggered via the
HX-Triggerresponse header; a JS
listener instatic/js/app.jspops them. - Out-of-band swaps (
hx-swap-oob) let one response update a distant
element โ for example, the Continue button state outside the DPG grid.
Session model
One in-memory store (internal/handlers/session.go) keyed by a cookie.
Holds the currently-picked DPG per role, wallet contents, last seen error,
expand state for DPG cards, schema-builder draft, and the auth token
returned by the OIDC provider.
Per-session locking is not implemented โ two concurrent HTMX requests
from the same browser could race on writes. Real deployments should move
sessions to Redis or wrap each request in a per-session mutex.
Authentication
internal/auth loads OIDC providers from config/auth-providers.json,
does discovery via .well-known/openid-configuration, and handles PKCE
code exchange on the /auth/callback route. The resulting access token
lives in the session; adapters pick it up via httpx.WithToken(ctx, tok).
Inside a docker deployment the provider URL has two forms: the
browser-visible one (http://localhost:8180, what HX-Redirect sends the
browser to) and the container-visible one (http://keycloak:8180, what
the Go server uses for discovery + token exchange). oidc.Discover
rewrites endpoints to the internal form for server-side use and flips
them back to public for browser redirects.
Translation
internal/adapters/libretranslate caches translations on disk
(locales/<lang>.json) so repeat renders are instant.
Two layers keep every surface translated:
-
Template-level
{{t "string" $.Lang}}โ static strings known at
template-write time are wrapped explicitly. Parse-time binding means
the helper is a single package-level function that looks up the
translator from a request-scoped package variable. -
Post-render HTML walker (
internal/handlers/i18n_postprocess.go)
โ when Lang != "en", the render output is captured to a buffer,
parsed withgolang.org/x/net/html, walked node-by-node, and every
text node (plustitle/placeholder/alt/aria-label
attributes) is translated. Skips<script>,<style>,<code>,
<pre>,<textarea>, and elements withtranslate="no"or class
mono/notrโ those hold identifiers, URLs, and brand names that
must render verbatim.
The safety-net walker means a template author can forget to wrap a string
and translation still works. Brand names that get incorrectly translated
("Keycloak" โ "Clรฉ" in French) are fixed by adding a mono class to the
span holding them.
Inji-proxy (did:web resolver + credential forwarder)
Two separate Inji Certify instances run behind two separate nginx
front-ends, each publishing its OWN DID document. Before the split, both
instances signed under did:web:certify-nginx and collided on kid (two
different Ed25519 keypairs claiming the same kid fragment), which
stranded whichever flow's VC didn't happen to resolve to the winning
entry in the merged did.json. The per-instance DID split eliminates
that class of failure entirely:
| Flow | Instance | Nginx | DID |
|---|---|---|---|
| Auth-code | inji-certify |
certify-nginx |
did:web:certify-nginx |
| Pre-auth | inji-certify-preauth-backend |
certify-preauth-nginx |
did:web:certify-preauth-nginx |
Each nginx routes GET /.well-known/did.json back through
host.docker.internal:8080 to its own verifiably-go handler; each
handler fetches ONLY its own upstream's did.json (no merge), and each
has its own injidid.Observer tracking the kids the corresponding
instance has signed with. Four endpoints total:
-
POST /inji-proxy/issuance/credentialโ forwards to
inji-certify:8090, patchingcredential_definition.@contextif the
wallet omitted it (walt.id's Kotlin wallet does; Mimoto doesn't).
Records observed kids intoinjidid.Primary. -
GET /inji-proxy/.well-known/did.jsonโ serves did:web:certify-nginx.
Fetchesinji-certify:8090/v1/certify/.well-known/did.json, appends
syntheticverificationMethodentries for every kidinjidid.Primary
has seen. -
GET /inji-proxy-preauth/.well-known/did.jsonโ serves
did:web:certify-preauth-nginx. Fetches
inji-certify-preauth-backend:8090/v1/certify/.well-known/did.json,
appends entries for every kidinjidid.Preauthhas seen. Pre-auth
kids come from the direct-to-PDF flow in
adapters/injicertify/pdf.go, not through the proxy endpoints. -
GET /inji-proxy/credentials/status-list/{id}โ forwards the
primary status-list VC and records itsproof.verificationMethodkid
intoinjidid.Primary.
Observers can be pre-seeded for restarts: INJI_PROXY_EXTRA_KIDS feeds
primary, INJI_PROXY_PREAUTH_EXTRA_KIDS feeds pre-auth. Both are
comma-separated kid fragments.
The individual kid-synthesis workarounds (derived from multiple hash
paths over one Ed25519 key) stay in place within each handler because
Inji Certify v0.14.0 still publishes a kid in its did.json that
isn't the kid its signer uses. What's gone is the cross-instance merge
โ Inji Verify never sees keys from a different instance while resolving
one instance's DID.
See dpg-matrix.md for the upstream bugs each of these
workarounds target.
Testing
e2e/ holds puppeteer-core tests per DPG flow:
waltid-test.mjsโ end-to-end issue + hold + present on walt.idinji-test.mjs/injiweb-test.mjs/injiverify-test.mjsโ
flow-specific checks per MOSIP componentmatrix-test.mjsโ every DPG ร role combination renders and commitsinjiweb-credentials-visible.mjsโ regression for the FX5 bug (UI origin
mismatch caused "No Credentials found")i18n-inner-pages.mjsโ translation middleware covers deep pagesbulk-csv-test.mjs/scan-upload-test.mjs/present-test.mjsโ
isolated feature checks
Run a single test with CHROME_PATH=/usr/bin/google-chrome node e2e/<name>.mjs.
Go unit tests (currently internal/adapters/registry routing) run with
go test ./....