holger-ui — web operator console for holger
Leptos/WASM single-page console for holger,
talking gRPC-web straight to the server's tonic-web door. No REST layer, no
BFF — proto/holger.proto (+ proto/holger-room.proto) is the entire
contract. Login is holger's own passkey door (/-/auth/passkey/*); the
browser carries holger's session as a bearer and holger decides every call.
Crate name is
holger-ui-web(the holger main repo's egui app was once the crateholger-ui, nowholger-server-ui); the repo keeps the short name.
Architecture
browser (Leptos/WASM)
└─ tonic-web-wasm-client (fetch, gRPC-web)
└─ holger server — tonic-web layer over the same gRPC services
AdminService · RepositoryService · SearchService · …
build.rs— generates client stubs at build time (client-only, no transport — the browser transport istonic-web-wasm-client) with protox, pure Rust, noprotoc. It compiles against holger's own../holger/proto/holger.protowhenever holger is checked out beside this repo.proto/holger.proto— the fallback copy for a standalone checkout only.tests/proto_is_holgers.rsfails when it differs from holger's; refresh withcp ../holger/proto/holger.proto proto/.src/grpc.rs— the one place that knows the server URL, builds clients, and attaches the bearer credential.src/app.rs— the console shell and views, routed byleptos_router.assets/login.html+login.js— the passkey page, served at/login: holger's ceremony, holger's session kept for the tab.crates/holger-ui-server— the thin server: the bundle, and a same-origin pass-through (/grpc/*→ holger's gRPC door,/-/*and/→ holger's HTTP door). No users, permissions or store. holger's estate front links it.
Routes — every view is a shareable URL
/ repositories
/repo/:name browse one repository
/artifact?repo=&ns=&name=&version= artifact detail (query-addressed —
raw artifact names contain slashes)
/search?name=&namespace=&version=&path=&checksum=
search; a shared URL auto-runs
/replicate replication (preview-first)
/login token paste + OIDC/PKCE login
/marketplace/landing?subscription=&plan=&status=&http=&grpc=&client=&tenant=
post-purchase hand-over from holger-server's
marketplace door (or ?error=holger-marketplace-<n>)
A directly-opened URL fetches everything it needs from its address. Serving
rule: the host must fall back to index.html for unknown paths — Trunk's dev
server already does; give a production reverse proxy the same rule.
Version note: this repo pins tonic 0.14 (codegen only); holger's server is on tonic 0.13. The gRPC-web wire protocol is independent of either side's tonic version, so the skew is deliberate and harmless.
Prerequisites
rustup target add wasm32-unknown-unknown
cargo install trunk
Dev loop
trunk serve # http://127.0.0.1:8090
Trunk.toml proxies /holger.v1.* to http://127.0.0.1:50051, so run a local
holger with its gRPC-web door on 50051 and the console is live — same-origin,
no CORS setup.
Server-side prerequisite: holger must expose a tonic-web layer on its gRPC
endpoint. That work lives in the holger main repo (tonic 0.13 ⇒ tonic-web 0.13, wrapped around the existing tonic router); until it lands, the console
renders with the health badge showing unreachable.
Override the target for a non-proxied build:
HOLGER_GRPC_WEB_URL=https://holger.example.com trunk build --release
Authentication
Two login forms, one credential store (sessionStorage; dies with the tab,
dropped by Log out). Whatever lands there rides every gRPC-web call as
authorization: Bearer via one tonic interceptor — roles stay the server's
RBAC mapping, the console adds no client-side gating.
- Paste a token — a static API token (
StaticTokenmethod) or an IdP-issued access token. - OIDC with PKCE — authorization-code flow, public client, S256. Configure
issuer + client id in the login view (persisted in localStorage) or prefill
at build time with
HOLGER_OIDC_ISSUER/HOLGER_OIDC_CLIENT_ID. The IdP must register the console origin as redirect URI and allow it CORS on the token endpoint (standard SPA registration). The scope isopenid; the marketplace route below asks for more. The server validates the token through its configuredOidcmethod (JWKS offline or userinfo online).
Marketplace hand-over. When holger is bought through a cloud marketplace,
holger-server's /-/marketplace/landing door signs the buyer in with Entra
(the marketplace contract requires sign-in before activation), exchanges the
purchase token server-side (resolve · provision · activate) and 303s the
buyer's browser to /marketplace/landing here, carrying only the outcome in
the query — subscription id, plan, status, the tenant's HTTP and gRPC
addresses, the SSO app's client id (client=) and the buyer's Entra directory
id (tenant=) — or ?error=holger-marketplace-<n> when it refused (the
server's codes are frozen, 1–14; each has a sentence in
util::marketplace_error_sentence, guarded by a native test). Neither the
purchase token nor the fulfilment client secret ever reaches the browser: the
token is single-use and consumed by the server, and the secret is read from
the server's environment, which is why this CSR-only console cannot be where
the exchange happens. That sign-in at the door is what made the buyer the
tenant's first Admin (keyed on its OIDC sub); signing in on the landing
route is the same identity, now for the console.
The route shows what was provisioned, prints the cargo/pip addresses, and
offers the same PKCE sign-in as above with the issuer derived from the
directory (https://login.microsoftonline.com/<tenant>/v2.0, the one issuer
the provisioned tenant trusts) and the client id preferring client= from the
hand-over over the saved / HOLGER_OIDC_CLIENT_ID value (persisted on
sign-in, so the Account page then shows the same). The scope it asks for is
openid profile email <client_id>/.default, not the plain openid of the
Account page: against Entra v2, openid alone yields an access token for
Microsoft Graph (aud = Graph), which the tenant — checking aud against the
SSO app's client id — refuses; <client_id>/.default is Entra's form for "an
access token for this app registration". For that to resolve, the publisher's
app registration must expose an API (an Application ID URI and at least
one scope under Expose an API) and issue v2 access tokens
(requestedAccessTokenVersion: 2 in the manifest), so the token's aud is the
bare client id and its iss the v2 issuer the tenant pins — a v1 token
carries aud: api://<client_id> and iss: sts.windows.net and fails both
checks. Once signed in the route shows the WhoAmI subject and role: the
buyer's is already mapped; a colleague's is what an operator adds to the
tenant's roles map for a further admin, since an Entra subject is per user
and per app and cannot be written down before that first login. Deploy
note: the Entra origin must be in the CSP
(IDP_ORIGIN=https://login.microsoftonline.com cargo xtask publish), and the
console origin must be a single-page application redirect URI on that app
registration.
Release build
cargo xtask build # what the recipe declares — trunk build --release
cargo xtask build --list # every declared build point, and its command line
chmod -R a+rX dist # the chmod matters when a rootless container serves them
Two console bundles (A28 M13). The open one, dist/ from index.html, renders no word
about money and carries no sign-up, welcome or order page. The metered one,
dist-metered/ from index-metered.html (cargo xtask build --variant mvp-ui-web-metered: trunk build --release --features metered --dist dist-metered index-metered.html), is the one holger's paid front embeds.
cargo xtask build owns no build knowledge: the package, the profile and the
target all come from the ONE declaration in crates/holger-ui-recipe
(.nornir/build-variants.toml is that declaration EMITTED — describing the build
for the tools that discover a repo by that path, never read back as authority;
re-emit with cargo run -p holger-ui-build-thing -- emit). Today it declares one
row, mvp-ui-web, and drives it through trunk — trunk also runs wasm-bindgen,
wasm-opt and the index.html pipeline, and writes the content-hashed names the
deploy globs for, so a bare cargo build would produce a file no browser loads.
trunk build --release by hand still does the same thing; the verb exists so
that the build point is written down once, in Rust, instead of in three prose
passages that can drift.
The open product: cargo install (Holger v2.0 H5)
The server installs with plain cargo, and the Build Thing row ui (beside holger's OPEN
rows upstream and downstream; here in this repository's one delivery, MVP) builds
exactly the same binary:
cargo install --locked --path crates/holger-ui-server
The host target, the release profile, no feature. tests/ui_row_equals_cargo_install.rs
holds the row to this line and to its tree guard (a crate bound, no outbound client, nothing
sold, no public site). The console bundle itself is trunk's (above); mvp-ui is the same
server cross-built for musl as the unit --install writes.
Production deploy — nginx in rootless podman (quadlet)
deploy/ holds the whole shape: nginx.conf (SPA fallback,
/grpc/ stripped and proxied to holger on 50051, immutable caching for the
hashed wasm/js, streaming 1 GiB uploads) and
holger-console.container (the quadlet unit,
host networking, loopback :8480).
The served tree is a published copy under ~/.local/share/holger-console,
never the repo's dist/ — the dev loop (trunk serve) rewrites dist/ on
every rebuild and would silently replace the release with a debug build.
cargo xtask publish is the ONLY supported publish path: it builds the release,
computes the sha256 of trunk's inline bootstrap and generates the nginx config
with a matching Content-Security-Policy, then restarts holger-console over
D-Bus. Copying deploy/nginx.conf by hand leaves the CSP marker as a comment —
the site then serves with NO CSP at all.
cp deploy/holger-console.container ~/.config/containers/systemd/ # once
systemctl --user daemon-reload # once
loginctl enable-linger # once — survives logout, starts at boot
cargo xtask publish # every release; add IDP_ORIGIN=… for OIDC
cargo xtask verify-chain # the whole prod chain, outside-in
The listener stays loopback HTTP by design: TLS and exposure are the outer layer's decision. On a tailnet:
tailscale serve --bg http://127.0.0.1:8480
The server behind it
deploy/holger-server.service runs holger-server
itself as a systemd user unit: release binary in ~/.local/bin, RON config in
~/.config/holger-console/server.ron (no inline secrets — the bearer token is
a chmod 600 file resolved via token_file), data under
~/.local/share/holger-console/store. The full prod chain is then three
persistent pieces: holger-server.service (gRPC-web on 50051) ←
holger-console (nginx quadlet, :8480) ← tailscale serve (HTTPS on the
tailnet), all surviving reboot once loginctl enable-linger is set.
License
AGPL-3.0-only, © 2026 Vetra Automation AB and Ignalina ApS (Rickard Ernst Björn Lundin) (jointly owned in equal
shares, 50/50). Commercial terms are available from both co-owners jointly; write to
gunnar@gunnar.rs. Versions published to crates.io on or before 2026-10-02 keep the
license they were published with (mostly MIT). See LICENSE, NOTICE,
COMMERCIAL-LICENSING.md, TRADEMARKS.md and THIRD_PARTY_LICENSES.md.