Merge release/0-32 into main: /today.json, the /hi page, and the badge that counts messages
ci / checks (pull_request) Successful in 21s
ci / swift (pull_request) Failing after 3m54s
ci / image (pull_request) Skipped
ci / deploy (pull_request) Skipped

14 conflict hunks across 8 files. Wave 2 landed on main while this branch sat,
and the two sides rewrote the same send path in different directions.

Routes.swift and RoutesFederation.swift: wave 2 extracted the write, the wake
and the push into `Delivery.write`, and this branch still had the inline
version plus one thing wave 2 had no idea about, the `announced` column. The
extraction is kept; `Delivery.Item` gains `announced`, defaulting false as
`Envelope.init` does, and `Delivery.insert` writes it. Without that the model,
the migration and `badgeCount` would all have merged cleanly and every badge
would have read zero, because nothing on the surviving path ever set the
column. The submit route passes `!silent`; a federated deposit passes false,
since nothing announced it.

docs/CONFIGURATION.md: the union. Main lists six outbound connections with the
setting that enables each; this branch added the Today fetch and dropped the
other five. Seven are listed now.

PostgresMigrationTests: both sides' tests, with the `LockedRows` actor left
last in the file.

.e2e-ref moves to d0274ca0, the current flamenet-e2e main. The old pin was
310c315, which was a branch head and not on main at all.

The three generated doc pages were not resolved by hand. `check-doc-pages.py`
reported spec, audit, engine and self-hosting all stale against their sources,
so `Scripts/build-doc-pages.sh` ran against the new pin and all nine pages now
match. That also fixes the spec page's section numbers: flamenet-e2e moved
anonymous reads and contact links to 8.13 and 8.14 to make room for the push
token and the Today file.

Eight more references renumbered by hand, in Wire, RoutesAnonymous,
RoutesDiscovery, ContactLinkPageTests and AnonymousReadsTests. The remaining
8.11 and 8.12 references are about push and the Today file and are correct.

`swift build` is clean on Swift 6.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-10-14 12:34:32 -04:00
co-authored by Claude Opus 5
38 changed files with 3953 additions and 127 deletions
+1 -1
View File
@@ -1 +1 @@
310c315155833ef964962d55d74b6bb9faa3ab49
d0274ca05ea904d50b6f855aab3267c61cf45f6c
+20
View File
@@ -315,3 +315,23 @@ FN_RELAY_REPLICAS=1
# verification hash is worse than none.
# FN_ANDROID_SHA256=
# FN_ANDROID_NOTES=What changed in this build.
# ---- The Today file (optional) ----------------------------------------------
#
# One public file at /today.json, the same bytes for everybody: headlines,
# markets and weather, built and signed elsewhere. The relay serves it only
# after the signature checks against FN_TODAY_KEYS, and a newer copy that fails
# the check never replaces one that passed. With no source set, /today.json
# answers 404 "not_offered".
#
# Set ONE source. FN_TODAY_FILE is a path (inside the container, so mount it),
# read again whenever the file changes; replace it by renaming a new file over
# it. FN_TODAY_SOURCE is an http(s) URL fetched every fifteen minutes; to mirror
# another relay's file, point it at that relay's /today.json.
FN_TODAY_FILE=
FN_TODAY_SOURCE=
# Comma-separated base64 Ed25519 public keys the file may be signed with.
FN_TODAY_KEYS=
# Where the last good copy is kept, so a restart can serve it at once.
# /data/today-kept.json sits on the volume the compose files keep.
FN_TODAY_CACHE=
+94
View File
@@ -0,0 +1,94 @@
// The hello-link page (§6.34).
//
// A hello link carries an address after the #, the part of a link a browser
// keeps to itself. This reads it there, checks that it is an address, and
// shows it. The address is written to the page and to the app link,
// flamenetmessenger://hi#<address>, and to nothing else: no request, no
// beacon, no image, no other link.
(function (global) {
// fnid: and 52 characters of the apps' Crockford base32 alphabet (no i, l,
// o or u), then @ and the relay's host: the same rule both apps apply.
var FNID = /^fnid:[0-9a-hjkmnp-tv-z]{52}$/;
var HOST = /^[a-z0-9.-]+$/;
// The address in a fragment, or null. Percent-decoded once, and no space
// anywhere in it. The host is lowered by ASCII rules only, A to Z and
// nothing else: toLowerCase folds a Kelvin sign into "k", which would turn
// a host the apps refuse into one they accept, so any non-ASCII character
// is left as it is and fails the host check. The fnid is not lowered,
// because the apps read it only in lower case. 400 characters is far past
// any real address.
function parseHelloAddress(fragment) {
var raw = String(fragment || '');
if (raw.charAt(0) === '#') raw = raw.slice(1);
if (raw.length === 0 || raw.length > 400) return null;
try {
raw = decodeURIComponent(raw);
} catch (e) {
return null;
}
if (/\s/.test(raw)) return null;
var at = raw.indexOf('@');
if (at < 0 || raw.indexOf('@', at + 1) >= 0) return null;
var fnid = raw.slice(0, at);
var host = raw.slice(at + 1).replace(/[A-Z]/g, function (c) {
return String.fromCharCode(c.charCodeAt(0) + 32);
});
if (!FNID.test(fnid) || !HOST.test(host) || host.indexOf('.') < 0) return null;
return fnid + '@' + host;
}
global.parseHelloAddress = parseHelloAddress;
if (typeof document === 'undefined') return;
var found = document.getElementById('hi-found');
var missing = document.getElementById('hi-missing');
var shown = document.getElementById('hi-address');
var open = document.getElementById('hi-open');
var copy = document.getElementById('hi-copy');
var note = document.getElementById('hi-copy-note');
if (!found || !missing || !shown || !open || !copy || !note) return;
var address = null;
function render() {
address = parseHelloAddress(location.hash);
note.textContent = '';
// textContent, never markup: whatever the link says is shown as text.
shown.textContent = address || '';
open.setAttribute('href', address ? 'flamenetmessenger://hi#' + address : 'flamenetmessenger://hi');
found.hidden = !address;
missing.hidden = !!address;
}
copy.addEventListener('click', function () {
if (!address) return;
function say(key) {
note.textContent = copy.getAttribute(key) || '';
}
// Selected, so a long press or a keyboard shortcut can take it from there.
function byHand() {
var range = document.createRange();
range.selectNodeContents(shown);
var selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
var copied = false;
try {
copied = document.execCommand('copy');
} catch (e) {
copied = false;
}
say(copied ? 'data-copied' : 'data-select');
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(address).then(function () { say('data-copied'); }, byHand);
} else {
byHand();
}
});
// A second hello link opened in the same tab changes only the fragment, and
// the page does not reload. Without this it would go on showing the first.
window.addEventListener('hashchange', render);
render();
})(this);
+12
View File
@@ -867,3 +867,15 @@ a.platform:hover { background: linear-gradient(180deg, #FFFFFF 0%, #F6F3E9 52%,
}
/* The front page's throwaway poll. */
/* The hello-link page. An address is some sixty characters with no space in
them, so it may break at any character; unbroken, it would push the column
sideways. The hidden rule is here because .btn and friends set their own
display. */
.hi-address { display: inline-block; word-break: break-all; font-size: 12px; padding: 2px 5px; }
.hi [hidden] { display: none; }
/* Open and Copy stay in one row on a phone as well: the row is a flex box,
and the longer label wraps inside its own button. */
.hi .cta-row { display: flex; gap: 6px; }
.hi .cta-row .btn { margin: 0; text-align: center; }
+28 -3
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/API.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: a1111a39ae2bb75a -->
source-sha256: bb629ec09b19747a -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="relay-api">Relay API</h1>
@@ -84,6 +84,31 @@ relay's users could read would be useless to the peers it exists to
inform, and a transparency log only its own users could audit would not
be a transparency log.</p>
<hr />
<h2 id="the-today-file">The Today file</h2>
<table>
<thead>
<tr>
<th>Route</th>
<th>Auth</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>GET /today.json</code></td>
<td>none</td>
<td>The signed Today file, when this relay offers one (§8.12)</td>
</tr>
</tbody>
</table>
<p>The same bytes for everybody, served exactly as they were signed and
only after the signature checks against <code>FN_TODAY_KEYS</code>. 404
<code>{"error":"not_offered"}</code> when no source is configured, 503
while nothing has verified yet. A 200 carries an <code>ETag</code> and
<code>Cache-Control: public, max-age=900</code>, and
<code>If-None-Match</code> gets a 304. Nothing else is read from the
request: no credential, no cookie, no query.</p>
<hr />
<h2 id="accounts">Accounts</h2>
<p>Present only when this relay runs the built-in identity provider
(<code>FN_ISSUER_PRIVATE_KEY</code> set).</p>
@@ -682,8 +707,8 @@ refused with <code>kept_on_device</code>: those live on the device</td>
this document says a route not in it is not served: <code>/</code>,
<code>/es</code>, <code>/status</code>, <code>/status.json</code>,
<code>/health</code>, <code>/releases</code>, <code>/join/{code}</code>,
<code>/donate</code>, <code>/es/donate</code>,
<code>POST /donate/intent</code>,
<code>/hi</code>, <code>/es/hi</code>, <code>/donate</code>,
<code>/es/donate</code>, <code>POST /donate/intent</code>,
<code>/downloads/android-latest.json</code>, <code>/robots.txt</code>,
<code>/sitemap.xml</code>, <code>/llms.txt</code>,
<code>/llms-full.txt</code>, <code>/.well-known/security.txt</code>, and
+5 -5
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from flamenet-e2e/docs/AUDIT-SCOPE.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: feffcd7205d61bdf -->
source-sha256: 32e1c931312304d4 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="audit-scope">Audit scope</h1>
@@ -20,7 +20,7 @@ directly.</p>
<tbody>
<tr>
<td><a
href="https://gitlab.com/extant2000/flamenet-e2e">flamenet-e2e</a>
href="https://git.extant2000.com/extant2000/flamenet-e2e">flamenet-e2e</a>
<code>src/</code></td>
<td>JavaScript</td>
<td>Reference cryptographic engine. Normative where implementations
@@ -28,7 +28,7 @@ disagree. This repository.</td>
</tr>
<tr>
<td><a
href="https://gitlab.com/extant2000/flamenet-messenger-ios">flamenet-messenger-ios</a>
href="https://git.extant2000.com/extant2000/flamenet-messenger-ios">flamenet-messenger-ios</a>
<code>Sources/E2E/</code></td>
<td>Swift (CryptoKit)</td>
<td>iOS engine and app. Checked against the reference in both
@@ -36,7 +36,7 @@ directions.</td>
</tr>
<tr>
<td><a
href="https://gitlab.com/extant2000/flamenet-messenger-android">flamenet-messenger-android</a>
href="https://git.extant2000.com/extant2000/flamenet-messenger-android">flamenet-messenger-android</a>
<code>engine/</code></td>
<td>Kotlin</td>
<td>Android engine and app. Held to the published vectors rather than to
@@ -44,7 +44,7 @@ the other two engines.</td>
</tr>
<tr>
<td><a
href="https://gitlab.com/extant2000/flamenet-relay">flamenet-relay</a></td>
href="https://git.extant2000.com/extant2000/flamenet-relay">flamenet-relay</a></td>
<td>Swift (Vapor)</td>
<td>The relay. Holds no key that opens an encrypted message.</td>
</tr>
+71 -1
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from docs/CONFIGURATION.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: 9135be46efeade65 -->
source-sha256: 990bef688fcc2b5c -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="configuring-a-relay">Configuring a relay</h1>
@@ -395,6 +395,9 @@ needs <code>FN_FEDERATION_KEY</code>.</li>
<code>FN_DONATE_SECRET</code> is set.</li>
<li>Attachments, to an S3-compatible store, when
<code>FN_S3_ENDPOINT</code> is set.</li>
<li>The Today file, to the address in <code>FN_TODAY_SOURCE</code>,
about every fifteen minutes. Set <code>FN_TODAY_FILE</code> instead to
serve one from disk and fetch nothing.</li>
</ul>
<p>It also connects to the databases you point it at: Postgres at
<code>FN_POSTGRES_URL</code> and Redis at <code>FN_REDIS_URL</code>.
@@ -950,6 +953,73 @@ looking wrong. With this set, the publishable key stays the
</tr>
</tbody>
</table>
<h2 id="the-today-file">The Today file</h2>
<p>Off. When it is on, the relay serves one public file at
<code>GET /today.json</code> (§8.12): headlines, markets, weather and
the like, built and signed somewhere else, and the same bytes for
everybody. The relay checks the signature and serves the file exactly as
it arrived.</p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Default</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>FN_TODAY_FILE</code></td>
<td>unset</td>
<td>Path to the signed file. Read again whenever it changes (its
modification time, size or inode). Write a new file and rename it over
the old one, so the relay never reads a half-written one. Relative paths
resolve under <code>FN_WORKDIR</code>.</td>
</tr>
<tr>
<td><code>FN_TODAY_SOURCE</code></td>
<td>unset</td>
<td>An <code>http</code> or <code>https</code> URL to fetch the file
from instead, every 15 minutes give or take a minute, with a 10 second
limit and a 512 KB cap. Set this or <code>FN_TODAY_FILE</code>, not
both: with both set nothing is served and the log says why. A
self-hosted relay mirrors the official file by pointing this at another
relay's <code>/today.json</code>.</td>
</tr>
<tr>
<td><code>FN_TODAY_KEYS</code></td>
<td>unset</td>
<td>Comma-separated base64 Ed25519 public keys the file may be signed
with. An envelope names its key by the first eight hex characters of the
key's SHA-256. A file signed by any other key, or by none, is never
served.</td>
</tr>
<tr>
<td><code>FN_TODAY_CACHE</code></td>
<td>unset</td>
<td>Path where the last file that verified is kept, so a restart can
serve it straight away. It is checked against <code>FN_TODAY_KEYS</code>
again when it is read back. Relative paths resolve under
<code>FN_WORKDIR</code>.</td>
</tr>
</tbody>
</table>
<p>Nothing is served until it verifies, and a file that fails the check
never replaces one that passed. A bad signature, a key that is not
listed, a file over 512 KB, a source that is down or slow: the relay
goes on serving the last good file, and logs the refusal, once for each
new reason. With no source set, <code>/today.json</code> answers 404
<code>{"error":"not_offered"}</code>; with a source set and nothing
verified yet, 503.</p>
<p>The route reads no credential, no cookie and no query, and writes no
log line per request. A good answer carries
<code>Cache-Control: public, max-age=900</code> and an
<code>ETag</code>, and <code>If-None-Match</code> gets a 304.</p>
<p><code>FN_TODAY_SOURCE</code> makes the relay open a connection of its
own: one GET to that URL every fifteen minutes. Whatever comes back
still has to verify, so the host at that URL cannot change what the file
says. It can only hold back a newer file or hand back an older signed
one.</p>
<h2 id="iphone-push-apns">iPhone push (APNs)</h2>
<p>Only the iOS app needs this; Android is woken directly. All five must
be set together, and the relay logs that push is disabled if any is
+8 -6
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: 6cc4ad7c77fbc6f2 -->
source-sha256: 5eab873f2de2e473 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="flamenet-e2e">flamenet-e2e</h1>
@@ -15,7 +15,7 @@ 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/extant2000/flamenet-e2e &amp;&amp; cd flamenet-e2e
<pre><code>git clone https://git.extant2000.com/extant2000/flamenet-e2e &amp;&amp; cd flamenet-e2e
npm test</code></pre>
<pre><code>274 passed, 0 failed</code></pre>
<p>No install step, no dependencies to fetch, nothing to trust before it
@@ -33,7 +33,7 @@ one:</p>
<tbody>
<tr>
<td>iOS (CryptoKit)</td>
<td><code>test/interop.mjs</code>, 77 assertions, both directions</td>
<td><code>test/interop.mjs</code>, 92 assertions, both directions</td>
<td>v1 + post-quantum</td>
</tr>
<tr>
@@ -43,7 +43,7 @@ one:</p>
</tr>
<tr>
<td>iOS ⇄ Android directly</td>
<td><code>test/interop-swift-kotlin.mjs</code>, 48 assertions</td>
<td><code>test/interop-swift-kotlin.mjs</code>, 77 assertions</td>
<td>v1 + post-quantum</td>
</tr>
</tbody>
@@ -84,7 +84,7 @@ for a sealed message.</li>
sent-copies</strong>, both client-side only: there is no server route
for either, by design.</li>
<li><strong>The server is published too</strong>, as <a
href="https://gitlab.com/extant2000/flamenet-relay">flamenet-relay</a>:
href="https://git.extant2000.com/extant2000/flamenet-relay">flamenet-relay</a>:
its own repository, with its own pipeline. Self-hosting is not a claim
you have to take on trust: the relay is the same code, published.</li>
</ul>
@@ -198,7 +198,9 @@ every change, because each needs something <code>npm test</code> cannot
assume: <code>federation</code> and <code>multihoming</code> want a
second relay, <code>interop-kotlin</code> and
<code>interop-swift-kotlin</code> an Android toolchain,
<code>fleet</code> and <code>load</code> a throwaway stack.</p>
<code>fleet</code> and <code>load</code> a throwaway stack, and
<code>relay-today</code> a relay built with the Today file route
(§8.12).</p>
<p>Server-side behaviour is covered by the relay's own Swift test target
alongside <code>test/relay.mjs</code>.</p>
<h2 id="protocol-versions">Protocol versions</h2>
+68
View File
@@ -0,0 +1,68 @@
#extend("layout"):
#export("content"):
<!-- The same page as ../hi.leaf, in Spanish. The address is after the #("#")
in the link, which a browser never sends, so Public/hi.js fills it in on
the device. -->
<section class="band band-hero hi">
<div class="wrap">
<span class="eyebrow">Enlace de saludo</span>
<h1>Saluda en <span class="hl">Flamenet Messenger</span></h1>
<div id="hi-found" hidden>
<p class="band-lede">Alguien compartió esta dirección para que puedas escribirle:</p>
<p><code class="hi-address" id="hi-address"></code></p>
<p class="cta-row">
<a class="btn btn-primary" id="hi-open" href="flamenetmessenger://hi">Abrir en Flamenet Messenger</a>
<button type="button" class="btn" id="hi-copy" data-copied="Copiada." data-select="La dirección está seleccionada. Cópiala desde tu navegador.">Copiar la dirección</button>
</p>
<p class="fineprint" id="hi-copy-note" aria-live="polite"></p>
<p class="fineprint">
Esta página nunca envía la dirección a ningún sitio. La lee de la parte
del enlace que va después del #("#"), que tu navegador no envía a ningún
servidor.
</p>
</div>
<div id="hi-missing" hidden>
<p class="band-lede">Este enlace no trae ninguna dirección.</p>
<p>
Un enlace de saludo termina con #("#") y una dirección, la que la
aplicación muestra en su tarjeta de dirección. A este le falta esa parte,
o lo que va después del #("#") no es una dirección. Pídele el enlace otra
vez a quien te lo mandó.
</p>
</div>
<noscript>
<p class="band-lede">
Esta página necesita JavaScript para mostrar la dirección. La dirección es
la parte del enlace que va después del #("#"), y tu navegador se la guarda
para sí.
</p>
</noscript>
</div>
</section>
<section class="band">
<div class="wrap">
<h2>Descarga la aplicación</h2>
<p>Si todavía no la tienes en el teléfono, instálala y vuelve a abrir este enlace.</p>
<div class="platforms">
#if(iosLive && iosStoreURL != ""):<a class="platform" href="#(iosStoreURL)" rel="noopener"><span class="platform-name">iPhone</span><span class="platform-state">Descargar en el App Store</span></a>#else:<a class="platform" href="https://testflight.apple.com/join/SZF8uKU6" rel="noopener"><span class="platform-name">iPhone</span><span class="platform-state">Únete a la beta de TestFlight</span></a>#endif
<a class="platform" href="/downloads/flamenet-messenger.apk" download>
<span class="platform-name">Android</span>
<span class="platform-state">Descargar la aplicación (APK)</span>
</a>
<a class="platform" href="/es/get">
<span class="platform-name">Otras formas</span>
<span class="platform-state">Todas las descargas, explicadas</span>
</a>
</div>
</div>
</section>
<script src="/hi.js?v=#(assetVersion)"></script>
#endexport
#endextend
+3 -1
View File
@@ -16,6 +16,7 @@
<li>Guarda <strong>los datos de tu cuenta</strong>: nombre de usuario, correo, contraseña (guardada revuelta) y la mitad pública de tus claves.</li>
<li><strong>No</strong> guarda cuándo añadiste a un amigo, bloqueaste a alguien o entraste en un grupo. Eso se quitó a propósito.</li>
<li>Sus propios registros no contienen <strong>ninguna cuenta, dispositivo ni dirección</strong>, salvo que un operador active a propósito el registro detallado.</li>
#if(todayOffered):<!-- DRAFT: the line below waits for the owner to approve its wording, and for a native speaker, before any deploy. --><li>También sirve <strong>un único archivo público de Hoy</strong>, el mismo para todo el mundo. Tu aplicación lo pide sin iniciar sesión, así que pedirlo no le dice al servidor nada que no vea ya.</li>#endif
</ul>
<p>El resto de esta página es la misma respuesta con cada campo escrito uno por uno.</p>
@@ -34,7 +35,8 @@
<tr><td>Adjuntos (fotos, notas de voz, historias, todo cifrado)</td><td>Los dispositivos los descargan aparte del sobre que los referencia</td><td>#(retention.attachmentDays) días</td></tr>
<tr><td>Presentaciones</td><td>Cuando alguien que todavía no tiene forma de escribirte te busca por tu nombre de usuario, o abre uno de tus enlaces de contacto, el servidor le da un pase válido para un mensaje sellado a ti. Guarda el hash del pase y a qué cuenta da acceso. No anota quién preguntó, desde dónde ni cuándo. Las aplicaciones todavía no lo usan.</td><td>Cuando se usa el pase, o 10 minutos después de crearlo</td></tr>
<tr><td><strong>&ldquo;Este es urgente&rdquo;</strong>, una marca en el sobre, <strong>no se guarda</strong></td><td>Una llamada tiene que sonar mientras todavía está sonando, así que el remitente le dice al servidor que envíe la notificación con prioridad alta en vez de esperar a que la aplicación consulte. En la práctica, eso significa que el servidor distingue una llamada de un mensaje: no quién llama, ni sobre qué, pero sí que este es urgente. Es lo más informativo que un remitente decide revelar, y es una decisión: nada obliga a poner la marca.</td><td>Nunca se escribe. Se lee para decidir la notificación y se va con la petición.</td></tr>
<tr><td><strong>&ldquo;Este no necesita atención&rdquo;</strong>, la misma forma, <strong>no se guarda</strong></td><td>La otra cara de la misma moneda: el remitente dice que un sobre es mantenimiento y no algo por lo que interrumpir a una persona, así que el servidor envía un despertar en segundo plano en vez de una alerta. Eso le dice más o menos lo mismo que la marca de arriba, al revés. La alternativa, no enviar nada y dejar que la aplicación decida tras descifrar, significa depender de notificaciones silenciosas que iOS limita, así que el trato honesto es una pequeña revelación a cambio de notificaciones que sí llegan.</td><td>Nunca se escribe, igual que arriba.</td></tr>
<!-- DRAFT: this row waits for a native speaker to check it before any deploy. -->
<tr><td><strong>&ldquo;Este no necesita atención&rdquo;</strong>, la misma forma, <strong>se guarda como un sí o un no en el sobre</strong></td><td>La otra cara de la misma moneda: el remitente dice que un sobre es mantenimiento y no algo por lo que interrumpir a una persona, así que el servidor envía un despertar en segundo plano en vez de una alerta. Eso le dice más o menos lo mismo que la marca de arriba, al revés. La alternativa, no enviar nada y dejar que la aplicación decida tras descifrar, significa depender de notificaciones silenciosas que iOS limita, así que el trato honesto es una pequeña revelación a cambio de notificaciones que sí llegan. El servidor guarda la respuesta en el sobre que espera, para que el número del icono de la aplicación en el iPhone cuente mensajes y no los acuses y las claves que viajan con ellos.</td><td>Con el sobre del que forma parte: #if(retention.deleteOnAck):en cuanto tu aplicación confirma la recepción#else:#(retention.deliveredDays) día(s) después de entregarse#endif, o #(retention.undeliveredDays) días si nunca se recoge.</td></tr>
<tr><td>Contadores del límite de registros (por IP)</td><td>Las cuentas nuevas están limitadas por dirección y por hora, y un contador necesita la dirección</td><td>una hora; el contador solo vive su ventana</td></tr>
</tbody>
</table>
+65
View File
@@ -0,0 +1,65 @@
#extend("layout"):
#export("content"):
<!-- Where a hello link lands. The address is after the #("#") in the link, which a
browser never sends, so the server renders this same page for every link
and Public/hi.js fills it in on the device. -->
<section class="band band-hero hi">
<div class="wrap">
<span class="eyebrow">Hello link</span>
<h1>Say hello on <span class="hl">Flamenet Messenger</span></h1>
<div id="hi-found" hidden>
<p class="band-lede">Somebody shared this address so you can message them:</p>
<p><code class="hi-address" id="hi-address"></code></p>
<p class="cta-row">
<a class="btn btn-primary" id="hi-open" href="flamenetmessenger://hi">Open in Flamenet Messenger</a>
<button type="button" class="btn" id="hi-copy" data-copied="Copied." data-select="The address is selected. Copy it from your browser.">Copy address</button>
</p>
<p class="fineprint" id="hi-copy-note" aria-live="polite"></p>
<p class="fineprint">
This page never sends the address anywhere. It reads it from the part of
the link after the #("#"), which your browser does not send to any server.
</p>
</div>
<div id="hi-missing" hidden>
<p class="band-lede">This link has no address in it.</p>
<p>
A hello link ends with #("#") and an address, the one the app shows on its
address card. This one is missing that part, or what comes after the #("#")
is not an address. Ask the person who sent it for the link again.
</p>
</div>
<noscript>
<p class="band-lede">
This page needs JavaScript to show the address. The address is the part
of the link after the #("#"), which your browser keeps to itself.
</p>
</noscript>
</div>
</section>
<section class="band">
<div class="wrap">
<h2>Get the app</h2>
<p>If it is not on your phone yet, install it, then open this link again.</p>
<div class="platforms">
#if(iosLive && iosStoreURL != ""):<a class="platform" href="#(iosStoreURL)" rel="noopener"><span class="platform-name">iPhone</span><span class="platform-state">Download on the App Store</span></a>#else:<a class="platform" href="https://testflight.apple.com/join/SZF8uKU6" rel="noopener"><span class="platform-name">iPhone</span><span class="platform-state">Join the TestFlight beta</span></a>#endif
<a class="platform" href="/downloads/flamenet-messenger.apk" download>
<span class="platform-name">Android</span>
<span class="platform-state">Download the app (APK)</span>
</a>
<a class="platform" href="/get">
<span class="platform-name">Other ways</span>
<span class="platform-state">Every download, explained</span>
</a>
</div>
</div>
</section>
<script src="/hi.js?v=#(assetVersion)"></script>
#endexport
#endextend
+1
View File
@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>#(title)</title>
<meta name="description" content="#(description)">
#if(noindex):<meta name="robots" content="noindex">#endif
<meta property="og:title" content="#(title)">
<meta property="og:description" content="#(description)">
<meta property="og:type" content="website">
+2 -1
View File
@@ -17,6 +17,7 @@
<li>It keeps <strong>your account details</strong>: username and the public half of your keys. On this server your password check and any recovery code live with the shared account service, and there is no email. A server with its own accounts also keeps your email and your password, stored scrambled.</li>
<li>It does <strong>not</strong> keep a record of when you added a friend, blocked someone, or joined a group. Those were removed on purpose.</li>
<li>Its own logs contain <strong>no account, device or address</strong> unless an operator deliberately turns on detailed logging.</li>
#if(todayOffered):<!-- DRAFT: the line below waits for the owner to approve its wording before any deploy. --><li>It also serves <strong>one public Today file</strong>, the same for everybody. Your app asks for it without signing in, so asking tells the server nothing it does not already see.</li>#endif
</ul>
<p>The rest of this page is the same answer with every field spelled out.</p>
@@ -35,7 +36,7 @@
<tr><td>Attachment blobs (photos, voice notes, stories, all ciphertext)</td><td>Devices fetch them separately from the envelope that references them</td><td>#(retention.attachmentDays) days</td></tr>
<tr><td>Introductions</td><td>When somebody with no way to reach you yet looks you up by your username, or opens one of your contact links, the server hands them a pass good for one sealed message to you. It keeps the hash of the pass and which account it opens. It does not record who asked, from where, or when. The apps do not use this yet.</td><td>When the pass is used, or 10 minutes after it was made</td></tr>
<tr><td><strong>&ldquo;This one is urgent&rdquo;</strong>, a flag on the envelope, <strong>not stored</strong></td><td>A call has to ring while it is still ringing, so the sender tells the server to push at high priority instead of waiting for the app to poll. In practice that means the server can tell a call from a message, not who is calling, or about what, but that this one is time-critical. It is the single most informative thing a sender chooses to reveal, and it is a choice: nothing forces the flag on.</td><td>Not stored as a value. But it leaves a trace, and saying &ldquo;never written down&rdquo; was wrong: every row in <strong>Ring attempts</strong> below is written only for an urgent envelope, so a row existing for your device is the flag. That is 7 days, with the device and the time, and no content.</td></tr>
<tr><td><strong>&ldquo;This one needs no attention&rdquo;</strong>, the same shape, <strong>not stored</strong></td><td>The other side of the same coin: the sender says an envelope is housekeeping rather than something a person should be interrupted for, so the server sends a background wake instead of an alert. That tells it roughly as much as the flag above does, inverted. The alternative, sending nothing and letting the app decide after it decrypts, means relying on silent pushes that iOS throttles, so the honest trade is a small disclosure for notifications that actually arrive.</td><td>Never written down, and unlike the flag above this one really does leave no trace: it selects the kind of push and nothing records that it was set.</td></tr>
<tr><td><strong>&ldquo;This one needs no attention&rdquo;</strong>, the same shape, <strong>kept as one yes-or-no on the envelope</strong></td><td>The other side of the same coin: the sender says an envelope is housekeeping rather than something a person should be interrupted for, so the server sends a background wake instead of an alert. That tells it roughly as much as the flag above does, inverted. The alternative, sending nothing and letting the app decide after it decrypts, means relying on silent pushes that iOS throttles, so the honest trade is a small disclosure for notifications that actually arrive. The server keeps the answer on the waiting envelope so the number on the iPhone app's icon counts messages, not the receipts and keys that travel with them.</td><td>With the envelope it belongs to: #if(retention.deleteOnAck):the moment your app confirms receipt#else:#(retention.deliveredDays) day(s) once delivered#endif, or #(retention.undeliveredDays) days if never collected.</td></tr>
<tr><td>Sign-up throttle counters (per IP)</td><td>New accounts are limited per address per hour, and a counter needs the address</td><td>one hour, the counter lives only for its window</td></tr>
</tbody>
</table>
+6 -6
View File
@@ -3,7 +3,7 @@
<!-- GENERATED by Scripts/build-doc-pages.sh from flamenet-e2e/SELF-HOSTING.md.
Edits made HERE are deleted the next time that script runs.
Change the markdown source and re-run it.
source-sha256: 5f3dd761a60d7f48 -->
source-sha256: ffb090338af8b300 -->
<article class="band doc doc-generated">
<div class="wrap wrap-narrow">
<h1 id="self-hosting">Self-hosting</h1>
@@ -23,23 +23,23 @@ licence.</p>
<tbody>
<tr>
<td><a
href="https://gitlab.com/extant2000/flamenet-relay">flamenet-relay</a></td>
href="https://git.extant2000.com/extant2000/flamenet-relay">flamenet-relay</a></td>
<td>The server you are about to run</td>
</tr>
<tr>
<td><a
href="https://gitlab.com/extant2000/flamenet-e2e">flamenet-e2e</a></td>
href="https://git.extant2000.com/extant2000/flamenet-e2e">flamenet-e2e</a></td>
<td>The protocol: JavaScript engine, specification, published test
vectors</td>
</tr>
<tr>
<td><a
href="https://gitlab.com/extant2000/flamenet-messenger-ios">flamenet-messenger-ios</a></td>
href="https://git.extant2000.com/extant2000/flamenet-messenger-ios">flamenet-messenger-ios</a></td>
<td>The iOS app, and the source of truth for all of the above</td>
</tr>
<tr>
<td><a
href="https://gitlab.com/extant2000/flamenet-messenger-android">flamenet-messenger-android</a></td>
href="https://git.extant2000.com/extant2000/flamenet-messenger-android">flamenet-messenger-android</a></td>
<td>The Android app, with its own Kotlin engine</td>
</tr>
</tbody>
@@ -48,7 +48,7 @@ href="https://gitlab.com/extant2000/flamenet-messenger-android">flamenet-messeng
"self-hostable" is something you can check rather than something you are
told.</p>
<h2 id="quick-start">Quick start</h2>
<pre class="bash"><code>git clone https://gitlab.com/extant2000/flamenet-relay
<pre class="bash"><code>git clone https://git.extant2000.com/extant2000/flamenet-relay
cd flamenet-relay
cp .env.example .env</code></pre>
<p>Generate the three secrets <code>.env</code> asks for:</p>
+1531 -76
View File
File diff suppressed because it is too large Load Diff
+4 -1
View File
@@ -165,7 +165,10 @@ fi
# and the deploy that templated the contact addresses shipped /es/safety
# answering 500 through a gate that rendered only the English table.
ES_PAGES=$(ls "$GATE_SRC/Resources/Views/es/" 2>/dev/null | sed 's/\.leaf$//' | grep -v '^landing$' | sed 's|^|es/|' | tr '\n' ' ')
ALL_PAGES="donate releases $PAGES es/ $ES_PAGES"
# `hi` is named for the same reason as `releases` below: its route is outside
# the page table, so it stays out of the sitemap, and the derived list cannot
# see it. Its Spanish twin is es/hi.leaf, which the listing above picks up.
ALL_PAGES="donate releases hi $PAGES es/ $ES_PAGES"
echo "GATE: rendering $(echo "$ALL_PAGES" | wc -w | tr -d ' ') public pages"
# `releases` is named here for the same reason `donate` is: it is NOT in
# the table above. Its route is deliberately outside `pages` because its
+10 -1
View File
@@ -220,6 +220,10 @@ struct Config: Sendable {
let issuerSigningKeyRaw: Data?
let tokenLifetime: Int
/// The Today file (§8.12): its source, the keys that may sign it, and where
/// a copy is kept across restarts. Unset, the relay does not offer it.
var today = TodayConfig(file: nil, source: nil, keys: nil, cache: nil)
// `atRestSecret` is gone with the plaintext transport (relay#50).
enum ConfigError: Error, CustomStringConvertible {
@@ -377,7 +381,12 @@ struct Config: Sendable {
turnTTL: int("FN_TURN_TTL", 7200),
issuerSigningKeyRaw: env["FN_ISSUER_PRIVATE_KEY"]
.flatMap { $0.isEmpty ? nil : Data(base64Encoded: $0) },
tokenLifetime: int("FN_TOKEN_LIFETIME", Issuer.defaultLifetime)
tokenLifetime: int("FN_TOKEN_LIFETIME", Issuer.defaultLifetime),
today: TodayConfig(
file: env["FN_TODAY_FILE"],
source: env["FN_TODAY_SOURCE"],
keys: env["FN_TODAY_KEYS"],
cache: env["FN_TODAY_CACHE"])
)
config.federationPeerKeys = peers.keys
config.reportRatePerHour = int("FN_REPORT_RATE", 10)
+6
View File
@@ -18,6 +18,11 @@ enum Delivery {
var fromDevice: String
var payload: String
var envelopeID: String?
/// Whether the relay announced this one with a visible push (§6.14).
/// The iPhone badge counts only these. False by default, as
/// `Envelope.init` is, so a path that does not say leaves the badge
/// short and can never push it up.
var announced = false
}
/// How the push after the commit alerts (§6.14).
@@ -64,6 +69,7 @@ enum Delivery {
e.payload = item.payload
e.envelopeID = item.envelopeID
e.delivered = false
e.announced = item.announced
try await e.create(on: db)
if let id = e.id { ids.append(id) }
}
+37 -5
View File
@@ -265,6 +265,25 @@ func configure(_ app: Application) async throws {
try register(routes: routes, on: app)
// §8.12, the Today file. The route exists either way: a relay that does not
// offer the file answers 404 not_offered in JSON, which a client can tell
// apart from a missing page.
TodayFile.describe(config.today, logger: app.logger)
var todayFetch: (@Sendable (URL) async throws -> Data)? = nil
if case .url = config.today.source {
let client = app.http.client.shared
todayFetch = { url in try await TodayFile.fetch(url, using: client) }
}
let today = TodayFile(
config: config.today,
workingDirectory: app.directory.workingDirectory,
fetch: todayFetch,
logger: app.logger)
register(today: today, on: app)
if today.offered {
app.lifecycle.use(BootTask { await today.run() })
}
// The built-in identity provider, only when a signing key is configured.
if config.federationPolicy != "closed" {
guard let seed = config.federationKeyRaw,
@@ -523,6 +542,9 @@ func register(routes r: Routes, on app: Application) throws {
return response
}
// Where a hello link lands (§6.34). Not in the page table, so it stays out
// of the sitemap.
registerHelloPages(on: app, config: r.config)
// SPEC 8.12: the web form of a contact link. The payload is in the
// fragment, which no browser sends, so this is one page for every link;
@@ -1125,7 +1147,8 @@ func register(routes r: Routes, on app: Application) throws {
reportDays: r.config.reportRetentionDays,
// Read per-request: the storage flag is set later in
// configure than this loop runs.
deleteOnAck: req.application.storage[Routes.DeleteOnAckKey.self] ?? false))
deleteOnAck: req.application.storage[Routes.DeleteOnAckKey.self] ?? false),
todayOffered: r.config.today.offered)
}
for (slug, title, description) in pages {
@@ -1193,7 +1216,8 @@ func register(routes r: Routes, on app: Application) throws {
deviceExpiryDays: r.config.deviceExpiryDays,
maxDevices: r.config.maxDevicesPerAccount,
reportDays: r.config.reportRetentionDays,
deleteOnAck: req.application.storage[Routes.DeleteOnAckKey.self] ?? false)))
deleteOnAck: req.application.storage[Routes.DeleteOnAckKey.self] ?? false),
todayOffered: r.config.today.offered))
}
}
@@ -1804,7 +1828,7 @@ enum AssetVersion {
static let value: String = {
// The brand images are in here too, and belong here.
let paths = [
"Public/portal.css", "Public/landing.js", "Public/donate.js",
"Public/portal.css", "Public/landing.js", "Public/donate.js", "Public/hi.js",
"Public/brand/mark-128.png", "Public/brand/og.png",
"Public/brand/apple-touch-icon.png",
"Public/shots/conversation.png", "Public/shots/conversation@3x.png",
@@ -2029,6 +2053,9 @@ struct LandingContext: Encodable {
/// The numbers /metadata states, taken from the running configuration so
/// the page cannot drift from what this instance actually does.
var retention: RetentionFacts? = nil
/// Whether this relay offers the Today file (§8.12), so /metadata only
/// describes it where it is served.
var todayOffered: Bool = false
}
/// The public origin a request arrived on, for canonical and Open Graph URLs.
@@ -2217,7 +2244,10 @@ struct SecurityHeadersMiddleware: AsyncMiddleware {
)
// API answers are per-credential and per-moment: a token, a device
// list, a 401. No cache between here and the phone may keep one.
if response.headers.contentType?.subType == "json" {
// The Today file is not one of them: the same bytes for everybody, and
// its route sets its own Cache-Control (§8.12).
let todayFile = request.url.path.split(separator: "/") == ["today.json"]
if response.headers.contentType?.subType == "json", !todayFile {
response.headers.replaceOrAdd(name: .cacheControl, value: "no-store")
}
return response
@@ -2270,7 +2300,9 @@ private func textFromHTML(_ html: String) -> String {
/// told exactly what they were told before.
struct HTMLNotFoundMiddleware: AsyncMiddleware {
/// Everything under these answers machines, whatever it sets in Accept.
private static let apiPrefixes = ["e2e", "api", "v1", ".well-known", "health", "metrics"]
private static let apiPrefixes = [
"e2e", "api", "v1", ".well-known", "health", "metrics", "today.json",
]
func respond(to request: Request, chainingTo next: any AsyncResponder) async throws -> Response
{
+25
View File
@@ -574,6 +574,29 @@ struct AddEnvelopeDeliveredAt: AsyncMigration {
}
}
/// One bit per waiting envelope: did the relay announce it. See
/// `Envelope.announced`.
///
/// Rows already waiting take false. Nothing recorded whether they were
/// announced, and the badge is the count of announced envelopes, so false can
/// only leave a message that was already waiting at the upgrade out of the
/// count until its device collects it. True would count every silent envelope
/// already queued, which is the over-count this column exists to stop.
///
/// One ADD with a constant default: SQLite accepts NOT NULL there only with a
/// default, and on Postgres 11 or later a constant default is a catalogue
/// change, not a rewrite of the table.
struct AddEnvelopeAnnounced: AsyncMigration {
func prepare(on database: any Database) async throws {
try await database.schema(Envelope.schema)
.field("announced", .bool, .required, .sql(.default(false)))
.update()
}
func revert(on database: any Database) async throws {
try await database.schema(Envelope.schema).deleteField("announced").update()
}
}
/// Bound how fast dead tuples accumulate in the tables holding user data.
///
/// Postgres defaults are 50 rows plus 20% of the table, so `prekeys` needs
@@ -848,6 +871,8 @@ enum RelayMigrations {
GuardKeyLogAppendOnly(), CreateKeyLogWatermark(),
// Last, so it can name every table above it.
TuneUserDataAutovacuum(),
// A column, not a table, so it comes after the list above.
AddEnvelopeAnnounced(),
]
// Pairs stage 2-A (2026-10-07). Each irreversible, each on its own,
// and each dropping only data nothing reads. After the tuning above,
+11 -1
View File
@@ -106,9 +106,19 @@ final class Envelope: Model, @unchecked Sendable {
/// Swept with the envelope it belongs to, because it is part of the same
/// row and not a separate record of anybody.
@OptionalField(key: "delivered_at") var deliveredAt: Date?
/// Whether the relay announced this envelope with a visible push, from the
/// send's `silent` flag. The iPhone badge counts only these, so the control
/// traffic around a message (delivery keys, receipts, answers) does not
/// show as more messages. It goes with the row, as the rest of it does.
@Field(key: "announced") var announced: Bool
@Timestamp(key: "created_at", on: .create) var createdAt: Date?
init() {}
/// Unannounced until a send says otherwise, so a row written by any other
/// path can leave the badge short but never push it up. SQLite writes an
/// unset field as NULL, which the column refuses.
init() {
announced = false
}
}
/// A device's push token (§8.11).
+26 -9
View File
@@ -773,15 +773,22 @@ struct Routes: Sendable {
request.logger.notice("\(detail)")
}
// §6.14: the sender says whether this is time-critical, and whether
// it is to be announced at all. Urgent wins if a sender sets both.
// Carried onto each row, because the iPhone badge counts announced
// envelopes only, so the control traffic around a message does not
// show as more messages.
let urgent = input.urgent == true
let silent = input.silent == true && !urgent
let items = input.messages.filter { owned.contains($0.to_device) }.map { m in
Delivery.Item(
toUser: input.to, toDevice: m.to_device,
fromUser: senderUser, fromDevice: senderDevice,
payload: m.payload, envelopeID: m.envelope_id)
payload: m.payload, envelopeID: m.envelope_id,
announced: !silent)
}
// §6.14, the sender says whether this is time-critical.
let ids = try await Delivery.write(
items, push: .init(urgent: input.urgent == true, silent: input.silent == true),
items, push: .init(urgent: urgent, silent: silent),
db: request.db, app: request.application)
await sendMetrics.record(sealed: sealed, count: input.messages.count)
await sendMetrics.recordPayloads(payloadLengths, build: build)
@@ -844,12 +851,11 @@ struct Routes: Sendable {
}
continue
}
// The badge is this device's undelivered count, a number the relay
// holds by necessity, carrying no content and no sender.
let waiting =
(try? await Envelope.query(on: app.db)
.filter(\.$toDevice == device)
.filter(\.$delivered == false).count()) ?? 0
// A count the relay holds by necessity, carrying no content and no
// sender. Unknown counts as none: a badge that says too little is
// put right by the next push, and one that says too much is the
// complaint this count was narrowed for.
let waiting = (try? await Self.badgeCount(forDevice: device, on: app.db)) ?? 0
// An empty one is a device that has a VoIP token and no alert
// token yet, not a device to push at.
guard !row.token.isEmpty else { continue }
@@ -864,6 +870,17 @@ struct Routes: Sendable {
}
}
/// The number on the iPhone badge: this device's waiting envelopes that
/// were announced. Silent ones (delivery keys, receipts, answers) wait in
/// the same mailbox and are not messages, so they are not counted.
static func badgeCount(forDevice device: String, on db: any Database) async throws -> Int {
try await Envelope.query(on: db)
.filter(\.$toDevice == device)
.filter(\.$delivered == false)
.filter(\.$announced == true)
.count()
}
/// Delete the envelopes a poll cursor has acknowledged.
static func deleteAcknowledged(
toDevice deviceID: String, after: Int,
+1 -1
View File
@@ -19,7 +19,7 @@ import Vapor
/// The authenticated routes stay until the fleet has moved (DD4).
extension Routes {
/// Tokens per anonymous presence request: eight, the batch size §8.11
/// Tokens per anonymous presence request: eight, the batch size §8.13
/// gives clients (DD3), so one request cannot be a whole contact list.
static let maxAnonymousPresenceTokens = 8
/// Presence tokens per address per hour.
+3 -3
View File
@@ -43,9 +43,9 @@ extension Routes {
let perDeviceDigests: Bool
/// Wave 0b (2026-10-07), served on every relay of this build:
/// `POST /e2e/intro`, its challenge, and `GET /e2e/handle-cert`
/// (§14.5); `/e2e/anon/*` and `GET /e2e/devices/me` (§8.11);
/// `POST /e2e/contact-links` (§8.12); and `discoverable` on
/// `/messenger/me` (§8.12). The 0.32 apps turn sealed first
/// (§14.5); `/e2e/anon/*` and `GET /e2e/devices/me` (§8.13);
/// `POST /e2e/contact-links` (§8.14); and `discoverable` on
/// `/messenger/me` (§8.14). The 0.32 apps turn sealed first
/// contact, anonymous reads, reusable links and the Listed setting
/// on only when these say so.
let introductions: Bool
+4 -1
View File
@@ -117,7 +117,10 @@ extension Routes {
items.append(
Delivery.Item(
toUser: userID, toDevice: e.to_device, fromUser: 0, fromDevice: "",
payload: e.payload, envelopeID: e.envelope_id))
payload: e.payload, envelopeID: e.envelope_id,
// A deposit sends no push, so nothing announced it, and it
// stays out of the badge count.
announced: false))
}
return (items, unknown)
}
+84
View File
@@ -0,0 +1,84 @@
import Foundation
import Vapor
/// The page a hello link opens (§6.34): `https://<this host>/hi#<address>`.
///
/// The address is in the fragment, which a browser never sends, so this route
/// never sees it. Every hello link gets the same page, and `Public/hi.js`
/// reads the address on the device. Nothing here reads the request beyond the
/// host it arrived on, and the page asks not to be indexed.
func registerHelloPages(on app: Application, config: Config) {
app.get("hi") { req async throws -> Response in
var page = HelloContext(
title: "Say hello on Flamenet Messenger",
description: "Somebody shared their Flamenet Messenger address with you. "
+ "Open it in the app, or get the app first.",
audience: config.audience,
iosLive: config.iosLive,
iosStoreURL: config.iosStoreURL)
page.altLang = "es"
page.altHref = publicOrigin(req) + "/es/hi"
page.defaultHref = publicOrigin(req) + "/hi"
page.canonical = publicOrigin(req) + "/hi"
page.origin = publicOrigin(req)
let view: View = try await req.view.render("hi", page)
return HelloContext.noindex(view)
}
app.get("es", "hi") { req async throws -> Response in
var page = HelloContext(
title: "Saluda en Flamenet Messenger",
description: "Alguien compartió contigo su dirección de Flamenet Messenger. "
+ "Ábrela en la aplicación, o descarga la aplicación primero.",
audience: config.audience,
iosLive: config.iosLive,
iosStoreURL: config.iosStoreURL)
page.lang = "es"
page.chrome = .spanish
page.altLang = "en"
page.altHref = publicOrigin(req) + "/hi"
page.defaultHref = publicOrigin(req) + "/hi"
page.canonical = publicOrigin(req) + "/es/hi"
page.origin = publicOrigin(req)
let view: View = try await req.view.render("es/hi", page)
return HelloContext.noindex(view)
}
}
/// The hello-link page's context. The layout's fields, as `JoinContext`
/// carries them, and nothing about any address.
struct HelloContext: Encodable {
var lang: String = "en"
var section: String = ""
var hasAlternate: Bool = true
var altLang: String = ""
var altHref: String = ""
var defaultHref: String = ""
var chrome: LandingContext.Chrome = LandingContext.Chrome()
var canonical: String = ""
var origin: String = ""
/// Read by the layout, which then writes a robots meta tag.
var noindex: Bool = true
let title: String
let description: String
let audience: String
let assetVersion: String = AssetVersion.value
let iosLive: Bool
let iosStoreURL: String
init(title: String, description: String, audience: String, iosLive: Bool, iosStoreURL: String) {
self.title = title
self.description = description
self.audience = audience
self.iosLive = iosLive
self.iosStoreURL = iosStoreURL
}
/// Built by hand, as the invite page is, so the header can say noindex too.
static func noindex(_ view: View) -> Response {
let response = Response(status: .ok, body: .init(buffer: view.data))
response.headers.contentType = .html
response.headers.replaceOrAdd(name: "X-Robots-Tag", value: "noindex")
return response
}
}
+475
View File
@@ -0,0 +1,475 @@
import AsyncHTTPClient
import Crypto
import Foundation
import NIOCore
import Vapor
/// Where the Today file comes from and which keys may sign it (§8.12). Read
/// once at startup with the rest of `Config`.
struct TodayConfig: Sendable {
enum Source: Sendable, Equatable {
/// Neither variable is set: this relay does not offer the file.
case none
/// `FN_TODAY_FILE`, re-read whenever it changes.
case file(String)
/// `FN_TODAY_SOURCE`, fetched every fifteen minutes or so.
case url(URL)
/// Set, but in a way nothing can be served from. The reason goes to the
/// boot log and the route answers 503, not 404: the file was asked for,
/// and "not offered" would tell the apps it never was.
case unusable(String)
}
let source: Source
/// Raw 32-byte Ed25519 public keys, in the order given.
let keys: [Data]
/// How many entries of `FN_TODAY_KEYS` were not keys. Counted, never
/// echoed: a secret pasted into the wrong variable should not reach a log.
let rejectedKeys: Int
/// Where the last good envelope is kept across restarts, if anywhere.
let cachePath: String?
var offered: Bool { source != .none }
init(file: String?, source: String?, keys: String?, cache: String?) {
func clean(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespaces), !trimmed.isEmpty
else { return nil }
return trimmed
}
switch (clean(file), clean(source)) {
case (nil, nil):
self.source = .none
case (let path?, nil):
self.source = .file(path)
case (nil, let raw?):
if let url = URL(string: raw),
let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https",
let host = url.host, !host.isEmpty
{
self.source = .url(url)
} else {
self.source = .unusable("FN_TODAY_SOURCE is not an http or https URL")
}
case (.some, .some):
self.source = .unusable("FN_TODAY_FILE and FN_TODAY_SOURCE are both set; set one")
}
var accepted: [Data] = []
var rejected = 0
for entry in (keys ?? "").split(separator: ",") {
let value = entry.trimmingCharacters(in: .whitespaces)
guard !value.isEmpty else { continue }
if let raw = Data(base64Encoded: value), raw.count == 32,
(try? Curve25519.Signing.PublicKey(rawRepresentation: raw)) != nil
{
accepted.append(raw)
} else {
rejected += 1
}
}
self.keys = accepted
self.rejectedKeys = rejected
self.cachePath = clean(cache)
}
}
/// The one public file a relay can offer its apps (§8.12): the same bytes for
/// everybody, signed by a key this relay was configured with, served exactly
/// as they arrived.
///
/// Nothing is served until it verifies, and a newer file that does not verify
/// never displaces an older one that did. Nothing here is per person: the route
/// reads no credential, no cookie and no query, and writes no log line per
/// request.
actor TodayFile {
/// The envelope as the builder writes it. Only these four fields are read;
/// anything else in the object is ignored, and the bytes go out unchanged.
struct Envelope: Decodable {
let v: Int
let kid: String
let body: String
let sig: String
}
/// A key the relay accepts, with the id an envelope names it by: the first
/// eight hex characters of the SHA-256 of the 32 raw bytes.
struct Key: Sendable {
let kid: String
let key: Curve25519.Signing.PublicKey
init?(raw: Data) {
guard let key = try? Curve25519.Signing.PublicKey(rawRepresentation: raw) else {
return nil
}
self.key = key
self.kid = TodayFile.kid(of: raw)
}
}
/// What the route hands out.
struct Served: Sendable, Equatable {
let bytes: ByteBuffer
/// The quoted SHA-256 of `bytes`. See `etag(of:)`.
let etag: String
}
enum Refusal: Error, Equatable, CustomStringConvertible {
case tooLarge
case malformed
case unsupportedVersion(Int)
case unknownKey(String)
case badSignature(String)
case unreadable
case status(UInt)
case timedOut
var description: String {
switch self {
case .tooLarge: return "it is larger than 512 KB"
case .malformed: return "it is not a Today envelope"
case .unsupportedVersion(let v): return "it is envelope version \(v), not 1"
case .unknownKey(let kid): return "it names key \(kid), which is not in FN_TODAY_KEYS"
case .badSignature(let kid): return "its signature does not verify against key \(kid)"
case .unreadable: return "it could not be read"
case .status(let code): return "the source answered \(code)"
case .timedOut: return "the source took longer than 10 seconds"
}
}
}
/// The same cap the apps hold the file to.
static let maxBytes = 512 * 1024
static let refreshSeconds = 15 * 60
/// Either way, so relays mirroring one source do not all ask at once.
static let jitterSeconds = 60
static let fetchTimeout: TimeAmount = .seconds(10)
/// Fifteen minutes, the builder's own cadence.
static let cacheControl = "public, max-age=900"
let source: TodayConfig.Source
let keys: [Key]
let cachePath: String?
private let fetch: (@Sendable (URL) async throws -> Data)?
private let logger: Logger
private var served: Served?
/// The file's identity the last time it was looked at, so it is read again
/// only once it has changed.
private var stamp: FileStamp?
private var looked = false
private var cacheLoaded = false
/// The refusal last logged at error level. A source that stays broken is
/// reported there once, and its repeats go to info.
private var lastComplaint: String?
private var cacheComplained = false
/// Paths that are not absolute are taken relative to `workingDirectory`,
/// the same rule as `FN_ATTACHMENTS_DIR`.
init(
config: TodayConfig,
workingDirectory: String,
fetch: (@Sendable (URL) async throws -> Data)?,
logger: Logger
) {
func resolved(_ path: String) -> String {
path.hasPrefix("/") ? path : workingDirectory + path
}
switch config.source {
case .file(let path): self.source = .file(resolved(path))
default: self.source = config.source
}
self.keys = config.keys.compactMap(Key.init(raw:))
self.cachePath = config.cachePath.map(resolved)
self.fetch = fetch
self.logger = logger
}
nonisolated var offered: Bool { source != .none }
// MARK: - Serving
/// The envelope to serve, or nil while nothing has verified.
func current() -> Served? {
loadCacheOnce()
if case .file(let path) = source { readIfChanged(path) }
return served
}
/// Started once the relay has booted. A file is read now and then again on
/// any request after it changes; a URL is fetched now and then every
/// fifteen minutes, give or take a minute, until the relay stops.
func run() async {
loadCacheOnce()
switch source {
case .file(let path):
readIfChanged(path)
case .url(let url):
while !Task.isCancelled {
await refresh(from: url)
let seconds = Self.refreshSeconds + Int.random(in: -Self.jitterSeconds...Self.jitterSeconds)
try? await Task.sleep(nanoseconds: UInt64(seconds) * 1_000_000_000)
}
case .none, .unusable:
return
}
}
/// One fetch of a URL source.
func refresh(from url: URL) async {
guard let fetch else { return }
loadCacheOnce()
do {
try take(try await fetch(url), from: Self.shown(url))
} catch {
// The relay is stopping; that is not the source's fault.
if Task.isCancelled { return }
complain(error, from: Self.shown(url))
}
}
private func readIfChanged(_ path: String) {
let now = FileStamp.of(path)
if looked && now == stamp { return }
looked = true
stamp = now
do {
guard now != nil else { throw Refusal.unreadable }
try take(try Self.read(path), from: path)
} catch {
complain(error, from: path)
}
}
private func take(_ data: Data, from origin: String) throws {
try Self.verify(data, keys: keys)
let etag = Self.etag(of: data)
if served?.etag != etag {
served = Served(bytes: ByteBuffer(bytes: data), etag: etag)
persist(data)
logger.info("Today file from \(origin) accepted, \(etag)")
}
if lastComplaint != nil {
logger.notice("Today file from \(origin) verifies again")
lastComplaint = nil
}
}
private func complain(_ error: any Error, from origin: String) {
let reason = (error as? Refusal)?.description ?? "it could not be fetched: \(error)"
let holding = served.map { "still serving \($0.etag)" } ?? "nothing to serve yet"
let line = "Today file from \(origin) refused: \(reason); \(holding)"
if reason == lastComplaint {
logger.info("\(line)")
} else {
logger.error("\(line)")
lastComplaint = reason
}
}
// MARK: - The copy kept across restarts
private func loadCacheOnce() {
guard !cacheLoaded else { return }
cacheLoaded = true
guard let path = cachePath, FileManager.default.fileExists(atPath: path) else { return }
do {
let data = try Self.read(path)
try Self.verify(data, keys: keys)
if served == nil {
served = Served(bytes: ByteBuffer(bytes: data), etag: Self.etag(of: data))
}
} catch {
// Most often the keys changed since it was written, and then not
// serving it is the point.
logger.notice("the kept Today file at \(path) was not served: \(error)")
}
}
private func persist(_ data: Data) {
guard let path = cachePath else { return }
do {
try data.write(to: URL(fileURLWithPath: path), options: .atomic)
cacheComplained = false
} catch {
if !cacheComplained {
logger.error("could not keep a copy of the Today file at \(path): \(error)")
}
cacheComplained = true
}
}
// MARK: - Checking an envelope
/// The kid a key is named by.
static func kid(of raw: Data) -> String {
SHA256.hash(data: raw).prefix(4).map { String(format: "%02x", $0) }.joined()
}
/// Throws unless `data` is an envelope signed by one of `keys`.
@discardableResult
static func verify(_ data: Data, keys: [Key]) throws -> Envelope {
guard data.count <= maxBytes else { throw Refusal.tooLarge }
guard let envelope = try? JSONDecoder().decode(Envelope.self, from: data) else {
throw Refusal.malformed
}
guard envelope.v == 1 else { throw Refusal.unsupportedVersion(envelope.v) }
// Checked before it is named in a log line: it came from the source.
let hex = Set("0123456789abcdef")
guard envelope.kid.count == 8, envelope.kid.allSatisfy(hex.contains) else {
throw Refusal.malformed
}
let named = keys.filter { $0.kid == envelope.kid }
guard !named.isEmpty else { throw Refusal.unknownKey(envelope.kid) }
guard let body = Data(base64Encoded: envelope.body), !body.isEmpty,
let sig = Data(base64Encoded: envelope.sig), sig.count == 64
else { throw Refusal.malformed }
guard named.contains(where: { $0.key.isValidSignature(sig, for: body) }) else {
throw Refusal.badSignature(envelope.kid)
}
return envelope
}
/// The quoted lower-case hex SHA-256 of exactly the bytes served, and
/// nothing else. Every client gets the same one, and an app can compute it
/// from its own cached copy, so a validator cannot be used to tell one
/// device from another (DESIGN 19).
static func etag(of data: Data) -> String {
"\"" + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + "\""
}
/// RFC 9110 §13.1.2: a list, `*`, and the weak comparison.
static func matches(_ ifNoneMatch: [String], etag: String) -> Bool {
for value in ifNoneMatch {
for candidate in value.split(separator: ",") {
var tag = candidate.trimmingCharacters(in: .whitespaces)
if tag == "*" { return true }
if tag.hasPrefix("W/") { tag.removeFirst(2) }
if tag == etag { return true }
}
}
return false
}
/// At most one byte past the cap is read, so a file that is far too large
/// costs no more than one that is slightly too large.
static func read(_ path: String) throws -> Data {
guard let handle = FileHandle(forReadingAtPath: path) else { throw Refusal.unreadable }
defer { try? handle.close() }
guard let data = try handle.read(upToCount: maxBytes + 1) else { return Data() }
guard data.count <= maxBytes else { throw Refusal.tooLarge }
return data
}
/// GET a URL source with the cap applied while the body arrives, not after.
///
/// The deadline covers the whole answer. AsyncHTTPClient's own timeout stops
/// counting once the headers are in, so a source that sends its headers
/// and then trickles the body would otherwise hold the fetch open for as
/// long as it liked.
static func fetch(
_ url: URL, using client: HTTPClient,
timeout: TimeAmount = fetchTimeout, cap: Int = maxBytes
) async throws -> Data {
try await withThrowingTaskGroup(of: Data.self) { group in
group.addTask {
var request = HTTPClientRequest(url: url.absoluteString)
request.headers.add(name: "Accept", value: "application/json")
let response = try await client.execute(request, timeout: timeout)
guard response.status == .ok else { throw Refusal.status(response.status.code) }
do {
let buffer = try await response.body.collect(upTo: cap)
return Data(buffer.readableBytesView)
} catch is NIOTooManyBytesError {
throw Refusal.tooLarge
}
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeout.nanoseconds))
throw Refusal.timedOut
}
defer { group.cancelAll() }
guard let data = try await group.next() else { throw Refusal.timedOut }
return data
}
}
/// A URL as the log shows it: no credentials, no query.
static func shown(_ url: URL) -> String {
"\(url.scheme ?? "https")://\(url.host ?? "")\(url.port.map { ":\($0)" } ?? "")\(url.path)"
}
/// Said once at startup.
static func describe(_ config: TodayConfig, logger: Logger) {
let kids = config.keys.map(kid(of:)).joined(separator: ", ")
switch config.source {
case .none:
return
case .unusable(let reason):
logger.critical("\(reason). /today.json answers 503 until that is fixed.")
return
case .file(let path):
logger.notice("Today file from \(path), checked against key(s) \(kids)")
case .url(let url):
logger.notice("Today file fetched from \(shown(url)), checked against key(s) \(kids)")
}
if config.rejectedKeys > 0 {
let what = config.rejectedKeys == 1 ? "1 entry is" : "\(config.rejectedKeys) entries are"
logger.critical(
"FN_TODAY_KEYS: \(what) not a base64 32-byte Ed25519 public key, and ignored")
}
if config.keys.isEmpty {
logger.critical(
"FN_TODAY_KEYS has no usable key, so no Today file can verify and /today.json answers 503")
}
}
}
/// What a file looked like the last time it was read: modification time,
/// size and inode, so an in-place edit and a rename over it are both seen.
struct FileStamp: Equatable {
let modified: Date?
let size: UInt64?
let inode: UInt64?
static func of(_ path: String) -> FileStamp? {
guard let a = try? FileManager.default.attributesOfItem(atPath: path) else { return nil }
return FileStamp(
modified: a[.modificationDate] as? Date,
size: (a[.size] as? NSNumber)?.uint64Value,
inode: (a[.systemFileNumber] as? NSNumber)?.uint64Value)
}
}
/// `GET /today.json` (§8.12).
///
/// Reads `If-None-Match` and nothing else from the request. No credential, no
/// cookie, no query, and no log line: the apps ask for this at most hourly, and
/// a line per fetch would be a record of who opened the app when.
func register(today: TodayFile, on app: Application) {
app.get("today.json") { req async -> Response in
guard today.offered else {
return todayRefusal(.notFound, #"{"error":"not_offered"}"#)
}
guard let served = await today.current() else {
return todayRefusal(.serviceUnavailable, #"{"error":"not_ready"}"#)
}
var headers = HTTPHeaders()
headers.replaceOrAdd(name: .eTag, value: served.etag)
headers.replaceOrAdd(name: .cacheControl, value: TodayFile.cacheControl)
if TodayFile.matches(req.headers[.ifNoneMatch], etag: served.etag) {
return Response(status: .notModified, headers: headers)
}
headers.replaceOrAdd(name: .contentType, value: "application/json")
return Response(status: .ok, headers: headers, body: .init(buffer: served.bytes))
}
}
/// A refusal is never cached: the next fetch might find a file.
private func todayRefusal(_ status: HTTPResponseStatus, _ body: String) -> Response {
Response(
status: status,
headers: ["Content-Type": "application/json", "Cache-Control": "no-store"],
body: .init(string: body))
}
+1 -1
View File
@@ -332,7 +332,7 @@ struct DeviceListResponse: Content {
let ik_sig: String
let proto: Int
/// The device's signed capability block, when it published one. Part
/// of its identity, as in a bundle (§8.11 lists it in the directory).
/// of its identity, as in a bundle (§8.13 lists it in the directory).
var caps_sig: String? = nil
/// ISO-8601.
let created_at: String?
File diff suppressed because one or more lines are too long
@@ -63,7 +63,7 @@ struct AnonymousReadsTests {
@Test("anonymous presence is capped per request and metered per address")
func presenceLimits() async throws {
try await withRelay { h in
// Nine, written out: §8.11 says batches of at most eight, and a
// Nine, written out: §8.13 says batches of at most eight, and a
// test that read the constant would move with it.
let many = (0..<9).map { _ in
"\"\(W0B.random32().base64EncodedString())\""
@@ -103,7 +103,7 @@ struct AnonymousReadsTests {
}
#expect(a.status == 200)
#expect(a.body.contains("aaaa-0007") && a.body.contains("ik_sig"))
#expect(a.body.contains("caps-signature"), "§8.11 lists caps_sig in the directory")
#expect(a.body.contains("caps-signature"), "§8.13 lists caps_sig in the directory")
#expect(!a.body.contains("created_at") && !a.body.contains("last_seen"))
}
}
@@ -0,0 +1,204 @@
import Crypto
import Fluent
import Foundation
import SQLKit
import Testing
import Vapor
@testable import FlamenetRelay
/// The iPhone badge counts messages, not the traffic that travels with them.
///
/// It counted every undelivered envelope, and delivery keys, receipts and
/// answers wait in the same mailbox as messages. One real message showed as 2.
/// The sender marks that traffic silent; the relay now keeps one bit per
/// waiting envelope saying whether it announced it, and counts only those.
@Suite("the badge counts announced envelopes only")
struct BadgeCountTests {
private let now = 1_700_000_000
private let ip = "203.0.113.9"
private func pair(_ h: RelayHarness) async throws
-> (sender: RelayHarness.TestDevice, recipient: RelayHarness.TestDevice)
{
let s = h.device(user: 1, id: "aaaa-0001")
let r = h.device(user: 2, id: "bbbb-0002")
try await h.register(s, on: h.db)
try await h.register(r, on: h.db)
return (s, r)
}
/// `count` envelopes to the recipient's device in one identified send.
private func send(
_ h: RelayHarness, from s: RelayHarness.TestDevice, to r: RelayHarness.TestDevice,
silent: Bool? = nil, urgent: Bool? = nil, count: Int = 1
) async throws {
let envelopes = (0..<count).map { #"{"to_device":"\#(r.deviceID)","payload":"ct\#($0)"}"# }
var parts = ["\"to\":\(r.userID)", "\"messages\":[\(envelopes.joined(separator: ","))]"]
if let silent { parts.append("\"silent\":\(silent)") }
if let urgent { parts.append("\"urgent\":\(urgent)") }
let body = Data("{\(parts.joined(separator: ","))}".utf8)
let req = h.request(.POST, "/e2e/messages", as: s, body: body, now: now)
let res = try await h.routes.send(req, body: body, clientIP: ip, now: now)
#expect(res.status == .ok)
}
private func badge(_ h: RelayHarness, _ d: RelayHarness.TestDevice) async throws -> Int {
try await Routes.badgeCount(forDevice: d.deviceID, on: h.db)
}
@Test("one message with silent traffic around it is a badge of 1")
func oneMessageIsOne() async throws {
try await withRelay { h in
let (s, r) = try await pair(h)
try await send(h, from: s, to: r)
// A receipt, a delivery key and an answer, say.
try await send(h, from: s, to: r, silent: true, count: 3)
#expect(try await badge(h, r) == 1)
// All four are still waiting: the count changed, not the mailbox.
let waiting = try await Envelope.query(on: h.db)
.filter(\.$toDevice == r.deviceID).filter(\.$delivered == false).count()
#expect(waiting == 4)
}
}
@Test("collecting the mail clears the count")
func collectingClears() async throws {
try await withRelay { h in
let (s, r) = try await pair(h)
try await send(h, from: s, to: r, count: 2)
try await send(h, from: s, to: r, silent: true)
#expect(try await badge(h, r) == 2)
_ = try await h.routes.poll(
h.request(.GET, "/e2e/messages", as: r, now: now), now: now)
#expect(try await badge(h, r) == 0)
}
}
@Test("silent sends never count, however many; a call is announced")
func silentNeverCounts() async throws {
try await withRelay { h in
let (s, r) = try await pair(h)
for _ in 0..<5 { try await send(h, from: s, to: r, silent: true) }
try await send(h, from: s, to: r, silent: true, count: 10)
#expect(try await badge(h, r) == 0)
// Urgent wins over silent, as it does for the push itself: the
// relay rings the phone, so the envelope was announced.
try await send(h, from: s, to: r, silent: true, urgent: true)
#expect(try await badge(h, r) == 1)
}
}
@Test("a sealed send carries the flag the same way")
func sealedSendsToo() async throws {
try await withRelay { h in
let (_, r) = try await pair(h)
let raw = Data((0..<32).map { UInt8($0) })
let published = DeliveryKey()
published.id = r.userID
published.digest = Data(SHA256.hash(data: raw)).base64EncodedString()
try await published.create(on: h.db)
for silent in [true, false, true] {
let body = Data(
(#"{"to":\#(r.userID),"messages":[{"to_device":"\#(r.deviceID)","payload":"ct"}],"#
+ #""delivery_key":"\#(raw.base64EncodedString())","silent":\#(silent)}"#).utf8)
let req = h.anonymousRequest(.POST, "/e2e/messages", body: body)
_ = try await h.routes.send(req, body: body, clientIP: ip, now: now)
}
#expect(try await badge(h, r) == 1)
}
}
@Test("a deposit from another relay is not announced, so it is not counted")
func depositsAreNotCounted() async throws {
try await withRelay { h in
let (_, r) = try await pair(h)
let user = User()
user.id = r.userID
user.username = "bob"
user.email = "bob@example.com"
user.passwordHash = "x"
user.displayName = "Bob"
user.chosenStatus = ChosenStatus.online.rawValue
user.fnid = "fnid:bob"
try await user.create(on: h.db)
let placed = try await Routes.placeDeposits(
[
FederationDepositRequest.Item(
to_fnid: "fnid:bob", to_device: r.deviceID, payload: "afar",
envelope_id: nil)
], on: h.db)
#expect(placed.accepted == 1)
#expect(try await badge(h, r) == 0)
}
}
// MARK: - The migration
/// Everything before the migration under test, then rows written the way
/// the old schema wrote them, then the migration.
private func migrateAcross(_ app: Application) async throws {
let all = RelayMigrations.all()
let cut = try #require(all.firstIndex { $0 is AddEnvelopeAnnounced })
for m in all[..<cut] { app.migrations.add(m) }
try await app.autoMigrate()
let sql = try #require(app.db as? any SQLDatabase)
try await sql.raw(
"""
INSERT INTO envelopes (to_user, to_device, from_user, from_device, payload, delivered)
VALUES (2, 'bbbb-0002', 1, 'aaaa-0001', 'a', false),
(2, 'bbbb-0002', 1, 'aaaa-0001', 'b', false),
(2, 'bbbb-0002', 1, 'aaaa-0001', 'c', true)
"""
).run()
try await AddEnvelopeAnnounced().prepare(on: app.db)
}
/// Rows waiting at the upgrade were written before anybody recorded
/// whether they were announced. Counting them would put back the
/// over-count, so they come out false.
@Test("rows already waiting at the upgrade come out unannounced, on SQLite")
func migrationOnSQLite() async throws {
let app = try await Application.make(.testing)
app.logger.logLevel = .critical
app.databases.use(.sqlite(.memory), as: .sqlite)
do {
try await migrateAcross(app)
let rows = try await Envelope.query(on: app.db).all()
#expect(rows.count == 3)
#expect(rows.allSatisfy { !$0.announced })
#expect(try await Routes.badgeCount(forDevice: "bbbb-0002", on: app.db) == 0)
// New rows after the upgrade still count.
let e = Envelope()
e.toUser = 2
e.toDevice = "bbbb-0002"
e.fromUser = 1
e.fromDevice = "aaaa-0001"
e.payload = "d"
e.delivered = false
e.announced = true
try await e.create(on: app.db)
#expect(try await Routes.badgeCount(forDevice: "bbbb-0002", on: app.db) == 1)
// And it comes back out.
try await AddEnvelopeAnnounced().revert(on: app.db)
let sql = try #require(app.db as? any SQLDatabase)
let columns = try await sql.raw("PRAGMA table_info(envelopes)").all()
.map { try $0.decode(column: "name", as: String.self) }
#expect(!columns.contains("announced"))
#expect(columns.contains("delivered"), "the column check read nothing")
} catch {
try? await app.asyncShutdown()
throw error
}
try await app.asyncShutdown()
}
}
@@ -5,7 +5,7 @@ import VaporTesting
@testable import FlamenetRelay
/// SPEC §8.12: the web form of a contact link, `https://<relay>/add#…`, and the
/// SPEC §8.14: the web form of a contact link, `https://<relay>/add#…`, and the
/// two files that let a phone open it in the app instead of a browser.
@Suite("contact link page and app links", .serialized)
struct ContactLinkPageTests {
@@ -21,6 +21,8 @@ struct DeleteOnAckTests {
// new column show up as five red tests rather than as nothing, which
// is the trade this harness is deliberately making.
app.migrations.add(AddEnvelopeDeliveredAt())
// And announced, the bit the badge counts.
app.migrations.add(AddEnvelopeAnnounced())
try await app.autoMigrate()
// Shut down on BOTH paths, and AWAITED.
//
@@ -0,0 +1,413 @@
import Crypto
import Foundation
import Testing
import Vapor
import VaporTesting
#if canImport(JavaScriptCore)
import JavaScriptCore
#endif
@testable import FlamenetRelay
/// The page a hello link opens (§6.34), and the script on it that reads the
/// address out of the fragment and must send it nowhere.
@Suite("hello link page", .serialized)
struct HelloPageTests {
/// A real-shaped address: `fnid:` and the Crockford base32 of 32 bytes,
/// as both apps encode them. Not anybody's key.
static let fnid = "fnid:hc6m2jr7wrjqs8ytp12z5pn42rqrc7m4depk52h29k3r0j2sgnn0"
static let address = fnid + "@relay.flamenet.io"
static var root: URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent().deletingLastPathComponent()
.deletingLastPathComponent()
}
static func script() throws -> String {
try String(contentsOf: root.appendingPathComponent("Public/hi.js"), encoding: .utf8)
}
private func withRelayApp(_ body: (Application) async throws -> Void) async throws {
// Serialised against the other suites that boot a relay this way.
// `setenv` is process-global; see ProcessEnvironmentLock.
try await ProcessEnvironmentLock.withExclusiveEnvironment {
let issuer = Curve25519.Signing.PrivateKey()
let dbPath = NSTemporaryDirectory() + "fn-hi-\(UUID().uuidString).sqlite"
let attachments = NSTemporaryDirectory() + "fn-hi-att-\(UUID().uuidString)"
let env: [String: String] = [
"FN_ISSUER_PUBLIC_KEY": issuer.publicKey.rawRepresentation.base64EncodedString(),
"FN_ISSUER_PRIVATE_KEY": issuer.rawRepresentation.base64EncodedString(),
"FN_RELAY_AUDIENCE": "test.relay",
"FN_DB": dbPath,
"FN_ATTACHMENTS_DIR": attachments,
"FN_LOG_LEVEL": "critical",
]
let previous = env.keys.reduce(into: [String: String?]()) {
$0[$1] = ProcessInfo.processInfo.environment[$1]
}
for (k, v) in env { setenv(k, v, 1) }
defer {
for (k, v) in previous {
if let v { setenv(k, v, 1) } else { unsetenv(k) }
}
try? FileManager.default.removeItem(atPath: dbPath)
try? FileManager.default.removeItem(atPath: attachments)
}
let app = try await Application.make(.testing)
do {
try await configure(app)
try await body(app)
} catch {
try? await app.asyncShutdown()
throw error
}
try? await app.asyncShutdown()
}
}
// MARK: - The pages
@Test("both pages render, ask not to be indexed, and stay out of the sitemap")
func pagesRender() async throws {
try await withRelayApp { app in
for (path, lang) in [("/hi", "en"), ("/es/hi", "es")] {
try await app.testing().test(.GET, path) { res in
#expect(res.status == .ok, "\(path)")
#expect(res.headers.first(name: "X-Robots-Tag") == "noindex")
#expect(res.headers.first(name: .setCookie) == nil)
let html = res.body.string
#expect(html.contains(#"<html lang="\#(lang)">"#))
#expect(html.contains(#"<meta name="robots" content="noindex">"#))
#expect(html.contains(#"<script src="/hi.js?v="#))
#expect(html.contains(#"id="hi-found" hidden"#))
#expect(html.contains(#"id="hi-missing" hidden"#))
#expect(html.contains(#"href="https://testflight.apple.com/join/SZF8uKU6""#))
#expect(html.contains(#"href="/downloads/flamenet-messenger.apk""#))
// landing.js fetches the release manifest for any page
// carrying one of these, and this page fetches nothing.
#expect(!html.contains("data-apk-"), "\(path) carries a slot landing.js would fetch for")
}
}
try await app.testing().test(.GET, "/sitemap.xml") { res in
#expect(res.status == .ok)
#expect(!res.body.string.contains("/hi</loc>"))
}
// The robots tag is this page's, not every page's.
try await app.testing().test(.GET, "/about") { res in
#expect(!res.body.string.contains(#"name="robots""#))
#expect(res.headers.first(name: "X-Robots-Tag") == nil)
}
}
}
/// The URLs a page loads by itself: scripts, stylesheets, icons, images,
/// frames and media. A link somebody chooses to follow is not a load.
static func loads(in html: String) throws -> [String] {
let tag = try NSRegularExpression(
pattern: #"<(script|link|img|iframe|frame|source|video|audio|embed|object|track)\b[^>]*>"#,
options: [.caseInsensitive])
let attribute = try NSRegularExpression(
pattern: #"\b(src|href|srcset|data|poster)\s*=\s*"([^"]*)""#, options: [.caseInsensitive])
var found: [String] = []
for match in tag.matches(in: html, range: NSRange(html.startIndex..., in: html)) {
guard let range = Range(match.range, in: html) else { continue }
let element = String(html[range])
// A canonical or an alternate names a page; nothing fetches it.
if element.lowercased().hasPrefix("<link"),
element.contains(#"rel="canonical""#) || element.contains(#"rel="alternate""#)
{
continue
}
for value in attribute.matches(in: element, range: NSRange(element.startIndex..., in: element)) {
if let r = Range(value.range(at: 2), in: element) { found.append(String(element[r])) }
}
}
return found
}
@Test("nothing on either page is loaded from another host, and nothing runs inline")
func noThirdPartyLoads() async throws {
try await withRelayApp { app in
for path in ["/hi", "/es/hi"] {
try await app.testing().test(.GET, path) { res in
let html = res.body.string
let loads = try Self.loads(in: html)
#expect(loads.contains { $0.hasPrefix("/hi.js") }, "the scan did not see the page's own script")
for url in loads {
#expect(url.hasPrefix("/") && !url.hasPrefix("//"), "\(path) loads \(url)")
}
#expect(!html.contains("url("), "\(path) loads something from a style")
// Every script is a file of ours or a data block; inline
// code is refused by the policy below in any case.
for script in html.components(separatedBy: "<script").dropFirst() {
let open = script.prefix(while: { $0 != ">" })
#expect(
open.contains("src=\"/") || open.contains("application/ld+json"),
"\(path) has an inline script: <script\(open)>")
}
let policy = try #require(res.headers.first(name: .contentSecurityPolicy))
#expect(policy.hasPrefix("default-src 'self'"))
#expect(!policy.contains("https:") && !policy.contains("http:"))
}
}
}
}
// MARK: - The script, read
/// The script with its comments removed. Quote-aware, so a word in a
/// comment cannot fail the check and the `//` in a string is not taken for
/// one.
static func code(_ js: String) -> String {
enum State { case code, line, block, single, double, template }
var state = State.code
var out = ""
let chars = Array(js)
var i = 0
while i < chars.count {
let c = chars[i]
let next: Character = i + 1 < chars.count ? chars[i + 1] : "\0"
switch state {
case .code:
if c == "/" && next == "/" { state = .line; i += 2; continue }
if c == "/" && next == "*" { state = .block; i += 2; continue }
if c == "'" { state = .single } else if c == "\"" { state = .double } else if c == "`" { state = .template }
out.append(c)
case .line:
if c == "\n" { state = .code; out.append(c) }
case .block:
if c == "*" && next == "/" { state = .code; i += 2; continue }
case .single, .double, .template:
out.append(c)
if c == "\\", i + 1 < chars.count { out.append(next); i += 2; continue }
if (state == .single && c == "'") || (state == .double && c == "\"")
|| (state == .template && c == "`")
{
state = .code
}
}
i += 1
}
return out
}
@Test("the script makes no request of any kind, and builds no link but the app's")
func theScriptSendsNothing() throws {
let code = Self.code(try Self.script())
#expect(code.contains("location.hash"), "the comment stripper ate the code")
for forbidden in [
"fetch", "XMLHttpRequest", "sendBeacon", "Image", "new URL", "URL(", "WebSocket",
"EventSource", "import(", "importScripts", "Worker", "createElement", ".src", "srcset",
"innerHTML", "outerHTML", "insertAdjacentHTML", "document.write", "location.href",
"location.assign", "location.replace", "window.open", "submit", "postMessage", "cookie",
"Storage", "indexedDB", "RTCPeerConnection", "eval(", "Function(", "setTimeout(",
"setInterval(",
] {
#expect(!code.contains(forbidden), "hi.js uses \(forbidden)")
}
func count(_ needle: String) -> Int { code.components(separatedBy: needle).count - 1 }
// The fragment is read, and nothing else about the page's address is.
#expect(count("location.") == count("location.hash"))
// The only scheme anywhere in the code is the app's own.
#expect(count("://") == count("'flamenetmessenger://hi"))
#expect(count("'flamenetmessenger://hi#' + address") == 1)
// The only attribute it sets is that link's href.
#expect(count("setAttribute(") == 1)
#expect(count("setAttribute('href'") == 1)
}
@Test("the address check uses the apps' alphabet and length")
func theScriptUsesTheAppsAlphabet() throws {
let script = try Self.script()
// Both apps: "0123456789abcdefghjkmnpqrstvwxyz", 52 characters.
#expect(script.contains("var FNID = /^fnid:[0-9a-hjkmnp-tv-z]{52}$/;"))
let alphabet = Array("0123456789abcdefghjkmnpqrstvwxyz")
let ranges = "0123456789" + "abcdefgh" + "jk" + "mn" + "pqrst" + "vwxyz"
#expect(Array(ranges) == alphabet)
}
@Test("the gate renders both pages before a deploy")
func theGateRendersBoth() throws {
let gate = try String(
contentsOf: Self.root.appendingPathComponent("Scripts/staging-gate.sh"), encoding: .utf8)
#expect(gate.contains(#"ALL_PAGES="donate releases hi "#))
// The Spanish twin comes from the listing of Resources/Views/es.
#expect(gate.contains(#"ES_PAGES=$(ls "$GATE_SRC/Resources/Views/es/""#))
#expect(
FileManager.default.fileExists(
atPath: Self.root.appendingPathComponent("Resources/Views/es/hi.leaf").path))
}
// MARK: - The script, run
#if canImport(JavaScriptCore)
/// The check alone, in a context with no document.
static func parse(_ fragment: String) throws -> String? {
let context = try #require(JSContext())
var thrown: String?
context.exceptionHandler = { _, exception in thrown = exception?.toString() }
context.evaluateScript(try script())
let function = try #require(context.objectForKeyedSubscript("parseHelloAddress"))
let result = try #require(function.call(withArguments: [fragment]))
#expect(thrown == nil, "\(thrown ?? "")")
return result.isNull ? nil : result.toString()
}
static let valid: [(String, String)] = [
("#" + address, address),
(address, address),
("#" + fnid + "@Relay.Flamenet.IO", address),
("#" + fnid + "%40relay.flamenet.io", address),
("#" + fnid + "@127.0.0.1", fnid + "@127.0.0.1"),
("#" + fnid + "@a-b.example", fnid + "@a-b.example"),
// Letters, digits, hyphens and dots with one dot at least is the
// apps' rule, so the page takes what they take.
("#" + fnid + "@relay.flamenet.io.", fnid + "@relay.flamenet.io."),
]
static let invalid: [String] = [
"", "#", "#fnid:", "#@relay.flamenet.io",
// Missing the prefix, or the wrong case for it.
"#" + fnid.dropFirst(5) + "@relay.flamenet.io",
"#FNID:" + fnid.dropFirst(5) + "@relay.flamenet.io",
// i, l, o and u are not in the alphabet; upper case is not either.
"#fnid:i" + fnid.dropFirst(6) + "@relay.flamenet.io",
"#fnid:l" + fnid.dropFirst(6) + "@relay.flamenet.io",
"#fnid:o" + fnid.dropFirst(6) + "@relay.flamenet.io",
"#fnid:u" + fnid.dropFirst(6) + "@relay.flamenet.io",
"#" + fnid.uppercased().replacingOccurrences(of: "FNID:", with: "fnid:") + "@relay.flamenet.io",
// One short, one long.
"#" + fnid.dropLast() + "@relay.flamenet.io",
"#" + fnid + "0@relay.flamenet.io",
// No host, or not a host.
"#" + fnid, "#" + fnid + "@", "#" + fnid + "@localhost",
"#" + fnid + "@relay.flamenet.io:443", "#" + fnid + "@relay.flamenet.io/x",
"#" + fnid + "@relay_1.flamenet.io", "#" + fnid + "@relay.flamenet.io?x=1",
"#" + address + "@relay.flamenet.io", "#" + fnid + " @relay.flamenet.io",
"#" + fnid + "@relay.flamenet.io ", "#" + fnid + "@relay.flamenet.io#x",
"#" + fnid + "@réla y.io",
// Longer than any real address.
"#" + address + String(repeating: "a", count: 400),
// Markup and script in the fragment, plain and percent-encoded.
"#" + address + "\"><script>alert(1)</script>",
"#<img src=x onerror=alert(1)>",
"#" + fnid + "@<script>alert(1)</script>.io",
"#javascript:alert(1)",
"#" + fnid + "%40relay.flamenet.io%22%3E%3Cscript%3Ealert(1)%3C%2Fscript%3E",
"#" + address + "%0Aevil.example",
"#%E0%A4%A",
]
@Test("a fragment is an address only in the shape the apps show")
func addressCheck() throws {
for (fragment, expected) in Self.valid {
#expect(try Self.parse(fragment) == expected, "\(fragment)")
}
for fragment in Self.invalid {
#expect(try Self.parse(fragment) == nil, "\(fragment)")
}
}
/// DESIGN 41: the host is ASCII only, lowered by ASCII rules only.
/// JavaScript's toLowerCase folds a Kelvin sign into "k", which would
/// show and link relay.ekample.com for a host both apps refuse.
@Test("a non-ASCII host is refused, never folded into ASCII")
func noFoldedHosts() throws {
for host in [
"relay.e\u{212A}ample.com", "relay.\u{212A}.io", "r\u{0130}lay.io",
"relay.flamenet.i\u{00F6}", "relay.e%E2%84%AAample.com",
] {
#expect(try Self.parse("#" + Self.fnid + "@" + host) == nil, "\(host)")
}
// Upper-case ASCII is still lowered.
#expect(try Self.parse("#" + Self.fnid + "@RELAY.Flamenet.IO") == Self.address)
}
@Test("no space anywhere in the address, around the @ included")
func noSpaces() throws {
for fragment in [
"#" + Self.fnid + " @relay.flamenet.io",
"#" + Self.fnid + "@ relay.flamenet.io",
"#" + Self.fnid + " @ relay.flamenet.io",
"#" + Self.fnid + "%20@relay.flamenet.io",
"#" + Self.fnid + "@%20relay.flamenet.io",
"#" + Self.address + "%20",
"# " + Self.address,
"#" + Self.fnid + "\u{00A0}@relay.flamenet.io",
"#" + Self.fnid + "@relay.flamenet.io%09",
] {
#expect(try Self.parse(fragment) == nil, "\(fragment)")
}
}
/// The page's own wiring, run against stand-ins for the elements it
/// touches, so what reaches the page is checked, not only the check.
static func render(_ hash: String, then next: String? = nil) throws -> [String: String] {
let context = try #require(JSContext())
var thrown: String?
context.exceptionHandler = { _, exception in thrown = exception?.toString() }
context.evaluateScript(
"""
var elements = {};
['hi-found', 'hi-missing', 'hi-address', 'hi-open', 'hi-copy', 'hi-copy-note']
.forEach(function (id) {
elements[id] = {
hidden: true, textContent: 'unset', attributes: {}, listeners: {},
setAttribute: function (k, v) { this.attributes[k] = String(v); },
getAttribute: function (k) { return this.attributes[k] || null; },
addEventListener: function (t, f) { this.listeners[t] = f; }
};
});
var document = { getElementById: function (id) { return elements[id] || null; } };
var location = { hash: '' };
var windowListeners = {};
var window = { addEventListener: function (t, f) { windowListeners[t] = f; } };
var navigator = {};
""")
context.objectForKeyedSubscript("location").setObject(hash, forKeyedSubscript: "hash" as NSString)
context.evaluateScript(try script())
if let next {
context.objectForKeyedSubscript("location").setObject(next, forKeyedSubscript: "hash" as NSString)
context.evaluateScript("windowListeners.hashchange();")
}
#expect(thrown == nil, "\(thrown ?? "")")
func read(_ js: String) -> String { context.evaluateScript(js).toString() }
return [
"found.hidden": read("String(elements['hi-found'].hidden)"),
"missing.hidden": read("String(elements['hi-missing'].hidden)"),
"address": read("elements['hi-address'].textContent"),
"href": read("elements['hi-open'].attributes.href"),
"attributes": read("Object.keys(elements['hi-open'].attributes).join(',')"),
]
}
@Test("a good link shows the address and links the app to it; anything else shows neither")
func pageWiring() throws {
let good = try Self.render("#" + Self.address)
#expect(good["found.hidden"] == "false")
#expect(good["missing.hidden"] == "true")
#expect(good["address"] == Self.address)
#expect(good["href"] == "flamenetmessenger://hi#" + Self.address)
#expect(good["attributes"] == "href")
for fragment in ["", "#<img src=x onerror=alert(1)>", "#" + Self.address + "\"><b>x</b>"] {
let bad = try Self.render(fragment)
#expect(bad["found.hidden"] == "true", "\(fragment)")
#expect(bad["missing.hidden"] == "false", "\(fragment)")
#expect(bad["address"] == "", "\(fragment)")
#expect(bad["href"] == "flamenetmessenger://hi", "\(fragment)")
}
// A second link opened in the same tab replaces the first, and a
// bad one clears it: the old address does not stay up.
let other = Self.fnid + "@relay2.flamenet.io"
#expect(try Self.render("#" + Self.address, then: "#" + other)["address"] == other)
let cleared = try Self.render("#" + Self.address, then: "#nonsense")
#expect(cleared["address"] == "")
#expect(cleared["found.hidden"] == "true")
}
#endif
}
@@ -369,6 +369,47 @@ struct PostgresMigrationTests {
#expect(values == [2, 2])
}
}
/// The badge's bit: a NOT NULL boolean with a default, added to a table
/// that already holds rows. Rows already waiting come out false, so the
/// count cannot jump at the upgrade.
@Test("the announced column adds to a populated envelopes table")
func announcedAddsWithData() async throws {
try await withPostgres { app in
let all = RelayMigrations.all()
guard let cut = all.firstIndex(where: { $0 is AddEnvelopeAnnounced }) else {
Issue.record("the migration under test is not in the boot list")
return
}
for m in all[..<cut] { app.migrations.add(m) }
try await app.autoMigrate()
guard let sql = app.db as? any SQLDatabase else { return }
try await sql.raw(
"""
INSERT INTO envelopes (to_user, to_device, from_user, from_device, payload, delivered)
VALUES (2, 'bbbb-0002', 1, 'aaaa-0001', 'a', FALSE),
(2, 'bbbb-0002', 1, 'aaaa-0001', 'b', TRUE)
"""
).run()
try await AddEnvelopeAnnounced().prepare(on: app.db)
let announced = try await sql.raw("SELECT announced FROM envelopes").all()
.map { try $0.decode(column: "announced", as: Bool.self) }
#expect(announced == [false, false])
#expect(try await Routes.badgeCount(forDevice: "bbbb-0002", on: app.db) == 0)
// Reversible, and applies again after the revert.
try await AddEnvelopeAnnounced().revert(on: app.db)
try await AddEnvelopeAnnounced().prepare(on: app.db)
let names =
try await sql.raw(
"SELECT column_name FROM information_schema.columns "
+ "WHERE table_name = 'envelopes'"
).all().map { try $0.decode(column: "column_name", as: String.self) }
#expect(names.contains("announced"))
}
}
}
/// Per-key counters shared between the two sides of a race.
@@ -0,0 +1,638 @@
import AsyncHTTPClient
import Crypto
import Foundation
import NIOCore
import Testing
import Vapor
import VaporTesting
@testable import FlamenetRelay
/// The Today file (§8.12): what the relay serves, what it refuses, and that a
/// refusal never takes the last good file down with it.
@Suite("today file", .serialized)
struct TodayFileTests {
// MARK: - Fixtures
/// The dev key the flamenet.io builder signs with in testing. Public: its
/// private half never leaves the build machine and is not in this repo.
static let devPublicKey = "o6fwSoRjw0zldgUOpSClF9/Z2ySQcf0bTDtmtoDgFoA="
static var root: URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent().deletingLastPathComponent()
.deletingLastPathComponent()
}
/// The builder's own output, byte for byte, signed with the dev key.
static func builderSample() throws -> Data {
try Data(contentsOf: root.appendingPathComponent("Tests/Fixtures/today-sample.json"))
}
/// A key made for one test, and envelopes it signs.
struct Signer {
let key = Curve25519.Signing.PrivateKey()
var publicBase64: String { key.publicKey.rawRepresentation.base64EncodedString() }
var kid: String { TodayFile.kid(of: key.publicKey.rawRepresentation) }
func envelope(
_ body: String, kid: String? = nil, v: Int = 1, sig: Data? = nil, extra: String = ""
) throws -> Data {
let bytes = Data(body.utf8)
let signature = try sig ?? key.signature(for: bytes)
return Data(
(#"{"v":\#(v),"kid":"\#(kid ?? self.kid)","body":"\#(bytes.base64EncodedString())","#
+ #""sig":"\#(signature.base64EncodedString())"\#(extra)}"#).utf8)
}
}
/// The validator DESIGN 19 fixes, computed here and not by the code
/// under test: the quoted lower-case hex SHA-256 of the bytes.
static func sha256(_ data: Data) -> String {
"\"" + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + "\""
}
static func keys(_ base64: String...) -> [TodayFile.Key] {
base64.compactMap { Data(base64Encoded: $0) }.compactMap(TodayFile.Key.init(raw:))
}
static var quietLogger: Logger {
var logger = Logger(label: "today-test")
logger.logLevel = .critical
return logger
}
static func temporaryDirectory() throws -> String {
let dir = NSTemporaryDirectory() + "fn-today-\(UUID().uuidString)/"
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
return dir
}
/// Answers a URL source gives, one per fetch, in order.
actor Answers {
private var queue: [Result<Data, TodayFile.Refusal>]
init(_ answers: [Result<Data, TodayFile.Refusal>]) { queue = answers }
func next() throws -> Data { try queue.removeFirst().get() }
}
// MARK: - Keys and envelopes
@Test("a key's id is the first eight hex characters of its SHA-256")
func kidOfTheDevKey() throws {
let raw = try #require(Data(base64Encoded: Self.devPublicKey))
#expect(TodayFile.kid(of: raw) == "a9a94e74")
}
/// The builder signs in PHP with libsodium; this checks it in Swift with
/// swift-crypto. If the two ever disagree about the bytes, this is where
/// it shows.
@Test("the builder's own signed file verifies against the dev key, and against no other")
func theBuildersFileVerifies() throws {
let sample = try Self.builderSample()
let envelope = try TodayFile.verify(sample, keys: Self.keys(Self.devPublicKey))
#expect(envelope.kid == "a9a94e74")
#expect(throws: TodayFile.Refusal.unknownKey("a9a94e74")) {
try TodayFile.verify(sample, keys: Self.keys(Signer().publicBase64))
}
}
@Test("every way an envelope can be wrong is refused, and unknown fields are not one of them")
func refusals() throws {
let signer = Signer()
let keys = Self.keys(signer.publicBase64)
let body = #"{"v":1,"date":"2026-10-04"}"#
try TodayFile.verify(signer.envelope(body), keys: keys)
try TodayFile.verify(signer.envelope(body, extra: #","note":"ignored""#), keys: keys)
// The signature covers the body, so a different body under it fails.
let other = try signer.key.signature(for: Data(#"{"v":1}"#.utf8))
#expect(throws: TodayFile.Refusal.badSignature(signer.kid)) {
try TodayFile.verify(signer.envelope(body, sig: other), keys: keys)
}
var flipped = try signer.key.signature(for: Data(body.utf8))
flipped[0] ^= 0x01
#expect(throws: TodayFile.Refusal.badSignature(signer.kid)) {
try TodayFile.verify(signer.envelope(body, sig: flipped), keys: keys)
}
#expect(throws: TodayFile.Refusal.unknownKey("00000000")) {
try TodayFile.verify(signer.envelope(body, kid: "00000000"), keys: keys)
}
#expect(throws: TodayFile.Refusal.unsupportedVersion(2)) {
try TodayFile.verify(signer.envelope(body, v: 2), keys: keys)
}
// A kid that is not eight lower-case hex characters is refused before
// it can be written into a log line.
for kid in ["A9A94E74", "a9a94e7", "a9a94e74\\n", "zzzzzzzz"] {
#expect(throws: TodayFile.Refusal.malformed) {
try TodayFile.verify(signer.envelope(body, kid: kid), keys: keys)
}
}
#expect(throws: TodayFile.Refusal.malformed) {
try TodayFile.verify(signer.envelope(body, sig: Data(repeating: 1, count: 32)), keys: keys)
}
#expect(throws: TodayFile.Refusal.malformed) {
try TodayFile.verify(signer.envelope(""), keys: keys)
}
for junk in ["", "not json", "[]", #"{"v":1}"#, #"{"v":"1","kid":"a","body":"","sig":""}"#] {
#expect(throws: TodayFile.Refusal.malformed) {
try TodayFile.verify(Data(junk.utf8), keys: keys)
}
}
// Over the cap is refused before it is parsed at all.
let padded = try signer.envelope(
body, extra: #","pad":""# + String(repeating: "x", count: TodayFile.maxBytes) + #"""#)
#expect(throws: TodayFile.Refusal.tooLarge) {
try TodayFile.verify(padded, keys: keys)
}
}
@Test("FN_TODAY_KEYS keeps the keys and counts the rest without repeating them")
func keyList() {
let a = Signer()
let b = Signer()
let config = TodayConfig(
file: "/x", source: nil,
keys: " \(a.publicBase64) , not-a-key,, \(b.publicBase64)", cache: nil)
#expect(config.keys == [a.key.publicKey.rawRepresentation, b.key.publicKey.rawRepresentation])
#expect(config.rejectedKeys == 1)
}
@Test("one source or none: both at once, or a URL that is not http, serve nothing")
func sources() {
func source(_ file: String?, _ url: String?) -> TodayConfig.Source {
TodayConfig(file: file, source: url, keys: nil, cache: nil).source
}
#expect(source(nil, nil) == .none)
#expect(source(" ", "") == .none)
#expect(source("/srv/today.json", nil) == .file("/srv/today.json"))
#expect(source(nil, "https://flamenet.io/api/flamenet/v1/messenger-today")
== .url(URL(string: "https://flamenet.io/api/flamenet/v1/messenger-today")!))
let unusable: [(String?, String?)] = [
("/srv/today.json", "https://flamenet.io/x"), (nil, "ftp://flamenet.io/x"),
(nil, "flamenet.io/today.json"), (nil, "https://"),
]
for (file, url) in unusable {
guard case .unusable = source(file, url) else {
Issue.record("\(String(describing: file)) / \(String(describing: url)) should be unusable")
continue
}
}
#expect(TodayConfig(file: "/x", source: "https://y/z", keys: nil, cache: nil).offered)
}
@Test("If-None-Match matches a list, a weak tag and a star, and nothing else")
func ifNoneMatch() {
let etag = "\"0123456789abcdef0123456789abcdef\""
#expect(TodayFile.matches([etag], etag: etag))
#expect(TodayFile.matches(["\"aaaa\", \(etag)"], etag: etag))
#expect(TodayFile.matches(["\"aaaa\"", "W/\(etag)"], etag: etag))
#expect(TodayFile.matches(["*"], etag: etag))
#expect(!TodayFile.matches([], etag: etag))
#expect(!TodayFile.matches(["\"aaaa\""], etag: etag))
#expect(!TodayFile.matches(["0123456789abcdef0123456789abcdef"], etag: etag))
}
// MARK: - A URL source
@Test("a URL source that turns bad, or goes away, leaves the last good file serving")
func urlSourceKeepsTheLastGood() async throws {
let signer = Signer()
let stranger = Signer()
let good = try signer.envelope(#"{"v":1,"n":1}"#)
let newer = try signer.envelope(#"{"v":1,"n":2}"#)
let forged = try signer.envelope(
#"{"v":1,"n":3}"#, sig: signer.key.signature(for: Data("other".utf8)))
let strangers = try stranger.envelope(#"{"v":1,"n":4}"#)
let answers = Answers([
.success(good), .success(forged), .failure(.timedOut),
.success(strangers), .failure(.tooLarge), .success(newer),
])
let url = try #require(URL(string: "https://source.example/today"))
let today = TodayFile(
config: TodayConfig(file: nil, source: url.absoluteString, keys: signer.publicBase64, cache: nil),
workingDirectory: "/", fetch: { _ in try await answers.next() }, logger: Self.quietLogger)
#expect(await today.current() == nil, "nothing has verified yet")
await today.refresh(from: url)
let first = try #require(await today.current())
#expect(first.bytes == ByteBuffer(bytes: good))
for _ in 0..<4 {
await today.refresh(from: url)
#expect(await today.current() == first)
}
await today.refresh(from: url)
#expect(await today.current()?.bytes == ByteBuffer(bytes: newer))
}
@Test("the last good file is kept on disk and served after a restart, while its key is still listed")
func keptAcrossARestart() async throws {
let dir = try Self.temporaryDirectory()
defer { try? FileManager.default.removeItem(atPath: dir) }
let signer = Signer()
let good = try signer.envelope(#"{"v":1,"n":1}"#)
let url = try #require(URL(string: "https://source.example/today"))
func life(keys: String, fetch: @escaping @Sendable (URL) async throws -> Data) -> TodayFile {
TodayFile(
config: TodayConfig(file: nil, source: url.absoluteString, keys: keys, cache: "today-kept.json"),
workingDirectory: dir, fetch: fetch, logger: Self.quietLogger)
}
await life(keys: signer.publicBase64, fetch: { _ in good }).refresh(from: url)
#expect(FileManager.default.contents(atPath: dir + "today-kept.json") == good)
// The source is down when the relay comes back: the kept copy serves.
let restarted = life(keys: signer.publicBase64, fetch: { _ in throw TodayFile.Refusal.timedOut })
#expect(await restarted.current()?.bytes == ByteBuffer(bytes: good))
await restarted.refresh(from: url)
#expect(await restarted.current()?.bytes == ByteBuffer(bytes: good))
// The key was taken off the list: the kept copy no longer verifies.
let rotated = life(keys: Signer().publicBase64, fetch: { _ in throw TodayFile.Refusal.timedOut })
#expect(await rotated.current() == nil)
}
/// A source on 127.0.0.1 answering `GET /today` with whatever `answer`
/// returns, and the HTTP client to reach it with.
private func withSource(
_ answer: @escaping @Sendable (Request) async throws -> Response,
_ body: (URL, HTTPClient) async throws -> Void
) async throws {
let server = try await Application.make(.testing)
server.logger.logLevel = .critical
server.get("today", use: answer)
do {
try await server.asyncBoot()
try await server.server.start(address: .hostname("127.0.0.1", port: 0))
let port = try #require(server.http.server.shared.localAddress?.port)
let url = try #require(URL(string: "http://127.0.0.1:\(port)/today"))
try await body(url, server.http.client.shared)
} catch {
await server.server.shutdown()
try? await server.asyncShutdown()
throw error
}
await server.server.shutdown()
try await server.asyncShutdown()
}
@Test("a fetch brings the bytes back exactly as the source sent them")
func fetchIsExact() async throws {
let sample = try Self.builderSample()
try await withSource({ _ in Response(status: .ok, body: .init(data: sample)) }) { url, client in
let fetched = try await TodayFile.fetch(url, using: client)
#expect(fetched == sample)
try TodayFile.verify(fetched, keys: Self.keys(Self.devPublicKey))
}
}
@Test("a source over 512 KB is refused, whether or not it says so up front")
func fetchCapsTheSize() async throws {
let big = String(repeating: "x", count: TodayFile.maxBytes + 1)
try await withSource({ _ in Response(status: .ok, body: .init(string: big)) }) { url, client in
await #expect(throws: TodayFile.Refusal.tooLarge) {
try await TodayFile.fetch(url, using: client)
}
}
// No Content-Length: the cap has to hold while the body streams in.
try await withSource({ _ in
Response(
status: .ok,
body: .init(managedAsyncStream: { writer in
for _ in 0..<12 {
try await writer.writeBuffer(ByteBuffer(repeating: UInt8(ascii: "x"), count: 64 * 1024))
}
}))
}) { url, client in
await #expect(throws: TodayFile.Refusal.tooLarge) {
try await TodayFile.fetch(url, using: client)
}
}
}
@Test("a source that answers anything but 200 is refused")
func fetchNeedsAnOK() async throws {
try await withSource({ _ in Response(status: .serviceUnavailable) }) { url, client in
await #expect(throws: TodayFile.Refusal.status(503)) {
try await TodayFile.fetch(url, using: client)
}
}
}
/// The headers arrive at once and the body never finishes. The client's
/// own timeout has stopped counting by then, so this is the deadline that
/// has to end it.
@Test("a body that trickles in past the deadline is a timeout, not a short file")
func fetchDeadlineCoversTheBody() async throws {
try await withSource({ _ in
Response(
status: .ok,
body: .init(managedAsyncStream: { writer in
try await writer.writeBuffer(ByteBuffer(string: "{"))
try await Task.sleep(nanoseconds: 4_000_000_000)
try await writer.writeBuffer(ByteBuffer(string: "}"))
}))
}) { url, client in
let began = Date()
await #expect(throws: TodayFile.Refusal.timedOut) {
try await TodayFile.fetch(url, using: client, timeout: .seconds(1))
}
#expect(Date().timeIntervalSince(began) < 3, "the deadline did not end the fetch")
}
}
// MARK: - The route, through the whole relay
/// A relay booted the way production boots it, with `extra` in its
/// environment.
private func withRelayApp(
_ extra: [String: String] = [:],
_ body: (Application) async throws -> Void
) async throws {
// Serialised against the other suites that boot a relay this way.
// `setenv` is process-global; see ProcessEnvironmentLock.
try await ProcessEnvironmentLock.withExclusiveEnvironment {
let issuer = Curve25519.Signing.PrivateKey()
let dbPath = NSTemporaryDirectory() + "fn-today-\(UUID().uuidString).sqlite"
let attachments = NSTemporaryDirectory() + "fn-today-att-\(UUID().uuidString)"
var env: [String: String] = [
"FN_ISSUER_PUBLIC_KEY": issuer.publicKey.rawRepresentation.base64EncodedString(),
"FN_ISSUER_PRIVATE_KEY": issuer.rawRepresentation.base64EncodedString(),
"FN_RELAY_AUDIENCE": "test.relay",
"FN_DB": dbPath,
"FN_ATTACHMENTS_DIR": attachments,
"FN_LOG_LEVEL": "critical",
"FN_TODAY_FILE": "", "FN_TODAY_SOURCE": "", "FN_TODAY_KEYS": "", "FN_TODAY_CACHE": "",
]
for (k, v) in extra { env[k] = v }
let previous = env.keys.reduce(into: [String: String?]()) {
$0[$1] = ProcessInfo.processInfo.environment[$1]
}
for (k, v) in env { setenv(k, v, 1) }
defer {
for (k, v) in previous {
if let v { setenv(k, v, 1) } else { unsetenv(k) }
}
try? FileManager.default.removeItem(atPath: dbPath)
try? FileManager.default.removeItem(atPath: attachments)
}
let app = try await Application.make(.testing)
do {
try await configure(app)
try await body(app)
} catch {
try? await app.asyncShutdown()
throw error
}
try? await app.asyncShutdown()
}
}
private func get(
_ app: Application, _ path: String = "/today.json", headers: HTTPHeaders = [:]
) async throws -> (status: HTTPStatus, headers: HTTPHeaders, body: Data) {
var answer: (HTTPStatus, HTTPHeaders, Data) = (.internalServerError, [:], Data())
try await app.testing().test(.GET, path, headers: headers) { res in
answer = (res.status, res.headers, Data(res.body.readableBytesView))
}
return answer
}
@Test("a relay that does not offer the file says so, in JSON, to a browser too")
func notOffered() async throws {
try await withRelayApp { app in
for accept in ["application/json", "text/html"] {
let res = try await get(app, headers: ["Accept": accept])
#expect(res.status == .notFound)
#expect(String(decoding: res.body, as: UTF8.self) == #"{"error":"not_offered"}"#)
#expect(res.headers.first(name: .contentType) == "application/json")
#expect(res.headers.first(name: .cacheControl) == "no-store")
#expect(res.headers.first(name: .setCookie) == nil)
}
}
}
@Test("configured with nothing good yet: 503, until a good file appears")
func configuredButEmpty() async throws {
let dir = try Self.temporaryDirectory()
defer { try? FileManager.default.removeItem(atPath: dir) }
let path = dir + "today.json"
try await withRelayApp(["FN_TODAY_FILE": path, "FN_TODAY_KEYS": Self.devPublicKey]) { app in
let missing = try await get(app)
#expect(missing.status == .serviceUnavailable)
#expect(String(decoding: missing.body, as: UTF8.self) == #"{"error":"not_ready"}"#)
#expect(missing.headers.first(name: .cacheControl) == "no-store")
try Self.builderSample().write(to: URL(fileURLWithPath: path))
#expect(try await get(app).status == .ok)
}
// A file is there, but no key to check it with.
try Self.builderSample().write(to: URL(fileURLWithPath: path))
try await withRelayApp(["FN_TODAY_FILE": path]) { app in
let res = try await get(app)
#expect(res.status == .serviceUnavailable)
}
// Two sources at once is a mistake, and nothing is served from either.
try await withRelayApp([
"FN_TODAY_FILE": path, "FN_TODAY_SOURCE": "https://flamenet.io/x",
"FN_TODAY_KEYS": Self.devPublicKey,
]) { app in
let res = try await get(app)
#expect(res.status == .serviceUnavailable)
}
}
@Test("200 is the source's bytes unchanged, public for fifteen minutes, with a strong ETag and no cookie")
func servesTheBytes() async throws {
let dir = try Self.temporaryDirectory()
defer { try? FileManager.default.removeItem(atPath: dir) }
let sample = try Self.builderSample()
try sample.write(to: URL(fileURLWithPath: dir + "today.json"))
try await withRelayApp([
"FN_TODAY_FILE": dir + "today.json", "FN_TODAY_KEYS": "\(Signer().publicBase64),\(Self.devPublicKey)",
]) { app in
let res = try await get(app)
#expect(res.status == .ok)
#expect(res.body == sample)
#expect(res.headers.first(name: .contentType) == "application/json")
#expect(res.headers.first(name: .cacheControl) == "public, max-age=900")
#expect(res.headers.first(name: .setCookie) == nil)
let etag = try #require(res.headers.first(name: .eTag))
#expect(etag == Self.sha256(sample), "a strong tag, the quoted hash of the bytes")
}
}
@Test("If-None-Match gets a 304 with the validator and the cache rule, and no body")
func notModified() async throws {
let dir = try Self.temporaryDirectory()
defer { try? FileManager.default.removeItem(atPath: dir) }
let sample = try Self.builderSample()
try sample.write(to: URL(fileURLWithPath: dir + "today.json"))
let etag = TodayFile.etag(of: sample)
try await withRelayApp(["FN_TODAY_FILE": dir + "today.json", "FN_TODAY_KEYS": Self.devPublicKey]) { app in
for value in [etag, "\"other\", \(etag)", "W/\(etag)", "*"] {
let res = try await get(app, headers: ["If-None-Match": value])
#expect(res.status == .notModified, "\(value)")
#expect(res.body.isEmpty)
#expect(res.headers.first(name: .eTag) == etag)
#expect(res.headers.first(name: .cacheControl) == "public, max-age=900")
}
let stale = try await get(app, headers: ["If-None-Match": "\"0000\""])
#expect(stale.status == .ok)
#expect(stale.body == sample)
}
}
/// A validator that differed by client would be a way to tell devices
/// apart: hand each one its own tag and see it come back. This one is
/// the hash of the bytes, so it cannot differ.
@Test("every client gets the same ETag, and it is the SHA-256 of the bytes served")
func sameETagForEveryone() async throws {
let dir = try Self.temporaryDirectory()
defer { try? FileManager.default.removeItem(atPath: dir) }
let sample = try Self.builderSample()
try sample.write(to: URL(fileURLWithPath: dir + "today.json"))
try await withRelayApp(["FN_TODAY_FILE": dir + "today.json", "FN_TODAY_KEYS": Self.devPublicKey]) { app in
let iphone = try await get(app, headers: [
"User-Agent": "FlamenetMessenger/0.32.0", "X-Forwarded-For": "198.51.100.7",
"Accept-Language": "en-US",
])
let android = try await get(app, headers: [
"User-Agent": "FlamenetMessenger/0.32 (Android)", "X-Forwarded-For": "203.0.113.9",
"Accept-Language": "es-MX", "Cookie": "a=b",
])
let a = try #require(iphone.headers.first(name: .eTag))
let b = try #require(android.headers.first(name: .eTag))
#expect(a == b)
#expect(a == Self.sha256(iphone.body))
#expect(a == Self.sha256(sample))
#expect(a.count == 66 && a == a.lowercased())
}
}
@Test("nothing in the query changes the answer")
func queryIgnored() async throws {
let dir = try Self.temporaryDirectory()
defer { try? FileManager.default.removeItem(atPath: dir) }
let sample = try Self.builderSample()
try sample.write(to: URL(fileURLWithPath: dir + "today.json"))
try await withRelayApp(["FN_TODAY_FILE": dir + "today.json", "FN_TODAY_KEYS": Self.devPublicKey]) { app in
let plain = try await get(app)
for path in ["/today.json?city=Paris", "/today.json?device=abc&user=1", "/today.json?v=2"] {
let res = try await get(app, path)
#expect(res.status == .ok)
#expect(res.body == plain.body)
#expect(res.headers.first(name: .eTag) == plain.headers.first(name: .eTag))
}
}
}
/// Same size and the same inode, written in place: only the modification
/// time says it changed, and that has to be enough.
@Test("a file that changes on disk is read again")
func readsAChangedFile() async throws {
let dir = try Self.temporaryDirectory()
defer { try? FileManager.default.removeItem(atPath: dir) }
let path = dir + "today.json"
let signer = Signer()
let first = try signer.envelope(#"{"v":1,"n":1}"#)
let second = try signer.envelope(#"{"v":1,"n":2}"#)
#expect(first.count == second.count)
try first.write(to: URL(fileURLWithPath: path))
try FileManager.default.setAttributes(
[.modificationDate: Date(timeIntervalSince1970: 1_700_000_000)], ofItemAtPath: path)
try await withRelayApp(["FN_TODAY_FILE": path, "FN_TODAY_KEYS": signer.publicBase64]) { app in
#expect(try await get(app).body == first)
let handle = try #require(FileHandle(forWritingAtPath: path))
try handle.write(contentsOf: second)
try handle.close()
try FileManager.default.setAttributes(
[.modificationDate: Date(timeIntervalSince1970: 1_700_000_100)], ofItemAtPath: path)
let res = try await get(app)
#expect(res.body == second)
#expect(res.headers.first(name: .eTag) == TodayFile.etag(of: second))
}
}
@Test("a bad signature, an unknown key or an oversized file never replaces the last good one")
func keepsTheLastGood() async throws {
let dir = try Self.temporaryDirectory()
defer { try? FileManager.default.removeItem(atPath: dir) }
let path = dir + "today.json"
let signer = Signer()
let good = try signer.envelope(#"{"v":1,"n":1}"#)
let later = try signer.envelope(#"{"v":1,"n":2}"#)
try good.write(to: URL(fileURLWithPath: path))
try await withRelayApp(["FN_TODAY_FILE": path, "FN_TODAY_KEYS": signer.publicBase64]) { app in
#expect(try await get(app).body == good)
var stamp = 1_700_000_000.0
for bad in [
try signer.envelope(#"{"v":1,"n":9}"#, sig: signer.key.signature(for: Data("x".utf8))),
try Signer().envelope(#"{"v":1,"n":9}"#),
try signer.envelope(#"{"v":1,"n":9}"#, kid: "00000000"),
Data(repeating: UInt8(ascii: " "), count: TodayFile.maxBytes + 1),
Data("{}".utf8),
] {
try bad.write(to: URL(fileURLWithPath: path))
stamp += 10
try FileManager.default.setAttributes(
[.modificationDate: Date(timeIntervalSince1970: stamp)], ofItemAtPath: path)
let res = try await get(app)
#expect(res.status == .ok)
#expect(res.body == good, "a refused file displaced the good one")
}
// Gone altogether: still the last good one.
try FileManager.default.removeItem(atPath: path)
#expect(try await get(app).body == good)
try later.write(to: URL(fileURLWithPath: path))
#expect(try await get(app).body == later)
}
}
@Test("an oversized file is refused from the start")
func oversizedFromTheStart() async throws {
let dir = try Self.temporaryDirectory()
defer { try? FileManager.default.removeItem(atPath: dir) }
let signer = Signer()
let big = try signer.envelope(
#"{"v":1}"#, extra: #","pad":""# + String(repeating: "x", count: TodayFile.maxBytes) + #"""#)
try big.write(to: URL(fileURLWithPath: dir + "today.json"))
try await withRelayApp(["FN_TODAY_FILE": dir + "today.json", "FN_TODAY_KEYS": signer.publicBase64]) { app in
let res = try await get(app)
#expect(res.status == .serviceUnavailable)
}
}
/// /metadata is read from the running configuration, so it describes the
/// file only where the file is served.
@Test("the metadata page mentions the Today file only on a relay that serves one")
func metadataLine() async throws {
let english = "one public Today file"
let spanish = "archivo público de Hoy"
try await withRelayApp { app in
for path in ["/metadata", "/es/metadata"] {
let res = try await get(app, path)
#expect(res.status == .ok)
let html = String(decoding: res.body, as: UTF8.self)
#expect(!html.contains(english) && !html.contains(spanish), "\(path)")
}
}
let dir = try Self.temporaryDirectory()
defer { try? FileManager.default.removeItem(atPath: dir) }
try Self.builderSample().write(to: URL(fileURLWithPath: dir + "today.json"))
try await withRelayApp(["FN_TODAY_FILE": dir + "today.json", "FN_TODAY_KEYS": Self.devPublicKey]) { app in
let en = try await get(app, "/metadata")
#expect(String(decoding: en.body, as: UTF8.self).contains(english))
let es = try await get(app, "/es/metadata")
#expect(String(decoding: es.body, as: UTF8.self).contains(spanish))
}
}
}
+16 -1
View File
@@ -64,6 +64,21 @@ transparency log only its own users could audit would not be a transparency log.
---
## The Today file
| Route | Auth | Purpose |
|---|---|---|
| `GET /today.json` | none | The signed Today file, when this relay offers one (§8.12) |
The same bytes for everybody, served exactly as they were signed and only
after the signature checks against `FN_TODAY_KEYS`. 404
`{"error":"not_offered"}` when no source is configured, 503 while nothing has
verified yet. A 200 carries an `ETag` and `Cache-Control: public, max-age=900`,
and `If-None-Match` gets a 304. Nothing else is read from the request: no
credential, no cookie, no query.
---
## Accounts
Present only when this relay runs the built-in identity provider
@@ -326,7 +341,7 @@ plaintext message transport that once lived here was removed (relay#50, and
Served as HTML or plain text to a browser, not as API. Listed because this
document says a route not in it is not served: `/`, `/es`, `/status`,
`/status.json`, `/health`, `/releases`, `/join/{code}`, `/donate`,
`/status.json`, `/health`, `/releases`, `/join/{code}`, `/hi`, `/es/hi`, `/donate`,
`/es/donate`, `POST /donate/intent`, `/downloads/android-latest.json`,
`/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt`,
`/.well-known/security.txt`, and the generated documentation pages.
+33
View File
@@ -152,6 +152,9 @@ on. Each one, and the setting that turns it on:
Sending to a peer also needs `FN_FEDERATION_KEY`.
- Donations, to `api.stripe.com`, when `FN_DONATE_SECRET` is set.
- Attachments, to an S3-compatible store, when `FN_S3_ENDPOINT` is set.
- The Today file, to the address in `FN_TODAY_SOURCE`, about every fifteen
minutes. Set `FN_TODAY_FILE` instead to serve one from disk and fetch
nothing.
It also connects to the databases you point it at: Postgres at
`FN_POSTGRES_URL` and Redis at `FN_REDIS_URL`. Nothing reports to this
@@ -303,6 +306,36 @@ All optional. Each is absent rather than half-enabled when unconfigured.
| `FN_DONATE_PUBLISHABLE_KEY` | Required alongside the two above: the payment fields are Stripe's own, mounted on the page, and they cannot mount without it. Set the secret and not this and the donate page shows no form at all. |
| `FN_DONATE_ACCOUNT` | The connected account to charge, if `FN_DONATE_SECRET` belongs to a Stripe *platform* rather than to the account meant to receive the money. Sent as `Stripe-Account`, which makes the payment a direct charge there. Blank charges whatever account the key itself belongs to, the right answer when you are self-hosting with your own key, and the wrong one with a platform key, where Stripe answers 200 and the money lands on the platform with nothing on the page looking wrong. With this set, the publishable key stays the **platform's**. |
## The Today file
Off. When it is on, the relay serves one public file at `GET /today.json`
(§8.12): headlines, markets, weather and the like, built and signed somewhere
else, and the same bytes for everybody. The relay checks the signature and
serves the file exactly as it arrived.
| Variable | Default | Notes |
|---|---|---|
| `FN_TODAY_FILE` | unset | Path to the signed file. Read again whenever it changes (its modification time, size or inode). Write a new file and rename it over the old one, so the relay never reads a half-written one. Relative paths resolve under `FN_WORKDIR`. |
| `FN_TODAY_SOURCE` | unset | An `http` or `https` URL to fetch the file from instead, every 15 minutes give or take a minute, with a 10 second limit and a 512 KB cap. Set this or `FN_TODAY_FILE`, not both: with both set nothing is served and the log says why. A self-hosted relay mirrors the official file by pointing this at another relay's `/today.json`. |
| `FN_TODAY_KEYS` | unset | Comma-separated base64 Ed25519 public keys the file may be signed with. An envelope names its key by the first eight hex characters of the key's SHA-256. A file signed by any other key, or by none, is never served. |
| `FN_TODAY_CACHE` | unset | Path where the last file that verified is kept, so a restart can serve it straight away. It is checked against `FN_TODAY_KEYS` again when it is read back. Relative paths resolve under `FN_WORKDIR`. |
Nothing is served until it verifies, and a file that fails the check never
replaces one that passed. A bad signature, a key that is not listed, a file
over 512 KB, a source that is down or slow: the relay goes on serving the last
good file, and logs the refusal, once for each new reason. With no source set, `/today.json`
answers 404 `{"error":"not_offered"}`; with a source set and nothing verified
yet, 503.
The route reads no credential, no cookie and no query, and writes no log line
per request. A good answer carries `Cache-Control: public, max-age=900` and an
`ETag`, and `If-None-Match` gets a 304.
`FN_TODAY_SOURCE` makes the relay open a connection of its own: one GET to
that URL every fifteen minutes. Whatever comes back still has to verify, so the
host at that URL cannot change what the file says. It can only hold back a
newer file or hand back an older signed one.
## iPhone push (APNs)