Skip to content

Stand Up the Web Client

What this is: deploying the browser client — the alternative to the Android app for getting onto the WayPoint network. See System overview. When you'd do it: bringing the rack up, or bringing the web tier back after it was switched off. How long it takes: minutes — it is part of a normal converge. The only slow part is minting the service identity token by hand.

The web client is an AdonisJS application that serves a real-time tactical map and handles user login. It needs two things to do its job: a Directory (to authenticate users and issue identity tokens) and a server (a Zenoh relay to carry traffic — the browser reaches it through a remote-api bridge, see Step 3 → Server / relay).

On the PETRA rack it runs as a container on core-01, alongside the Directory, its Postgres, and the Caddy that fronts both. You do not provision it separately: it is a role in playbooks/core.yml, and a converge stands it up.

Who can do this: a deployment engineer with rack access — an SSH key in ssh_authorized_keys and the vault password on their laptop (see the infrastructure repo's Onboarding section). Minting the service token additionally needs a Directory Admin login. Users still need onboarding to the Directory before they can log in.

The shape of it

flowchart LR
    A["ansible-playbook<br/>playbooks/core.yml"] --> B["web + zenoh-bridge<br/>containers on core-01"]
    B --> C["Caddy fronts<br/>app.bedrockdefence.com"]
    C --> D["Directory handles<br/>passkey login;<br/>issues session"]
    D --> E["User sees the<br/>live tactical map"]

Before you start

  • A running Directory on core-01 (https://auth.bedrockdefence.com). It comes up earlier in the same play — playbooks/core.yml orders Postgres → Directory → web.
  • At least one running server (relay) on core-02 — users see no map data without one.
  • Your controller is set up: bin/bootstrap-controller.sh has put the vault password on your laptop and your SSH key is on the boxes.
  • The web image is in the rack registry. playbooks/foundation.yml mirrors it there via bin/mirror-to-rack.sh; the rack pulls from itself and never from Artifact Registry.

Step 1 — Converge

ansible-playbook playbooks/core.yml

That writes web's on-disk state and its Compose fragment, and roles/platform/compose brings the whole project up. There is nothing web-specific to run.

Three secrets are generated on the first converge and then left alone, because roles/service/web writes its secrets file with force: false:

Secret Where it comes from
APP_KEY generated in playbooks/core.yml
WAYPOINT_API_JWT_SECRET generated in playbooks/core.yml
DIRECTORY_WEBAUTH_CLIENT_SECRET read out of the Directory's own persisted secrets file, so both sides match

Because of force: false, changing those inputs later does not change a running deployment — delete /etc/bedrock/web/secrets.env on core-01 to pick up new values.

Step 2 — Give web its machine identity

The web tier polls the Directory's /api/revoked-principals endpoint and signs the envelopes it publishes. Both need a Directory-signed IdentityToken. There is no node ace command for it — mint it from the Directory admin UI:

  1. Sign in to the Directory as an Admin, click Devices → Create Device.
  2. Give it a callsign (e.g. web-rack) and set Platform Type to web — the web tier holds a Directory-signed IdentityToken but is a client, not a router, so it needs no hostname and no coverage cells.
  3. On the Device Created page, download both credentials: the Identity Token and the Signing Key. They are shown once. If you navigate away without saving them, use Reissue Credentials to mint a fresh pair.

Then put both into the vault — this is the delivery mechanism, there is no upload script:

base64 -i web-rack-service-token.bin | tr -d '\n'    # → web_service_token_b64
ansible-vault edit inventories/rack/group_vars/all/vault.yml
Vault key Value
web_service_token_b64 the identity token .bin, base64-encoded
web_service_signing_key_hex the signing key as 64 hex characters, pasted verbatim (no base64)

Re-run playbooks/core.yml. Ansible decodes the token on the controller and copies the bytes across, so an unchanged token is a genuine no-op rather than a permanent "changed".

Both values are fail-soft, and they fail differently:

  • Without the identity token, the revocation cache never primes and logins fail closed. You will notice immediately.
  • Without the signing key, the web tier cannot sign what it publishes, so server-side control-measure and plan-shape distribution stays switched off. The app starts and looks healthy. There is no error to notice, so don't skip it.

Supply them together. The signing key's public half is embedded in the identity token presented for verification, so a token paired with a stale key from a previous mint is rejected — reissuing means replacing both.

Step 3 — What is already wired for you

Most of what used to be manual configuration is derived on the rack. This section is mainly so you know where to look when something is wrong.

Directory (for login)

Web is pointed at https://auth.bedrockdefence.com and shares DIRECTORY_WEBAUTH_CLIENT_SECRET with the co-located Directory, so there is no client registration step. Web reaches it over HTTPS behind a rack-CA leaf that no public trust store knows, which is why the container gets NODE_EXTRA_CA_CERTS pointing at the rack CA. If that CA is missing, web starts and then fails every call to the Directory — logins included.

The callback URI is webauth_redirect_uris in group_vars/all/vars.yml.

Server / relay (for map traffic)

Browsers join the Zenoh mesh through a remote-api bridge, not directly. The browser library (@eclipse-zenoh/zenoh-ts) speaks only the zenoh-plugin-remote-api WebSocket protocol — not the router's native tls/quic transport. That is why Android connects on quic/…:7447 but a browser pointed at the same port just gets a 1006 and reconnect-loops.

On the rack the bridge is a sidecar container on core-01, and Caddy publishes its WebSocket as wss on app.bedrockdefence.com:7448:

Piece Value Set where
Public live bridge dials the mesh ordered native locators for every configured router web_bridge_connect_endpoints
Browser endpoint app.bedrockdefence.com — the app expands a bare domain to wss://<domain>:7448 web_router_endpoints
Web retained-query endpoints one Docker-network-only remote-api bridge pinned to each configured router web_query_bridge_connect_endpoint_groups
Router trust rack CA, server-authenticated TLS web_bridge_ca_b64

The public bridge carries browser live traffic. The Web backend separately sends each authenticated retained query through every private query bridge because retained stores are router-local and are not replicated by federation. Each returned page still needs its valid signed Server receipt before Web accepts it.

Every bridge has the same transport posture as Android and every other node. It presents no special certificate identity and receives no fleet-wide ACL grant; Web declares only the exact selectors in its current Directory-issued authority. mTLS remains available only as a deployment-wide option when every node supports it.

The bridge version must match the router's zenoh version and the web client's @eclipse-zenoh/zenoh-ts — bump all three together.

Machine ingest and external imagery

Config key Required? What it does
WAYPOINT_API_JWT_SECRET Yes HS256 secret signing the machine ingest JWTs for the force-tracking /api/feed/* API. Must be at least 32 characters in production or the app fails at boot. Rotating it invalidates all outstanding ingest tokens at once, with no overlap window. To mint a credential see Issue an API credential; the contract is in Machine Ingest API.
WAYPOINT_IMAGERY_IMG_SRC For remote imagery Explicit origin allowlist feeding the CSP img-src directive for external GEOINT/CAT-1 imagery loaded by URL. Every trusted imagery origin must be listed: production has no blanket https: source. On the rack, web_imagery_img_src sets it to https://imagery.bedrockdefence.com. An omitted origin is blocked, reported to /api/csp-report, and shown as an imagery-unavailable placeholder; an invalid configured URL fails the app at boot.

WAYPOINT_API_JWT_SECRET is the signing root for a separate, machine-to-machine trust boundary — it is not Directory identity and is not classification-gated.

Step 4 — Users sign in

Users browse to https://app.bedrockdefence.com. The web client redirects to the Directory's browser auth flow, the user authenticates with their passkey, and the Directory issues a session back.

Browsers will warn about the certificate until the rack CA is trusted on the client machine — see environments/rack/README.md in the infrastructure repo. This is expected: the rack mints its own certificates and has no public CA.

Users must already be onboarded in the Directory. If not, see Onboard an operator.

How to know it worked

  • https://app.bedrockdefence.com loads (no certificate warning once the rack CA is trusted).
  • A user can click Log in, authenticate with their passkey, and land on the map.
  • The map shows live unit positions for the area the connected server covers.
  • On core-01, docker compose -f /etc/bedrock/compose/docker-compose.yml ps shows both web and zenoh-bridge up.

If something goes wrong

  • Every call to the Directory fails; logins included. The rack CA never reached the container, so NODE_EXTRA_CA_CERTS points at a file that does not exist — Node only warns about that, so the app starts and then fails every HTTPS call. The converge prints a message when this happens; re-run playbooks/core.yml.

  • Logins fail closed. The service identity token is missing, so the revocation cache never primes. Check web_service_token_b64 in the vault and re-run.

  • Everything looks healthy but control measures never distribute. The signing key is missing. This is the fail-soft one with no error — check web_service_signing_key_hex in the vault.

  • Can't log in — "user not found" or access denied. The user isn't onboarded to the Directory yet. See Onboard an operator.

  • Site loads but the map is empty. Check, in order: the browser console — a 1006 / "disconnected from remote-api-plugin" loop means the browser is pointed at the router's :7447 rather than the bridge's wss://…:7448; then that the zenoh-bridge container is up and its endpoints reach a live router; then that a server is live and covers the relevant geohash cells. See Add a server.

  • Certificate warning in the browser. The rack CA isn't trusted on that machine. This is not a misconfiguration — see environments/rack/README.md.

  • app.bedrockdefence.com doesn't resolve. This is the owned, browser-facing name. Rack services resolve it through the managed /etc/hosts block; an attached operator laptop needs the reviewed LAN name-to-address path, while an off-LAN managed device needs active WARP and the Cloudflare private-hostname route. Do not substitute a .lan service name — that suffix is retained only for rack-internal management names.

  • Reaching it from off the LAN. 443 and 7448 are opened only to rack_lan_cidr. Off-site access is through the Cloudflare tunnel, not by widening the firewall.

Local development

The web repo runs standalone for development — .env.example is the starting point and npm run dev runs migrations and serves with HMR. It needs a reachable PostgreSQL, which you supply yourself. See that repo's README.md; it is the source of truth for the environment variables the app validates at startup (start/env.ts).

See also


Verified against web@f5683354 / infrastructure@5ac55aeb on 2026-09-02 — Directory/Web browser origins, callback and bridge publication were checked against inventories/rack/group_vars/all/vars.yml, roles/service/web/, and playbooks/core.yml. CSP behavior was checked against Web's config/shield.ts, start/env.ts, and CSP tests; the rack supplies the exact https://imagery.bedrockdefence.com origin through web_imagery_img_src.