Stop notifications after removing a host (#3176)

* fix(notifications): stop pushes after host removal

Push tokens were persisted indefinitely with no relationship to app pairing state. Give registrations a 48-hour renewable lease so offline unpairing converges, and revoke immediately when the host is reachable.

* fix(notifications): make token revocation bounded and durable
This commit is contained in:
Mohamed Boudra
2026-08-10 23:33:31 +02:00
committed by GitHub
parent 29ae25d7aa
commit 6aa9f92156
33 changed files with 702 additions and 227 deletions
@@ -0,0 +1,12 @@
import { revokeSubscription, startSubscription } from "./internal/subscriptions";
import type { RevokePushNotificationsInput, StartPushNotificationsInput } from "./internal/types";
export function startPushNotifications(input: StartPushNotificationsInput): () => void {
return startSubscription(input);
}
export function revokePushNotifications(input: RevokePushNotificationsInput): Promise<void> {
return revokeSubscription(input).catch((error) => {
console.warn("[PushNotifications] Failed to remove local push subscription", error);
});
}
@@ -0,0 +1,9 @@
import type { RevokePushNotificationsInput, StartPushNotificationsInput } from "./internal/types";
export function startPushNotifications(_input: StartPushNotificationsInput): () => void {
return () => undefined;
}
export async function revokePushNotifications(_input: RevokePushNotificationsInput): Promise<void> {
// Push notifications are native-only.
}
@@ -0,0 +1,9 @@
import type { RevokePushNotificationsInput, StartPushNotificationsInput } from "./internal/types";
export function startPushNotifications(_input: StartPushNotificationsInput): () => void {
return () => undefined;
}
export async function revokePushNotifications(_input: RevokePushNotificationsInput): Promise<void> {
// Push notifications are native-only.
}
@@ -0,0 +1,103 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import Constants from "expo-constants";
import * as Notifications from "expo-notifications";
import { Platform } from "react-native";
import type { RevokePushNotificationsInput, StartPushNotificationsInput } from "./types";
const STORAGE_PREFIX = "@paseo:expo-push-token:";
function storageKey(serverId: string): string {
return `${STORAGE_PREFIX}${serverId}`;
}
function getExpoProjectId(): string | null {
const constants = Constants as unknown as {
easConfig?: { projectId?: unknown };
expoConfig?: { extra?: { eas?: { projectId?: unknown } } };
};
const fromEas = constants.easConfig?.projectId;
if (typeof fromEas === "string" && fromEas.trim()) return fromEas.trim();
const fromExtra = constants.expoConfig?.extra?.eas?.projectId;
return typeof fromExtra === "string" && fromExtra.trim() ? fromExtra.trim() : null;
}
async function ensurePushPermission(): Promise<boolean> {
const existing = await Notifications.getPermissionsAsync();
if (existing.status === Notifications.PermissionStatus.GRANTED) return true;
if (!existing.canAskAgain) return false;
const requested = await Notifications.requestPermissionsAsync();
return requested.status === Notifications.PermissionStatus.GRANTED;
}
async function resolveToken(serverId: string): Promise<string | null> {
const key = storageKey(serverId);
const cached = await AsyncStorage.getItem(key);
if (!(await ensurePushPermission())) {
await AsyncStorage.removeItem(key);
return null;
}
if (Platform.OS === "android") {
await Notifications.setNotificationChannelAsync("default", {
name: "default",
importance: Notifications.AndroidImportance.DEFAULT,
});
}
const projectId = getExpoProjectId();
if (!projectId) {
console.warn("[PushNotifications] Missing EAS projectId; cannot fetch Expo push token");
return cached;
}
const result = await Notifications.getExpoPushTokenAsync({ projectId });
const token = result.data.trim();
if (!token) return cached;
await AsyncStorage.setItem(key, token);
return token;
}
export function startSubscription(input: StartPushNotificationsInput): () => void {
let stopped = false;
let token: string | null = null;
const register = () => {
if (!stopped && token && input.client.isConnected) {
input.client.registerPushToken(token);
}
};
void resolveToken(input.serverId)
.then((resolved) => {
if (stopped) return undefined;
token = resolved;
register();
return undefined;
})
.catch((error) => console.warn("[PushNotifications] Failed to register push token", error));
const unsubscribe = input.client.subscribeConnectionStatus((state) => {
if (state.status === "connected") register();
});
return () => {
stopped = true;
unsubscribe();
};
}
export async function revokeSubscription(input: RevokePushNotificationsInput): Promise<void> {
const key = storageKey(input.serverId);
const token = await AsyncStorage.getItem(key);
if (
token &&
input.client?.isConnected &&
input.client.getLastServerInfoMessage()?.features?.pushTokenRevocation === true
) {
try {
await input.client.unregisterPushToken(token);
} catch (error) {
console.warn("[PushNotifications] Failed to revoke push token", error);
}
}
await AsyncStorage.removeItem(key);
}
@@ -0,0 +1,11 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
export interface StartPushNotificationsInput {
client: DaemonClient;
serverId: string;
}
export interface RevokePushNotificationsInput {
client: DaemonClient | null;
serverId: string;
}