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:
co-authored by
Claude Opus 5
parent
bd46682723
commit
1384f27eaa
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user