Operations & Infrastructure Document

How Heimdall UI is configured, built, packaged, and released for each of its four targets and four environments.

Operations & Infrastructure Document — Heimdall UI

1. Purpose

This document covers everything between a checkout and a running application: how the client is configured, how it is built for each target, what each build produces, what the host needs, and what continuous integration does. The technologies themselves are recorded in the Technology Stack Document .

Heimdall UI has no server-side component of its own. It is a client; the Heimdall API is the only backend, and every operational concern about data, tokens, and email belongs there.


2. Configuration

Configuration is supplied at build time through Dart defines. Nothing is read from a file at runtime, and no value is baked into source.

KeyRequiredDefaultPurpose
HEIMDALL_API_BASE_URLYes, in any real deploymenthttp://localhost:8080The API root. A trailing slash is trimmed. The default is the local heimdall-api (§2.2). A release build of the Windows, Linux or Android app refuses to start unless it is https://, or http:// to localhost or 127.0.0.1; it shows the reason on screen and in the log, naming the define to set.
HEIMDALL_GOOGLE_CLIENT_IDOnly for Google Sign-InunsetThe Google client id for the target. When unset, the Google control is hidden rather than shown broken.
HEIMDALL_SCOPE_IDOnly when no calling application supplies oneunsetThe PublicId of the scope this build acts in. See §2.1 — on the web the calling application’s value wins.

2.1 The target scope

Heimdall UI is opened by the applications that use Heimdall’s services, and the scope being entered is theirs to name. There is no screen here that asks for it, and no endpoint an anonymous caller could list scopes from, so it arrives from outside.

On the web, the calling application names the scope’s PublicId as the scope query parameter of the URL it sends the user to, on any route:

https://heimdall.example.com/login?scope=<scope-public-id>

At start-up, before the router reads the address, the UI checks that the value is a GUID, copies it into its own session storage under heimdall.scopeId, and replaces the address with one without it (history.replaceState), so the history entry does not keep it either. A value that is not a GUID is ignored, and removed all the same. Session storage is per origin, so a caller on another origin could never have written the UI’s; the URL is what lets the caller live elsewhere.

The calling application must not be hosted on the UI’s origin. Script on that origin can read everything the UI keeps in the browser, the session token included, so a flaw in a co-hosted caller would hand over Heimdall sessions. A caller that does share the origin may still write the value itself, as earlier versions required, and that keeps working; a stored value that is not a GUID is ignored there too:

sessionStorage.setItem('heimdall.scopeId', '<scope-public-id>');

Session storage rather than a cookie, deliberately:

  • It is scoped to the tab, so two tabs can act in two scopes at once.
  • It is never attached to a request, so the scope cannot become a header the API did not ask for, and cannot be replayed by a third party’s form.
  • It dies with the tab, so a scope does not outlive the visit on a shared machine.

The value is read on every use rather than captured at start-up, so opening the UI again with another scope changes it.

On Windows, Linux, and Android there is no calling web application, so HEIMDALL_SCOPE_ID is the whole answer. On the web it is the fallback used when the caller left nothing — a build opened directly rather than handed off to.

When neither supplies a scope, the request is made without one and the API decides what that means. Nothing is invented on the client’s side.

Supply them per command:

flutter run --dart-define=HEIMDALL_API_BASE_URL=https://heimdall.example.com

Or keep them in a file that is not committed — config/local.json is git-ignored. The tracked config/local.json.example holds the local environment’s values; copy it and fill in what applies:

{
  "HEIMDALL_API_BASE_URL": "http://localhost:8080",
  "HEIMDALL_GOOGLE_CLIENT_ID": "",
  "HEIMDALL_SCOPE_ID": ""
}
flutter run --dart-define-from-file=config/local.json

A Google client id is not a secret, but the API base URL of a private deployment can be. Neither belongs in the repository.

2.2 Environments

The UI is deployed to the same four environments as the Heimdall API . It has no env file of its own: a deployed build’s values are build arguments, kept in yggdrasil ’s env files on the VPS (/etc/yggdrasil/<environment>/heimdall-ui.env). example.com stands for the real domain.

EnvironmentWhereDeployed byUI atHEIMDALL_API_BASE_URL
localThe developer’s Windows machine, Docker DesktopBy hand — flutter run, or yggdrasil’s scripts/deploy.sh local heimdall-ui ...http://localhost:8081http://localhost:8080, the local heimdall-api
developmentThe VPS, on demand (started only when used)Jenkins, on every push to develop; a stopped environment stays stopped, and scripts/ygg.sh env start development on the VPS turns it onhttps://heimdall-dev.example.comhttps://heimdall-dev.example.com
homologationThe VPS, on demandJenkins, on every push of a release/x.y.z branchhttps://heimdall-hml.example.comhttps://heimdall-hml.example.com
productionThe VPS, always onJenkins, on a green release/x.y.z → main pull request, which it then merges and tagshttps://heimdall.example.comhttps://heimdall.example.com

On the VPS the API base URL is the UI’s own origin. Traefik routes /api/ on the UI’s host to the same environment’s heimdall-api, and every API route begins with /api/, so the browser never makes a cross-origin request and the API needs no CORS entry for the UI. Locally they are two origins — the UI on 8081, the API on 8080 — so the local heimdall-api lists http://localhost:8081 in HEIMDALL_CORS_ALLOWED_ORIGINS, and flutter run -d chrome takes --web-port 8081 to be that origin.

Because the values are compiled into the bundle, the image is built once per environment — the same commit, a different bundle — and tagged <environment>-<version> by yggdrasil.


3. Prerequisites

TargetRequirements
AllFlutter 3.47.7 or newer on the stable channel
WebA Chromium-based browser for flutter run -d chrome
WindowsVisual Studio (or Build Tools) with the Desktop development with C++ workload and the C++ ATL component
Linuxclang, cmake, ninja-build, pkg-config, libgtk-3-dev, liblzma-dev, libstdc++-12-dev, libsecret-1-dev
AndroidThe Android SDK including cmdline-tools, the API 37 platform (platforms;android-37.0), accepted SDK licences, and JDK 17

The Windows ATL component is not optional. flutter_secure_storage_windows, which holds the session token in DPAPI, includes atlstr.h. Without C++ ATL for latest v143 build tools in the Visual Studio Installer, flutter build windows fails with error C1083: Cannot open include file: 'atlstr.h'. Install it from the Visual Studio Installer’s Individual components tab.

Android needs more than the SDK directory. flutter doctor reports cmdline-tools component is missing and Android license status unknown on a partial installation, and the build fails. Install the command-line tools and run flutter doctor --android-licenses once. Note that the command-line tools package Google’s manifest lists is old enough to misread a current SDK’s XML; use it to install cmdline-tools;latest and then discard it.

The app compiles against API 37, not Flutter’s default of 36. flutter_secure_storage 11 publishes AAR metadata requiring its consumers to compile against 37, so android/app/build.gradle.kts sets compileSdk = 37 explicitly. minSdk and targetSdk are untouched — this changes what the app compiles against, not which devices it runs on. Google publishes that platform as platforms;android-37.0; there is no plain platforms;android-37 package.

On Debian or Ubuntu:

sudo apt-get update && sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev libsecret-1-dev

Confirm the toolchain before building:

flutter doctor

4. Building

Each command below produces a release build configured for a real API.

Web — a static bundle in build/web:

flutter build web --release --dart-define=HEIMDALL_API_BASE_URL=https://heimdall.example.com

Windows — an executable and its DLLs in build/windows/x64/runner/Release:

flutter build windows --release --dart-define=HEIMDALL_API_BASE_URL=https://heimdall.example.com

Linux — a bundle in build/linux/x64/release/bundle:

flutter build linux --release --dart-define=HEIMDALL_API_BASE_URL=https://heimdall.example.com

Android — an APK in build/app/outputs/flutter-apk, or an App Bundle in build/app/outputs/bundle/release:

flutter build apk --release --dart-define=HEIMDALL_API_BASE_URL=https://heimdall.example.com
flutter build appbundle --release --dart-define=HEIMDALL_API_BASE_URL=https://heimdall.example.com

5. Hosting the web build

build/web is a static bundle. Any static host serves it, with two requirements:

  1. Single-page routing. Unknown paths must fall back to index.html, or a deep link such as /scopes/{id} returns a 404 from the host instead of reaching the router.
  2. CORS. The API must permit the origin the bundle is served from. This is configured on the API, not here — unless, as in the VPS environments (§2.2), the API is served under the bundle’s own origin, in which case there is nothing to permit.

Serve the bundle under a sub-path by building with a base href:

flutter build web --release --base-href=/heimdall/

5.1 Security headers

The container image’s docker/nginx.conf sends these headers; another host should send the same (NFR-10).

HeaderValueWhy
Content-Security-PolicyEnforced; see belowA second line of defence against injected script
Referrer-Policyno-referrer on /password-reset and /verify-email; strict-origin-when-cross-origin elsewhereThe reset and verification pages are opened with a token in the URL. Elsewhere Google Identity Services asks for strict-origin-when-cross-origin, and a stricter policy would send it nothing. yggdrasil’s edge (0.7.4 and later) passes it through; see below
X-Frame-OptionsDENYNo framing, alongside frame-ancestors 'none'
X-Content-Type-Optionsnosniff

Referrer-Policy behind a proxy. nginx’s per-route value reaches the browser when the image is served directly, and behind yggdrasil’s Traefik from 0.7.4: the UI’s routers carry the secure-headers-ui middleware, which is the shared secure-headers set without referrerPolicy. The edge still sends Strict-Transport-Security: max-age=31536000; includeSubDomains and X-Content-Type-Options: nosniff, and removes Server and X-Powered-By.

Behind a proxy that does replace the header (yggdrasil before 0.7.4, whose secure-headers set strict-origin-when-cross-origin on every response, or another proxy’s security-headers middleware), those two routes get the proxy’s value. There the protection is that the application removes a link token from the address as it starts, so the requests it makes afterwards do not carry it.

The Content-Security-Policy:

DirectiveSourcesFor
default-src'self'Everything not listed below
script-src'self' 'wasm-unsafe-eval' https://accounts.google.com/gsi/clientThe bundle; compiling CanvasKit’s or skwasm’s WebAssembly; Google Sign-In’s library
style-src'self' 'unsafe-inline' https://accounts.google.com/gsi/styleThe engine and Google Sign-In inject <style> elements and style attributes; Google Sign-In’s stylesheet
connect-src'self', the API’s origin, https://accounts.google.com/gsi/, https://*.googleusercontent.com, https://fonts.gstatic.comAPI calls; Google Sign-In’s endpoints; Google profile pictures, which the engine fetches; the Noto fallback fonts the engine fetches for text the bundled Roboto has no glyphs for (emoji, CJK, Arabic and other scripts). The engine registers those bytes itself, so font-src needs no entry for them
img-src'self' data: https://*.googleusercontent.comInline images; Google profile pictures shown as images
font-src, manifest-src'self'The bundled fonts (--no-web-resources-cdn bundles CanvasKit and Roboto) and the manifest
frame-srchttps://accounts.google.com/gsi/The Google Sign-In button and its prompts
object-src, base-uri, form-action, frame-ancestors'none', 'self', 'self', 'none'Nothing else may embed, re-base, post from or frame the page

The API’s origin is taken from HEIMDALL_API_BASE_URL when the image is built: the Dockerfile replaces the @HEIMDALL_API_ORIGIN@ placeholder in nginx.conf, and refuses to build when the URL is not an absolute http(s) URL with a plain host name. On the VPS it is the page’s own origin, and the entry repeats 'self'.

The policy was verified against release builds, CanvasKit and --wasm alike, served by the image’s nginx with headless Chromium: the sign-in screen, Google Sign-In’s button, a sign-in request to an API on another origin, an API answer in emoji, CJK and Arabic (its fallback fonts load from fonts.gstatic.com and render), the reset and verification links, and the not-found screen, with no violation. Without https://fonts.gstatic.com in connect-src such text renders as empty boxes. 'unsafe-inline' in style-src is required; without it the engine’s own styles are refused. No worker source is listed: neither renderer starts a worker on a page that is not cross-origin isolated, which this one is not, so skwasm runs single-threaded. Google profile pictures were not exercised. Before widening the policy, check the browser console for Refused to … messages, and add the narrowest source that silences them.

flutter run serves without these headers, so a development session runs without the policy.


6. Desktop and Android distribution

  • Windows and Linux builds are unsigned by default. Signing and packaging (MSIX, AppImage, deb) are deployment decisions and are not part of this repository.
  • Android release builds are signed with a keystore held outside the repository, read from the environment variables HEIMDALL_UI_KEYSTORE_PATH, HEIMDALL_UI_KEYSTORE_PASSWORD, HEIMDALL_UI_KEY_ALIAS and (optionally, defaulting to the keystore password) HEIMDALL_UI_KEY_PASSWORD, or from a git-ignored android/key.properties with the keys storeFile, storePassword, keyAlias and keyPassword. When none is set, flutter build apk --release signs with the debug key and prints a warning in its output (at Gradle’s error level, so Flutter’s quiet Gradle run shows it); that APK is suitable for testing and not for distribution. A partial configuration, or a keystore that does not exist, fails the release build. ci/release-build.sh maps the Jenkins credentials heimdall-ui-android-keystore (the keystore, base64-encoded, decoded to a file in a temporary directory), heimdall-ui-android-keystore-password, heimdall-ui-android-key-alias and heimdall-ui-android-key-password to those variables; they are bound only on the release build. Without them it warns and produces a debug-signed file named heimdall-ui-android-<version>-debug-signed.apk.
  • No target auto-updates. Distribution and update are the operator’s responsibility.

7. Refreshing the API client

The generated client tracks api/heimdall.json, a committed snapshot of the API’s specification. When the API changes:

dart run tool/refresh_openapi.dart ../heimdall-api/docs/openapi/heimdall.json
dart run tool/generate_api_client.dart

The generator also rewrites every path parameter of the generated clients to go through a small _pathSegment helper, which percent-encodes it as one segment and refuses ., .. and the empty string: retrofit would otherwise interpolate an id as it is, and an id holding / or ? would reach a different endpoint.

Commit the refreshed specification together with the regenerated client. CI regenerates and fails if the committed output differs, so the two cannot drift apart.

The privacy notice shown at /privacy is the API’s own docs/requirements/Privacy Notice.md, bundled verbatim as assets/privacy/privacy_notice.md. When the API publishes a new version, copy it from the API’s main:

dart run tool/refresh_privacy_notice.dart "https://raw.githubusercontent.com/artur-rios/heimdall-api/main/docs/requirements/Privacy%20Notice.md"

https://<host>/privacy is the Application privacy policy link on Google Auth Platform’s Branding page.


8. Continuous integration

Jenkins runs these checks (yggdrasil.yml, scripts under ci/) on every pull request into develop or main:

CheckWhat it does
analyze-and-testdart format --set-exit-if-changed, flutter analyze, flutter test --coverage (keeps coverage/lcov.info, no threshold)
build-webflutter build web --release
api-client-driftRegenerates the client and fails if the committed output differs
branch-policyChecks the branching model described in CONTRIBUTING.md , and that a release’s pubspec.yaml says its version

The release files are built when a release pull request is merged: ci/release-build.sh on the release agent (web zip, Linux tarball, signed Android package) and the Release Windows workflow (release-windows.yml, dispatched on the tag, on the GitHub copy) for the Windows zip. There is no scheduled build.

CI never receives a real API base URL: the analyze-and-test job needs none, and the build jobs use the default so that no deployment address is exposed in a public log.

The web container image (see the README’s Container image (web)) is built and deployed by the self-hosted Jenkins in yggdrasil , which builds the repository’s Dockerfile (Jenkins does not read a Jenkinsfile from the repository): a push to develop deploys to development, a pushed release/x.y.z branch to homologation, and its pull request into main to production once every check passes (§2.2) — see CONTRIBUTING.md .


9. Runtime behavior worth knowing

  • The token is the only stored state, held in the platform’s secure storage. Signing out deletes it. A challenge token is never stored.
  • On the web the token lives in the tab’s session storage. It is kept per tab and not shared: other tabs sign in on their own, and signing out clears it. A reload keeps the session, and so may a tab the browser brings back: a duplicated tab starts with a copy of the original’s session storage, and reopening a closed tab or restoring the browser’s last session can restore it too. On a shared machine, signing out is what ends the session; closing the tab may not. The token is encrypted there, but its key is kept in the same storage, so it is not protected from scripts running on the UI’s own origin: what limits it on the web is the token’s own lifetime.
  • An older build’s token is removed. Builds before per-tab sessions kept the token in local storage, beside the raw key it was encrypted with. Start-up on the web removes both, so it does not linger there until it expires.
  • Reset and verification links lose their token at start-up. On the web the UI keeps the token of a /password-reset or /verify-email link in memory and removes it from the address, so neither the history entry nor the Referer of a later request carries it. Reloading the page afterwards drops it, and the link has to be opened again. Once used (a password set, or any answer to the verification) it is forgotten, so going back to the screen does not send it again.
  • Theme mode is the only other persisted preference, in shared_preferences.
  • A 401 to any request made under the session clears it and returns the user to sign-in; a session cannot outlive the API’s opinion of its token. The exception is a 401 refusing a password or code typed into the request (a wrong second factor, or the password asked for to disable two-factor): that is the person’s to correct, and the session is left alone.
  • A secure store the platform cannot use — on Linux, a Secret Service keyring that is not running or is locked — means no session: start-up settles on sign-in, and signing in says the session could not be stored. On Android the app opts out of auto backup, so a restored device never holds a token it cannot decrypt.
  • Nothing is cached across launches. Every listing is fetched from the API, paginated by it.
  • A session that ends takes what it showed with it. Every screen’s state is reset when the session ends, and an answer to a request made before that is dropped when it arrives, so the next person to sign in on the device starts from nothing.

10. Troubleshooting

SymptomLikely cause
Every request fails on the web target while the API is reachable in a browserThe API does not permit the bundle’s origin by CORS — locally, the page is not on http://localhost:8081; deployed, the base URL is not the UI’s own origin (§2.2)
Sign-in succeeds, then every subsequent request answers 401The API base URL points at a different deployment from the one that issued the token
The Google control never appearsNo HEIMDALL_GOOGLE_CLIENT_ID was supplied at build time, or the scope has Google Sign-In switched off
A deep link 404s in production but works locallyThe static host is not falling back to index.html
flutter build linux fails on a fresh machineThe apt packages in §3 are not installed
flutter build linux fails with required packages were not found: libsecret-1libsecret-1-dev is missing — it is a plugin dependency, not part of Flutter’s generic desktop list
flutter build windows fails with Cannot open include file: 'atlstr.h'The C++ ATL component is missing from Visual Studio (see §3)
flutter build apk fails after flutter doctor flags the Android toolchaincmdline-tools is missing, or the SDK licences were never accepted (see §3)
flutter build apk fails with Failed to find target with hash string 'android-37'The API 37 platform is not installed — sdkmanager --install "platforms;android-37.0"
A plugin module fails with Could not close incremental caches in ...caches-jvmKotlin incremental compilation; kotlin.incremental=false in android/gradle.properties turns it off, and is already set
CI fails on api-client-driftThe specification changed without the client being regenerated, or the reverse

11. References