Embedded live streams in stories, behind a disclosure (§6.13)

Every other media path here is a sealed blob — the relay stores ciphertext and
nobody else is involved. A live stream is not that: it is continuous and hosted
elsewhere, so there is nothing to seal, and watching one means this device
connects to that host, which learns the viewer's IP address and that they
watched.

🔴 **§6.12's answer does not transfer.** A link preview works because the sender
fetches it once and puts the result inside the ciphertext. A stream cannot be
fetched once. So this is disclosure rather than prevention.

The URL rides inside the ciphertext, so the relay learns nothing about what is
watched or by whom. Playback is an explicit tap with the host named and what it
learns spelled out — a card that started playing on its own would make that
disclosure after the fact, which is not a disclosure.

⚠️ The attachment is the POSTER, not the stream. Keeping a real blob means the
strip, the unseen rings and the expiry sweep all work unchanged, and the viewer
sees a still before deciding whether to connect.

⚠️ The host is derived from the URL, never the caption — a story captioned
"youtube.com" pointing elsewhere is the phishing shape link previews already
guard against. Non-http(s) URLs and ones with no host are refused: a `file:` URL
arriving from another person is not something to hand a media player, and a
destination nobody can name is one nobody can consent to.

The wording lives in the engine rather than the UI so it can be asserted.

engine 177, client 96, app 46.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-08-29 13:14:08 -04:00
co-authored by Claude Opus 4.8
parent b8ffe2ea71
commit 1bbf8b460b
6 changed files with 244 additions and 2 deletions
@@ -0,0 +1,103 @@
package io.flamenet.e2e
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* Embedded live streams in stories (spec §6.13).
*
* 🔴 **The one media path where the viewer must contact a third party.** Every
* other one is a sealed blob; a live stream is continuous and hosted elsewhere,
* so watching it means this device connects to that host, which learns the
* viewer's address and that they watched. §6.12's answer — have the sender
* fetch it once — does not transfer, because a stream cannot be fetched once.
*
* Everything here exists to make that a decision rather than a surprise.
*/
class StreamDisclosureTest {
@Test
fun `the disclosure names the host`() {
assertTrue("live.example.com" in StreamDisclosure.text("live.example.com"))
}
/** ⚠️ Must say what the host learns, not merely that something connects. */
@Test
fun `the disclosure says what the host learns`() {
val text = StreamDisclosure.text("live.example.com")
assertTrue("IP address" in text, text)
assertTrue("watched" in text, text)
}
@Test
fun `web streams are acceptable`() {
assertTrue(StreamDisclosure.acceptable("https://live.example.com/s.m3u8"))
assertTrue(StreamDisclosure.acceptable("http://live.example.com/s.m3u8"))
}
/**
* 🔴 A `file:` URL arriving from another person is not something to hand a
* media player — playing it would read this device.
*/
@Test
fun `non-web schemes are refused`() {
assertFalse(StreamDisclosure.acceptable("file:///etc/passwd"))
assertFalse(StreamDisclosure.acceptable("javascript:alert(1)"))
assertFalse(StreamDisclosure.acceptable("rtsp://example.com/s"))
}
/** ⚠️ A destination nobody can name is one nobody can consent to. */
@Test
fun `a url with no host is refused`() {
assertFalse(StreamDisclosure.acceptable("https://"))
assertFalse(StreamDisclosure.acceptable("not a url"))
assertFalse(StreamDisclosure.acceptable(""))
}
// -- The wire ------------------------------------------------------------
private fun ref() = Attachment.Ref(
kind = Attachment.STORY, id = "abc", key = ByteArray(32) { 1 },
digest = ByteArray(32) { 2 }, bytes = 10, mime = "image/jpeg",
width = 1, height = 1, duration = 0.0, expires = 0.0)
@Test
fun `a stream url survives the wire`() {
val wire = Content.attachment(
ref(), caption = "live now", streamUrl = "https://live.example.com/s.m3u8")
val parsed = Content.parse(wire)
assertEquals("https://live.example.com/s.m3u8", parsed.streamUrl)
assertEquals("live now", parsed.text)
}
/** An ordinary story must be unchanged on the wire. */
@Test
fun `an ordinary story carries no stream`() {
val wire = Content.attachment(ref(), caption = "just a photo")
assertNull(Content.parse(wire).streamUrl)
assertFalse("stream" in wire)
}
/**
* 🔴 The host comes from the URL, never the caption. A story captioned
* "youtube.com" pointing elsewhere is the phishing shape §6.12 guards
* against.
*/
@Test
fun `the host comes from the url not the caption`() {
val story = Vault.Story(
id = "s1", authorId = 2L, mine = false, ref = ref(),
caption = "totally youtube.com", postedAt = 0L,
streamUrl = "https://evil.example/s.m3u8")
assertEquals("evil.example", story.streamHost)
}
@Test
fun `an ordinary story has no stream host`() {
val story = Vault.Story("s1", 2L, false, ref(), "photo", 0L)
assertNull(story.streamHost)
}
}