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
}
⚠️ 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.
Accounts
Present only when this relay runs the built-in identity provider
(FN_ISSUER_PRIVATE_KEY set).
| Route | Auth | Purpose |
|---|---|---|
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 |
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 |
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 |
GET /e2e/messages |
Poll this device's inbox |
POST /e2e/delivery-key |
Publish a delivery-key digest for sealed sending |
POST /e2e/attachments |
Upload one encrypted blob |
GET /e2e/attachments/{id} |
Download it |
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 |
GET /e2e/log/{head,inclusion,consistency,device/{id}} |
Transparency log |
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.
Federation
Off by default. When FN_FEDERATION is not
closed:
| Route | Auth | Purpose |
|---|---|---|
POST /fed/v1/deposit |
fnfed-v1 |
Accept envelopes from a peer relay |
GET /fed/v1/keys/{fnid} |
fnfed-v1 |
Serve bundles for a local account |
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.