Overview
OpenChamber has a private relay: a client (mobile app, browser, another desktop) reaches a user's instance through an OpenChamber-hosted relay over an end-to-end encrypted tunnel. All of the app's traffic — many HTTP requests, the event stream (SSE), and WebSockets (terminal, dictation) — is multiplexed and encrypted through one connection per client.
Architecture overview: packages/web/server/lib/relay/DOCUMENTATION.md. Code: packages/ui/src/lib/relay/ (client + shared, TS) and packages/web/server/lib/relay/ (host, JS).
Load ui-api-decoupling when the change adds or alters a shared runtime API, URL/auth contract, bridge, proxy, or runtime-switch behavior. This skill owns relay mechanics; ui-api-decoupling owns the shared UI/runtime boundary.
Why this skill exists: relay bugs do not show up in normal testing. The event stream is SSE (which behaves differently from WebSockets), so a new WebSocket feature is often the first real WebSocket to cross the tunnel on mobile — and it fails there while working everywhere else. We have fixed the same class of bug across several iterations. The rules below are those lessons.
The core mental model
- The tunnel is transparent. A feature should reach the server through the shared runtime transport (
runtimeFetch, openRuntimeWebSocket) and never know whether it is direct or relayed. If a feature constructs its own fetch/WebSocket against a runtime URL, it bypasses the tunnel and breaks in relay mode.
- Three transports behave differently over the tunnel:
- HTTP and SSE authenticate with the client's bearer token (a header). They "just work" through the tunnel for any allowlisted
/api/*, /auth/*, /health path.
- WebSockets cannot send headers. They authenticate with a short-lived URL-scoped token (
oc_url_token) that must be minted first and passed as a query parameter. This is the source of most relay WS bugs.
WebSocket Endpoint Branch
Adding a new WS endpoint (or porting one, e.g. the planned terminal port) requires ALL of these, or it breaks over the relay:
- Open it via
openRuntimeWebSocket (packages/ui/src/lib/relay/runtime-socket.ts), never new WebSocket(...) directly. A raw new WebSocket against a runtime URL fails in relay mode (the resolver yields a tunnel-virtual/custom-scheme URL the platform rejects — surfaced as "The string did not match the expected pattern").
- Add the path to BOTH allowlists (they are separate and both required):
- Host tunnel dispatcher:
ALLOWED_WS_PATHS in packages/web/server/lib/relay/tunnel-host.js.
- URL-token auth gate:
isUrlAuthWebSocketPath in packages/web/server/lib/ui-auth/ui-auth.js (otherwise the oc_url_token is refused for that path → 401).
- Mint the URL token before connecting. Call
refreshRuntimeUrlAuthToken() and build the URL through the resolver's websocket(...) so oc_url_token is appended. SSE/HTTP do not need this; WS does.
- Do not touch origin handling. The server rejects WS upgrades whose
Origin it does not trust. Over the tunnel the host dials loopback and presents the loopback origin (http://127.0.0.1:<port>), which the server trusts as same-origin — this already covers every allowlisted WS path. Never reintroduce reliance on window.location.origin: in the iOS WKWebView it is "null"/empty for the custom scheme, so forwarding it produces a 403.
- Test over the relay, not just direct/desktop. A new WS may be the first WebSocket the mobile client runs through the tunnel (events are SSE-locked on Capacitor). Passing on desktop or a direct connection proves nothing about the relay path.
Wire Format And Codec Branch
- Two implementations must stay byte-compatible. The E2EE and framing exist as TS (
packages/ui/src/lib/relay/{crypto,handshake,tunnel-codec}.ts, normative) and a JS host mirror (packages/web/server/lib/relay/{e2ee,tunnel-codec}.js). Any wire-format, frame-type, handshake, or batching change must update both and keep packages/web/server/lib/relay/cross-compat.test.js green.
- Frame types live in
protocol.ts and must match across protocol.ts, tunnel-codec.ts, and tunnel-codec.js. Adding a frame type without mirroring it corrupts the stream on one side.
- Frame batching is capability-negotiated in the handshake with a legacy fallback, so mixed client/host app versions still interoperate. Preserve the negotiation and the single-frame fallback; do not make batching unconditional.
- The encrypted-frame counter/IV is per-direction and strictly increasing. One encrypted WS message = one encrypt call = one counter tick. Keep encrypt+send serialized per direction; do not reorder or parallelize it.
Runtime Transport Branch
- Relay mode routes through
runtime-switch (activates the tunnel singleton), runtime-fetch (routes runtime requests through it), runtime-url/runtime-socket (tunnel-backed URLs/sockets), and runtime-auth (mints the URL token through the tunnel). When refactoring any of these, preserve the relay branch and the direct-URL/Electron-realtime-proxy branches — they must remain byte-identical in behavior for non-relay runtimes.
- The host dispatcher never injects credentials. Tunneled requests carry the client's own token; the server authenticates them. Do not add host-side auth shortcuts, and do not trust loopback source address as authentication (relay traffic arrives at loopback but represents remote clients).
Reconnect Branch
For indefinite SSE/WebSocket reconnect loops:
- Use exponential backoff based on consecutive failures, not a constant short delay.
- Use the long backoff cap while
navigator.onLine is false or document.visibilityState is hidden.
- Treat permanent 4xx responses as long-backoff failures; keep 408 and 429 retryable.
- Make waits interruptible by
online, visibility becoming visible, and the pipeline abort signal.
- Reset failure state only after a genuinely healthy connection.
Blind short retries on hidden, offline, unauthorized, or stale-path clients waste battery and flood server logs.
Verification
- Exercise the real auth and origin gates. An end-to-end test whose stub server accepts any WS upgrade will pass while the real server rejects it — this is precisely how the origin-check bug shipped. When writing a relay integration test, mirror the real gates (
ensureSessionToken via oc_url_token, isRequestOriginAllowed) or run against the real server pieces.
- Run relay tests per file (
bun test <file>); the suite has order sensitivity.
- Validate both sides:
packages/ui type-check/lint, and node --check on changed JS host files.
Completion requires every applicable branch above: WS path allowlists/auth/origin and real relay exercise; mirrored TS/JS wire changes with cross-compat coverage; preserved direct and relay runtime branches; or reconnect pacing under offline, hidden, permanent-failure, recovery, and abort conditions.
1---2name: relay-transport3description: Use when adding or changing OpenChamber WebSocket, SSE, streaming, realtime endpoints, shared UI sockets, runtime transport internals, private relay behavior, or files under the UI/server relay modules.4license: MIT5---6
7## Overview
8
9OpenChamber has a private relay: a client (mobile app, browser, another desktop) reaches a user's instance through an OpenChamber-hosted relay over an **end-to-end encrypted tunnel**. All of the app's traffic — many HTTP requests, the event stream (SSE), and WebSockets (terminal, dictation) — is multiplexed and encrypted through **one** connection per client.
10
11Architecture overview: `packages/web/server/lib/relay/DOCUMENTATION.md`. Code: `packages/ui/src/lib/relay/` (client + shared, TS) and `packages/web/server/lib/relay/` (host, JS).
12
13Load `ui-api-decoupling` when the change adds or alters a shared runtime API, URL/auth contract, bridge, proxy, or runtime-switch behavior. This skill owns relay mechanics; `ui-api-decoupling` owns the shared UI/runtime boundary.
14
15**Why this skill exists:** relay bugs do not show up in normal testing. The event stream is SSE (which behaves differently from WebSockets), so a new WebSocket feature is often the *first* real WebSocket to cross the tunnel on mobile — and it fails there while working everywhere else. We have fixed the same class of bug across several iterations. The rules below are those lessons.
16
17## The core mental model
18
19- **The tunnel is transparent.** A feature should reach the server through the shared runtime transport (`runtimeFetch`, `openRuntimeWebSocket`) and never know whether it is direct or relayed. If a feature constructs its own `fetch`/`WebSocket` against a runtime URL, it bypasses the tunnel and breaks in relay mode.
20- **Three transports behave differently over the tunnel:**
21 - HTTP and SSE authenticate with the client's **bearer token** (a header). They "just work" through the tunnel for any allowlisted `/api/*`, `/auth/*`, `/health` path.
22 - **WebSockets cannot send headers.** They authenticate with a short-lived **URL-scoped token** (`oc_url_token`) that must be minted first and passed as a query parameter. This is the source of most relay WS bugs.
23
24## WebSocket Endpoint Branch
25
26Adding a new WS endpoint (or porting one, e.g. the planned terminal port) requires ALL of these, or it breaks over the relay:
27
281. **Open it via `openRuntimeWebSocket`** (`packages/ui/src/lib/relay/runtime-socket.ts`), never `new WebSocket(...)` directly. A raw `new WebSocket` against a runtime URL fails in relay mode (the resolver yields a tunnel-virtual/custom-scheme URL the platform rejects — surfaced as "The string did not match the expected pattern").
292. **Add the path to BOTH allowlists** (they are separate and both required):
30 - Host tunnel dispatcher: `ALLOWED_WS_PATHS` in `packages/web/server/lib/relay/tunnel-host.js`.
31 - URL-token auth gate: `isUrlAuthWebSocketPath` in `packages/web/server/lib/ui-auth/ui-auth.js` (otherwise the `oc_url_token` is refused for that path → 401).
323. **Mint the URL token before connecting.** Call `refreshRuntimeUrlAuthToken()` and build the URL through the resolver's `websocket(...)` so `oc_url_token` is appended. SSE/HTTP do not need this; WS does.
334. **Do not touch origin handling.** The server rejects WS upgrades whose `Origin` it does not trust. Over the tunnel the host dials loopback and presents the loopback origin (`http://127.0.0.1:<port>`), which the server trusts as same-origin — this already covers every allowlisted WS path. **Never reintroduce reliance on `window.location.origin`**: in the iOS WKWebView it is `"null"`/empty for the custom scheme, so forwarding it produces a 403.
345. **Test over the relay, not just direct/desktop.** A new WS may be the first WebSocket the mobile client runs through the tunnel (events are SSE-locked on Capacitor). Passing on desktop or a direct connection proves nothing about the relay path.
35
36## Wire Format And Codec Branch
37
38- **Two implementations must stay byte-compatible.** The E2EE and framing exist as TS (`packages/ui/src/lib/relay/{crypto,handshake,tunnel-codec}.ts`, normative) and a JS host mirror (`packages/web/server/lib/relay/{e2ee,tunnel-codec}.js`). Any wire-format, frame-type, handshake, or batching change must update **both** and keep `packages/web/server/lib/relay/cross-compat.test.js` green.
39- **Frame types live in `protocol.ts`** and must match across `protocol.ts`, `tunnel-codec.ts`, and `tunnel-codec.js`. Adding a frame type without mirroring it corrupts the stream on one side.
40- **Frame batching is capability-negotiated** in the handshake with a legacy fallback, so mixed client/host app versions still interoperate. Preserve the negotiation and the single-frame fallback; do not make batching unconditional.
41- **The encrypted-frame counter/IV is per-direction and strictly increasing.** One encrypted WS message = one encrypt call = one counter tick. Keep encrypt+send serialized per direction; do not reorder or parallelize it.
42
43## Runtime Transport Branch
44
45- Relay mode routes through `runtime-switch` (activates the tunnel singleton), `runtime-fetch` (routes runtime requests through it), `runtime-url`/`runtime-socket` (tunnel-backed URLs/sockets), and `runtime-auth` (mints the URL token through the tunnel). When refactoring any of these, preserve the relay branch and the direct-URL/Electron-realtime-proxy branches — they must remain byte-identical in behavior for non-relay runtimes.
46- **The host dispatcher never injects credentials.** Tunneled requests carry the client's own token; the server authenticates them. Do not add host-side auth shortcuts, and do not trust loopback source address as authentication (relay traffic arrives at loopback but represents remote clients).
47
48## Reconnect Branch
49
50For indefinite SSE/WebSocket reconnect loops:
51
52- Use exponential backoff based on consecutive failures, not a constant short delay.
53- Use the long backoff cap while `navigator.onLine` is false or `document.visibilityState` is hidden.
54- Treat permanent 4xx responses as long-backoff failures; keep 408 and 429 retryable.
55- Make waits interruptible by `online`, visibility becoming visible, and the pipeline abort signal.
56- Reset failure state only after a genuinely healthy connection.
57
58Blind short retries on hidden, offline, unauthorized, or stale-path clients waste battery and flood server logs.
59
60## Verification
61
62- Exercise the real auth and origin gates. An end-to-end test whose stub server accepts any WS upgrade will pass while the real server rejects it — this is precisely how the origin-check bug shipped. When writing a relay integration test, mirror the real gates (`ensureSessionToken` via `oc_url_token`, `isRequestOriginAllowed`) or run against the real server pieces.
63- Run relay tests per file (`bun test <file>`); the suite has order sensitivity.
64- Validate both sides: `packages/ui` `type-check`/`lint`, and `node --check` on changed JS host files.
65
66Completion requires every applicable branch above: WS path allowlists/auth/origin and real relay exercise; mirrored TS/JS wire changes with cross-compat coverage; preserved direct and relay runtime branches; or reconnect pacing under offline, hidden, permanent-failure, recovery, and abort conditions.