main
17
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
fbc102a99f |
Cross-relay send, as far as it actually goes
Item 3 through the app: paste an address, open a conversation, send. Where
it stops is recorded honestly below.
Works, driven through the UI on relay1 against a live peer on relay2:
- a pasted `fnid@relay` is recognised as an address rather than sent to
the username lookup, which would ask OUR relay about somebody it has
never heard of and answer "no such user" — true and useless
- the remote directory and bundles are fetched through our own relay
- the client REFUSES a bundle whose SPK signature does not verify. Found
for real: the federation harness registers placeholder keys because it
only checks envelope plumbing, and a real client rightly threw
"Invalid SPK signature on recipient bundle" at it
- with a peer holding real keys, the envelope is sealed and relay1
accepts it
🔴 Does not work: nothing arrives in the peer's inbox on relay2.
relay1 reports envelopes accepted, relay2's poll returns none. The
relay-to-relay path itself is fine — engine/test/federation.mjs is 11/11
against these same two relays — so something differs between the envelopes
that harness builds and the ones the client does. Not yet found.
And a bug of my own that this caught: transmitRemote checked `unknown` and
`unreachable` and never `accepted`, so a send where the relay took NOTHING
reported success. The app said sent, the inbox stayed empty, and nothing
anywhere said otherwise — the exact failure this codebase keeps producing.
`accepted == 0` is now a failure.
RemoteChatScreen is deliberately separate from ChatScreen: that screen is
built around a row number this relay assigned, with presence and a
server-side buddy list behind it, none of which exists for somebody homed
elsewhere.
55 unit tests passing.
|
||
|
|
8c7cd79f13 |
Send across a relay boundary from the client
Item 3. The relays have federated since the fleet went up — 11/11 against it — and no client had ever sent a byte across a boundary. transmitRemote is a PARALLEL path and local transmit is untouched. The local one is keyed on a row number this relay assigned; a remote peer has an fnid and a home. Folding them together would mean editing the code where a mistake loses a message silently, on the path that already works. Ratchets need no change at all — they are keyed by device id, which is globally unique. 🔴 Always sealed, and here that is not an improvement but a requirement. A relay accepting a federated envelope stores it with no sender — it was not told and must not invent one — so an unsealed envelope arrives unattributable and is dropped. Trust gets its own keyspace, keyed by address. Two relays hand out the same row numbers to different people, so folding a remote peer into the integer keyspace would let one relay's user 7 inherit another's pins. ⚠️ And I had the model wrong first time round, which is worth recording because the mistake is easy to repeat. Three things were conflated: sealing uses the recipient DEVICE's ik_dh, which the remote directory returns; the delivery key is a separate anti-abuse capability the local relay checks before accepting unattributed mail; and SendRemoteRequest carries no delivery key at all. I had put it in the address, which published a secret-shaped thing for nothing and doubled the length of every address. The address is just fnid@relay. One envelope id per MESSAGE, not per device, or a recipient polling several homes sees the same message once per mailbox. And `unknown` and `unreachable` are surfaced rather than reduced to a boolean, because with several homes "sent" is genuinely partial. 55 unit tests passing. |
||
|
|
1a228429a6 |
Your address as a QR code, and put Redis in the compose files
**The address**
Across a relay boundary the receiving relay stores an envelope with no
sender at all — "we were not told who sent it and must not invent an
attribution" — so a federated message can only be attributed if the sender
sealed it, and sealing needs the recipient's delivery key. Locally that key
is handed over on first contact, riding the ratchet. There is no first
contact to ride across a boundary.
So the address has to carry it: flamenet:<fnid>@<relay>?k=<deliveryKey>.
That is far too long to read aloud or retype, and an address that is nearly
right addresses somebody else — hence a QR code, with the string underneath
because a scanner is not always to hand.
Six tests. The ones that earn their place: base64 contains + and /, and
without percent-encoding the / ends the host and the + decodes as a space;
and a relay is a hostname, so a card naming a URL is refused rather than
letting it point the client at a path on somebody else's host. The screen
also says when a card has no delivery key, rather than handing over one
that silently cannot receive a first message from another relay.
⚠️ Error correction M, not H: at H the module count grows enough that the
code stops scanning from a phone held at arm's length, which is the only
way anyone will use it. And .interpolation(.none), or the modules blur and
it will not scan at all.
**The compose files**
Postgres was in both; Redis was in neither, and docker-compose.prod.yml had
drifted from what actually runs — it was missing FN_REDIS_URL,
FN_DELETE_ON_ACK and the five APNs variables too. A self-hoster following
the documented compose got no Redis at all.
Both now carry it, marked optional and deliberately unpersisted: a queued
token only says "look now", the poll cursor is the truth, so losing every
token on a restart costs one poll and nothing else.
|
||
|
|
f8d52c8c75 |
Document the nine, test Android sign-up, and correct the federation claim
api-docs described the protocol thoroughly and never said how to start a
relay. It now has a compose file, every variable with whether it is
actually required, and curl checks. The parts worth reading twice, each of
which has bitten somebody: FN_RELAY_AUDIENCE must be the hostname clients
reach, because a mismatch fails as a signature error rather than a name
error; FN_TURN_HOST must stay unproxied in DNS or every relayed call fails
while the record looks healthy; FN_DELETE_ON_ACK is Postgres-only; and
FN_LOG_LEVEL must never be debug in production, because a protocol URL
contains a device id.
Android sign-up had never been driven. It now passes against all four
relays. Two silent faults in the driving, both of which reported a broken
sign-up for a form that was never filled: `adb shell input text` drops
characters on longer strings — "@example.test" arrived as "@exs" — and the
first fix used `fold | while read`, which swallows the last chunk when
there is no trailing newline. Every field is typed in chunks and read back.
And the comparison page claimed "Servers can talk to each other — Yes"
beside Signal's No. True of the relay — 11/11 against the live fleet — and
false of the product somebody holds, because neither app calls a single
federation route. It now says "Server-to-server", that the apps do not use
it yet, and that we would rather say so than let you find out.
NINE-ITEMS.md records what was verified and how, including that no payment
was ever taken end to end.
⚠️ The demo passphrase is deliberately absent: this repository is public
and those are live accounts. Scripts/android-signup.sh reads
FN_DEMO_PASSPHRASE and refuses to run without it.
|
||
|
|
2537c771e0 |
A "fresh install" kept its pinned keys, so delivery looked broken
Twice today I concluded that message delivery was broken. Both times the
app was right and the harness was lying.
-FNMSGResetState only cleared SessionStore. It left KeychainE2EStore
entirely alone — the device id, the pinned identities, and the
identityChangeSeen flags. So a "clean" run inherited the previous run's
pin, saw the peer's new identity after their reinstall, and refused to
open the message. Correct behaviour, invisible symptom: the relay reports
the envelope delivered and the app shows nothing at all.
Reset now wipes both. The UserDefaults suites first — which moved the
device id and made it look fixed — and then the Keychain, which is where
the pins actually live. Clearing only the first is what made the second
investigation look like a different bug from the first.
Every silent drop in ingest now says why. There were four bare
`return true`s, and three separate investigations this week ended at one
of them. The reason is a fixed string, never a message or a key. It found
this one in a single run:
ingest dropped an envelope from b22c2aa8: identity key not accepted
Two tests: FreshPairUITests covers the fresh-install path, DemoRoundUITests
covers an established install — receive, reply, and see the group.
Verified: three consecutive demo rounds, both directions, group visible in
each, iOS OK and Android saw the reply every time.
|
||
|
|
e210fbfd50 |
Keep the demo passphrase out of a public repository
This repository is public and fndemo1/fndemo2 are live accounts on accounts.flamenet.io, so committing their passphrase hands them to anyone who clones it. It was about to go in via TESTFLIGHT.md and two UI tests. Checked before pushing: the value is not in the published history yet, so scrubbing the working tree is enough — no rewrite needed. Tests read FN_DEMO_PASSPHRASE instead, and say so, including the TEST_RUNNER_ prefix xcodebuild requires. Without that prefix the variable never reaches the runner and every sign-in fails while looking configured. |
||
|
|
dfaa557c0b |
Sign-up works on all three relay kinds; the harness was the problem
Items 1, 2 and 9 of the original list, driven through the app.
The repair pass in completeSignUp had two faults, and between them they
made a working sign-up look broken on two of the three relay kinds.
It ran unconditionally and then tried to interpret its own failures — and
one of those interpretations was an early `return` that skipped the submit
tap entirely. The form was filled, valid and enabled, with nobody pressing
the button. The reading that gives it away is passwordChars=21
submitEnabled=true with the sheet still up, which is what finally named it.
It also treated a field that was merely scrolled out of the accessibility
snapshot as absent. `signup.confirm` reports gone while the keyboard
covers it and reappears when it does not, so the repair died on a field
that was there all along.
The form already knows whether it is complete: signup.submit is enabled
exactly when the passwords match and the required fields are filled.
Repair only when it says no, then press the button.
🔴 And a finding that is not a bug: "3 clean rounds" cannot be run back to
back against the shared account service. It allows 3 new accounts per hour
per IP, so rounds 2 and 3 exhaust it — and the service is entirely
correct, returning 429 "Too many accounts created from here recently" with
the app showing that message verbatim on the sheet. I reported it as the
app swallowing an error before checking the screen; it does not. Throttled
now reports SKIP, because a suite that fails for a service working
correctly teaches everyone to ignore it.
Only local self-hosted survives repeated rounds, because it keeps its own
accounts and has no such quota — which is item 9: host the messenger alone
and the accounts service is genuinely optional.
|
||
|
|
c07101b420 |
A missed call left no trace, and the offer rang minutes late
Call signals are control traffic and are never rendered, so an offer that
expired while the app was closed simply vanished: the person called had no
way to know anyone had tried. Worse, when the app did open, the stale
offer rang — for a caller who gave up 45 seconds in and had long since
moved on. Answering it connected to nothing.
An offer older than the caller's own patience is not a call. It now
becomes a "Missed call" entry in the thread instead, checked before the
busy branch so a stale offer cannot make us answer "busy" to a call that
ended long ago either.
That needed the relay's timestamp threaded down through deliver(), not
the time we happened to poll — which for an app that has been closed is
"now" no matter how old the call is.
🔴 And the parser was wrong on the first attempt, in a way that would have
disabled the whole fix silently. ISO8601DateFormatter is exact: with
.withFractionalSeconds it REJECTS a string that has none. The relay's
iso8601() uses a default formatter and emits 2026-08-27T01:08:51Z, so the
strict parser returned nil for every envelope it would ever see — and nil
falls back to Date(), which reads every stale offer as brand new. Found by
comparing the parser against what the relay actually sends rather than
against what it seemed reasonable to send.
Three tests pin it, including one on the exact format the relay emits.
iOS 0 warnings, relay 16 tests in 5 suites passing.
|
||
|
|
b174949ce5 |
Send the iOS wake-ups, and test the part Apple will not explain
The APNs sender: an ES256 provider token, an HTTP/2 POST per device, and a content-free payload. 🔴 Apple reports every mistake in the token identically — 403 InvalidProviderToken, with nothing to say which part is wrong. So four tests assert the structure locally, where a failure can name the cause. The one that earns its place: JWS ES256 is the raw r‖s pair, 64 bytes for P-256, while derRepresentation gives ~70 bytes that nothing accepts. The test asserts the length and then verifies the signature against the public key. ⚠️ The provider token is cached for 30 minutes. Apple refuses one regenerated more than once every 20 with TooManyProviderTokenUpdates — minting per push passes every test and throttles the relay the moment it has users. ⚠️ The push is fired from a detached task. A push is an optimisation on top of a message already committed; holding the response open for a round trip to Apple per recipient device would charge the sender for the recipient's platform. It uses app.db, not the request's, because it runs after the response is gone. 🔴 410 Gone deletes the row. That token belongs to an app that is no longer installed, and keeping it means waking a device that does not exist, forever, while Apple counts it against the sender. ⚠️ FN_APNS_ENV defaults to sandbox. A production push to a debug token fails with BadDeviceToken and the error does not say which way round it is, so the safe default is what a developer build needs; TestFlight and App Store builds must set production. iOS now registers on session start and forgets on sign-out — before stopping the inbox, which is what can still reach the relay. A handset that signs out holding a token goes on being woken for an account it no longer has. The payload carries no message and must never carry one: the relay holds ciphertext it cannot read, so anything that could put the text there would mean it had read it. 16 tests, 5 suites, passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
7339592a64 |
The service was declared under the wrong package and never resolved
The manifest said ".MessengerService" while the activity beside it says
".app.MainActivity" — so the class resolved to
io.flamenet.messenger.MessengerService, which does not exist. `dumpsys
package` reported "No services found" and starting it would have thrown
ClassNotFoundException at the moment the app was backgrounded, which is
the moment nobody is watching.
Verified on the emulator after the fix, end to end:
- backgrounding the app starts it, isForeground=true, types=0x200
(REMOTE_MESSAGING), ongoing notice on flamenet.ongoing
- iOS sent PUSH2 at 932.474; the Android notification posted at 931.466
on a device whose clock matches the host — so the alert arrived about a
second BEFORE the sender finished rendering its own copy
- repeated with a fresh marker to prove it was not a stale notification:
when= moved 848.867 -> 931.466
That is a closed app on Android alerted in about a second with nobody in
the middle.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
27324ee125 |
Alert people when the app is not on screen
Two platforms, two mechanisms, because iOS leaves no choice. Android gets MessengerService, a foreground service holding the long-poll with nobody in the middle. It rebuilds its session from the vault rather than sharing the ViewModel's — by the time it runs the Activity may be gone, which is the situation it exists for. Notifications for messages, and a high-priority one naming the caller for an incoming call, because an unnamed ringing phone is not answerable. ⚠️ The persistent notification is not a bug. Android requires a visible notice for a foreground service; removing it means the OS kills the connection. That is the price of not routing wake-ups through Google. ⚠️ Exactly one poller at a time: the ViewModel polls while the app is on screen, the service while it is not, handed over in onStart/onStop. Two would race for the same envelopes and double-notify, so the pair has to stay symmetrical. iOS gets APNs, and that IS Apple on the delivery path — there is no alternative, because iOS suspends a backgrounded process and provides exactly one way to wake it. What Apple learns is metadata: that a device was woken and when. The push is content-free by construction, since the relay holds ciphertext it cannot read. ⚠️ Nothing may later put the message into the push payload; anything that could would mean the relay had read it. Relay stores one token per DEVICE, not per user — messages are sealed per device, so waking the wrong one wakes an app with nothing to decrypt. Re-registering replaces rather than accumulates, because APNs reissues tokens and stale ones wake devices that no longer exist. An empty token means forget: a sign-out must reach this, or a handset goes on being woken for an account it no longer holds. Android tokens are refused outright — it receives directly and needs no row. Not yet done: the APNs sender itself, which needs a .p8 auth key that only a human with the developer account can mint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f3f403aef4 |
Prove delete-on-ack before trusting it, and find it unsafe on SQLite
The flag deletes ciphertext irrecoverably, so it needed a test that says WHICH rows go — not that something got deleted. The first run found a real one. The cursor is a row id, so "delete everything at or below it" is sound only where ids are never reused. SQLite hands a deleted rowid straight back to the next insert: measured, first=1 then late=1, so a message arriving after the cursor is deleted UNREAD with nothing left to show it existed. Postgres sequences never reuse. FN_DELETE_ON_ACK is now REFUSED with a critical log on anything that is not Postgres, rather than discouraged — a self-hoster on SQLite must not lose mail to a flag. Five tests, each asserting the survivors and not just the casualties: a cursor deletes only at or below itself, a later arrival survives when ids are monotonic, one device's cursor never touches another's inbox, a zero cursor is not a bound (a reset client would otherwise wipe its own inbox), and the SQLite reuse itself — so if that ever changes the guard can be revisited rather than guessed at. The predicate is extracted as Routes.deleteAcknowledged so the test exercises the shipped code. A test that restates the query proves only that it agrees with itself. Also adds the Redis wake-up layer for held polls: LPUSH a token per delivered message, BRPOP in the poll. Chosen over Postgres LISTEN because LISTEN pins a backend per waiter and max_connections here is 100 — a ceiling of about 86 people with the app open. It is allowed to lose data: a token only says "look now", the cursor query remains the truth, so there is no persistence to configure. FN_REDIS_URL unset falls back to the interval, because a self-hoster should not need two services to run a messenger. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
572b27de2a |
Hold the poll open instead of asking every five seconds
The app polled every 5s while foregrounded, so a message took up to five seconds to appear and each device made twelve requests a minute to say "anything?". GET /e2e/messages now takes wait=<seconds>: with nothing to return it holds the request rather than answering "no", and replies the moment something lands. Roughly two requests a minute, and latency down to what the network costs. The client measures how long the request actually took rather than trusting the parameter to have been honoured. A relay that predates this ignores wait and answers at once, and sleeping the short interval against one of those would poll it HARDER than the old loop — sixty requests a minute. Held request, short gap; immediate answer, the old five seconds. wait is capped at 30 server-side and asked for at 20, because the client's own timeoutIntervalForRequest is 25 and Traefik cuts quiet connections. Ask for as long as the client will wait and every quiet poll dies on the client's timeout, surfacing as a network error rather than an empty inbox. Held requests poll the database on an interval rather than listening to it. Deliberate: LISTEN pins a Postgres backend per waiter and max_connections here is 100, so listening would cap simultaneous waiters at about 86. Redis LPUSH/BRPOP replaces the interval later. Also adds delete-on-ack — an advancing cursor is proof of receipt, so the envelopes below it can go rather than waiting for the retention sweep — but 🔴 OFF unless FN_DELETE_ON_ACK=1. It deletes ciphertext, the failure mode is silent unrecoverable loss, and the first deploy could not tell a working delete from a wiped test device. It needs a test that sends, polls twice, and asserts the row is gone AND the message was read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9a0c9407aa |
Stop the sign-up helper reporting the wrong cause, three times over
Every one of these named a line that was not the problem. It typed an email into a form that has no email field. SignUpView omits it entirely on a shared-account server, and the app's default server IS one, so a fixed three-Return chain sent the address into the password field and failed several steps later looking like a focus race. Ask whether the field is on screen. It dismissed the strong-password panel once, immediately — and the panel arrives a beat AFTER focus, so the check found nothing and the keystrokes were eaten by the panel that turned up next. The visible result was a disabled Create account button with nothing on screen to explain it. Wait for the panel, then close it. fillNewPassword typed into unfocused fields instead of giving up, so the failure named the typing rather than the occlusion behind it. Also: the sheet closing IS success. The Return after confirm submits, so on a form that took its text the sheet is gone and the repair pass hunted a SecureTextField that no longer existed. DemoAccountSetupUITests proves the App Review credentials by signing in through the app's own screens. It exists because curl against relay.flamenet.io proved the wrong thing twice: that host is pinned, so the app authenticates against accounts.flamenet.io with a handle, and an account made on the relay answers 200 to curl and "handle not found" to a person holding a phone. TESTFLIGHT.md carries every submission field. PUSH-SCOPING.md scopes delivery with no third party on the path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f612af7b74 |
Clear the four concurrency warnings, three of them real
A clean build reported 24 warning lines — four distinct, each an error under the Swift 6 language mode rather than noise. maxDuration was main-actor isolated on a @MainActor class, so the detached task that opens the audio route could not read it. It is an immutable TimeInterval; nonisolated is the honest answer. NotificationManager reached across the actor for `center` inside a callback that runs somewhere else. The async API inside a Task stays on this actor the whole way, same fire-and-forget behaviour, and keeps the method synchronous so clearThread does not have to become async. CallManager passed a non-Sendable closure where WebRTC declares @Sendable. It genuinely does cross threads — the only capture is the continuation, which is Sendable by design — so say so. Clean build after: 0 warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
22b933c7a9 |
A first message was unreachable, and the empty state hid the app
Two faults on one screen, both hit by anyone whose account has no buddies — which is every new account, and every account that has only ever been messaged first. Adding a buddy on the relay is one-way by design: "mutual visibility is two rows — following was never symmetric here." Home was driven only by that list, so when somebody added you and sent, the ciphertext arrived, decrypted into threads[senderID], and had no row to tap. Not an edge case: that is every first conversation. Correspondents with a thread but no buddy row now appear alongside the rest. The name is whatever displayName(for:) manages — often "Buddy <id>", because nothing in the envelope carries the sender's handle and the relay has no lookup-by-id route. A reachable thread with a poor name beats an unreachable one; naming it properly needs a relay change. Adding the sender fixes both the name and their presence. Separately, the "No buddies yet" empty state REPLACED the whole screen, taking the Groups section, New group and the Stories strip with it. It read as those features not existing. It is a row inside the list now, so nothing else disappears behind it. Verified in the simulator: an account with no buddies still shows Groups, New group and Stories, and a message from a non-buddy arrives and opens. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a803fb2dce |
A group could be named on Android and never created
Tapping New opened the form, the name field took text, and then nothing: no member checkboxes, no Create group button, Cancel the only thing that worked. The controls were there all along, below the fold on a screen that could not scroll. The scroll exemption was deliberate and its reason had expired. It read "only the screens WITHOUT a lazy list may scroll — Contacts owns a LazyColumn, and a lazy list inside a vertically scrolling Column is measured with infinite height and crashes outright." True of an UNBOUNDED list; the buddy list has carried heightIn(max = 320.dp) since it was moved into its own card. Groups sits below it, so everything the form added fell off the bottom. Measured on a 1080x2400 emulator: the name field at y~1879, Create group past the tab bar at y~2280, and a uiautomator dump finding no scrollable container and no Create group node at all. Checked every lazy list on the screen before changing it — the buddy list is bounded, Stories is a LazyRow. Driven on the emulator afterwards: the checkbox and button are reachable, a group was created, and it arrived on iOS as "Demo Group, 2 members". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |