Files
Paul HittandClaude Opus 5 b005993b95
ci / checks (pull_request) Successful in 21s
ci / swift (pull_request) Failing after 3m54s
ci / image (pull_request) Skipped
ci / deploy (pull_request) Skipped
Merge release/0-32 into main: /today.json, the /hi page, and the badge that counts messages
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>
2026-10-14 12:34:32 -04:00

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.