eleven / messenger Public

ios: sign-out unsubscribes push from every space before the wipe (#1149)

A signed-out phone kept getting notifications: sign-out wiped keys and
tokens locally but never told the instances, so they pushed sealed
payloads at the APNs token forever — and the NSE can neither decrypt
them (keys gone) nor drop them (no filtering entitlement yet). The
server's POST /api/apns/unsubscribe existed all along; call it for
every instance, awaited BEFORE AppReset.wipeAll — the calls
authenticate with the very device tokens the wipe erases. A dead
instance costs only its own 5s timeout.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d09564fdcc338682cd4c382a7d6261d1344ba584 parent c52d6e3
2 files changed, +33 −0
  • ios/LChat/LChatApp.swift +5 −0
  • ios/LChat/PushManager.swift +28 −0
diff --git a/ios/LChat/LChatApp.swift b/ios/LChat/LChatApp.swift
index 98d96d8..2f9a173 100644
--- a/ios/LChat/LChatApp.swift
+++ b/ios/LChat/LChatApp.swift
@@ -558,6 +558,11 @@ struct RootView: View {
if let session = HomeSessionStore.load() {
_ = await HomeSync.push(instances: app.instances, session: session)
}
+ // Stop the pushes before the wipe kills the tokens that authorise
+ // stopping them — otherwise every space keeps pushing sealed
+ // payloads at this phone forever (the NSE can't decrypt or drop
+ // them once the keys are gone).
+ await push.unsubscribeAll()
AppReset.wipeAll()
app.eraseAll()
signIn.refreshSession()
diff --git a/ios/LChat/PushManager.swift b/ios/LChat/PushManager.swift
index 0e66d91..a1ee0e9 100644
--- a/ios/LChat/PushManager.swift
+++ b/ios/LChat/PushManager.swift
@@ -108,6 +108,34 @@ final class PushManager: NSObject, ObservableObject, UIApplicationDelegate, UNUs
URLSession.shared.dataTask(with: req).resume() // best-effort
}
+ /// Tear this device's subscription down on every instance — the sign-out
+ /// path. Without it a signed-out phone keeps getting pushed sealed
+ /// payloads forever: the wipe removed the keys, so the NSE can neither
+ /// decrypt them nor drop them (dropping needs the filtering entitlement
+ /// we don't hold yet). Must run BEFORE the wipe — these calls authenticate
+ /// with the very instance tokens the wipe erases — and is awaited so the
+ /// wipe can't race it; a dead instance only costs its own short timeout.
+ func unsubscribeAll() async {
+ guard let hex = UserDefaults.standard.string(forKey: Self.tokenKey) else { return }
+ await withTaskGroup(of: Void.self) { group in
+ for inst in InstancesStore.load() {
+ group.addTask { await Self.unsubscribe(inst, apnsToken: hex) }
+ }
+ }
+ }
+
+ private static func unsubscribe(_ inst: SavedInstance, apnsToken: String) async {
+ guard let base = inst.baseURL else { return }
+ var req = URLRequest(url: base.appendingPathComponent("api/apns/unsubscribe"))
+ req.httpMethod = "POST"
+ req.setValue("application/json", forHTTPHeaderField: "Content-Type")
+ req.timeoutInterval = 5 // sign-out must not hang on an unreachable space
+ req.httpBody = try? JSONSerialization.data(withJSONObject: [
+ "token": inst.deviceToken, "apns_token": apnsToken,
+ ])
+ _ = try? await URLSession.shared.data(for: req)
+ }
+
// MARK: Presentation + taps
// Which chat is on-screen right now (set by the pushed ChatView) — a banner