proposals/2026-07-29-relay-audio-consent.md
Rama master · réplica de lectura del repositorio original.
# Relay consent at first relayed playback, per library
## Goal
Ask before PeerLoom's relay carries a library's AUDIO, once per library, instead of
relying on a global default that silently covers both the kilobytes of browsing and
the megabytes of listening.
## Tier
T3. It changes the auth/consent boundary around the one centralized piece of the
system, and it changes what the privacy page and both store listings claim. No wire
change and no host change, but "when does PeerLoom's infrastructure carry your music"
is a security-critical question, not a UI question.
## Context: what the default actually does today
Worth stating precisely, because "relay on by default" sounds worse than it is and
the mechanism matters for where the consent gate belongs.
`protocol/relay.js:relayThroughFor` returns the relay key only when
`(force || randomized) && useRelay && relayKey`. `force` is what Hyperswarm sets
after a `HOLEPUNCH_ABORTED`, so the normal attempt passes no `relayThrough` at all.
It is direct-FIRST, and that is hardware-verified: DECISIONS 2026-07-23 phase 3, Pixel
over cellular, both hosts connected via consistent punches with `random:0` and **the
relay stayed out**. Reproducing the relay path at all needed a throwaway force-relay
build.
So the status quo is not "your music goes through PeerLoom unless you opt out". It is
"if the direct punch fails, fall back rather than die". The live relay has carried
**163 MB total** since deploy (~6 days), against a 500 GB/month tier.
## Why change it anyway
Two reasons, one of which is specific to PearTune and does not apply to any sibling app.
1. **PearTune relays AUDIO.** PearCal relays calendar events, PearCircle location
pings: kilobytes. A relayed PearTune session is ~100% of the audio bytes, ~144
MB/hour at 320kbps and ~5x that for FLAC. That is an unbounded cost that scales
with adoption, and it is PeerLoom's bill.
2. **The current control cannot express the actual decision.** `useRelay` is one
global switch. "Relay to my own Umbrel" and "relay to a friend's library" are
different trust decisions, and a user cannot say yes to one and no to the other.
## Why NOT at pairing (the rejected alternative)
The obvious version is: default `useRelay` off, and when a pairing attempt times out,
offer the relay with a link to Settings. Rejected, for two reasons that are not
matters of taste:
- **It gates the cheap moment and misses the expensive one.** A pairing handshake is
a few KB. A user who pairs at home on wifi (direct, no prompt) and later plays a
three-hour album on cellular through the relay is never asked, and that is the path
that costs money.
- **It asks for a decision the user cannot yet make.** The prompt lands in the first
minute, before anything has worked, and it has to explain carrier NAT to somebody
who wanted to hear an album. Consent obtained there is worse than a documented
default, not better.
Note also that off-by-default is not "less private, still works": on a
double-randomized NAT there is no direct path at all, so `useRelay:false` means the
app does not work, full stop. That makes the prompt load-bearing rather than
informational.
## The load-bearing question, resolved against the source
**Can the phone tell WHICH library's connection is riding the relay?** Everything
here depends on it, and the answer is not obvious.
What does not work:
- `dht.stats.relaying` on the phone read **0 while actually relaying** (DECISIONS
2026-07-23 phase 3). It tracks a different relay role. The relay node's own
`journalctl` stats were the ground truth.
- The `relayThrough` callback we install is
`(force, s) => relayThroughFor(...)` (`src/bare.js:1671`). It receives **no peer
identity**, so we cannot attribute a decision to a library from inside it.
- Correlating by timing is unsafe *here specifically*: Tim runs two hosts (Mac mini
and Umbrel), so concurrent connects are the normal case, not an edge case.
- `hyperdht` does compute this, but privately. `lib/connect.js:102` holds
`relaySocket: null` on the internal connection state and
`lib/connect.js:489` sets `c.connect.relayed`, but `connect()` returns only
`encryptedSocket` (`lib/connect.js:129`) and Hyperswarm's `connection` event
surfaces neither. Sniffing `socket.rawStream`'s remote host against the relay
address would work but depends on an internal with no contract.
Note `relayed` at `lib/connect.js:225` is `diffAddress(serverAddress, relayAddress)`
and means something different (address discovery), so it is not the signal either.
**What does work.** `hyperswarm/index.js:210`:
```js
const relayThrough = this._maybeRelayConnection(peerInfo.forceRelaying)
```
Hyperswarm resolves the relay decision **per peer connect, with `peerInfo` in scope**,
then drops it: `_maybeRelayConnection(force)` calls `this.relayThrough(force, this)`
(`index.js:108-109`). So the peer identity exists at exactly the right moment and is
simply not forwarded.
Fix: a one-line patch passing it through, `this.relayThrough(force, this, peerInfo)`,
so our callback becomes `(force, s, peerInfo) => ...` and can record
`peerInfo.publicKey -> "we offered the relay for this peer"`. This repo already uses
patch-package (`patches/expo-audio+1.1.1.patch`, `postinstall: patch-package`), so the
mechanism is established. It is small and generic enough to upstream, and we should
offer it upstream rather than carry it forever.
This records **what we decided**, not what the wire did, which is the honest thing to
gate on: if we handed Hyperswarm the relay key and the connection then succeeded after
a punch abort, the relay is why the user has a connection.
## Scope
Changes:
- `patches/hyperswarm+<v>.patch` - forward `peerInfo` to the `relayThrough` callback.
- `protocol/relay.js` - add a pure `relayAudioAllowed({ relayed, consent })` policy fn
beside `relayThroughFor`, so the decision is unit-testable away from the transport.
- `src/bare.js` - capture the per-peer relay decision at the `relayThrough` call site
(`:1671`) and keep it on the per-library connection state. Proposal
2026-07-26-one-connection-per-library means "is this library relayed" is a single
well-defined fact per library.
- Media path - before opening `peartune/media/1` for a track, if the library's
connection is relayed and its consent is not `allow`, do not start: emit an IPC
event and let the UI ask. Pinned/cached tracks still play (they need no connection).
- Per-library persisted `relayAudio: 'ask' | 'allow' | 'deny'`, default `'ask'`.
Declining writes `'deny'` and it STAYS (decision 3).
- UI - a two-button prompt (`Stream via relay` / `Not now`) with a
`Remember for this library` checkbox, NOT a third button (decision 2). Plus a row in
the library's own settings showing the current state, so a standing `'deny'` is
findable and reversible rather than an app that mysteriously will not play
(decision 3). The existing global `useRelay` stays as the master kill switch and
keeps its meaning: off = never relay anything, current behavior.
- Disclosure, which decision 1 makes part of this change rather than a follow-up:
`website/peartune/privacy.html` and both store listings must say that a library
reachable only via the relay has its BROWSING metadata cross the relay too, not just
its audio. Shipping the code without this would make the privacy page inaccurate.
Does NOT change:
- The wire protocol, the host, the grant model, revoke semantics.
- `relayThroughFor`'s direct-first behavior, or the global toggle's default.
- Browse, search and artwork over the relay: allowed without a prompt (decision 1).
## Compat
Phone-side only, so nothing an old peer does changes. The host never knew whether a
connection was relayed (blind-relay phase 1 finding: the host needs no code change)
and still does not.
- Existing installs: `useRelay` stays `true`; the new per-library `relayAudio` defaults
to `'ask'`. A user on a 0%-punch network sees one prompt per library, once.
- Downgrade: an older build ignores the unknown per-library field and behaves as it
does today. No migration, additive field.
- The patch is a build-time dependency change; a build without it must fail loudly
rather than silently treat every library as non-relayed. A test asserts the callback
receives a third argument.
## Verify
Unit:
- `test/relay-policy.test.js` - extend for `relayAudioAllowed`: allow/deny/ask x
relayed/direct, plus master-switch-off short-circuits everything. Per decision 1,
also assert a NON-audio request over a relayed connection is allowed with consent
still `'ask'` - that is the case a later "tighten it up" refactor would silently
break.
- A test asserting the patched `relayThrough` callback is invoked with a `peerInfo`
carrying a `publicKey`. This is the guard that fails loudly if a hyperswarm bump
drops the patch.
Hardware, and this is the part that cannot be faked. A normal build on cellular
punches directly and never relays, so reproducing the gate needs the same method
phase 3 used: a **throwaway force-relay build** (`relayThroughFor` returns the key
always, reverted after), Pixel over cellular, both hosts.
1. Decline the prompt: no audio starts, and the relay node's `bytes.relayed` does not
move for that library.
2. Accept: audio plays and the relay node's `sessions/streams/bytes` climb.
3. Re-enter the library: no second prompt.
4. A pinned album still plays with the prompt declined.
Two traps to respect, both already paid for:
- **The relay node's `journalctl` stats are ground truth, not the phone's counters.**
The phone's `dht.stats.relaying` reads 0 while relaying.
- **A flat byte counter means nothing until the sampling window has elapsed.** Read it
twice, spaced, before concluding "nothing relayed" - a mid-window read once nearly
produced the opposite conclusion.
## Rollback
Revert the branch. The per-library field is additive and ignored by the previous
build, the patch is removed by dropping the file, and `relayThroughFor` is untouched,
so the fallback returns to today's behavior with no persisted state to unwind. Nothing
here is published or irreversible.
## Decisions (Tim, 2026-07-29)
1. **Browse, search and artwork over the relay: ALLOWED without a prompt, and
disclosed.** Only audio is gated. The reasoning that won: gating browse too means a
hard-NAT user opens a library to an empty screen and a dialog, which is the
pairing-prompt problem moved one step later rather than solved. Kilobytes against
the audio's megabytes. **The disclosure is not optional** and is in scope above -
the privacy page's relay section is currently about reaching a library, and it now
has to say browsing crosses the relay too.
2. **Two buttons plus a `Remember for this library` checkbox.** No third button. A
session-only choice is arguably more honest for a friend's library, but three-button
dialogs are worse to use one-handed, and the checkbox makes the stickiness explicit
rather than implied.
3. **Declining is sticky (`'deny'`), with the state shown in that library's settings.**
Sticky avoids nagging the user who already said no; the settings row is what stops
it becoming an app that inexplicably will not play. Rejected: return-to-ask (nags
exactly the wrong person) and session-only (the user cannot tell what state they
are in).
4. **The hyperswarm patch: carry it locally, decide about upstreaming AFTER it works.**
We patch locally either way, so nothing is blocked, and offering it to Holepunch is
a separate decision that costs nothing to delay. The loud-failure test for the patch
is required regardless, since it is what stops a dependency bump silently disabling
the consent gate.
## Open questions
1. **Should the host be able to refuse relayed connections?** An owner might want "my
library is LAN-only, never via PeerLoom". Deliberately OUT OF SCOPE here: it is a
host-side grant-policy change, not a phone-side consent change, and it needs its own
proposal. Named so nothing here is designed in a way that makes it awkward later. In
particular the per-library consent field lives on the PHONE, so a future host-side
policy would be a separate independent gate rather than a migration of this one.