hardware/mac-studio/services/nginx-pwa-injection.md

Nginx PWA Injection Pattern

How self-hosted dev tools (OpenCode, Reasonix, ttyd, pi-webui, etc.) get turned into installable, home-screen PWAs with custom UX tweaks — without touching their source code. Everything happens at the nginx layer: static assets served from ~/Work/pwa/<app>/, injected into each proxied app's <head> via sub_filter.

This is a distinct concern from the auth layer (nginx-auth-service.md) and the nginx server itself (nginx.md), but all three live in the same sites-enabled/*.conf files — this doc is the reference for the PWA/injection half specifically.

Why this exists

None of these upstream apps (OpenCode, Reasonix, pi-webui, ttyd) ship a PWA manifest, custom icon, or mobile-friendly toolbar out of the box. Rather than forking/patching each one, nginx:

  1. Serves PWA metadata (manifest.json, icons, optionally a service worker) from a static directory, so "Add to Home Screen" works and installs a proper app icon/name instead of a bare Safari tab.
  2. Rewrites the proxied HTML on the fly — sub_filter '<head>' '<head><link ...><script ...>' — to inject <link rel="manifest">, <link rel="stylesheet">, and <script> tags into every page the backend serves, without the backend knowing anything happened.

File layout: ~/Work/pwa/<app>/

~/Work/pwa/
├── opencode/    manifest.json, icon-192.png, icon-512.png, icon-512-maskable.png,
│                script.js, style.css, sw.js   (full set incl. service worker)
├── pi/          manifest.json, icon.svg, script.js
├── reasonix/    manifest.json, icon.svg, icon.png, script.js, style.css
├── ttyd/        manifest.json, icon.svg, script.js, style.css, README.md
├── pi-web/      manifest.json, icon.svg                          (metadata only)
├── pi-win/      manifest.json, icon.svg                          (metadata only)
├── pi-wsl/      manifest.json, icon.svg                          (metadata only)
├── dash/        manifest.json, icon.png                          (metadata only)
└── dsh/         manifest.json, icon.svg                          (metadata only)

Every subdirectory maps 1:1 to a *.dph.am site (c-de.dph.am reuses opencode/, since it's the same backend app). Not every app has the full set — some just get a manifest/icon for installability with no behavioral changes (pi-web, pi-win, pi-wsl, dash, dsh); others (opencode, pi, reasonix, ttyd) also ship script.js/style.css for real UX fixes.

How nginx wires it up

Two patterns are in use across sites-enabled/:

Pattern A — explicit per-file locations

Used when the app's own routing might otherwise swallow the path (SPA catch-alls). Each asset gets its own exact-match location block, served directly off disk with alias:

# pi.dph.am.conf
location = /manifest.json { alias /Users/dominick/Work/pwa/pi/manifest.json; }
location = /icon.svg      { alias /Users/dominick/Work/pwa/pi/icon.svg; }
location = /script.js     { alias /Users/dominick/Work/pwa/pi/script.js; }

Used by 0.dph.am (as /manifest.json + /_static/*), pi.dph.am, rsx.dph.am, term.dph.am.

Pattern B — single /_static/ bucket

Simpler: one location /_static/ maps the whole app subdirectory via alias, and everything is referenced under that prefix:

# dash.dph.am.conf, dsh.dph.am.conf, pi-web.dph.am.conf, c-de.dph.am.conf
location /_static/ { alias /Users/dominick/Work/pwa/dash/; }

2026-09-20 update: these used to proxy_pass http://192.168.1.189/pwa/<app>/... — nginx proxying to itself over the LAN interface, landing on the default_server block (_localhost.conf, root /Users/dominick/Work) to resolve the file. That made sense back when nginx and the PWA files could plausibly have lived on different machines, but now that they're guaranteed to be co-located, it was a pointless network round-trip through a second, unrelated routing path — and that indirection is exactly the kind of thing that caused the earlier 403 bug (worker-user permissions on the fallback root). Switched every asset location to alias, reading straight off disk with no extra hop and no dependency on _localhost.conf's routing at all. Confirmed all apps still serve their manifest/icon/script/style at 200 after the change.

The injection itself

In the app's main location / block, after auth_request passes:

sub_filter '<head>' '<head><link rel="stylesheet" href="/_static/style.css"/><script src="/_static/script.js"></script>';
sub_filter_once off;
proxy_set_header Accept-Encoding "";   # sub_filter can't rewrite compressed responses

Accept-Encoding "" is mandatory whenever sub_filter is used — nginx can't pattern-match <head> inside gzipped bytes, so compression has to be disabled on that response.

Per-app reference

Domain PWA dir Assets What script.js/style.css actually does Perimeter auth
0.dph.am opencode/ manifest, 3 icons, script, style, sw.js Injects refresh + home buttons into OpenCode's titlebar; only app with a real service worker (offline-caches the PWA shell only, never the live session) ✅ auth_request + hardcoded upstream Basic Auth (see below)
pi.dph.am pi/ manifest, icon, script Mobile keyboard/viewport CSS fixes + a full floating toolbar (settings, scroll-to-bottom, refresh, session menu) for pi-webui ✅ auth_request
rsx.dph.am reasonix/ manifest, icon (svg+png), script, style PWA metadata for Reasonix's dashboard ✅ auth_request + query-token (see below)
term.dph.am ttyd/ manifest, icon, script, style Glassmorphism toolbar for tmux control (prev/next tab, new tab, kill pane, rename, mouse toggle) + a touch-scroll pill on mobile — see ttyd/README.md for the full mechanism (synthetic WheelEvents dispatched onto .xterm, since xterm.js ignores untrusted synthetic keyboard/wheel events otherwise) ✅ auth_request
pi-web.dph.am pi-web/ manifest, icon Metadata only — app name "Studio Pi Web" (short name "Studio Pi"), π glyph + Apple-logo badge. Backend is the Mac Studio itself (192.168.1.189:49997) ✅ auth_request
pi-win.dph.am pi-win/ manifest, icon Metadata only — app name "Windows Pi Web" (short name "Win Pi"), π glyph + Windows-blue quad-pane badge to distinguish it from pi-web on the home screen ✅ auth_request
pi-wsl.dph.am pi-wsl/ manifest, icon Metadata only — app name "WSL Pi Web" (short name "WSL Pi"), π glyph + Ubuntu circle-of-friends badge. Same Windows box as pi-win (192.168.1.153) but the WSL instance on port 30142 vs Windows-native 30141 ✅ auth_request
dash.dph.am dash/ manifest, icon Metadata only ✅ auth_request
dsh.dph.am dsh/ manifest, icon Metadata only ✅ auth_request
c-de.dph.am (reuses opencode/) style only Same OpenCode backend as 0.dph.am (both hit :4096), style-only injection App-level HTTP Basic Auth — see note below (not the nginx-auth-service cookie wall)

ccli.dph.am (had no perimeter auth at all) and the codex/ PWA assets (never wired into a live site) were both removed 2026-09-20 as unused/unneeded.

⚠️ Security notes worth remembering

  • c-de.dph.am has no auth_request block, and that's intentional, not a gap. It proxies to the exact same OpenCode instance as 0.dph.am (:4096), which itself requires HTTP Basic Auth. 0.dph.am auto-supplies that credential via a hardcoded header (see next point) so the nginx-auth-service cookie is the only thing you see; c-de.dph.am skips that header, so hitting it gets you OpenCode's own native Basic Auth browser prompt instead. Two different front doors onto the same app, both real, deliberately.
  • 0.dph.am injects a hardcoded Authorization: Basic <base64> header toward its upstream (proxy_set_header Authorization "Basic ..." in 0.dph.am.conf). This is the credential that c-de.dph.am deliberately omits (above) — a real credential sitting in plaintext in the conf file. Treat sites-enabled/*.conf as containing secrets, not just routing rules.
  • rsx.dph.am auto-injects a bearer token into the query string if one isn't already present:
    if ($args !~ token=) {
        set $args "token=<redacted>&$args";
    }
    
    A second, independent secret beyond the auth_request cookie — again, live in the conf file itself.

The service worker gotcha (OpenCode only)

opencode/sw.js registers with scope: "/" from opencode/script.js:

navigator.serviceWorker.register("/_static/sw.js", { scope: "/" })

Service workers can normally only claim a scope at or below the directory they're served from — a worker at /_static/sw.js would be clamped to /_static/ unless the server explicitly widens it. 0.dph.am.conf does this:

location /_static/ {
    alias /Users/dominick/Work/pwa/opencode/;
    add_header Service-Worker-Allowed "/";
}

Without that header, registration throws a SecurityError and PWA offline support silently doesn't work. The service worker itself only ever caches the four static shell files (manifest + 3 icons) as an offline fallback — it explicitly passes through the live app HTML, API calls, and WebSocket/SSE traffic untouched, so an OpenCode session is never served stale.

Adding a new app to this pattern

  1. mkdir ~/Work/pwa/<name>/ — add at minimum manifest.json + an icon. Add script.js/style.css only if you actually want behavior changes.
  2. Add a sites-enabled/<name>.dph.am.conf (copy an existing one as a template — pi.dph.am.conf for Pattern A, dash.dph.am.conf for Pattern B).
  3. Point asset location block(s) directly at the file(s) on disk with alias — location = /manifest.json { alias /Users/dominick/Work/pwa/<name>/manifest.json; } for single files, or location /_static/ { alias /Users/dominick/Work/pwa/<name>/; } for a whole-directory bucket. No proxying needed — nginx serves it straight off the filesystem.
  4. In the main location /, after the auth_request /auth; block (don't skip this unless you have a specific reason not to — see security notes above), add:
    proxy_set_header Accept-Encoding "";
    sub_filter '<head>' '<head><link rel="manifest" href="/manifest.json">...';
    sub_filter_once off;
    
  5. sudo nginx -t && sudo nginx -s reload.

Other improvement ideas (not yet done)

Raised during the 2026-09-20 review; none of these are urgent, but worth doing opportunistically:

  • Extract the repeated auth_request/login/logout block into a shared include snippet. It's identical (or near-identical) across 0.dph.am, dash.dph.am, dsh.dph.am, pi.dph.am, pi-web.dph.am, rsx.dph.am, term.dph.am — seven copies of the same ~20 lines. An include /opt/homebrew/etc/nginx/snippets/auth-gate.conf; would mean fixing the auth flow once instead of in nine files, and makes it obvious at a glance which sites don't include it (i.e. c-de.dph.am) rather than having to check each file for absence.
  • Pull the hardcoded secrets out of sites-enabled/*.conf entirely. The 0.dph.am Basic Auth header and the rsx.dph.am token are both real credentials sitting in plaintext inside nginx config files that aren't secrets-managed. Nginx supports reading these from a separate included file (e.g. secrets.conf, chmod 600, outside of any backed-up/synced directory) so the credential itself isn't duplicated across whatever the primary conf's exposure surface is (backups, screen-shares of the conf, etc.).
  • Add rate limiting on /login. These *.dph.am hosts are directly reachable from the public internet (wildcard DNS → router → this box). The auth service's login endpoint has no visible brute-force protection at the nginx layer (no limit_req). Worth a limit_req_zone/limit_req on location /login regardless of what the auth service itself does internally.
  • Standardize service-worker support if any other app besides OpenCode ever wants offline PWA behavior — right now the Service-Worker-Allowed header + sw.js caching pattern is bespoke per-app rather than a reusable snippet.