ADR: Localhost bridge between the public web UI and a local refgenie dash
Date: 2026-08-12 Status: Accepted
Context
Section titled “Context”The public catalog UI (https://ui.refgenie.org) and a user’s local
refgenie dash were two disconnected worlds: the public page could not show
which assets already sit on the user’s disk, nor hand a pull to the local
instance. Closing that gap means a public HTTPS page talking to a loopback
HTTP server — exactly the shape of a localhost-probing attack, and a pattern
browsers have spent a decade restricting (mixed-content exemptions for
http://localhost, Chrome’s Local Network Access permission prompt, WebKit
blocking it outright).
Three decisions from the bridge design have cross-repo, long-lived consequences and are recorded here. The full design (probe behavior, outcome classification, threat model) lives in the bridge implementation plan.
Decision 1 (D1): /ping is its own endpoint, not an extension of /service-info
Section titled “Decision 1 (D1): /ping is its own endpoint, not an extension of /service-info”The local server exposes a dedicated, unversioned, unprefixed /ping
endpoint (present in both server and local modes, Cache-Control: no-store)
carrying service, an integer bridge_version, instance identity, the
bridge mode, and the same capability key set /service-info emits.
Rationale. /service-info is a GA4GH-shaped public discovery document
with a stable published meaning (seqcol.refget_store.url is a client
bootstrap field). Stuffing browser-handshake fields into it would couple an
internal contract to a standards-adjacent document and force every public
consumer to parse fields that only matter to a web page. The capability
vocabulary, however, is shared — not forked — so the UI gates features
identically whichever document it read.
Decision 2 (D4): cross-origin scope is browse + presence + pull; everything else deep-links to the local UI
Section titled “Decision 2 (D4): cross-origin scope is browse + presence + pull; everything else deep-links to the local UI”The only action reachable cross-origin is POST /v1/actions/pull (plus
job-status reads). Delete, alias, subscribe, build, and recipe/asset-class
changes are same-origin only, enforced server-side by an explicit
allowed-path set (and DELETE is absent from the CORS method list
entirely). For everything else the remote page deep-links into the local SPA
(/genomes/{digest}, /pull?... prefilled and never auto-executed).
Rationale.
- Pull is the only verb whose natural trigger lives on the remote page (you are looking at a remote asset because you do not have it); every other verb operates on data the local SPA already browses better (same-origin, no CORS, no permission prompts).
- Blast radius: the worst case of an over-trusted bridge is disclosure plus an unwanted download — not data loss.
- Auditability:
fullmode’s entire cross-origin contract is one POST. - Chrome’s local-network permission is per-origin and persistent, so an XSS on the public UI would inherit bridge access forever; that is survivable when the bridge can browse and pull, not when it can delete.
Decision 3 (D5): reads default-on for allowlisted origins; writes default-off
Section titled “Decision 3 (D5): reads default-on for allowlisted origins; writes default-off”REFGENIE_BRIDGE_MODE has three values — off / read / full — with
default read. Cross-origin pull requires an explicit opt-in
(refgenie dash --bridge full); the server’s 403 names that exact remedy so
the UI can render it verbatim.
Rationale. read gives the feature its no-friction first-run experience
while confining exposure to origins the user already trusts enough to visit
(the origin allowlist defaults to https://ui.refgenie.org only —
https://refgenie.org is the docs site and is deliberately not allowlisted).
full being opt-in means no web page can talk a user into a
state-changing configuration.
Consequences
Section titled “Consequences”- The
/pingcontract is versioned by a single integer bumped only on breaking changes; feature gating is exclusively via capability flags, so UI and refgenie versions can skew indefinitely in either direction. - One security module (
refgenie/server/local/security.py) owns CORS, the host guard, the action header, and the bridge policy; the bridge added settings and one middleware (the Local Network Access preflight header) to it rather than a parallel path. - The local dash remains unauthenticated by design; the documented trust
boundary is the machine, not the user account (do not run
refgenie dashon shared multi-user machines). - Safari cannot use the bridge at all (WebKit bug 171934); it gets a
documented fallback to
http://localhost:8080, not a workaround.
Related documents
Section titled “Related documents”- Bridge how-to:
docs/refgenie/bridge.md - Implementation plan: jot
refgenie1_localhost_bridge_plan_v1.md - Cross-plan contracts note: jot
refgenie1/web_ui_contracts.md