diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs
index 92bc8cb9..97a66f91 100644
--- a/core/archipelago/src/bootstrap.rs
+++ b/core/archipelago/src/bootstrap.rs
@@ -32,6 +32,12 @@ const DOCTOR_TIMER_PATH: &str = "/etc/systemd/system/archipelago-doctor.timer";
const NGINX_CONF_PATH: &str = "/etc/nginx/sites-available/archipelago";
const NGINX_ENABLED_CONF_PATH: &str = "/etc/nginx/sites-enabled/archipelago";
+/// Per-app proxy snippet included by the HTTPS (:443) server block. Carries its
+/// own `/app/fedimint/` location, so it needs the same B13 asset-rewrite heal as
+/// the main conf — browsers reach fedimint over HTTPS via this snippet. Absent on
+/// HTTP-only nodes, in which case the bootstrap loop skips it.
+const NGINX_HTTPS_SNIPPET_PATH: &str =
+ "/etc/nginx/snippets/archipelago-https-app-proxies.conf";
const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime";
/// Inserted into every server block of the nginx config that lacks the
@@ -56,6 +62,26 @@ const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles
/// block in image-recipe/configs/nginx-archipelago.conf.
const NGINX_PEER_CONTENT_BLOCK: &str = "\n # Peer content streaming proxy (B3) — Range-streams a peer's media file\n location /api/peer-content/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header Range $http_range;\n proxy_buffering off;\n proxy_connect_timeout 10s;\n proxy_read_timeout 120s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n";
+/// B13 — Fedimint UI asset rewrite. Pre-fix nodes proxy /app/fedimint/ with only
+/// the nostr-provider injection (`sub_filter_once on`), so the UI's root-rooted
+/// CSS/JS asset URLs (href="/…", url("/…")) miss the proxy and load the SPA shell
+/// → unstyled UI. We swap that single sub_filter for the full rewrite set that
+/// reroots every asset URL under /app/fedimint/. NEW matches the canonical block
+/// in image-recipe/configs/nginx-archipelago.conf byte-for-byte so self-healed
+/// nodes converge to the same config fresh ISOs ship with.
+const NGINX_FEDIMINT_OLD: &str = " sub_filter_once on;\n sub_filter '' '';\n }\n location /app/fedimint-gateway/ {";
+const NGINX_FEDIMINT_NEW: &str = " sub_filter_types text/css application/javascript application/json;\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/app/fedimint/';\n sub_filter 'src=\"/' 'src=\"/app/fedimint/';\n sub_filter \"href='/\" \"href='/app/fedimint/\";\n sub_filter \"src='/\" \"src='/app/fedimint/\";\n sub_filter 'url(\"/' 'url(\"/app/fedimint/';\n sub_filter \"url('/\" \"url('/app/fedimint/\";\n sub_filter '' '';\n }\n location /app/fedimint-gateway/ {";
+
+/// B13 Style B — the HTTPS app-proxy snippet's fedimint block has NO sub_filter
+/// at all (older than the main conf's), and the directive that follows it varies
+/// per node (fedimint-gateway vs tailscale), so a full-block match is unreliable.
+/// Instead we anchor on the unique :8175 proxy_pass (fedimint is the only block
+/// proxying there) and insert the reroot set right after it — directive order
+/// inside a location block is irrelevant to nginx. Idempotent via the same
+/// `href="/app/fedimint/` marker the main-conf heal leaves behind.
+const NGINX_FEDIMINT_SNIPPET_ANCHOR: &str = "proxy_pass http://127.0.0.1:8175/;";
+const NGINX_FEDIMINT_SNIPPET_INSERT: &str = "proxy_pass http://127.0.0.1:8175/;\n proxy_set_header Accept-Encoding \"\";\n sub_filter_types text/css application/javascript application/json;\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/app/fedimint/';\n sub_filter 'src=\"/' 'src=\"/app/fedimint/';\n sub_filter \"href='/\" \"href='/app/fedimint/\";\n sub_filter \"src='/\" \"src='/app/fedimint/\";\n sub_filter 'url(\"/' 'url(\"/app/fedimint/';\n sub_filter \"url('/\" \"url('/app/fedimint/\";\n sub_filter '' '';";
+
/// Entry point called from main startup. Never returns an error to the caller —
/// failing to bootstrap host artifacts must not prevent the backend from serving.
pub async fn ensure_doctor_installed() {
@@ -511,7 +537,11 @@ async fn run_nginx() -> Result {
let mut changed = false;
let mut patched_paths = Vec::::new();
- for path in [NGINX_CONF_PATH, NGINX_ENABLED_CONF_PATH] {
+ for path in [
+ NGINX_CONF_PATH,
+ NGINX_ENABLED_CONF_PATH,
+ NGINX_HTTPS_SNIPPET_PATH,
+ ] {
let candidate = Path::new(path);
if !candidate.exists() {
debug!("{} missing — skipping nginx bootstrap", path);
@@ -541,16 +571,29 @@ async fn patch_nginx_conf(path: &str) -> Result {
let content = fs::read_to_string(path)
.await
.with_context(|| format!("read {}", path))?;
- let missing_app_catalog = !content.contains("location /api/app-catalog");
- let missing_bitcoin_status = !content.contains("location /bitcoin-status");
- let missing_lnd_proxy = !content.contains("location /proxy/lnd/");
- let missing_peer_content = !content.contains("location /api/peer-content");
+ // Each "missing" flag is gated on the splice anchor actually being present,
+ // so an included snippet that legitimately has none of these endpoints (the
+ // HTTPS app-proxy snippet) neither tries to patch them nor logs warn-skips on
+ // every boot — it falls through to the fedimint heal alone.
+ let has_lnd_anchor = content.contains(" location /lnd-connect-info {")
+ || content.contains(" location /electrs-status {");
+ let missing_app_catalog = content
+ .contains(" # DWN endpoints — peer access over Tor (no auth)")
+ && !content.contains("location /api/app-catalog");
+ let missing_bitcoin_status = content.contains(" location /electrs-status {")
+ && !content.contains("location /bitcoin-status");
+ let missing_lnd_proxy = has_lnd_anchor && !content.contains("location /proxy/lnd/");
+ let missing_peer_content = has_lnd_anchor && !content.contains("location /api/peer-content");
let has_lnd_dup_cors = content.contains(NGINX_LND_DUP_CORS);
+ // B13: fedimint block present but lacking the asset-rewrite sub_filters.
+ let needs_fedimint_css = content.contains("location /app/fedimint/")
+ && !content.contains("'href=\"/' 'href=\"/app/fedimint/'");
if !missing_app_catalog
&& !missing_bitcoin_status
&& !missing_lnd_proxy
&& !missing_peer_content
&& !has_lnd_dup_cors
+ && !needs_fedimint_css
{
return Ok(false);
}
@@ -563,6 +606,22 @@ async fn patch_nginx_conf(path: &str) -> Result {
patched = patched.replace(NGINX_LND_DUP_CORS, "");
}
+ if needs_fedimint_css {
+ // Style A (main conf): the block already injects nostr-provider, so swap
+ // its single-sub_filter tail for the full asset-rewrite set. No-op if the
+ // node's fedimint block doesn't match OLD.
+ patched = patched.replace(NGINX_FEDIMINT_OLD, NGINX_FEDIMINT_NEW);
+ // Style B (HTTPS app-proxy snippet): the block has no sub_filter to swap,
+ // so insert the reroot set after the unique :8175 proxy_pass. Guarded on
+ // the marker so it can never double-apply after Style A already healed.
+ if !patched.contains("'href=\"/' 'href=\"/app/fedimint/'") {
+ patched = patched.replace(
+ NGINX_FEDIMINT_SNIPPET_ANCHOR,
+ NGINX_FEDIMINT_SNIPPET_INSERT,
+ );
+ }
+ }
+
if missing_lnd_proxy {
// Prefer the `/lnd-connect-info` anchor (present since 2026-03-17); fall
// back to `/electrs-status` (since 2026-03-08) for even older configs.
diff --git a/tests/production-quality/TRACKER.md b/tests/production-quality/TRACKER.md
index d274aed8..fc895d0b 100644
--- a/tests/production-quality/TRACKER.md
+++ b/tests/production-quality/TRACKER.md
@@ -8,21 +8,13 @@ cd ~/Projects/archy && git fetch gitea-vps2 && git checkout main && git reset --
```
Then continue from "IN PROGRESS" below.
-**Committed & ready for .97 (vps2 main):** B5 (LND CORS, verified .116/.198/.103), B1, B2, B4, B14, B21, B3 (incl. /api/peer-content nginx via bootstrap), B15, B7. B6 pruned-gate already live.
+**Committed & ready for .97 (vps2 main):** B5 (LND CORS, verified .116/.198/.103), B1, B2, B4, B14, B21, B3 (incl. /api/peer-content nginx via bootstrap), B15, B7, **B13 (fedimint CSS self-heal — main conf + HTTPS snippet, verified .198 both paths app-icon 404→200)**. B6 pruned-gate already live.
-**IN PROGRESS — B13 Fedimint CSS:** nginx template + https snippet committed (fixes FRESH ISOs). REMAINING (resume here): add the bootstrap self-heal for already-deployed nodes, then BUILD (cargo build --release -p archipelago — ALWAYS check EXIT 0 before commit), sideload to .198, verify `curl http://192.168.1.198/app/fedimint/` styled + an asset returns CSS not HTML. Exact code to add in core/archipelago/src/bootstrap.rs:
+**IN PROGRESS — pick up at B12.** B13 DONE (committed this session; bootstrap.rs self-heals both the main conf and the HTTPS app-proxy snippet — see B13 entry below for full verification). REMAINING:
+1. **B12** (mempool host detect — stacks.rs:1278 hardcodes CORE_RPC_HOST=bitcoin-knots; fails on bitcoin-core nodes → dynamic host detect; backend, medium risk, test .116).
+2. Then **B16** (bitcoin status retain — UI-test), **B6** no-node-present half, **B14b** (FIPS reachability depth), **B22/B23** (peer download + group chat — need live repro), B9/B10/B11/B17/B18/B19, B8 (low), B20 (mesh-headers feature).
-```rust
-// after NGINX_PEER_CONTENT_BLOCK const:
-const NGINX_FEDIMINT_OLD: &str = " sub_filter_once on;\n sub_filter '' '';\n }\n location /app/fedimint-gateway/ {";
-const NGINX_FEDIMINT_NEW: &str = " sub_filter_types text/css application/javascript application/json;\n sub_filter_once off;\n sub_filter 'href=\"/' 'href=\"/app/fedimint/';\n sub_filter 'src=\"/' 'src=\"/app/fedimint/';\n sub_filter \"href='/\" \"href='/app/fedimint/\";\n sub_filter \"src='/\" \"src='/app/fedimint/\";\n sub_filter 'url(\"/' 'url(\"/app/fedimint/';\n sub_filter \"url('/\" \"url('/app/fedimint/\";\n sub_filter '' '';\n }\n location /app/fedimint-gateway/ {";
-// in patch_nginx_conf(): add to guard + body:
-// let needs_fedimint_css = content.contains("location /app/fedimint/") && !content.contains("'href=\"/' 'href=\"/app/fedimint/'");
-// (add `&& !needs_fedimint_css` to the early-return guard)
-// if needs_fedimint_css { patched = patched.replace(NGINX_FEDIMINT_OLD, NGINX_FEDIMINT_NEW); }
-```
-
-**Then, in priority order:** B12 (mempool host detect — stacks.rs:1278), B16 (bitcoin status retain — UI-test), B6 no-node-present half, B14b (FIPS reachability depth), B22/B23 (peer download + group chat — need live repro), B9/B10/B11/B17/B18/B19, B8 (low), B20 (mesh-headers feature).
+Note: .198 is currently running a sideloaded .97-dev binary (md5 4c83803d, built from this B13 commit) — NOT an official release. Reflashing/OTA will replace it.
**Ship .97 when ready:** ./scripts/create-release.sh 1.7.97-alpha (curate CHANGELOG ≥3 layman bullets first + run scripts/sync-whats-new.py; SKIP_RELEASE_TESTS=1 only for the 2 known-flaky vitest timing tests) → scripts/publish-release-assets.sh 1.7.97-alpha gitea-vps2 → git push gitea-vps2 main + tag. (gitea-local push fails: token rejected — non-blocking.)
@@ -114,8 +106,18 @@ Apps meant to open in a new/external browser don't launch from the companion app
### B12 — Mempool not connecting — ROOT-CAUSED (stacks.rs:1278 hardcodes CORE_RPC_HOST=bitcoin-knots; fails on bitcoin-core nodes. Fix=dynamic host detect. Backend, medium risk, test .116)
mempool can't reach the Bitcoin backend on some nodes. Investigate on .116. Check mempool→electrs→bitcoind wiring + deps.
-### B13 — Fedimint UI not applying CSS — TODO
-Actual Fedimint UI (not pre-sync) renders unstyled. Likely asset path / proxy base-href (assets rooted at `/` vs `/app/fedimint/`). PROGRESS: nginx /app/fedimint/ proxies to :8175 with sub_filter for nostr-provider but NO base-href/asset rewrite. Fedimint UI head has no static CSS link in first 1.2KB (likely JS-module-loaded assets rooted at /). NEXT: dump full fedimint HTML + check if assets request /assets/* (which hit the SPA, not :8175). Fix = inject via sub_filter OR rebuild app with correct base. Needs deeper per-app look (fresh context ok).
+### B13 — Fedimint UI not applying CSS — FIXED + VERIFIED on .198 (both HTTP + HTTPS)
+Root cause confirmed: the Fedimint Guardian page (served by :8175) is a server-rendered status page with ~7.8KB INLINE CSS plus image assets referenced root-rooted (`src="/assets/img/app-icons/fedimint.jpg"`, `url("/assets/img/bg-network.jpg")`). Without an asset rewrite those `/assets/...` URLs resolve against the archipelago SPA root: `bg-network.jpg` happens to exist there (shared design asset → loaded by luck) but `app-icons/fedimint.jpg` does NOT → **404** (the broken/visibly-missing icon). The `location /assets/` block uses `try_files $uri =404`, so missing fedimint assets 404 rather than fall through.
+
+Fix = nginx sub_filter set that reroots every root-rooted asset URL (`href="/`, `src="/`, `url("/`, and single-quote variants) under `/app/fedimint/`, plus `proxy_set_header Accept-Encoding ""` so the upstream doesn't gzip (sub_filter can't rewrite gzipped bodies). Shipped two ways:
+- **Fresh ISOs** (committed a50b6df2): templates `image-recipe/configs/nginx-archipelago.conf` (HTTP) + `image-recipe/configs/snippets/archipelago-https-app-proxies.conf` (HTTPS).
+- **Already-deployed nodes** (bootstrap self-heal, this commit): `core/archipelago/src/bootstrap.rs::patch_nginx_conf` now heals BOTH the main conf (Style A — swaps the old single nostr-provider sub_filter tail for the full reroot set, byte-matches the shipped template) AND the HTTPS app-proxy snippet (Style B — anchors on the unique `:8175` proxy_pass and inserts the reroot set; robust to the snippet's varying trailing directive). `missing_*` flags now gated on their splice anchors so the healed snippet early-returns cleanly (no per-boot warn-skips). Idempotent via the `'href="/' 'href="/app/fedimint/'` marker.
+
+VERIFIED on .198 (sideloaded built binary, restart, async self-heal converged ~15s):
+- HTTP `/app/fedimint/`: live conf healed byte-identical to template; app-icon **404→200 image/jpeg (41944b)**.
+- HTTPS `/app/fedimint/` (snippet): healed; same app-icon **404→200**; bg-network 200; root `/assets/img/app-icons/fedimint.jpg` returns 200 **text/html** (SPA shell) — proving the reroot is necessary.
+- `nginx -t` OK both times; containers survived restart (Quadlet); both files carry the marker exactly once (idempotent steady state); no warn spam in logs.
+NOTE: self-healed snippet is functionally correct but NOT byte-identical to the fresh-ISO snippet template (insert-after-proxy_pass vs full block) — acceptable; nginx ignores directive order/whitespace.
### B15 — Bitcoin UI sync progress lags — FIXED (Home.vue poll 30s→10s). UI-confirm.
Bitcoin UI doesn't update its sync progress fast enough even though the console clearly already has the block-height data. Likely a polling-interval / reactive-update gap between the status source and the UI.