Wolffish Relay

The zero-retention meeting point that carries encrypted traffic between your desktop and your phone — and cannot read, keep, or log any of it.

example run: 55/55 checks passed MIT Cloudflare Workers wss://relay.wolffi.sh

How it works

Two devices need to talk. One sits behind a home router, the other behind a mobile carrier's NAT; neither can accept an incoming connection. So both dial outward to the same address and meet there. That address is the relay.

Desktop wolffish-app · host parks and waits behind home NAT relay.wolffi.sh Worker + Durable Object one instance per rendezvous ID RAM only · no disk · no logs forwards bytes it cannot read Phone wolffish-mobile · guest dials in when active behind carrier CGNAT outbound wss outbound wss no port forwarding no static IP end-to-end encrypted Because both devices connect outward, the tunnel forms across any NAT. The relay never holds a key, so it can only move sealed bytes.
Neither device listens for connections. They meet in the middle, and the middle can't read what passes through it.

The relay's whole job is to notice that two sockets presented the same rendezvous ID and to pass bytes between them. It performs no authentication of its own, keeps no accounts, and holds nothing once the sockets close. Everything that makes the tunnel safe happens on the devices, before a byte is handed over.

Pairing happens once, by QR

The desktop displays a QR code carrying three things: the relay URL, its own long-lived public key, and a freshly generated 32-byte pairing secret. The phone's camera reads it.

That camera hop matters more than it looks. Because the secret travels out of band — screen to lens, never over the network — a hostile relay cannot insert itself into the pairing. It never sees the secret, so it can never complete the handshake in the middle.

From that secret both devices derive the same rendezvous ID: rid = HMAC(secret, "rid-v1"). It is 256 bits, unguessable, and the only fact about a pairing the relay ever learns. It identifies a meeting, not a person.

Example pairing QR code

illustrative — each pairing
generates a fresh one

Then a handshake, every time they reconnect

Pairing is once. Reconnecting is automatic and needs no user action: the devices already hold each other's keys, so they run a Noise IKpsk2 handshake — the pattern designed for exactly this asymmetry, where the initiator already knows the responder's static key but not vice versa.

Desktop Phone shows a QR carrying relay URL · static pubkey · 32-byte secret scanned by camera — out of band, so the relay can never sit in the middle rid = HMAC(secret, "rid-v1") both sides derive the same 256-bit rendezvous ID — the only thing the relay learns message 1: e, es, s, ss phone's identity travels encrypted; desktop learns and pins it message 2: e, ee, se, psk the pairing secret is mixed in here — only the device that scanned can finish two transport keys, one per direction fresh every session — yesterday's traffic stays unreadable if a key leaks tomorrow
Two messages, one round trip. The pairing secret enters as the PSK, binding the session to the device that scanned the code.

Three properties fall out of it. Mutual authentication: each side proves possession of its long-term key, and the desktop pins the phone's key on first contact. Forward secrecy: fresh ephemeral keys per session mean a key stolen next year cannot decrypt traffic captured today. Pairing binding: the PSK means knowing the rendezvous ID is not enough — an attacker who somehow learns it still cannot complete a handshake.

How it's built

The entire cloud footprint is one Worker script containing one Durable Object class — about 250 lines. There is no server, no container, no database, and nothing to patch.

FileRole
src/index.tsStateless front door: validates the URL shape and the role, then routes to the one Durable Object that owns this rendezvous ID
src/tunnel.tsThe Durable Object: pairs the two roles, forwards binary frames verbatim, replaces zombie sockets, enforces limits
src/protocol.tsThe wire contract in one file — ID format, roles, frame cap, keepalive, close codes, presence notices
wrangler.jsoncConfiguration, and where the retention guarantees are pinned so a deploy re-asserts them

Why a Durable Object

A plain Worker is stateless by design: two requests can land on two machines in two cities that share nothing. If both devices simply "connected to the Worker", they would land on unrelated instances and never find each other.

A Durable Object adds exactly one thing: named single-instance routing. Every request for a given name reaches the same one running instance, anywhere on earth. That is what lets two sockets be in the same place at the same time. "Durable" refers to that stable identity — not to storage. A Durable Object may attach persistent storage; this one simply never calls it.

The relay uses the WebSocket Hibernation API, so an idle tunnel is evicted from memory entirely and costs nothing while a desktop sits parked. Connection state lives inside the sockets themselves, never in instance fields — and eviction is the whole cleanup story.

The wire contract

RouteBehaviour
GET /t/<rid>?role=host|guest + upgradeJoin the tunnel for that rendezvous ID
GET /healthz200 ok
GET /Status page
anything else404 / 400 / 405 / 426

rid must match ^[0-9a-f]{64}$. The desktop connects as host — the parked, always-on side. The phone connects as guest.

Frames

Close codeMeaning
4000 ReplacedA newer connection for your role arrived; this socket was evicted
4400 ProtocolViolationYou sent a non-keepalive text frame
4413 MessageTooLargeYou sent a frame over 1 MiB

Two semantics matter for client authors. One live socket per role — a reconnect replaces its predecessor, and the peer never sees a false "gone" during that swap. And frames sent while the peer is absent are dropped: presence notices tell you when someone is there, and delivery guarantees belong to the end-to-end protocol's acknowledgements, never to the relay.

Moving data and files

Above the sealed frame layer sit three kinds of traffic, all multiplexed on the one socket: RPC (request/response — fetch the config, list conversations, run an agent turn), events (unsolicited pushes — streaming agent output, "this conversation changed"), and file transfer.

Files never move as files. They move as a stream of independently sealed chunks, which is what makes the transfer resumable, memory-flat, and verifiable.

Sender Receiver MANIFEST — name, size, chunk size, count, sha256 WANT — "start from chunk N" N is 0 for a new file, or the last checkpoint for one already part-received CHUNK × up to 32 in flight — 256 KiB each, sealed individually memory stays flat: the window is held, never the file ACK every 16 chunks — releases credit and writes a checkpoint phone loses signal — socket dies mid-flight reconnect → new handshake → WANT from the checkpoint the transfer continues; nothing already delivered is sent twice
The sender holds a window, never the file. Memory stays flat whether the file is 3 MB or 300 GB.

Each chunk is encrypted on its own, so a corrupted or reordered one fails loudly and alone rather than poisoning the file. The receiver writes each chunk straight to disk at its offset and checkpoints its progress every sixteen chunks; if the connection dies, reconnecting replays the manifest and the receiver simply asks to continue from its checkpoint. When the last chunk lands, the whole-file hash is compared against the manifest before the file is made visible.

There is no protocol size limit. The practical bounds are disk space on the receiving device and patience — a 1 GB file over a relay path is minutes, not seconds, and survives any number of interruptions along the way.

Security promises

The relay is untrusted by design. Every guarantee below holds even if the relay is fully compromised, run by someone else, or replaced by a hostile clone.

PromiseHow it's achieved
Content is unreadable in transitEvery frame is sealed on-device with ChaCha20-Poly1305 under keys the relay never sees
Tampering is detectedAEAD authentication on every frame — a single flipped bit fails the tag and the frame is discarded
Replay and reordering failPer-session keys with a strictly increasing nonce counter
Past traffic stays safeForward secrecy: fresh ephemeral keys per session, so a stolen device key cannot decrypt earlier sessions
The relay cannot impersonate either deviceMutual authentication against pinned static keys; the pairing secret it never saw is required to finish a handshake
Pairing cannot be interceptedThe secret travels out of band, screen to camera, and is mixed into the handshake as the PSK
Strangers cannot joinThe rendezvous ID is 256 bits and unguessable; even holding it does not let an attacker complete a handshake
Nothing survives the sessionNo storage is ever written — see the retention section below

What a hostile relay could still do

Stated plainly, because a security claim without its limits is marketing. A relay operator — including Cloudflare, including you if you self-host — can observe, while the sockets are live: the two IP addresses, the rendezvous ID, the timing of frames, and their sizes. It can also refuse service or drop frames.

It cannot read content, alter it undetected, replay it, forge a frame either device will accept, impersonate either side, or recover anything after disconnection. Traffic metadata is the irreducible cost of any relayed system; the design keeps it to the minimum and never records it.

Zero data retention

"We don't store your data" is a promise. This is the version you can check: an inventory of every piece of state in the system and where it lives.

Desktop keys in Keychain conversations, files sync cursors, outbox durable Relay two live sockets a role tag per socket nothing else, ever amnesiac Phone keys in Keystore mirrored data, file cache resume checkpoints durable Unpairing deletes two keychain entries. There is nothing in the middle to delete. When the last socket closes the relay instance is evicted — and eviction is the whole cleanup.
All durable state sits on the two devices you own. The middle holds live sockets and nothing else.
StateWhere it livesLifetime
Device keypairs, peer public key, pairing secretPlatform secure storage — see belowUntil you unpair
Conversations, configs, files, sync cursorsEach app's own local databaseThe product's own data — endpoints, not the pipe
Session keysBoth devices' RAMOne connection
The socket pair and a role tagRelay RAMDies with the sockets
Any database, object store, queue or logDoes not exist—

Unpairing is deleting the stored key material on each device. There is nothing in the cloud to delete because nothing was ever written there — which is also why reconnection is free: there is no server-side session to restore, ever.

Measured, not asserted. After the example run below moved 249.1 MB through production, the relay's storage meters read 0 B across every counter — SQL storage, key-value storage, rows read, rows written, storage operations. Compute happened; storage did not.

Where the keys actually live

"Secure storage" is not one thing, and the guarantee differs per platform.

PlatformBackendWhat it protects against
macOSKeychain, via Electron safeStorageOther users and other apps in the same user session
iOSKeychain Services, via expo-secure-storeOther apps; survives app uninstall under the same bundle ID
AndroidKeystore-encrypted SharedPreferencesOther apps; cleared on uninstall
WindowsDPAPI, via Electron safeStorageOther users on the machine — not other apps running as you
Linuxgnome-libsecret / kwalletOther users, only when a secret store is present

The approach is deliberately boring: each platform's default. safeStorage on desktop and expo-secure-store on mobile are built in, need no native modules, and behave identically on every install — no passphrase prompts, no custom key derivation, nothing that can fail for a subset of users.

One documented gap comes with that choice. On Linux, if no secret store is available, Electron's docs say items "will be unprotected as they are encrypted via hardcoded plaintext password" — obfuscation rather than encryption, on headless boxes and minimal window managers. This is accepted rather than engineered around: a device whose home directory an attacker can already read has bigger problems than these keys, and every alternative trades a rare edge case for complexity that would fail for ordinary users. safeStorage.getSelectedStorageBackend() reports the active backend in one call, so a connection-details screen can simply show it.

No logs, no telemetry

Retention has a second face: even without a database, a service can leak everything through its logs. This one is configured so there is nothing to leak, and the settings live in version control so every deploy re-asserts them.

SurfaceState
Worker logs and tracesDisabled in wrangler.jsonc (observability.enabled = false)
Log export (Logpush)No jobs configured
console.log anywhere in the sourceNot a single one
Error-tracking or analytics SDKNone — the Worker has no dependencies at runtime
Storage bindings (KV, D1, R2, queues, analytics engine)None — the only binding is the Durable Object itself
Build-tool telemetryOff (send_metrics: false)
Browser telemetry headers (NEL / report-to)Switched off at the zone
Tail workers (a covert log path)None connected

What remains, honestly: the platform's own aggregate request counters, and TLS terminating at the edge — as it does for any hosted service. Neither exposes content, because what crosses the edge is already ciphertext sealed on your devices. If that residual matters to you, the design's answer is the last section: run the relay yourself.

Example run

Generated 2026-08-02T23:57:40.378Z · a real cycle against wss://relay.wolffi.sh, not a simulation. Reproduce it with npm run playground.

Checks
55/55
Files delivered
12
Payload moved
249.1 MB
Sealed frames
1,213
Handshake
234 ms
Wall clock
4m 10s

One run drives the whole lifecycle the way the apps will: it stages real data from a live Wolffish workspace, pairs by QR, hand-shakes, audits the wire, tries to break in, syncs configuration and conversations, streams a real agent turn, and moves files — including a 248 MB PDF that is deliberately interrupted mid-flight to prove resume.

The big one — miller.pdf

Desktop
248.1 MB
source of truth
── sealed, chunked ──▶
1.23 MB/s (10 Mbps)
3m 21s · resumed at chunk 160
Phone
248.1 MB
sha256 verified identical

The phone's socket was terminated outright partway through. It reconnected, ran a fresh handshake, asked to continue from its checkpoint, and finished — with the final hash matching the source byte for byte.

Phases

PhaseWhat it provesChecksTime
Stage from the published demo datasetmanifest, conversations, config and files — all from the CDN 4/4 30.1 s
Pair by QR codedesktop shows, mobile scans, both derive the same rendezvous ID 3/3 42 ms
Connect and hand-shakeNoise IKpsk2 over the live relay 4/4 1.3 s
Prove the wire is opaqueciphertext audit on live frames plus integrity probes 7/7 1.2 s
Resist intruderswrong rendezvous ID, and a scanner-less impostor 5/5 4.9 s
Sync configurationthe desktop config.json, projected for a phone 3/3 379 ms
Sync conversationsindex first, then full bodies on demand 2/2 1.5 s
Run a live conversationmobile asks, desktop streams a real agent turn back 3/3 658 ms
Move filespublished sample files plus deliberately awkward ones 12/12 5.4 s
Reverse direction — the phone serves the desktopdevice tools, a remote invocation, and an upload 6/6 3.2 s
Move the 248 MB PDF, and survive a dropoutthe phone loses signal mid-transfer and resumes 4/4 3m 21s
Verify deliverycompare every artifact on both sides 2/2 341 ms

Transfers

FileOriginSizeTimeThroughputResult
wolffish-sample.pngsamples/png 35.7 KB441 ms0.08 MB/s (1 Mbps) delivered
wolffish-sample.mdsamples/md 550 B442 ms0.00 MB/s (0 Mbps) delivered
wolffish-sample.jsonsamples/json 497 B432 ms0.00 MB/s (0 Mbps) delivered
wolffish-sample.htmlsamples/html 773 B390 ms0.00 MB/s (0 Mbps) delivered
wolffish-sample.pdfsamples/pdf 56.8 KB465 ms0.12 MB/s (1 Mbps) delivered
wolffish-sample.gifsamples/gif 28.1 KB451 ms0.06 MB/s (1 Mbps) delivered
wolffish-sample.mp4samples/mp4 629.8 KB1.1 s0.58 MB/s (5 Mbps) delivered
wolffish-sample.docxsamples/docx 4.3 KB462 ms0.01 MB/s (0 Mbps) delivered
empty-marker.txtgenerated (zero-byte edge case) 0 B203 ms0.00 MB/s (0 Mbps) delivered
تقرير-المزامنة-٢٠٢٦.txtgenerated (RTL filename edge case) 140 B456 ms0.00 MB/s (0 Mbps) delivered
chunk-boundary.bingenerated (chunk-boundary edge case) 256.0 KB561 ms0.45 MB/s (4 Mbps) delivered
camera-capture.jpgphone camera roll → desktop 59.2 KB2.2 s0.03 MB/s (0 Mbps) delivered
voice-memo.mdphone recording → desktop 141 B427 ms0.00 MB/s (0 Mbps) delivered
miller.pdfcdn.wolffi.sh/generic/miller.pdf 248.1 MB3m 21s1.23 MB/s (10 Mbps) delivered · resumed

Both endpoints run on one machine during the example, sharing a single uplink — read these as a floor, not a ceiling. Small files are latency-bound (two protocol round trips before the first byte); large files are bandwidth-bound.

What the relay actually carried

These are the first bytes of real frames, captured at the socket boundary — exactly the bytes the relay forwarded during this run.

guest→relay         152 B  noise msg1     010db5f4797d6c166bf09b9549513cffd1929a2a3d746ddffe85c2435cdee9d345a35e365559b9f318c6a59e1854d64d…
host→relay          104 B  noise msg2     01d854e12313bc355bf8ea65ee0b7f0bba0fdcfad3164c84924443bc10cdbb4e0cc4271614cc905515ce96c400be7ff8…
guest→relay          62 B  RPC_REQ        02a7ad0483804b6a83bef3b783ad4708b6c7c7779bb1222fa689d3e434f2eb9822bbb8b52b81115caa9eca4a754117fb…
host→relay          138 B  RPC_RES        02bcd536e39c417463d1741b12aab3e998801dfddc0381806d6481766b11fa2893ec706f3f8c4fe0f414c8d5ded61f81…
guest→relay          68 B  RPC_REQ        02a6ff52a5b08573f57bd0cefe8bd81bb998e26d4632ed819a946f3165ac1eb102cb432f7ad00d2e4d8a5109876400e5…
host→relay         2380 B  RPC_RES        026c9ad503d440813407dbb352a5f01f5baf0540907c9874949f0db9ee7c8f6f6ca4a2d9dadeb18719c7ccb18b08ae9e…
guest→relay          86 B  RPC_REQ        023ad6c226ae9b3c446fb00e03103b19068f8857315d4634f1ac636b9504bf5cbfeb15612fbec28248e419b04c151c98…
host→relay         8308 B  RPC_RES        023834a25d792f77dd2cf3bda2bd2327adf40b521c0693cd882e6e5a2a1f8d7f1b40bdd24e8d4fcff645bdf85e774937…

Across 12 frames and 17.4 KB, the captured ciphertext measures 7.980 bits per byte of entropy — 8.0 is indistinguishable from random noise. No plaintext marker searched for appears anywhere in it, including the %PDF header of the file being transferred at the time.

Every check

AssertionDetailPhase
✓demo manifest fetched from the CDN version 025fc50bc5f8Stage from the published demo dataset
✓demo conversations loaded 12Stage from the published demo dataset
✓file spread staged 11 filesStage from the published demo dataset
✓large PDF staged 248.1 MBStage from the published demo dataset
✓QR carries the desktop static key Pair by QR code
✓both sides derive the same rendezvous ID 0673974282b41176…Pair by QR code
✓rendezvous ID is 256-bit lowercase hex Pair by QR code
✓desktop pinned the real mobile static key Connect and hand-shake
✓both sides agree on the handshake transcript hash f9800c2b2554567bac2a0df4…Connect and hand-shake
✓desktop identity received by mobile Connect and hand-shake
✓mobile identity received by desktop Connect and hand-shake
✓RPC round-trip over the encrypted tunnel Prove the wire is opaque
✓no plaintext markers found in captured ciphertext 5 needles searchedProve the wire is opaque
✓captured frames are indistinguishable from random 7.980 bits/byte over 8.8 KB (random would score 7.980)Prove the wire is opaque
✓sealed frame does not contain its plaintext Prove the wire is opaque
✓a single flipped bit fails authentication Prove the wire is opaque
✓the wrong key cannot open a frame Prove the wire is opaque
✓the right key round-trips exactly Prove the wire is opaque
✓a socket on another rendezvous ID receives nothing Resist intruders
✓impostor cannot finish the handshake without the QR secret knowing the rendezvous ID is not enoughResist intruders
✓impostor derived no session keys — it can neither read nor write Resist intruders
✓forged frames are rejected — the impostor cannot drive the desktop responder keys exist but nothing authenticates against themResist intruders
✓the real session survived the probes Resist intruders
✓every config section arrived 12 sections in 378 msSync configuration
✓credentials were replaced before leaving the desktop Sync configuration
✓demo settings survived the trip 33 capabilities · 11 servicesSync configuration
✓conversation index delivered 12/12Sync conversations
✓conversation bodies match the desktop byte for byte 6/6Sync conversations
✓agent streamed deltas to the phone 7 eventsRun a live conversation
✓streamed transcript reassembled exactly 1812 charsRun a live conversation
✓tool-run event delivered Run a live conversation
✓delivered wolffish-sample.png 35.7 KB · 441 ms · 0.08 MB/s (1 Mbps)Move files
✓delivered wolffish-sample.md 550 B · 442 ms · 0.00 MB/s (0 Mbps)Move files
✓delivered wolffish-sample.json 497 B · 432 ms · 0.00 MB/s (0 Mbps)Move files
✓delivered wolffish-sample.html 773 B · 390 ms · 0.00 MB/s (0 Mbps)Move files
✓delivered wolffish-sample.pdf 56.8 KB · 465 ms · 0.12 MB/s (1 Mbps)Move files
✓delivered wolffish-sample.gif 28.1 KB · 451 ms · 0.06 MB/s (1 Mbps)Move files
✓delivered wolffish-sample.mp4 629.8 KB · 1.1 s · 0.58 MB/s (5 Mbps)Move files
✓delivered wolffish-sample.docx 4.3 KB · 462 ms · 0.01 MB/s (0 Mbps)Move files
✓delivered empty-marker.txt 0 B · 203 ms · 0.00 MB/s (0 Mbps)Move files
✓delivered تقرير-المزامنة-٢٠٢٦.txt 140 B · 456 ms · 0.00 MB/s (0 Mbps)Move files
✓delivered chunk-boundary.bin 256.0 KB · 561 ms · 0.45 MB/s (4 Mbps)Move files
✓every file completed on the mobile side 11/11Move files
✓desktop fetched the phone's own tool definitions Reverse direction — the phone serves the desktop
✓desktop read live device status from the phone battery 72% · cellularReverse direction — the phone serves the desktop
✓desktop invoked a tool that only the phone can run Reverse direction — the phone serves the desktop
✓phone uploaded camera-capture.jpg 59.2 KB · 2.2 sReverse direction — the phone serves the desktop
✓phone uploaded voice-memo.md 141 B · 427 msReverse direction — the phone serves the desktop
✓uploads are byte-identical on the desktop 2/2Reverse direction — the phone serves the desktop
✓the transfer really was interrupted mid-flight Move the 248 MB PDF, and survive a dropout
✓transfer resumed from a checkpoint, not from zero chunk 160Move the 248 MB PDF, and survive a dropout
✓delivered size matches 248.1 MBMove the 248 MB PDF, and survive a dropout
✓sha256 matches end to end 2ddd71a4a8fce8272e4073db…Move the 248 MB PDF, and survive a dropout
✓every desktop file is byte-identical on the phone 12/12Verify delivery
✓no partial files left behind 0 stragglersVerify delivery

Run your own relay

The desktop and mobile apps let you point at any relay, because the relay URL travels inside the pairing QR. Change it there and the devices meet at your address instead — no other setting, no rebuild.

This is deliberate. Content security never depended on the relay's honesty, so swapping in your own costs you nothing and gains you full control of the metadata surface.

Option A — your own Cloudflare deployment

The same code, on your account, in about ten minutes.

# 1. Fork or clone
git clone https://github.com/thewolffish/wolffish-relay.git
cd wolffish-relay && npm install

# 2. Point it at your own hostname
#    edit wrangler.jsonc → "routes": [{ "pattern": "relay.example.com", "custom_domain": true }]
#    (or delete "routes" and set "workers_dev": true to use a *.workers.dev address)

# 3. Deploy
npx wrangler login
npx wrangler deploy

# 4. Check it
curl https://relay.example.com/healthz    # → ok

To keep the retention guarantees, leave these exactly as they ship: observability.enabled: false, send_metrics: false, no storage bindings, and no Logpush job on the zone. If you deploy from CI instead of a laptop, the included GitHub Actions workflow needs only two secrets — a scoped API token and your account ID.

Option B — anywhere that runs Node

Nothing about the relay is Cloudflare-specific: it matches two sockets by name and forwards bytes. Here is the whole thing for a plain Node host, behind any TLS terminator you already run.

import { WebSocketServer } from 'ws'   // npm i ws

const rooms = new Map()                  // rid → { host, guest }   RAM only, never persisted
const RID = /^[0-9a-f]{64}$/
const MAX = 1024 * 1024

new WebSocketServer({ port: 8787 }).on('connection', (ws, req) => {
  const url = new URL(req.url, 'http://x')
  const rid = url.pathname.match(/^\/t\/([0-9a-f]{64})$/)?.[1]
  const role = url.searchParams.get('role')
  if (!rid || !RID.test(rid) || (role !== 'host' && role !== 'guest')) return ws.close(1008)

  const room = rooms.get(rid) ?? {}
  room[role]?.close(4000, 'replaced')     // newest connection per role wins
  room[role] = ws
  rooms.set(rid, room)

  const peer = () => (role === 'host' ? room.guest : room.host)
  if (peer()?.readyState === 1) {
    ws.send('{"t":"peer-present"}')
    peer().send('{"t":"peer-present"}')
  }

  ws.on('message', (data, isBinary) => {
    if (!isBinary) return data.toString() === 'ping' ? ws.send('pong') : ws.close(4400)
    if (data.length > MAX) return ws.close(4413)
    if (peer()?.readyState === 1) peer().send(data, { binary: true })
  })

  ws.on('close', () => {
    if (room[role] === ws) delete room[role]
    if (peer()?.readyState === 1) peer().send('{"t":"peer-gone"}')
    if (!room.host && !room.guest) rooms.delete(rid)   // forget everything
  })
})

That is the complete contract. Serve it over TLS (wss://), keep access logs off if you want the retention story to hold, and make sure your proxy does not buffer or time out WebSocket upgrades.

If you host it, you own the metadata. Content stays sealed either way, but connection times, IP addresses and frame sizes pass through your machine. Turn off access logging deliberately — most web servers record every connection by default, which would put back exactly what this design removes.

Verifying your relay

# health
curl https://relay.example.com/healthz

# two terminals: presence and forwarding
npx wscat -c "wss://relay.example.com/t/$(printf 'a%.0s' {1..64})?role=host"
npx wscat -c "wss://relay.example.com/t/$(printf 'a%.0s' {1..64})?role=guest"
# both should print {"t":"peer-present"} the moment the second connects

# or drive the whole tunnel against it, end to end
npm run playground -- --relay wss://relay.example.com

That last command runs the complete cycle in this document — pairing, handshake, ciphertext audit, intrusion probes, file transfer with an interruption, and byte-for-byte verification — against your own deployment, and writes you a report just like this one.