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>
20 KiB
Relay API
Every route a Flamenet relay serves, what authenticates it, and what it is for.
Read from Sources/FlamenetRelay/Entrypoint.swift rather than from memory. A
route not in this document is not served, and a document that lists routes a
relay does not serve is worse than no document, because it is believed. That is
not hypothetical: the relay once published its own index under a second prefix
it served nothing on, and every client that read it broke.
Base URL is https://<your-relay>. There is one REST prefix, api.
Discovery
| Route | Auth | Purpose |
|---|---|---|
GET /.well-known/flamenet/relay |
none | What this relay is |
GET /api/flamenet/v1 |
none | The route index |
GET /e2e/log/head |
none | Signed transparency-log head |
/.well-known/flamenet/relay is what a client reads before sending any
credential:
{
"audience": "relay.example.com",
"issuerPublicKey": "<base64 Ed25519>",
"protocolVersions": [1, 2, 3],
"keyLogHead": "https://relay.example.com/e2e/log/head",
"federation": "closed",
"federationKey": null,
"accounts": "local",
"accountsURL": null,
"reports": true,
"features": { "padAttachments": false, "perDeviceDigests": true }
}
features holds switches a client reads before doing something an older
client could not handle. features.padAttachments says whether senders may
pad attachments (FN_PAD_ATTACHMENTS); a client that does not find the key
treats it as false. features.perDeviceDigests says the relay keeps a
delivery-key digest and a presence-token set per device rather than per
account, so an app may publish its digest again to land in its own row, and
may send device_id with its roster tags.
accounts decides where a passphrase goes. local means this relay is
its own identity provider and authenticates the password itself. shared means
accounts live in a separate service named by accountsURL, the passphrase is
stretched on the device, and only a derived secret is sent, to that service,
never to the relay.
A client must not believe accountsURL blindly. It should accept only
account services it already trusts, and treat anything else as local.
Otherwise an operator could name a host they control and collect
passphrase-derived secrets. The relay's own code pins its accountsURL as a
constant for the same reason.
Everything else is unauthenticated on purpose: a descriptor only this 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.
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
(FN_ISSUER_PRIVATE_KEY set).
| Route | Auth | Purpose |
|---|---|---|
GET /account/challenge |
none | The proof-of-work parameters a sign-up must satisfy, when FN_SIGNUP_POW_BITS is set |
POST /account/signup |
none | Create an account |
POST /account/signin |
none | Exchange a password for a credential |
POST /account/token |
Basic | Mint a device-scoped relay token |
POST /account/reset/request |
none | Ask for a reset code |
POST /account/reset/confirm |
none | Redeem a reset code |
POST /account/verify |
none | Redeem an emailed address-verification code |
POST /account/vouch |
Basic | Mint an invite to hand to somebody (relay#43) |
Also mounted under /api/flamenet/v1/messenger/{signup,signin}.
signup takes {username, email, password, display_name?} and answers:
{ "user_id": 7, "username": "alice", "display_name": "Alice",
"app_password": "<returned once, never retrievable>" }
The credential is returned once and only its hash is kept. Losing it means signing in again. It is not the password, and changing the password invalidates it, which is what changing a password should do.
signin accepts a username or an email. The field has always been
labelled "username or email"; a client that sends only usernames rejects half
the people who typed exactly what they were asked for.
Reset answers identically whatever the truth is: unknown handle, no address on file, an account that authenticates elsewhere. A client cannot report whether it worked, and a UI that claims it can is lying.
Shared identity
| Route | Auth | Purpose |
|---|---|---|
POST /api/flamenet/v1/messenger/session |
none | Exchange an account-service token for a relay credential |
Present only when FN_ENCLAVE_JWT_SECRET or FN_ENCLAVE_PUBLIC_KEY is set.
Takes {token, handle, display_name?}.
The handle travels beside the token, not inside it. The account service will not put a handle in a token so that a relay cannot correlate its users with another product's; the token carries a product-scoped pseudonym. The relay uses the handle only the first time it sees that pseudonym, after that the pseudonym decides, and honouring the handle would let somebody rename themselves into an existing account.
Protocol routes (§8)
All under /api/flamenet/v1/e2e/…, and also at /e2e/….
These refuse the account credential. They take a short-lived token scoped to one device and one relay, plus a per-request signature proving possession of that device's identity key. A credential good for every route on every device would mean a token lifted from one device drains another's prekey pool and reads its inbox.
| Route | Purpose |
|---|---|
POST /e2e/devices |
Publish a device's identity and prekeys |
GET /e2e/devices/{user} |
List a user's devices, costs no prekey. Registration and last-poll times (created_at, last_seen) only when {user} is the caller's own account, kept there because both apps' Devices screens read created_at from it |
GET /e2e/devices/me |
The caller's own devices, with created_at and last_seen. Where those times belong; the line above keeps them only until the apps have moved here |
DELETE /e2e/devices/{device_id} |
Retire a device and stop its bundles being served |
GET /e2e/keys/{user} |
Fetch bundles. Pops a one-time prekey per device |
POST /e2e/prekeys |
Top the pool back up |
POST /e2e/messages |
Deposit envelopes. A sealed delivery_token may be one the recipient issued or one from POST /e2e/intro; intro, §14.5's name for the field, is taken the same way. With FN_PAD_ENFORCE=enforce, a request carrying any payload not exactly 16384 or 65536 characters long is refused with unpadded |
GET /e2e/messages |
Poll this device's inbox. Stops near 2 MiB of payload, never below one envelope, and then adds "more": true: poll again from the returned cursor |
POST /e2e/push/token |
Register this device's APNs token |
POST /e2e/delivery-key |
Publish this device's delivery-key digest for sealed sending (§14.4). A sealed send is accepted against any of the account's device digests |
POST /e2e/delivery-tokens |
Add and drop single-use delivery capabilities (§21.4) |
GET /e2e/handle-cert |
A certificate for the caller's own handle, see below |
POST /e2e/contact-links |
{"add": [hash], "revoke": [hash]}, each a base64 SHA-256 of a contact-link secret. At most 5 per account; a sixth is refused with 409 rather than dropped. The relay keeps the hash and the account, no time and no record of use. Answers {"ok": true, "links": n} |
POST /e2e/attachments |
Upload one encrypted blob |
GET /e2e/attachments/{id} |
Download it, supports Range |
POST /e2e/groups |
Create a group |
GET /e2e/groups |
List groups |
POST /e2e/groups/{gid}/members |
Add or remove members |
POST /e2e/groups/{gid}/leave |
Leave |
GET /e2e/turn |
TURN credentials for calls (§8.10) |
POST /e2e/moderation/report |
File a report about content (§6.25) |
POST /e2e/wipe |
Publish a signed order for one device to erase itself (§6.26) |
GET /e2e/wipe |
Read any order waiting for this device |
Sealed groups (§8.9b) are the same registry with the roster encrypted, so the relay holds a membership list it cannot read.
| Route | Purpose |
|---|---|
POST /e2e/groups/sealed |
Create one |
POST /e2e/groups/fetch |
Read the sealed records this device is named in |
POST /e2e/groups/{gid}/roster |
Replace the sealed roster |
POST /e2e/groups/{gid}/leave-sealed |
Leave one |
The transparency log (§19) is unauthenticated, all four routes. A log only this relay's own users could read would not be one.
| Route | Purpose |
|---|---|
GET /e2e/log/head |
The signed head: size, root, signature, audience |
GET /e2e/log/inclusion?index=&size= |
Audit path for one leaf |
GET /e2e/log/consistency?from=&to= |
Proof that from is a prefix of to |
GET /e2e/log/device/{device_id} |
Every entry for a device, with its index |
GET /e2e/log/subject |
Every entry under one subject, so a monitor can watch an account for devices its owner did not add (relay#32) |
Asking without logging in. These read no credential at all, and carry the account being asked about in the body, never the URL, so it reaches no access log. Every meter that is about an account is keyed by a keyed hash of it, never the id. The relay still sees the address.
| Route | Purpose |
|---|---|
POST /e2e/intro |
{"handle": h, "challenge": c, "nonce": n} (listed accounts only) or {"link": secret} (base64 or base64url, 32 bytes). A handle needs a solved challenge from GET /e2e/intro/challenge (FN_INTRO_POW_BITS, each solution good once; 400 challenge_required or challenge_failed otherwise, the same for every name); a link needs none. Returns user_id, fnid, intro, one token good for one sealed send (as intro or delivery_token) until exp in unix seconds, 10 minutes ahead (expires_in), the target's bundles (devices, one-time prekeys popped) and handle_cert. An unlisted or unknown handle, an unknown or revoked link and an account with no devices all get the same 404 not_found "No such account.", no sooner than 5 ms. Metered per address (FN_INTRO_RATE), per network (FN_INTRO_RATE_NETWORK), and once an account is found, per account (FN_INTRO_RATE_TARGET) and per network and account (a third of that); at most 30 unspent introductions per account |
GET /e2e/intro/challenge |
A proof-of-work challenge for an introduction by handle, in the sign-up challenge's format: {"challenge", "bits", "expires_in"}. Solve for a nonce such that SHA-256(challenge + "." + nonce) has bits leading zero bits |
POST /e2e/anon/presence |
{"tokens": [...]}, at most 8. The same answer as POST /messenger/presence. Metered per address, by token count |
POST /e2e/anon/devices |
{"user_id": n, "cap": token?}. Identity keys and caps_sig only, no created_at or last_seen |
POST /e2e/anon/bundles |
{"user_id": n, "cap": token?}. As GET /e2e/keys/{user}, popping a prekey per device |
For the last two, cap is a presence token the subject issued to the caller.
When it is one of the subject's, the token is the meter; otherwise, or without
one, the address is (and for bundles, the address and account together, the
shape GET /e2e/keys uses per caller). A cap that does not match is ignored,
not refused, so the route says nothing about whether a token is good.
Handle certificates state that user_id holds handle on this relay
until exp:
{ "v": 1, "aud": "relay.example", "uid": 7, "h": "ada",
"exp": 1760000000, "sig": "<base64 Ed25519>" }
sig is over the bytes fnhandle-v1|<aud>|<uid>|<h>|<exp>,
made with the key that signs the log head, whose public half is the
descriptor's issuerPublicKey that both apps pin on first sight. Handles are
lowercase letters and digits, so | cannot occur inside one. Good for 7 days.
A relay with no signing key answers GET /e2e/handle-cert with 503 handle_cert_unsigned and puts null in an introduction.
Addresses and remote relays. The first two answer without a credential, because a first message needs them before any session exists. Both answer yes or no for a name, which makes them a directory somebody can walk, so both are metered per address.
| Route | Auth | Purpose |
|---|---|---|
GET /e2e/account/identity/{handle} |
none, metered | Resolve a handle to an address. An unlisted handle answers 404 exactly as an unknown one |
GET /e2e/account/homes/{fnid} |
none, metered | Which relays that address publishes on |
POST /e2e/account/identity |
device token | Claim an address for this account |
POST /e2e/account/homes |
device token | Publish the relays this account can be reached on |
POST /e2e/send/remote |
device token | Hand an envelope to this relay for a peer relay |
GET /e2e/remote/{relay}/devices/{fnid} |
device token | List a remote account's devices, pops no prekey |
GET /e2e/remote/{relay}/bundles/{fnid} |
device token | Fetch a remote account's bundles, pops one |
Partial downloads
GET /e2e/attachments/{id} answers Accept-Ranges: bytes and honours a single
Range header, so an interrupted download can resume and a long voice note can
start playing before all of it has arrived.
Range: bytes=0-499 the first 500 bytes
Range: bytes=500- from 500 to the end
Range: bytes=-200 the last 200 bytes
A satisfiable range comes back as 206 with Content-Range: bytes 0-499/1000.
A Range this relay will not serve, several ranges in one header, or anything
malformed, is ignored rather than refused, and you get 200 and the whole
blob.
416 means the blob is not the length you think it is. A resume asks for
bytes=900- because the client already holds 900 bytes; if the blob was
replaced or is shorter than that, the answer is 416 Range Not Satisfiable
carrying Content-Range: bytes */<real length>. That is not "your request was
malformed", it is the length you need to start again from zero. Do not
retry the same range.
What a range costs you in privacy. Asking for part of a file tells the relay which part you wanted: that somebody stopped listening to a voice note after three seconds, which a whole-file fetch cannot say. There is no way to offer resumable downloads without the server seeing the offset. This relay does not log it, does not count it per blob, and does not store it on the attachment row, but a relay you do not run could, so a client that would rather not say should fetch whole blobs.
The three that bite
GET /e2e/keys/{user} consumes a one-time prekey per device, every call.
Use /e2e/devices/{user} to look at who somebody is; use keys only when
actually starting a session. Polling it drains the pool and pushes every peer
onto the last-resort key, costing initial forward secrecy for everyone.
POST /e2e/messages names the recipient once, at the top: not per
envelope:
{ "to": 42, "from_device": "…",
"messages": [ { "to_device": "…", "payload": "<base64>" } ] }
One envelope per device of theirs. The relay does no fan-out and no re-encryption; it moves opaque payloads.
POST /e2e/devices publishes prekeys under prekeys, not opks. A
registration carrying the wrong name is accepted with a 200 and stores
nothing, the device is published and can never be started with, which is a
failure with no error anywhere.
The per-request proof
Each authenticated request carries X-FN-Timestamp, X-FN-Nonce and
X-FN-Signature, an Ed25519 signature over:
METHOD \n LOGICAL_PATH \n TIMESTAMP \n NONCE \n hex(SHA-256(body))
LOGICAL_PATH is the /e2e/… path, not the URL's path. These routes are
mounted twice and signed against the former; signing the REST-prefixed path
produces a valid signature over the wrong string and a refusal with no hint why.
The nonce is not decoration. Without it, two inbox polls in the same second with the same empty body sign identically, and the replay cache cannot tell an honest repeat from a captured one, so it refuses the honest client.
The messenger surface
Mounted at /api/flamenet/v1/messenger/…. This is the shipped app's account
and contact-list surface, not the protocol: it carries no ciphertext, and the
plaintext message transport that once lived here was removed (relay#50, and
docs/PLAINTEXT.md records what went with it).
| Route | Auth | Purpose |
|---|---|---|
POST /signup, GET /challenge, POST /signin |
none | The account routes above, in the namespace the app asks in |
DELETE /account |
Basic | Remove the account |
GET /me |
Basic | The signed-in account, also how a restored session is validated |
PUT /me, POST /me |
Basic | Replace profile fields: name, password, and discoverable (false is Unlisted). GET and PUT answer with discoverable. PUT is the honest verb; POST is kept for clients that only speak it |
GET /users |
Basic | Exact-username lookup. It refuses without a slug, so it is not a directory. An unlisted name is [], as an unknown one, except your own |
GET /users/{id} |
Basic | One user's public profile. An unlisted account is 404, as an unknown id, except to itself |
GET /buddies, POST /buddies |
Basic | The contact list, read and add. Adding an unlisted name is 404, as an unknown one |
DELETE /buddies/{id} |
Basic | Remove one |
DELETE /buddies |
Basic | Forget the whole list. No id, deliberately |
POST /roster/tags |
Basic | Publish the contact list as opaque tokens (§8.9c). With device_id (one of this account's devices) it replaces that device's set; without, the account-wide set an older app publishes |
POST /presence |
Basic | Ask after presence by presenting a token, rather than by being on a list the relay can read |
POST /status, POST /conversation |
Basic | Availability, and accepting a correspondent. star, unstar, archive and unarchive are refused with kept_on_device: those live on the device |
GET /notifications |
Basic | This account's notifications |
POST /notifications/{id}/read, POST /notifications/read_all |
Basic | Mark them read |
GET /blocks, POST /blocks/{id}, DELETE /blocks/{id} |
Basic | Blocked accounts, read and toggle |
Pages
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}, /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.
Federation
Off by default. When FN_FEDERATION is not closed:
| Route | Auth | Purpose |
|---|---|---|
POST /e2e/federation/deposit |
fnfed-v1 |
Accept envelopes from a peer relay |
GET /e2e/federation/bundles/{fnid} |
fnfed-v1 |
Serve bundles for a local account |
GET /e2e/federation/devices/{fnid} |
fnfed-v1 |
List that account's devices, identity keys only (no created_at or last_seen since 2026-10-07) |
These sit under the same /e2e prefix as everything else, and under
/api/flamenet/v1/e2e as well, because the peer surface is mounted with the
client surface rather than on a path of its own.
Signed with the relay's federation key, which is deliberately not the issuer key: a key that only ever says "this relay said this" should not also be able to mint a token for any user on it.
Federation costs metadata. Each relay in an exchange learns the other had
traffic for it. If your relay exists so one group can talk without anyone else
involved, closed is the correct answer, not the cautious one.