Gunnar

nordisk/holger-ui

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 crate holger-ui, now holger-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 · …

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.

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.

Files

.cargo/
.nornir/
LICENSES/
assets/
browser-tests/
crates/
deploy/
docs/
proto/
src/
tests/
xtask/
.gitignore
COMMERCIAL-LICENSING.md
Cargo.lock
Cargo.toml
LICENSE
NOTICE
README.md
SECURITY.md
THIRD_PARTY_LICENSES.md
TRADEMARKS.md
Trunk.toml
build.rs
index-metered.html
index.html

Newest commits

bce2eed21b60Merge badge-docs/2026-10-29 into main (lane BADGE-DOCS): SECURITY.md opens with the private report address (holger@holger.rs) and points to Holger's security policy. Docs only2026-10-29
a567f563cf3ddocs: SECURITY.md opens with how to report a vulnerability (holger@holger.rs)2026-10-29
dc9f229cfc6eMerge ox-postpub/follow-2026-10-29 into main (lane OX-POSTPUB): root on nornir-xtask-support 0.1.6 (crates.io), browser-tests on estate-front 0.3.0 and monetize-proto 0.3.3. Gate on the merged tree (ci4, dwarf): lockcheck green (2 locks), browser-tests check --locked --tests clean, cargo test --workspace --locked 237/1: the one red is main's own (every_call_is_a_cli_command: holger-cli's table has no RepositoryService.PutArtifact)2026-10-29
809a064fa754locks: root nornir-xtask-support 0.1.6 from crates.io; browser-tests follows the siblings (estate-front 0.3.0, monetize-proto 0.3.3) (lane OX-POSTPUB)2026-10-29
fc5e46f56ab7Merge fix/ox-uimerge-ra5-2-browser-2026-10-28 into main (lane OX-UIMERGE): RA5-2 console half, the browser tests take nornir-identity 0.2.2 (every invite needs a fresh step-up at the core); the Invites pane asks for a touch in place and the mint then works (a_stale_touch_is_answered_in_place_on_the_invites_pane). No console code change was needed. Gate: lockcheck green, browser suite 20/0 --locked against holger main b892cf05 with 0.2.22026-10-28