Keep the landing page grouped, and regenerate the spec pages

The new features went onto the landing page as three more cards, which
took it to nine in a flat list and tripped relay#44: the rule is to group
by what somebody is trying to do rather than add another card to the
pile. The cards there are the three products sharing one lock, and these
are not a fourth product, so they read as prose in their own band and the
card count goes back to six.

The doc pages were stale against flamenet-e2e, which moved when the
private-reaction flag and the call-reaction kind were specified. The pin
is bumped to that commit and engine.leaf and spec.leaf are regenerated
from it, which is the procedure .e2e-ref.md asks for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-12 08:33:02 -04:00
co-authored by Claude Opus 5
parent bd6428444f
commit d6f0f345e0
5 changed files with 150 additions and 72 deletions
+1 -1
View File
@@ -1 +1 @@
57732df84e8d94366374d7576a6d156cf703e432
cbdadd9f82c2b64f2c0eb1b08b8ff2e88e48a5a7
+4 -2
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from flamenet-e2e/README.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: 1d0581b4b6b91fcb -->
source-sha256: 9b98fb4d38c257ca -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="flamenet-e2e">flamenet-e2e</h1>
@@ -11,7 +11,9 @@
Dependency-free JavaScript, WebCrypto only, runs in a browser or in Node
20+.</p>
<p>This is the reference implementation of <a
href="docs/SPEC.md"><code>docs/SPEC.md</code></a>.</p>
href="docs/SPEC.md"><code>docs/SPEC.md</code></a>, the engine behind
Flamenet Messenger: <a
href="/">https://flamenetmessenger.com</a>.</p>
<h2 id="check-it-yourself">Check it yourself</h2>
<pre><code>git clone https://gitlab.com/paulhitt/flamenet-e2e &amp;&amp; cd flamenet-e2e
npm test</code></pre>
+14 -28
View File
@@ -93,34 +93,20 @@
<p class="band-lede">
Cada una es pequeña, y cada una funciona como funciona el resto de la app.
</p>
<div class="cards cards-3">
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16v10H9l-5 4z"/></svg></span>
<h3>Una reacción que solo ve esa persona</h3>
<p>
En un grupo, reacciona a quien lo escribió en vez de a toda la sala.
A nadie más se le envía.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4l4 1 1 4-2 2a12 12 0 0 0 5 5l2-2 4 1 1 4a2 2 0 0 1-2 2A17 17 0 0 1 3 6a2 2 0 0 1 2-2"/></svg></span>
<h3>Reacciona durante una llamada</h3>
<p>
Di que sí sin interrumpir. Se ve un momento y no se guarda nada.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"/><circle cx="12" cy="12" r="2.5"/><path d="M3 3l18 18"/></svg></span>
<h3>Fuera de registro</h3>
<p>
Los dos teléfonos dejan de escribir la conversación. Cierra la app y
ya no está. Lo acuerdan los dos teléfonos, así que no puede estar
activo para uno y no para el otro.
</p>
</article>
</div>
<p class="band-lede band-lede-spaced">
<strong>Una reacción que solo ve esa persona.</strong> En un grupo,
reacciona a quien lo escribió en vez de a toda la sala. A nadie más se le
envía.
</p>
<p class="band-lede band-lede-spaced">
<strong>Reacciona durante una llamada.</strong> Di que sí sin interrumpir.
Se ve un momento y no se guarda nada.
</p>
<p class="band-lede band-lede-spaced">
<strong>Fuera de registro.</strong> Los dos teléfonos dejan de escribir la
conversación. Cierra la app y ya no está. Lo acuerdan los dos teléfonos,
así que no puede estar activo para uno y no para el otro.
</p>
<p class="fineprint">
En Android desde la versión 0.17. En iPhone, en la próxima compilación de
TestFlight.
+13 -29
View File
@@ -105,35 +105,19 @@
<p class="band-lede">
Each one is small, and each one works the way the rest of the app does.
</p>
<div class="cards cards-3">
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16v10H9l-5 4z"/></svg></span>
<h3>A reaction only they see</h3>
<p>
In a group, react to the person who wrote it rather than to the room.
Nobody else is sent it.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4l4 1 1 4-2 2a12 12 0 0 0 5 5l2-2 4 1 1 4a2 2 0 0 1-2 2A17 17 0 0 1 3 6a2 2 0 0 1 2-2"/></svg></span>
<h3>React during a call</h3>
<p>
Say yes without interrupting. It shows for a moment and nothing is
saved.
</p>
</article>
<article class="card">
<span class="card-icon" aria-hidden="true"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"/><circle cx="12" cy="12" r="2.5"/><path d="M3 3l18 18"/></svg></span>
<h3>Off the record</h3>
<p>
Both phones stop writing the conversation down. Close the app and it
is gone. It is agreed between the two phones, so it cannot be on for
one of you and not the other.
</p>
</article>
</div>
<p class="band-lede band-lede-spaced">
<strong>A reaction only they see.</strong> In a group, react to the person
who wrote it rather than to the room. Nobody else is sent it.
</p>
<p class="band-lede band-lede-spaced">
<strong>React during a call.</strong> Say yes without interrupting. It
shows for a moment and nothing is saved.
</p>
<p class="band-lede band-lede-spaced">
<strong>Off the record.</strong> Both phones stop writing the conversation
down. Close the app and it is gone. It is agreed between the two phones, so
it cannot be on for one of you and not the other.
</p>
<p class="fineprint">
On Android from version 0.17. On iPhone in the next TestFlight build.
</p>
+118 -12
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from flamenet-e2e/docs/SPEC.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: 8d1cd3d583818c4f -->
source-sha256: 7e3a8724ef27a6da -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="flamenet-messenger-end-to-end-encryption-spec">Flamenet
@@ -391,6 +391,66 @@ metadata already leaks (§11).</p>
<p>An unknown <code>kind</code> MUST render as a placeholder
("unsupported attachment"), not be silently dropped, the sender meant to
say something.</p>
<p><strong>Additive top-level keys.</strong> These ride beside
<code>kind</code> and <code>caption</code>, never inside
<code>att</code>. All are optional; a receiver that does not know one
ignores it. Each was on the wire before it was written down here, which
is the gap <code>test/vectors/content-v1.json</code> now closes.</p>
<table>
<thead>
<tr>
<th>Key</th>
<th>Type</th>
<th>Meaning</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>men</code></td>
<td>array of user ids</td>
<td>Who a group message is addressed to (§6.18).</td>
</tr>
<tr>
<td><code>fn</code></td>
<td>string</td>
<td>A document's own filename, for <code>kind: "file"</code>.</td>
</tr>
<tr>
<td><code>cls</code></td>
<td>integer</td>
<td>The label a document was filed under; <code>0</code> means
none.</td>
</tr>
<tr>
<td><code>cmp</code></td>
<td>array of integers</td>
<td>Compartments within that label.</td>
</tr>
<tr>
<td><code>st</code></td>
<td><code>1</code> or <code>true</code></td>
<td>This envelope is about a story rather than a message (§6.20,
§6.22).</td>
</tr>
<tr>
<td><code>gname</code></td>
<td>string</td>
<td>The group's name, carried by the sender (§6.7).</td>
</tr>
</tbody>
</table>
<p><code>fn</code>, <code>cls</code> and <code>cmp</code> are top-level
rather than part of <code>att</code> deliberately: they describe what
the document IS to the person receiving it, and a receiver that
understands attachments but not labels must still be able to show the
file.</p>
<p><code>st</code> is written by at least one client as the integer
<code>1</code> rather than the boolean <code>true</code>. Receivers MUST
accept both. This is not a style note: several JSON libraries decline to
coerce a number to a boolean, so a receiver reading only the boolean
form answers <code>false</code> and silently files a story reaction
against a message that does not exist. The same rule applies to
<code>fwd</code> (§6.16).</p>
<h3 id="67-groups-pairwise-fan-out">6.7 Groups (pairwise fan-out)</h3>
<p>Groups add <strong>no new cryptography</strong>. A group message is
the same content envelope, encrypted independently to every member
@@ -476,11 +536,20 @@ member).</p>
<hr />
<h3 id="68-voice-calls">6.8 Voice calls</h3>
<p>Calls use <strong>WebRTC</strong> for media (DTLS-SRTP encrypts the
audio) and the <strong>ratchet as the signalling channel</strong>, three
audio) and the <strong>ratchet as the signalling channel</strong>, four
content-envelope kinds, all with <code>att</code> absent:</p>
<pre class="json"><code>{ &quot;fnc&quot;: 1, &quot;kind&quot;: &quot;call-offer&quot;, &quot;call&quot;: { &quot;id&quot;: &quot;&lt;uuid&gt;&quot;, &quot;sdp&quot;: &quot;&lt;offer sdp&gt;&quot; } }
{ &quot;fnc&quot;: 1, &quot;kind&quot;: &quot;call-answer&quot;, &quot;call&quot;: { &quot;id&quot;: &quot;&lt;uuid&gt;&quot;, &quot;sdp&quot;: &quot;&lt;answer sdp&gt;&quot; } }
{ &quot;fnc&quot;: 1, &quot;kind&quot;: &quot;call-end&quot;, &quot;call&quot;: { &quot;id&quot;: &quot;&lt;uuid&gt;&quot;, &quot;reason&quot;: &quot;hangup&quot; | &quot;declined&quot; | &quot;busy&quot; | &quot;timeout&quot; } }</code></pre>
{ &quot;fnc&quot;: 1, &quot;kind&quot;: &quot;call-end&quot;, &quot;call&quot;: { &quot;id&quot;: &quot;&lt;uuid&gt;&quot;, &quot;reason&quot;: &quot;hangup&quot; | &quot;declined&quot; | &quot;busy&quot; | &quot;timeout&quot; | &quot;answered-elsewhere&quot; | &quot;declined-elsewhere&quot; } }
{ &quot;fnc&quot;: 1, &quot;kind&quot;: &quot;call-reaction&quot;, &quot;call&quot;: { &quot;id&quot;: &quot;&lt;uuid&gt;&quot;, &quot;emoji&quot;: &quot;&lt;one emoji&gt;&quot; } }</code></pre>
<p><strong>Reactions during a call.</strong> <code>call-reaction</code>
carries one emoji for the call named by <code>call.id</code>, so
somebody can agree without interrupting whoever is talking. A client
accepts it only while that call is connecting or up, shows it briefly,
and records it nowhere: it never becomes a message in a thread and never
reaches the call log. <code>emoji</code> is absent on every other kind,
so an older build sees the envelopes it always saw, and one that does
not know the kind ignores it.</p>
<p><strong>Why this is end-to-end.</strong> The SDP carries the DTLS
certificate fingerprint that the media channel's keys are derived
against. Because the SDP travels <em>inside the Double Ratchet</em>, the
@@ -500,10 +569,20 @@ rings only while the recipient's app is polling. The caller SHOULD give
up with <code>call-end</code> / <code>timeout</code> after ~45 s. A
<code>call-offer</code> for a call already ended, or arriving while
another call is active, is answered with <code>call-end</code> /
<code>busy</code>. Multi-device: every device of the callee receives the
offer; the first <code>call-answer</code> wins and other devices stop
ringing when they see the winner's answer come back through their own
poll (or the <code>call-end</code>).</p>
<code>busy</code>.</p>
<p><strong>Multi-device.</strong> Every device of the callee receives
the offer, and the first <code>call-answer</code> to reach the caller
wins. The answer goes to the caller only, so the device that answers or
declines also sends <code>call-end</code> to its own account's other
devices, with reason <code>answered-elsewhere</code> or
<code>declined-elsewhere</code>. A device still ringing for that
<code>call.id</code> stops and does not file the call as missed; a
device already past ringing ignores it. These two reasons go only to the
account's own devices, never to the other party. A device whose call has
connected ignores a <code>call-end</code> with reason
<code>declined</code>, <code>busy</code> or <code>timeout</code>: those
come from one of the callee's other devices, which never picked up. The
callee's own ring timeout ends the ring locally and sends nothing.</p>
<p><strong>TURN.</strong> <code>GET /e2e/turn</code> (§8.10) returns
time-limited credentials for the coturn relay. Clients put both STUN and
TURN entries in their ICE configuration; media flows peer-to-peer when
@@ -561,6 +640,18 @@ people turn a messenger off over. One reaction per sender per message, a
second one replaces the first.</p>
<p><code>gid</code> applies as elsewhere: a reaction on a group message
carries the group id and routes to the group thread.</p>
<p><strong>A reaction meant for one person.</strong> In a group, a
reaction MAY be addressed to the author of the message alone:</p>
<pre class="json"><code>{ &quot;fnc&quot;: 1, &quot;kind&quot;: &quot;react&quot;, &quot;caption&quot;: &quot;&lt;emoji&gt;&quot;, &quot;priv&quot;: 1, &quot;gid&quot;: &quot;&lt;group&gt;&quot;, &quot;re&quot;: { &quot;mid&quot;: &quot;&lt;target&gt;&quot;, &quot;author&quot;: 12, &quot;preview&quot;: &quot;&quot; } }</code></pre>
<p>It is sent to that author and to nobody else: no other member of the
group receives an envelope at all. That is the whole guarantee.
<code>priv</code> exists so the author's client can label what it
received, and is NOT a request to other clients to hide something,
because a request is not privacy. A client MUST keep these apart from
the public tally rather than folding them into the same count, MUST NOT
set <code>priv</code> on a reaction it sends to a whole thread, and MUST
treat an envelope without the key as public, which is what an older
build sends.</p>
<p><strong>Status.</strong> Implemented in all three engines. iOS and
JavaScript are checked against each other in both directions by
<code>test/interop.mjs</code>, including a preview containing quotes and
@@ -905,13 +996,28 @@ Kotlin engine and Android app, including the combining rule and
per-message pruning on both.</p>
<h3 id="618-mentions">6.18 Mentions</h3>
<p>A group message MAY name the members it is addressed to, in
<code>ment</code>:</p>
<pre class="json"><code>{ &quot;fnc&quot;: 1, &quot;kind&quot;: &quot;text&quot;, &quot;gid&quot;: &quot;&lt;group&gt;&quot;, &quot;ment&quot;: [12, 34], &quot;caption&quot;: &quot;@Ada @Adam thoughts?&quot; }</code></pre>
<code>men</code>:</p>
<pre class="json"><code>{ &quot;fnc&quot;: 1, &quot;kind&quot;: &quot;text&quot;, &quot;gid&quot;: &quot;&lt;group&gt;&quot;, &quot;men&quot;: [12, 34], &quot;caption&quot;: &quot;@Ada @Adam thoughts?&quot; }</code></pre>
<blockquote>
<p><strong>This key was written <code>ment</code> here until 2026-09-11,
and no client has ever sent that.</strong> Both shipped clients emit and
read <code>men</code>, and have since mentions were added. The clients
agreed with each other and only this document disagreed, so nothing
broke and nothing could: the only thing that would have caught it is a
conformance test written from the spec, and there was none. A third
client built honestly from this page would have shipped mentions that
silently never notified anybody.</p>
<p>The key is now pinned by <code>test/vectors/content-v1.json</code>,
generated by running a client's encoder rather than by reading it. The
spec was corrected to the clients rather than the other way round: they
are deployed, they agree, and changing the wire to match a document
would break every installed copy to fix a typo.</p>
</blockquote>
<p>A mention decides whether the receiver is notified, and nothing else.
It confers no permission, changes no state, and is not evidence of
anything. This matters because <code>ment</code> is a claim by the
sender like every other field in the envelope: the worst a dishonest one
can achieve is a notification the reader is able to turn off.</p>
anything. This matters because <code>men</code> is a claim by the sender
like every other field in the envelope: the worst a dishonest one can
achieve is a notification the reader is able to turn off.</p>
<p>Which is the rule that makes mentions safe to have. A mention that
always broke through a mute would hand every member of a group a way to
interrupt somebody whenever they liked, which is how mentions become the