# holger-ui — web operator console for holger

Leptos/WASM single-page console for [holger](https://git.gunnar.rs/nordisk/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 · …
```

- `build.rs` — generates client stubs at build time (client-only, no
  transport — the browser transport is `tonic-web-wasm-client`) with protox,
  pure Rust, no `protoc`. It compiles against **holger's own**
  `../holger/proto/holger.proto` whenever holger is checked out beside this repo.
- `proto/holger.proto` — the fallback copy for a standalone checkout only.
  `tests/proto_is_holgers.rs` fails when it differs from holger's; refresh with
  `cp ../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 by `leptos_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

```bash
rustup target add wasm32-unknown-unknown
cargo install trunk
```

## Dev loop

```bash
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:

```bash
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 (`StaticToken` method) 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 is `openid`; the
  marketplace route below asks for more. The server validates the token
  through its configured `Oidc` method (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

```bash
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:

```bash
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](deploy/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](deploy/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.

```bash
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:

```bash
tailscale serve --bg http://127.0.0.1:8480
```

### The server behind it

[deploy/holger-server.service](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`.
