A removed device is told so, instead of being refused for ever (401 → 410)

🔴 **This is why an iPhone stops receiving anything and says nothing.**

The design was already right and written down in two places: a device the
account removed must LEARN that rather than poll an empty mailbox for ever, so
`/e2e/messages` answers **410**, deliberately not 401 — "sign in again" is the
wrong instruction for a device somebody removed.

But the retirement check runs earlier, during authentication, and every
`AuthError` collapsed into `WireError.unauthorized`. So a revoked device threw
`deviceRetired`, got a generic **401**, and never reached the 410 the route
would have given it. The comment on that route even says "the retirement case
threw during authentication" — it did, into the wrong status.

The client only acts on 410. On a 401 it keeps polling, keeps failing, and
tells nobody:

    refused GET /api/flamenet/v1/e2e/messages: unauthorized
      this device was removed from the account

Eight of those in half an hour on production today, from a phone whose owner
was reporting that messages did not arrive, calls did not connect, and an
incoming call showed no screen. All one cause. iOS has the handler —
`AppEnvironment.deregistrationWatch` signs out and explains — and it is wired
to `deviceDeregistered`, which only fires on 410.

⚠️ **The only path that behaved correctly was the one nobody records.** A
device whose ROW vanished with no key-log entry hit the 410 branch and worked.
A real revocation appends a `revoke` entry to the key log, which is exactly
what triggered the 401. The good case was the accident.

⚠️ **And the existing test proved the accident.** `revokedDeviceLearnsItIsGone`
deletes the device row directly, so it exercised the branch that worked and
went green throughout. The new test revokes through `revokeDevice` — the door
the Devices screen uses — and asserts the key log recorded it, so it cannot
drift back onto the row-deleted path.

Falsified: with the new catch removed, the old test still passes and the new
one fails on both assertions (`code == "device_revoked"` and `status == 410`).

Same code and same words as the `/e2e/messages` refusal, on purpose: a client
switching on `device_revoked` should not have to know which of the two places
refused it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul Hitt
2026-09-07 10:16:14 -04:00
co-authored by Claude Opus 5
parent bd46682723
commit 1384f27eaa
2 changed files with 60 additions and 0 deletions
+23
View File
@@ -135,6 +135,29 @@ struct Routes: Sendable {
}
await noteAlive(caller.userID, on: request.db)
return (caller, known)
} catch AuthError.deviceRetired {
// 🔴 **410, not 401 — and the whole feature depended on it.** The
// note above says a removed device must LEARN that rather than poll
// an empty mailbox for ever, and `/e2e/messages` says the same and
// answers 410 when there is no row. But the retirement check runs
// during authentication, and every `AuthError` collapsed into a
// generic `unauthorized`, so the device the account actually
// removed — the one the key log remembers — got a 401 and the
// client's revocation handler never fired.
//
// The result on the phone: it keeps polling, keeps failing, and
// says nothing. Messages do not arrive, calls do not ring, and
// nothing anywhere explains why. The only case that behaved
// correctly was a device whose row had vanished WITHOUT a key-log
// entry, i.e. the one nobody records.
//
// ⚠️ Same code and same words as the `/e2e/messages` refusal on
// purpose. A client switching on `device_revoked` must not have to
// know which of the two places refused it.
throw WireError("device_revoked",
"This device was removed from the account. Its local "
+ "messages are being cleared; sign in to register it "
+ "as a new device.", 410)
} catch let e as AuthError {
throw WireError.unauthorized(e.description)
}
@@ -336,6 +336,43 @@ struct E2ERoutesTests {
}
}
/// 🔴 **The same thing, through the door people actually use — and this is
/// the one that was broken.**
///
/// The test above deletes the device ROW and gets its 410. A real
/// revocation goes through `revokeDevice`, which also appends a `revoke`
/// entry to the key log — and that entry made `authenticated()` throw
/// `AuthError.deviceRetired`, which collapsed into a generic 401 before it
/// ever reached the 410 above.
///
/// So the only case that behaved correctly was a row that vanished with
/// nothing recording it, and the case the product actually produces
/// returned 401 for ever. On the phone that is a client polling, failing,
/// and saying nothing: no messages arrive, no calls ring, and nothing
/// explains why. iOS only treats 410 as deregistration.
@Test("a device revoked the normal way is told it is gone, not merely refused")
func revokedThroughTheRouteLearnsItIsGone() async throws {
try await withRelay { h in
let alice = try await device(h)
// The account removes it, exactly as the Devices screen does.
_ = try await h.routes.revokeDevice(
get(h, "/e2e/devices/\(alice.deviceID)", as: alice),
deviceID: alice.deviceID, now: now)
// ⚠️ Proves the key log recorded it, so this test is exercising the
// retirement path and not the missing-row one above.
#expect(try await KeyLog.isRetired(deviceID: alice.deviceID, on: h.db))
let err = await #expect(throws: WireError.self) {
_ = try await h.routes.poll(self.get(h, "/e2e/messages", as: alice), now: self.now)
}
let code = (err as? WireError)?.code ?? "?"
#expect(code == "device_revoked",
"a revoked device got \(code) — the client only acts on device_revoked")
#expect((err as? WireError)?.data.status == 410,
"401 says sign in again, the wrong instruction for a removed device")
}
}
@Test("the cursor only returns messages after it")
func pollHonoursTheCursor() async throws {
try await withRelay { h in