A story reaches this account's own devices, so reactions have somewhere to land

Posting sent the story to the audience and saved it locally. Nothing went
to this account's other devices, so a story posted on the phone did not
exist on the tablet at all.

Reactions do go to every device the author has. The other device received
all of them and had no story to attach them to, so they sat in a map
nothing reads and the person watching saw a story drawing no response. The
only way out anybody found was to stop using the second device.

Each posted story is now mirrored to this account as well, carrying the
same story key so one story stays one story across the devices. Best
effort and outside the audience count: an account with one device has
nowhere to send it and that is still a successful post.

On the way in, a story from this account is filed as this account's own,
and only from a proven sender, or an unproven envelope claiming our id
could put a stranger's story on our own strip and show us its reactions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-16 13:57:30 -04:00
co-authored by Claude Opus 5
parent 13d49fed8e
commit 38e31fc5ee
3 changed files with 125 additions and 2 deletions
@@ -1246,7 +1246,12 @@ class Session(
.randomUUID()
.toString(),
authorId = message.fromUser,
mine = false,
// A story mirrored from this account's other device is
// this account's own, and only an own story shows who
// reacted to it. Proven senders only: an unproven
// envelope asserting our own id would otherwise put a
// stranger's story on our own strip.
mine = message.senderProven && message.fromUser == userId,
ref = null,
caption = message.content.text,
postedAt = System.currentTimeMillis(),
@@ -1344,7 +1349,9 @@ class Session(
Vault.Story(
id = ref.id,
authorId = message.fromUser,
mine = false,
// As above: mirrored from this account's other device,
// and only when the sender is proven.
mine = message.senderProven && message.fromUser == userId,
ref = ref,
caption = message.content.text,
postedAt = System.currentTimeMillis(),
@@ -345,6 +345,22 @@ class Conversations(
return Sent(delivered, failed, attachment = ref)
}
/**
* Send a story this account just posted to its OWN other devices.
*
* The audience loop reaches other people. Nothing reached the tablet in
* the next room, so a story posted here did not exist there at all, while
* REACTIONS to it did: a reaction is addressed to every device the author
* has. The second device received every reaction and had no story to show
* them against, so they sat in a map nothing reads and the person saw
* silence. Best effort, an account with one device has nowhere to send
* and that is not a failed post.
*/
private fun mirrorToOwnDevices(body: String) {
val me = myUserId ?: return
runCatching { sendPlaintext(me, body) }
}
/**
* Post a story to several people at once (§6.6).
*/
@@ -388,6 +404,7 @@ class Conversations(
for (userId in toUserIds) {
if (sendPlaintext(userId, body).reachedSomeone) reached++ else missed++
}
mirrorToOwnDevices(body)
return StoryPosted(ref, reached, missed)
}
@@ -418,6 +435,7 @@ class Conversations(
for (userId in toUserIds) {
if (sendPlaintext(userId, body).reachedSomeone) reached++ else missed++
}
mirrorToOwnDevices(body)
return TextStoryPosted(key, expires, reached, missed)
}
@@ -0,0 +1,98 @@
package io.flamenet.client
import io.flamenet.e2e.DeviceIdentity
import io.flamenet.e2e.Vault
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import javax.crypto.KeyGenerator
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* A story has to reach this account's OWN other devices.
*
* It did not. The audience loop reached other people and the story was then
* saved locally, so a second device never learned the story existed. Reactions
* are addressed to every device the author has, so that device received all of
* them and had nothing to show them against. What the person saw was a story
* that drew no reactions, and the only way out they found was to stop using
* the other device.
*/
class StoryReachesOwnDevicesTest {
private class MapStorage : Vault.Storage {
private val records = LinkedHashMap<String, ByteArray>()
override fun read(name: String) = records[name]
override fun write(
name: String,
bytes: ByteArray,
) {
records[name] = bytes
}
override fun delete(name: String) {
records.remove(name)
}
override fun names() = records.keys.toList()
}
private lateinit var server: MockWebServer
private lateinit var vault: Vault
private lateinit var me: DeviceIdentity
@BeforeTest fun start() {
server = MockWebServer().also { it.start() }
vault =
Vault(
MapStorage(),
KeyGenerator.getInstance("AES").apply { init(256) }.generateKey(),
)
me = DeviceIdentity.create()
}
@AfterTest fun stop() = server.shutdown()
private fun emptyDirectory() =
MockResponse()
.setResponseCode(200)
.setHeader("Content-Type", "application/json")
.setBody("""{"devices":[]}""")
@Test
fun `posting a text story addresses this account's own devices`() {
val conversations =
Conversations(me, vault, RelayClient(server.url("/").toString(), "a", "b"))
.also { it.myUserId = 4242 }
// One for the audience, one for the mirror.
server.enqueue(emptyDirectory())
server.enqueue(emptyDirectory())
conversations.postTextStory("posted from the phone", listOf(99L))
val asked = generateSequence { server.takeRequest(1, java.util.concurrent.TimeUnit.SECONDS) }
.map { it.path.orEmpty() }
.toList()
assertTrue(
asked.any { it.endsWith("/e2e/devices/4242") },
"the story never asked for this account's own devices, it reached $asked",
)
}
@Test
fun `an account that has not said who it is mirrors nothing`() {
val conversations =
Conversations(me, vault, RelayClient(server.url("/").toString(), "a", "b"))
server.enqueue(emptyDirectory())
conversations.postTextStory("posted from the phone", listOf(99L))
val asked = generateSequence { server.takeRequest(1, java.util.concurrent.TimeUnit.SECONDS) }
.map { it.path.orEmpty() }
.toList()
assertTrue(asked.size == 1, "an unknown account id sent somewhere, it reached $asked")
}
}