Skip to Content

Relay

The relay is a transparent forwarder. It does not understand product semantics. It exists so the phone (off-LAN) can reach the user’s extension (on the workstation) through a single TLS endpoint.

What the relay knows

ConceptRelay knowsRelay does NOT know
RoomsYes — each room is one workstation/extension pair plus 0..N phone clients
AuthYes — validates extension token + device token at first frameWhat the auth represents downstream
Sequence numbers (seqId)Yes — assigns + buffersWhat a message’s type field means
Capabilities (per client)Yes (since TPC v0.1 Task 3) — stored on the client record from relay_client_auth.capabilitiesWhat any individual capability means semantically
Per-message routing hintYes — reads _requiresCapability from extension-emitted messagesAnything else inside the payload

The relay is deliberately dumb about content. Adding logic that inspects terminal payloads, prompt content, session events, or any other payload field is a doctrine violation. The relay forwards bytes.

Auth

Two roles. The path determines the role:

  • wss://relay.vsrelay.dev/relay/room/<roomId>/extension — extension auth
  • wss://relay.vsrelay.dev/relay/room/<roomId>/client — phone (or any thin renderer)

First frame for the client role:

{ "type": "relay_client_auth", "roomId": "test-room-001", "deviceToken": "<device-token>", "protocolVersion":"2.0", "capabilities": ["terminal_observe", "session_subscriptions", "workspace_activation", ...], "lastSeqId": 42, // optional — request replay from this seqId "buildInfo": { ... } // optional }

The relay validates the deviceToken against Supabase before admitting the WS upgrade (see Room admission / validation below). On success the client receives a synthetic relay_connected payload carrying the extension’s last-known build info / environment / claudeIntegration so the renderer has the same shape it gets from a direct connected message.

Room admission / validation

The relay performs authenticated room admission before any message is forwarded. A WS upgrade is admitted only when:

  1. The roomId exists in Supabase (the extension registered it when it signed in).
  2. sha256(presented_deviceToken) matches the hash stored in Supabase for that room/device pair.

Validation uses a secret-gated relay_lookup_room(p_room_id, p_relay_secret) SECURITY DEFINER RPC that returns the per-room token hash. The result is cached briefly to avoid a Supabase round-trip on every reconnect. Connections that fail either check are rejected fail-closed — no partial admission.

What this means for a native client: just connect with the deviceToken you received from claim_pairing_code (see SupabaseAuth §5 for the pairing flow). The client presents the token; the relay hashes and compares it internally. The native client never hashes, never touches RELAY_SECRET (relay-host-only), and never calls Supabase directly for admission.

Message bodies are still forwarded transparently. Room admission validates who may enter the room; it does not inspect any payload content. The relay remains a transparent forwarder once a connection is admitted.

Capability tracking (since Task 3 of TPC v0.1)

The relay stores each client’s capabilities[] on the connection record. This is used only for the generic _requiresCapability routing primitive (below). The relay does not interpret what 'terminal_observe' means — it just compares strings.

_requiresCapability — internal relay routing metadata

This is the one thing the relay reads from extension→client messages besides type and seqId. It is internal routing metadata, never part of any protocol wire type.

How it works (from the extension’s perspective)

The extension’s RelayClient.sendToRelay accepts an optional opts.requiresCapability: string. When set, it adds _requiresCapability: '<cap>' to the wire frame:

// What the extension sends to the relay: { "type": "terminal_event", "terminalId": "term-1", "event": "opened", "info": {...}, "_requiresCapability": "terminal_observe" }

How it works (relay-side)

On receipt, the relay:

  1. Parses the message.
  2. Extracts _requiresCapability if present.
  3. Strips it from the parsed object (so it never reaches any client).
  4. Re-serialises the stripped object.
  5. Assigns a fresh seqId.
  6. Buffers (with requiresCapability stored separately on the buffer entry, not on the payload).
  7. Forwards to clients whose capabilities[] includes the named string.
  8. On reconnect-replay, re-applies the same per-client filter.

What the native client sees

// What the client receives: { "type": "terminal_event", "terminalId": "term-1", "event": "opened", "info": {...}, "seqId": 17 }

No _requiresCapability field. Ever. The native client never sees it on the wire. The native client never sends it.

Pure helper

The decision is one line:

function shouldForwardToClient( clientCapabilities: readonly string[], requiresCapability: string | undefined, ): boolean { if (requiresCapability === undefined) return true; return clientCapabilities.includes(requiresCapability); }

The source lives in packages/relay/src/room.ts as an exported named function — exported so its behaviour is unit-testable and auditable, but not exposed via any user-facing module entry.

Why this exists

Without it, the relay would have to either (a) broadcast every server message to every client and rely on the client to filter (security-fragile, wastes bandwidth, leaks “this terminal exists” to clients that didn’t ask for terminal observation) or (b) hardcode terminal-specific logic into the relay (couples the relay to a domain concept). The generic _requiresCapability primitive lets new capability-scoped server messages ship by adding data at the extension, with zero relay code changes.

Replay and reconnect

Each room maintains a 200-entry ring buffer of forwarded messages. On client reconnect with lastSeqId: N, the relay sends every buffered entry where seqId > N, with the same capability filter applied per buffered entry.

For native clients:

  • Store the highest seqId you’ve received in memory.
  • On reconnect, send it back as lastSeqId.
  • The relay handles the replay transparently. You receive messages in order with no special handling.

If the ring buffer wrapped past your lastSeqId (only happens for very long offline windows), some messages are unrecoverable. This is a known deferred reliability gap (same as the direct-mode case in SessionController).

What the relay does NOT do

  • ❌ Inspect payload bodies (terminal cwd, prompt text, session event content, SSH keys).
  • ❌ Apply any session-level subscription logic. That’s the extension’s job (session_subscriptions capability is interpreted server-side).
  • ❌ Persist anything to disk. Everything is in-memory per room.
  • ❌ Re-route based on payload content. Routing is purely (a) extension → room broadcast, (b) client → extension via room.

What the native client does NOT do

  • ❌ Send _requiresCapability upstream. It’s a downward-only routing hint.
  • ❌ Try to read _requiresCapability from any incoming message. The relay strips it; if you ever see one, your relay build is bugged — report it.
  • ❌ Cache the relay URL across user sessions. Treat it as a setup-time configuration.

One-extension-per-room rule

The relay enforces setExtension as keep-first (since IDE Instance Routing v1, Phase 1.21): if a new extension with a different editorInstanceId tries to auth into a room already occupied by another, the relay rejects the new connection with relay_instance_rejected + close 4001 (room_occupied). The same editorInstanceId may reconnect/replace normally.

The native client sees a relay_instance_rejected message when it is the client side of a room-occupied conflict. Render it as a “This workspace is already controlled by a different instance” banner with the occupying instance descriptor if available. Transient “Extension reconnecting…” banners should clear when the next normal message arrives.

Reference implementation