Open Free and open source — read the code

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.