โ† all docs

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-specific content_X template via PageData.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-Trigger response header; a JS
    listener in static/js/app.js pops 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:

  1. 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.

  2. Post-render HTML walker (internal/handlers/i18n_postprocess.go)
    โ€” when Lang != "en", the render output is captured to a buffer,
    parsed with golang.org/x/net/html, walked node-by-node, and every
    text node (plus title / placeholder / alt / aria-label
    attributes) is translated. Skips <script>, <style>, <code>,
    <pre>, <textarea>, and elements with translate="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, patching credential_definition.@context if the
    wallet omitted it (walt.id's Kotlin wallet does; Mimoto doesn't).
    Records observed kids into injidid.Primary.

  • GET /inji-proxy/.well-known/did.json โ€” serves did:web:certify-nginx.
    Fetches inji-certify:8090/v1/certify/.well-known/did.json, appends
    synthetic verificationMethod entries for every kid injidid.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 kid injidid.Preauth has 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 its proof.verificationMethod kid
    into injidid.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.id
  • inji-test.mjs / injiweb-test.mjs / injiverify-test.mjs โ€”
    flow-specific checks per MOSIP component
  • matrix-test.mjs โ€” every DPG ร— role combination renders and commits
  • injiweb-credentials-visible.mjs โ€” regression for the FX5 bug (UI origin
    mismatch caused "No Credentials found")
  • i18n-inner-pages.mjs โ€” translation middleware covers deep pages
  • bulk-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 ./....

Source: docs/architecture.md