Files
pulse/docs/release-control/v6/internal/subsystems/relay-runtime.md
T
rcourtman b69c8c8007 Wire PULSE_RELAY_ENABLED and PULSE_RELAY_SERVER as real env overrides
These two env vars were documented as relay overrides in v6 docs since
March 18 (CONFIGURATION.md, RELAY.md, and the frontend-served doc copy)
but no code ever read them. Operators trying to bootstrap relay headlessly
saw no effect.

Implement them rather than remove the documentation. Headless and
container deployments now have a real path to enable relay and point it
at a private endpoint without going through Settings → Relay.

internal/relay/config_env.go:
  - ApplyEnvOverrides(*Config) mutates relay.Config in place.
  - PULSE_RELAY_ENABLED accepts true/false/yes/no/1/0/on/off (case-
    insensitive). Unrecognized values log a warning and leave the file
    value untouched — important so "unset" reads differently from
    "explicit false."
  - PULSE_RELAY_SERVER goes through the existing validateRelayServerURL
    check; invalid URLs log a warning and fall through.

internal/config/persistence_relay.go:
  LoadRelayConfig calls ApplyEnvOverrides after the file load and after
  the default-fallback when relay.enc is absent, so the env override
  applies on every load.

Tests cover unset / true / false / garbage-bool / valid-URL / invalid-URL
/ both-together / nil-config paths in the relay package, plus two
end-to-end tests in internal/config that prove the override flows through
LoadRelayConfig against a real persisted file and against the
missing-file default branch.

Restore the env-var docs with the correct default URL (the full
wss://relay.pulserelay.pro/ws/instance, not the bare hostname the
original aspirational table claimed) and add an explicit precedence note:
saving from the UI after an env override persists the env-effective state
to disk, so clearing the env alone does not revert.

Add internal/relay/config_env_test.go to the relay-runtime registry's
desktop-relay-runtime exact_files so the new code surface is proof-tracked.
Update the matching pin in subsystem_lookup_test.py. Extend the
relay-runtime contract Extension Point 3 to document the override
semantics LoadRelayConfig must satisfy.
2026-05-12 11:18:31 +01:00

7.7 KiB

Relay Runtime Contract

Contract Metadata

{
  "subsystem_id": "relay-runtime",
  "lane": "L7",
  "contract_file": "docs/release-control/v6/internal/subsystems/relay-runtime.md",
  "status_file": "docs/release-control/v6/internal/status.json",
  "registry_file": "docs/release-control/v6/internal/subsystems/registry.json",
  "dependency_subsystem_ids": []
}

Purpose

Own the desktop and mobile relay runtimes, their persisted relay state boundaries, the server-owned mobile relay capability boundary, and the canonical reconnect, encryption, protocol, proxy, and relay-trust behavior for Pulse instance bridging.

Canonical Files

  1. internal/relay/client.go
  2. internal/relay/protocol.go
  3. internal/config/persistence_relay.go
  4. internal/api/relay_mobile_capability.go
  5. pulse-mobile:src/relay/client.ts
  6. pulse-mobile:src/relay/protocol.ts
  7. pulse-mobile:src/relay/proxy.ts
  8. pulse-mobile:src/relay/encryption.ts

Shared Boundaries

  1. internal/api/relay_mobile_capability.go shared with api-contracts: the backend-owned Pulse Mobile relay capability inventory is both a relay runtime boundary and a canonical API payload contract surface.

Extension Points

  1. Add or change desktop relay reconnect, registration, drain, proxy-stream, or encrypted channel behavior through internal/relay/
  2. Add or change relay control payload schemas, including mobile-visible push notification metadata, through internal/relay/protocol.go
  3. Add or change persisted relay enablement, server URL, or reconnect-safe default loading through internal/config/persistence_relay.go LoadRelayConfig must apply environment-variable overrides (PULSE_RELAY_ENABLED, PULSE_RELAY_SERVER) on top of the file-loaded or default-fallback relay.Config via relay.ApplyEnvOverrides. The env overrides must distinguish unset / empty / unparseable values (file or default wins) from explicit true/false/valid-URL values (env wins), and must reject invalid PULSE_RELAY_SERVER URLs through the canonical validateRelayServerURL check rather than silently accepting a malformed override. Saving the resulting relay.Config from the UI is allowed to persist the env-effective state to disk; the override is not stripped before save.
  4. Add or change the backend-owned mobile relay capability inventory and compatibility scope mapping through internal/api/relay_mobile_capability.go
  5. Add or change mobile relay reconnect, drain, channel, encryption, proxy, or identity behavior through pulse-mobile:src/relay/
  6. Keep desktop and mobile relay changes aligned with the governed server relay surfaces represented by the L7 lane evidence

Forbidden Paths

  1. Reintroducing relay client trust or TLS handling through ad hoc dialer configuration outside the canonical relay client
  2. Letting encrypted DATA frames race ahead of nonce-order guarantees by deferring inbound decrypt work to background goroutines
  3. Treating a missing persisted relay config file as a hard failure instead of falling back to the canonical disabled default

Completion Obligations

  1. Update this contract when new desktop relay runtime or persistence entry points become canonical
  2. Keep relay desktop runtime changes tied to explicit proof in internal/relay/client_test.go and internal/relay/encryption_test.go
  3. Keep persisted relay config loading tied to explicit proof in internal/config/persistence_relay_test.go
  4. Keep backend-owned mobile capability inventory changes tied to explicit proof in internal/api/relay_mobile_capability_test.go
  5. Keep mobile relay runtime changes tied to explicit proof in pulse-mobile:src/relay/__tests__/

Current State

The relay lane had been carrying real desktop and mobile runtime behavior without fully explicit subsystem ownership even though L7 already treats relay readiness as a governed release lane. This contract makes both relay-runtime boundaries explicit. The canonical relay runtime now includes local CA-bundle trust via SSL_CERT_FILE, so self-hosted relay deployments can use a private CA without forking the dial path or disabling TLS verification globally. Inbound DATA handling is also part of this boundary: encrypted relay frames must be decrypted before they are handed to background stream handlers so the channel nonce guard stays monotonic and relay arrival order cannot trip false replay failures under concurrency. Relay channel key derivation is part of that same encryption boundary. The X25519 shared secret may not feed the generic nil-salt HKDF form: desktop relay runtime must domain-separate the channel KDF with the relay-owned salt pulse-relay-e2e-channel-v1 together with the existing directional info strings before deriving AES-GCM keys. Relay status truthfulness is part of the same contract. If the client is in a governed reconnect or license-pause window, ClientStatus.reconnect_in must reflect that delay instead of silently presenting a disconnected state with no retry timing. Relay push notification identity is part of this same owned contract. The canonical PUSH_NOTIFICATION payload must carry the authoritative instance_id when the desktop relay client serializes it so multi-pairing mobile routing and repair flows cannot reinterpret a queued approval or finding against whichever instance happens to be active later. The relay HTTP proxy boundary is also part of this owned surface. Relay DATA requests must normalize outer method/path whitespace before building the local Pulse API request so trivial spacing drift does not cause a valid proxied operation to fail at the desktop boundary. The same normalization rule applies to allowlisted proxy header names, so safe headers like Content-Type are not silently stripped just because the upstream frame wrapped them in outer whitespace. That same proxy boundary also owns per-channel abuse control: one relay channel may not consume an unbounded share of the local HTTP proxy, so desktop relay runtime must enforce a channel-local request budget and fail excess DATA frames with owned proxy backpressure instead of letting one channel starve the rest of the connection. Persisted relay config loading is part of the same owned surface. A missing relay.enc file must fall back cleanly to the canonical disabled default so a fresh v6 install or a partially migrated instance does not fail closed just because relay settings have never been saved. That same persistence boundary also owns relay client secrets at rest: relay.enc may only accept plaintext relay config as migration input. Once the runtime can read legacy plaintext relay settings, it must rewrite canonical encrypted storage immediately on load instead of leaving instance_secret or the relay identity private key on disk as a normal runtime path. The mobile relay runtime is part of the same owned surface: reconnect drain failover hints are one-shot recovery instructions, not permanent relay URL overrides, so a successful failover reconnect must return future reconnects to the instance's canonical relay URL unless the server sends a fresh drain hint. The server-owned mobile relay capability boundary is part of that same owned surface too. The dedicated relay:mobile:access credential may only reach the explicit runtime route inventory in internal/api/relay_mobile_capability.go, and expanding that inventory is governed L7 work rather than a router-local compatibility tweak. The route that mints that dedicated credential is also part of the paid Relay boundary. POST /api/security/tokens/relay-mobile lives in the shared auth/security router, but it must require the paid relay entitlement before creating a relay:mobile:access token so Community installs cannot bypass Relay/mobile gating through direct API calls.