Commit Graph
6 Commits
Author SHA1 Message Date
Paul HittandClaude Opus 5 e07660559c No more WordPress, and the bug that made iOS unusable
The iOS app could not sign up against a Swift relay at all. Sign-up died on a
bare "Server returned HTTP 404." with nothing pointing at the cause.

The cause: the relay served its route index under BOTH `api` and a legacy
prefix, while serving the actual routes only under `api`. The app settles its
REST prefix by probing for that index and keeping the first prefix that answers
-- so it adopted the legacy prefix, and every call 404'd.

⚠️ This was supposed to have been fixed yesterday, and was not. `f5614ab`
(Aug 21, "Serve the four routes the app was calling into a 404") is what
INTRODUCED the two-prefix index loop; it was the cause, not the cure. The rule
got written down and the code change never landed. My own notes asserted a fix
that no branch contained. Believe grep.

The rule, which is more general than this bug: **serve a discovery document only
where the things it describes exist.** A document listing routes a prefix does
not serve is worse than no document, because it is believed.

Fixed in three places, because one was not enough:
  - the relay serves the index only under `api`;
  - the client probes only `api` -- there is one prefix, and probing for others
    only creates ways to be wrong;
  - the index no longer advertises routes that are not mounted.

And the purge the owner asked for: no `WordPress`, `wp-json`, `wp/v2`,
`wp-admin`, `wp-login`, `wp_` or `wp-cron` anywhere in the relay, the iOS app,
the Android app or the tests. `/api/wp/v2/users/me` is now
`/api/flamenet/v1/messenger/me`; the user lookup moves alongside it; the dead
`ajaxURL` and the two error cases only a web login form could produce are gone.

⚠️ Recorded because it cost time: the deployed relay source on the box is NOT in
any branch. It carries multi-homing `envelope_id` work that exists nowhere in
git, so copying local files over it broke the build. Restored from the prod
tree, then patched in place. That divergence needs closing.

Adds CrossPlatformUITests (iOS) and CrossPlatformTest (Android) -- each driven
by arguments so the two platforms can be interleaved from a shell, because two
iOS accounts in one simulator prove only that the app agrees with itself.

With this, item 5 is done: a message typed in the iOS app arrived and rendered
in the Android app, through relay1, end-to-end encrypted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:46:54 -04:00
Paul HittandClaude Opus 5 f2d74cff0c Android: groups, and the wire shape iOS got wrong
Groups were 100% broken on iOS -- not partly, entirely -- because the client
required a `name` on each member that the relay has never sent. Every group
decode failed, so the feature looked implemented and did nothing.

So this one was built by reading the relay's wire types first and asserting
them against live bytes second. `Member` is `{user_id}` and only that: the relay
has no user table and deliberately will not grow one, because a relay caching
display names is a relay that leaks them. Names are resolved client-side from
the contact list, and a member you have never added shows as an id, which is
honest.

Create, list, open, add and remove members, leave, and send -- all verified
against relay1, including that a group message carries its routing so it files
under the group rather than under whoever sent it.

Two things worth stating plainly:

The relay does no fan-out. A group is a membership list it keeps so clients
agree who is in it; the encryption is still pairwise, one ratchet per recipient
device. An n-person group is n sends. That is the honest cost of the relay not
being able to read anything, and the client is what knows it.

Thread history is now keyed by an opaque thread id -- `user:<id>` or
`group:<gid>` -- rather than by user. Filing a group message under its sender
scatters one conversation across as many threads as it has participants.

Leaving a group also clears its local thread. Keeping it would leave a
conversation nobody can add to and no way to remove it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 19:32:02 -04:00
Paul HittandClaude Opus 5 e65d1d987b Android: photos, and a door for every capability
Owner feedback, and it was fair: capabilities kept landing in `:client` with a
live test proving them and no way for a person to use them. Remove a contact.
Block someone. Clear a thread. Change a password. Read a notification. All
built, none reachable.

So this is the CRUD pass, plus attachments.

New in the UI: contact actions (block, unblock, clear chat, remove), a presence
picker, a notifications screen with per-item and mark-all-read, an account
screen with display name, password change and account deletion, password reset
on the sign-in screen, bottom navigation, and photos -- sent through the system
picker, rendered inline.

`CrudSurfaceTest` walks the app and asserts the controls exist. It checks
reachability rather than behaviour, because behaviour is what the live tests
cover and the door is what was missing. It found two real gaps immediately:
sign-out was reachable only from the contact list rather than from Account,
where people look for it, and several controls sat below the fold.

Underneath: the §6.6-§6.9 content envelope, which is one codec for attachments,
replies, reactions, group routing and call signalling. 17 unit tests.

Two things it has to get right, and both are one-line mistakes:

`fnc` must serialise FIRST, because the marker is a prefix test on the bytes. A
JSON library that sorts keys puts `att` first, every envelope it produces reads
as plain text on the far side, and somebody's chat fills with raw JSON. The top
level is assembled by hand.

Control messages travel down the same channel as chat -- the web client sends
`delivery_key`, `kt_head` and `sent` copies this way. A client that renders
every envelope it does not recognise shows those to the user. They are now
parsed, marked unrenderable, and handed to the caller with their payload.

Attachments verified end to end against a real relay, including that the relay
holds only ciphertext. That path was 401 on iOS for months because the
attachment routes sent the account password, which the relay refuses on
`/e2e/…`; this one uses the device-scoped token like everything else, and the
test is what says so.

Also: the instrumented suite passed one test at a time and failed as a set. The
Compose rule gives each test a fresh Activity but the process and vault survive,
so the app spends its first moments reading the vault behind a spinner -- and a
reset that checks for a signed-in control immediately concludes "already signed
out" while the session restores underneath it.

114 JVM tests, 37 cross-engine, 15 on-device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 18:47:45 -04:00
Paul HittandClaude Opus 5 d30a77dd1e Android: contacts, conversations, and a message that made the round trip
The app now holds a conversation. A message typed into it reaches a different
program through the real relay, that program answers, and the answer appears in
the thread -- verified three times, end to end, encrypted the whole way.

96 JVM tests, 37 cross-engine, 11 on-device, plus live round trips against the
throwaway fleet.

What landed: a fan-out send that reaches every device a person has, inbox
polling, message history encrypted at rest, contacts, presence, and a session
that survives a restart. Plus `:interop:peer` -- a Flamenet account you can
drive from a shell, so the correspondent on the other end is not this app
talking to itself.

Three wire shapes were wrong, and only a real relay could say so:

  - `prekeys`, not `opks`. The relay answered 200 and stored nothing, so the
    device was published and could not be started with.
  - The send body names the recipient once at the top, not per envelope.
  - The buddy list is `id`/`presence`/`screen_name`, not `user_id`/`status`.

The second and third at least failed loudly. The first did not, which is why
the live tests exist.

Decisions worth stating:

A send returns partial success rather than throwing. One of someone's three
devices being unreachable must not stop the message reaching the other two, and
the UI says "delivered to 2 of 3" rather than "sent".

The inbox watermark advances per message, after it is decrypted AND stored --
never once for the batch, which would lose everything after a crash. An
undecryptable message advances it too: it is never going to decrypt, and
leaving it would re-fail on every poll for ever.

A sent line is recorded only once a device took it. Writing it first produces a
thread showing messages the recipient never got, which is worse than an error --
the sender believes they said something.

Opening a thread marks it read locally and tells the relay nothing. A read
receipt is a disclosure, and this product does not make one by default.

History is keyed by person, not device. Someone's phone and tablet are one
conversation, and filing by device would split it into threads each holding
half the story.

Also improves how instrumented failures report: the assertion now prints what
the screen was showing. That turned a bare 90-second timeout into "No value for
user_id" and then "Failed to connect to relay1" -- one a bug, one the emulator's
network, and previously indistinguishable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:55:49 -04:00
Paul HittandClaude Opus 5 5f983c1455 Android: the app, and the two things only a phone could tell us
There is now a Flamenet Messenger app that installs, launches, creates an
account on a self-hosted relay, registers a device, publishes prekeys, and
stores all of it in an Android Keystore-backed vault. Verified on an emulator
through the actual screens, not by unit test.

92 JVM tests, 37 cross-engine, 9 on-device, 3 against live relays.

Two findings, both of which the JVM suite was structurally incapable of
producing.

**Android has no ordinary Ed25519 provider.** `KeyPairGenerator.Ed25519` and
`KeyFactory.Ed25519` come from `AndroidKeyStore` and from nowhere else -- probed
on API 35 rather than assumed -- and Keystore only makes keys it keeps. Every
Ed25519 key in this engine is one we must hold: a device identity in our own
vault, an account key derived from a passphrase. So the JCA route could never
have worked. It compiled, passed 81 tests on a laptop, and threw "Not
initialized" on the first launch on a phone. Worse, it was LATENT: a wrong
password failed before any key was minted, so the error path worked perfectly
while the happy path was broken.

Ed25519 now uses BouncyCastle's `math.ec.rfc8032` core directly -- the engine's
one real dependency, present because the platform leaves a hole, and a strictly
better answer than the non-constant-time keygen it replaces. ProviderProbeTest
records the measurement so nobody guesses again.

**The prekey field is `prekeys`, not `opks`.** I guessed. The relay accepted the
registration, returned 200, and stored nothing -- so the device was published
and could not be started with, a failure with no error anywhere. Caught by a
live test against the throwaway fleet, which exists because the same path
through the emulator takes four minutes to say "timed out" and nothing about
where.

Two testing traps, recorded where they bite:

An indeterminate progress spinner animates for ever, so Compose's test clock
never goes idle and `waitUntil` never evaluates its condition -- it fails on the
full timeout, reading as "sign-up is broken" when sign-up works. Auto-advance is
now disabled around the wait only; disabling it for the whole test is the next
mistake, because the app opens on a spinner while it reads the vault.

Wiping the Keystore in `@Before` deletes the key the already-launched activity
is holding, and every later write fails against a key that no longer exists. The
test signs out through the UI instead, which is both safe and what a person does.

Failure messages now include what the screen was showing. A bare timeout sends
you to the network, the relay and the crypto in turn while the app has been
displaying the reason the whole time.

Also corrects a note that said this Mac had no Android SDK. It does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 17:02:16 -04:00
Paul HittandClaude Opus 5 b1ba9cdc58 Kotlin: the relay transport
The engine can hold a conversation but had no way to reach a relay. This is
that: the §8 routes, both authentication modes, and the transparency log.

13 tests against a real HTTP server rather than a hand-written fake. That
distinction is the point -- a fake written alongside the client agrees with it
about everything including the field name it got wrong, which is exactly how
this protocol's one shipped bug survived until a live round trip.

Two more tests run against the deployed relay, opt-in via FN_LIVE_RELAY and
silent when unset. A test that reaches the network cannot sit in a hermetic
suite; it fails on an aeroplane and a suite that is red for unrelated reasons
stops being read. What they check is the thing MockWebServer structurally
cannot: that the URL this client builds is one relay.flamenet.io actually
serves. It does, on both mounts, and the Kotlin engine verified an inclusion
proof from the production log against a head fetched separately -- not against
the root the proof response carried, because a server that supplies both halves
of a comparison has proven nothing.

Details that are load-bearing:

The per-request signature covers the LOGICAL section-8 path, not the URL's. The
relay mounts these routes twice and signs against the former, so signing the
REST-prefixed path yields a valid signature over the wrong string and a refusal
with no hint as to why. Tested by asserting the other one does NOT verify.

The nonce is not decoration. Two inbox polls in the same second with the same
empty body would otherwise produce identical signatures, and the relay's replay
cache cannot tell an honest repeat from a captured one -- so it refuses the
honest client and the app quietly stops receiving mail.

Token minting is lazy, and a deployment with no token route is asked exactly
once. Laziness cost an hour here: the tests hung, because takeRequest() blocks
forever and I was waiting for a mint that had not happened yet. They now use a
bounded read, so that mistake fails instead of looking like a hung build.

Application passwords are stripped of spaces. The account service prints them
in readable groups and users paste what they were shown; sending the spaces is
a 401 nobody can diagnose, because the password on screen looks right.

Kotlin nests block comments, so `/e2e/` followed by a star inside a doc comment
opens one that never closes and the compiler blames the last line of the file.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 15:37:59 -04:00