diff --git a/resources/js/hooks/use-chat.ts b/resources/js/hooks/use-chat.ts
index b37269226..237d702c4 100644
--- a/resources/js/hooks/use-chat.ts
+++ b/resources/js/hooks/use-chat.ts
@@ -12,11 +12,10 @@ import type { ChatState, ChatTarget, ThreadState } from '@/lib/chat';
import type { RemoteAlertChannel } from '@/lib/remote-notifications';
import {
remoteAlertsEnabled,
- remoteNotificationsAllowed,
remoteNotificationsSupported,
subscribeRemoteAlerts,
} from '@/lib/remote-notifications';
-import { remotePushEnabled, subscribeRemotePush } from '@/lib/remote-push';
+import { remotePushSupported } from '@/lib/remote-push';
export function useRemoteAlertsEnabled(
userId: string | null,
@@ -29,22 +28,15 @@ export function useRemoteAlertsEnabled(
);
}
-export function useRemoteNotificationPermission(): boolean {
- return useSyncExternalStore(
- subscribeRemoteAlerts,
- remoteNotificationsAllowed,
- () => false,
- );
-}
-
export function useRemoteAlerts(userId: string | null): void {
const notifications = useRemoteAlertsEnabled(userId);
const sound = useRemoteAlertsEnabled(userId, 'sound');
- const enabled = notifications || sound;
+ const offsite = useRemoteAlertsEnabled(userId, 'offsite');
+ const enabled = notifications || sound || offsite;
useEffect(
() =>
enabled && userId !== null ? watchRemoteAlerts(userId) : undefined,
- [enabled, userId],
+ [enabled, userId, offsite],
);
}
@@ -89,18 +81,10 @@ export function useUnreadMessages(): number {
: auth.unreadMessagesCount;
}
-export function useRemotePushEnabled(): boolean {
- return useSyncExternalStore(
- subscribeRemotePush,
- remotePushEnabled,
- () => false,
- );
-}
-
export function useRemoteNotificationSupport(): boolean {
return useSyncExternalStore(
subscribeRemoteAlerts,
- remoteNotificationsSupported,
+ () => remoteNotificationsSupported() && remotePushSupported(),
() => false,
);
}
diff --git a/resources/js/lib/remote-notifications.ts b/resources/js/lib/remote-notifications.ts
index 998e48988..559d62864 100644
--- a/resources/js/lib/remote-notifications.ts
+++ b/resources/js/lib/remote-notifications.ts
@@ -1,8 +1,12 @@
-import { remotePushEnabled, syncRemotePush } from '@/lib/remote-push';
+import {
+ remotePushEnabled,
+ remotePushSupported,
+ syncRemotePush,
+} from '@/lib/remote-push';
import { readStorage, writeStorage } from '@/lib/storage';
import type { ConversationSummary } from '@/types';
-export type RemoteAlertChannel = 'notifications' | 'sound';
+export type RemoteAlertChannel = 'notifications' | 'sound' | 'offsite';
const listeners = new Set<() => void>();
const preferenceKey = (userId: string, channel: RemoteAlertChannel) =>
@@ -12,6 +16,9 @@ export function remoteAlertsEnabled(
userId: string,
channel: RemoteAlertChannel = 'notifications',
): boolean {
+ if (channel === 'offsite')
+ return readStorage(preferenceKey(userId, channel)) === 'on';
+
return (
(readStorage(preferenceKey(userId, channel)) ||
readStorage(`remote-alerts:${userId}`)) !== 'off'
@@ -60,27 +67,34 @@ export async function setRemoteAlerts(
enabled: boolean,
channel: RemoteAlertChannel = 'notifications',
): Promise {
- if (
- enabled &&
- channel === 'notifications' &&
- 'Notification' in window &&
- window.isSecureContext
- ) {
- // Tab titles need no permission, even when the browser declines or cannot show banners.
- await Notification.requestPermission().catch(() => undefined);
- }
-
- if (channel === 'notifications' && !enabled) {
- await syncRemotePush(false, true);
+ if (channel === 'offsite') {
+ if (enabled) {
+ if (!remotePushSupported())
+ throw new Error(
+ 'Off-site notifications are not supported here.',
+ );
+ const permission = await Notification.requestPermission();
+ if (permission !== 'granted')
+ throw new Error('Notification permission was not granted.');
+ const previous = readStorage(preferenceKey(userId, channel));
+ writeStorage(preferenceKey(userId, channel), 'on');
+ if (readStorage(preferenceKey(userId, channel)) !== 'on')
+ return false;
+ try {
+ await syncRemotePush(true, true);
+ } catch (error) {
+ writeStorage(preferenceKey(userId, channel), previous);
+ listeners.forEach((listener) => listener());
+ throw error;
+ }
+ } else {
+ await syncRemotePush(false, true);
+ }
}
writeStorage(preferenceKey(userId, channel), enabled ? 'on' : 'off');
listeners.forEach((listener) => listener());
- if (channel === 'notifications' && enabled) {
- await syncRemotePush(true, true);
- }
-
return (
readStorage(preferenceKey(userId, channel)) === (enabled ? 'on' : 'off')
);
@@ -100,7 +114,9 @@ export function createRemoteAlerts(
reading: () => string[],
onIncoming: (conversation: ConversationSummary) => void,
) {
- void syncRemotePush(remoteAlertsEnabled(userId)).catch(() => undefined);
+ void syncRemotePush(remoteAlertsEnabled(userId, 'offsite')).catch(
+ () => undefined,
+ );
let latest: Record | null = null;
let stopped = false;
let lastSoundAt = 0;
@@ -231,7 +247,8 @@ export function createRemoteAlerts(
`${previousSoundAt}\n${consumedState}`,
);
const showBanner =
- remoteAlertsEnabled(userId) && remoteNotificationsAllowed();
+ remoteAlertsEnabled(userId, 'offsite') &&
+ remoteNotificationsAllowed();
const playSound = remoteAlertsEnabled(userId, 'sound');
if (incoming === undefined) {
diff --git a/resources/js/lib/remote-push.ts b/resources/js/lib/remote-push.ts
index 9c86c5352..783ea3868 100644
--- a/resources/js/lib/remote-push.ts
+++ b/resources/js/lib/remote-push.ts
@@ -5,16 +5,19 @@ import {
} from '@/actions/App/Http/Controllers/PushSubscriptionController';
let active = false;
-const listeners = new Set<() => void>();
let pending = Promise.resolve();
export function remotePushEnabled(): boolean {
return active;
}
-export function subscribeRemotePush(listener: () => void): () => void {
- listeners.add(listener);
- return () => listeners.delete(listener);
+export function remotePushSupported(): boolean {
+ return (
+ window.isSecureContext &&
+ 'Notification' in window &&
+ 'PushManager' in window &&
+ 'serviceWorker' in navigator
+ );
}
async function request(
@@ -75,7 +78,8 @@ export function syncRemotePush(
!('PushManager' in window)
)
return;
- if (enabled && Notification.permission !== 'granted') return;
+ if (Notification.permission !== 'granted' && (!create || enabled))
+ return;
let publicKey: string | null = null;
if (enabled) {
const config = (await (await request(index.url())).json()) as {
@@ -84,7 +88,6 @@ export function syncRemotePush(
publicKey = config.public_key;
if (!publicKey) {
active = false;
- listeners.forEach((listener) => listener());
if (create)
throw new Error(
'Background notifications are unavailable.',
@@ -125,7 +128,21 @@ export function syncRemotePush(
);
}
if (subscription) {
- await request(store.url(), 'POST', subscription.endpoint);
+ try {
+ await request(store.url(), 'POST', subscription.endpoint);
+ } catch (error) {
+ active = false;
+ // The server may have saved it before its response was lost.
+ await request(
+ destroy.url(),
+ 'DELETE',
+ subscription.endpoint,
+ ).catch(() => undefined);
+ await pushResult(subscription.unsubscribe()).catch(
+ () => undefined,
+ );
+ throw error;
+ }
active = true;
}
} else if (subscription) {
@@ -133,7 +150,6 @@ export function syncRemotePush(
await pushResult(subscription.unsubscribe());
active = false;
}
- listeners.forEach((listener) => listener());
};
const result = pending.then(sync);
pending = result.catch(() => undefined);
diff --git a/tests/Unit/RemoteNotifications.test.tsx b/tests/Unit/RemoteNotifications.test.tsx
index 8cdd70e9a..63018dae9 100644
--- a/tests/Unit/RemoteNotifications.test.tsx
+++ b/tests/Unit/RemoteNotifications.test.tsx
@@ -52,6 +52,7 @@ beforeEach(() => {
time = Date.parse('2026-10-22T12:00:00Z');
permission = 'granted';
storage.clear();
+ storage.set('remote-alerts:me:offsite', 'on');
shown.length = 0;
announced.length = 0;
play.mockClear();
@@ -287,7 +288,7 @@ test.each([
await alerts.receive([], () => false);
await alerts.receive([conversation(1)], () => false);
- assert.equal(shown.length, Number(notifications));
+ assert.equal(shown.length, 1);
assert.equal(announced.length, Number(notifications));
assert.equal(play.mock.calls.length, Number(sound));
});
@@ -364,6 +365,7 @@ test.each([true, false])('background inbox polling alerts without consuming unre
Object.assign(window, { setTimeout, clearTimeout });
vi.stubGlobal('document', { hidden: true, cookie: '', addEventListener() {}, removeEventListener() {} });
await setRemoteAlerts('me', banners);
+ storage.set('remote-alerts:me:offsite', banners ? 'on' : 'off');
let messages = [conversation(30, { kind: 'org' })];
let unread = 1;
const page = { props: { auth: { user: { id: 'me' }, unreadNotificationsCount: 2, unreadMessagesCount: 8 } } } as Page;
@@ -826,3 +828,18 @@ test('alerts from recent activity even when older pins occupy the visible first
vi.useRealTimers();
}
});
+
+test('default notifications stay on-site even when this browser already has OS permission', async () => {
+ storage.delete('remote-alerts:me:offsite');
+ storage.set('remote-alerts:me', 'on');
+ const permissionRequest = vi.spyOn(FakeNotification, 'requestPermission');
+ assert.equal(remoteAlertsEnabled('me'), true);
+ assert.equal(remoteAlertsEnabled('me', 'offsite'), false);
+ await setRemoteAlerts('me', true);
+ const alerts = reader();
+ await alerts.receive([], () => false);
+ await alerts.receive([conversation(1)], () => false);
+ assert.equal(announced.length, 1);
+ assert.equal(shown.length, 0);
+ assert.equal(permissionRequest.mock.calls.length, 0);
+});
diff --git a/tests/Unit/RemotePush.test.tsx b/tests/Unit/RemotePush.test.tsx
index 2b79c0ce4..b8c0aad44 100644
--- a/tests/Unit/RemotePush.test.tsx
+++ b/tests/Unit/RemotePush.test.tsx
@@ -1,19 +1,19 @@
import { remoteAlertsEnabled, setRemoteAlerts } from '../../resources/js/lib/remote-notifications';
import assert from 'node:assert/strict';
import { afterEach, beforeEach, test, vi } from 'vite-plus/test';
-import { remotePushEnabled, subscribeRemotePush, syncRemotePush } from '../../resources/js/lib/remote-push';
+import { remotePushEnabled, syncRemotePush } from '../../resources/js/lib/remote-push';
const endpoint = 'https://fcm.googleapis.com/wp/browser';
const save = vi.fn(async (_url: string, _options: RequestInit) => new Response(null, { status: 204 }));
-const unsubscribe = vi.fn(async () => true);
+const unsubscribe = vi.fn(async () => {subscription = null; return true;});
let subscription: { endpoint: string; unsubscribe: typeof unsubscribe } | null;
-const subscribe = vi.fn(async () => ({ endpoint, unsubscribe }));
+const subscribe = vi.fn(async () => subscription = { endpoint, unsubscribe });
const registration = { pushManager: { getSubscription: async () => subscription, subscribe } };
beforeEach(() => {
subscription = null;
- vi.stubGlobal('window', { isSecureContext: true, PushManager: class {} });
- vi.stubGlobal('Notification', { permission: 'granted' });
+ vi.stubGlobal('window', { isSecureContext: true, PushManager: class {}, Notification: {} });
+ vi.stubGlobal('Notification', { permission: 'granted', requestPermission: vi.fn(async () => 'granted') });
vi.stubGlobal('document', { cookie: 'XSRF-TOKEN=csrf%20token' });
vi.stubGlobal('navigator', { serviceWorker: { getRegistration: async () => registration, register: vi.fn(async () => registration), ready: Promise.resolve(registration) } });
vi.stubGlobal('fetch', async (url: string, options: RequestInit) => url.endsWith('/push-subscriptions') && options.method === 'GET'
@@ -31,13 +31,9 @@ test('does not subscribe or request permission on page load', async () => {
});
test('creates an opt-in subscription with only its endpoint sent to the account', async () => {
- const changed = vi.fn();
- const stop = subscribeRemotePush(changed);
await syncRemotePush(true, true);
- stop();
assert.equal(remotePushEnabled(), true);
assert.equal(subscribe.mock.calls.length, 1);
- assert.equal(changed.mock.calls.length, 1);
const [, options] = save.mock.calls[0];
assert.equal(options.method, 'POST');
assert.equal(options.body, JSON.stringify({ endpoint }));
@@ -95,9 +91,10 @@ test('page alerts can be re-enabled even when background delivery is unavailable
Object.assign(window, {localStorage: {getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value)}});
vi.stubGlobal('Notification', {permission: 'granted', requestPermission: async () => 'granted'});
vi.stubGlobal('fetch', async () => Response.json({public_key: null}));
- await assert.rejects(setRemoteAlerts('me', true));
+ assert.equal(await setRemoteAlerts('me', true), true);
assert.equal(remoteAlertsEnabled('me'), true);
- assert.equal(remotePushEnabled(), false);
+ assert.equal(remoteAlertsEnabled('me', 'offsite'), false);
+ assert.equal(save.mock.calls.length, 0);
});
test('a stalled browser operation times out without saving and the next attempt can recover', async () => {
@@ -122,3 +119,48 @@ test('does not query native subscriptions before permission or server configurat
await assert.rejects(syncRemotePush(true, true));
assert.equal(query.mock.calls.length, 0);
});
+
+function preferenceStorage(): Map {
+ const storage = new Map();
+ Object.assign(window, {localStorage: {getItem: (key: string) => storage.get(key) ?? null, setItem: (key: string, value: string) => storage.set(key, value), removeItem: (key: string) => storage.delete(key)}});
+ return storage;
+}
+
+test('off-site delivery requires a separate explicit opt-in and turning it off revokes delivery', async () => {
+ const storage = preferenceStorage();
+ assert.equal(remoteAlertsEnabled('me'), true);
+ assert.equal(remoteAlertsEnabled('me', 'offsite'), false);
+ await setRemoteAlerts('me', true);
+ assert.equal(subscribe.mock.calls.length, 0);
+ assert.equal((Notification.requestPermission as ReturnType).mock.calls.length, 0);
+ assert.equal(await setRemoteAlerts('me', true, 'offsite'), true);
+ assert.equal(storage.get('remote-alerts:me:offsite'), 'on');
+ assert.equal(remotePushEnabled(), true);
+ assert.equal(await setRemoteAlerts('me', false, 'offsite'), true);
+ assert.equal(remoteAlertsEnabled('me', 'offsite'), false);
+ assert.equal(remoteAlertsEnabled('me'), true);
+ assert.equal(unsubscribe.mock.calls.length, 1);
+ assert.equal(save.mock.calls.at(-1)?.[1].method, 'DELETE');
+});
+
+test('denied permission and failed enrollment leave off-site notifications off', async () => {
+ const storage = preferenceStorage();
+ vi.stubGlobal('Notification', {permission: 'denied', requestPermission: async () => 'denied'});
+ await assert.rejects(setRemoteAlerts('me', true, 'offsite'));
+ assert.equal(remoteAlertsEnabled('me', 'offsite'), false);
+ assert.equal(subscribe.mock.calls.length, 0);
+ vi.stubGlobal('Notification', {permission: 'granted', requestPermission: async () => 'granted'});
+ save.mockImplementationOnce(async () => new Response(null, {status:503}));
+ await assert.rejects(setRemoteAlerts('me', true, 'offsite'));
+ assert.equal(remoteAlertsEnabled('me', 'offsite'), false);
+ assert.equal(storage.has('remote-alerts:me:offsite'), false);
+ assert.equal(save.mock.calls.at(-1)?.[1].method, 'DELETE');
+ assert.equal(unsubscribe.mock.calls.length, 1);
+});
+
+test('blocked preference storage cannot activate off-site delivery', async () => {
+ Object.assign(window, {localStorage: {getItem: () => null, setItem() {throw new Error('blocked');}}});
+ assert.equal(await setRemoteAlerts('me', true, 'offsite'), false);
+ assert.equal(subscribe.mock.calls.length, 0);
+ assert.equal(save.mock.calls.length, 0);
+});
From 56061b7b30a70c45ffb29dfa6b646ee74d1a2b78 Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 25 Oct 2026 02:24:09 +0100
Subject: [PATCH 04/16] fix: preserve push enrollment during passive sync
failures
---
resources/js/lib/remote-push.ts | 22 ++++++++++++----------
tests/Unit/RemotePush.test.tsx | 20 ++++++++++++++++++++
2 files changed, 32 insertions(+), 10 deletions(-)
diff --git a/resources/js/lib/remote-push.ts b/resources/js/lib/remote-push.ts
index 783ea3868..b18da6109 100644
--- a/resources/js/lib/remote-push.ts
+++ b/resources/js/lib/remote-push.ts
@@ -131,16 +131,18 @@ export function syncRemotePush(
try {
await request(store.url(), 'POST', subscription.endpoint);
} catch (error) {
- active = false;
- // The server may have saved it before its response was lost.
- await request(
- destroy.url(),
- 'DELETE',
- subscription.endpoint,
- ).catch(() => undefined);
- await pushResult(subscription.unsubscribe()).catch(
- () => undefined,
- );
+ if (create) {
+ active = false;
+ // A failed opt-in may have been saved before its response was lost.
+ await request(
+ destroy.url(),
+ 'DELETE',
+ subscription.endpoint,
+ ).catch(() => undefined);
+ await pushResult(subscription.unsubscribe()).catch(
+ () => undefined,
+ );
+ }
throw error;
}
active = true;
diff --git a/tests/Unit/RemotePush.test.tsx b/tests/Unit/RemotePush.test.tsx
index b8c0aad44..52b53a75f 100644
--- a/tests/Unit/RemotePush.test.tsx
+++ b/tests/Unit/RemotePush.test.tsx
@@ -48,6 +48,26 @@ test('reassociates an existing browser subscription without creating another', a
assert.equal(remotePushEnabled(), true);
});
+test('a transient passive registration failure preserves opt-in and recovers on the next load', async () => {
+ preferenceStorage();
+ assert.equal(await setRemoteAlerts('me', true, 'offsite'), true);
+ const enrolled = subscription;
+ save.mockImplementationOnce(async () => new Response(null, { status: 503 }));
+
+ await assert.rejects(syncRemotePush(true));
+ assert.equal(subscription, enrolled);
+ assert.equal(unsubscribe.mock.calls.length, 0);
+ assert.equal(save.mock.calls.filter(([, options]) => options.method === 'DELETE').length, 0);
+ assert.equal(remoteAlertsEnabled('me', 'offsite'), true);
+ assert.equal(remotePushEnabled(), true);
+
+ await syncRemotePush(true);
+ assert.equal(save.mock.calls.at(-1)?.[1].method, 'POST');
+ assert.equal(subscribe.mock.calls.length, 1);
+ assert.equal(remotePushEnabled(), true);
+ assert.equal(remoteAlertsEnabled('me', 'offsite'), true);
+});
+
test('turning notifications off revokes server delivery before unsubscribing', async () => {
subscription = { endpoint, unsubscribe };
await syncRemotePush(false, true);
From eb048363d803463ee319e80aaff6267af8565d89 Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 25 Oct 2026 02:24:09 +0100
Subject: [PATCH 05/16] docs: describe opt-in push for installed apps
---
KNOWLEDGE_BASE.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md
index ec11ee22f..3ab484113 100644
--- a/KNOWLEDGE_BASE.md
+++ b/KNOWLEDGE_BASE.md
@@ -413,7 +413,7 @@ Sources: [appearance settings](resources/js/pages/appearance/update.tsx), [lane
/dev/null is a progressive web app. Browsers that support installation (Chrome, Edge, and Safari's **Add to Home Screen** / **Add to Dock**) offer to install it from the web app manifest; the installed app opens in its own window without browser controls, uses the app icon, and follows the Light, Dark, or System appearance in its title and status bars. The app's icon menu offers shortcuts to the Following log, the Latest log, and Notifications. Where the browser supports it, links launched into the installed app reuse its existing window; installing the app does not guarantee that every browser will open links in it.
-A service worker caches the built assets of the current release and shows an offline page when a page cannot be reached; the page reloads on its own once the connection returns. The service worker does not cache pages or retain signed-in page content for offline access, and there is no offline reading of logs or offline posting. Browser-local preferences and [stashed drafts](#stashed-drafts) are separate from this asset cache. There are no push notifications; the installed app polls for new notifications and messages the same way the browser does.
+A service worker caches the built assets of the current release and shows an offline page when a page cannot be reached; the page reloads on its own once the connection returns. The service worker does not cache pages or retain signed-in page content for offline access, and there is no offline reading of logs or offline posting. Browser-local preferences and [stashed drafts](#stashed-drafts) are separate from this asset cache. While open, the installed app polls for new notifications and messages the same way the browser does. Remotes can also send silent, generic push notifications while the app is backgrounded or closed when you explicitly enable **Off-site notifications** under **Settings → Profile → Remotes**. This switch is off by default; activity notifications remain on-site.
Sources: [web app manifest](public/site.webmanifest), [service worker](public/sw.js), [offline page](public/offline.html), [service worker registration](resources/js/lib/service-worker.ts), [document head](resources/views/app.blade.php), [manifest tests](tests/Feature/WebAppManifestTest.php).
From 42e988fd4ffa5da8ef6fcbdc6e47c0b196a6e36f Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 25 Oct 2026 02:44:52 +0100
Subject: [PATCH 06/16] perf: skip remote recipients without push subscriptions
---
app/Jobs/SendRemotePush.php | 1 +
tests/Unit/Jobs/SendRemotePushTest.php | 14 ++++++++++++++
2 files changed, 15 insertions(+)
diff --git a/app/Jobs/SendRemotePush.php b/app/Jobs/SendRemotePush.php
index 05223c035..f6451f8c5 100644
--- a/app/Jobs/SendRemotePush.php
+++ b/app/Jobs/SendRemotePush.php
@@ -40,6 +40,7 @@ public function handle(RemotePush $push): void
->where('read_through_id', '<', $message->id)
->whereIn('status', config('remotes.requests') ? [RemoteStatus::Open, RemoteStatus::Fetched] : [RemoteStatus::Open])
->where(fn (Builder $query): Builder => $query->whereNull('muted_until_at')->orWhere('muted_until_at', '<=', now()))
+ ->whereHas('user.pushSubscriptions')
->with('user')->get();
$retry = false;
diff --git a/tests/Unit/Jobs/SendRemotePushTest.php b/tests/Unit/Jobs/SendRemotePushTest.php
index 55ea071f1..9144d367c 100644
--- a/tests/Unit/Jobs/SendRemotePushTest.php
+++ b/tests/Unit/Jobs/SendRemotePushTest.php
@@ -14,6 +14,7 @@
use Illuminate\Contracts\Queue\Job;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Request;
+use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
@@ -44,6 +45,19 @@
Http::assertSent(fn (Request $request): bool => $request->body() === '' && str_starts_with($request->header('Authorization')[0], 'vapid t=') && $request->header('TTL') === ['300']);
});
+it('does not load recipients who have no push subscriptions', function (): void {
+ $this->subscription->delete();
+ $loaded = [];
+ Event::listen('eloquent.retrieved: '.User::class, function (User $user) use (&$loaded): void {
+ $loaded[] = $user->id;
+ });
+
+ new SendRemotePush($this->message->id)->handle(resolve(RemotePush::class));
+
+ expect($loaded)->not->toContain($this->recipient->id);
+ Http::assertNothingSent();
+});
+
it('rechecks eligibility at delivery time', function (string $change): void {
match ($change) {
'read' => $this->participant->update(['read_through_id' => $this->message->id]),
From d79bab59e600d15c7a64cf3941abd01fdcce9abc Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 25 Oct 2026 02:46:33 +0100
Subject: [PATCH 07/16] docs: remove unsolicited web push setup guide
---
docs/web-push.md | 23 -----------------------
1 file changed, 23 deletions(-)
delete mode 100644 docs/web-push.md
diff --git a/docs/web-push.md b/docs/web-push.md
deleted file mode 100644
index 8a73ce0df..000000000
--- a/docs/web-push.md
+++ /dev/null
@@ -1,23 +0,0 @@
-# Remotes Web Push
-
-Remotes can send silent, generic notifications to opted-in browser and PWA subscriptions even when the page is closed. These are standard Web Push requests, with no payload, message text, sender, or conversation identifier sent to the browser push provider. Notifications open the Remotes inbox. Normal in-page sound and tab alerts remain independent.
-
-## Server setup
-
-Generate a stable VAPID key pair using `Minishlink\WebPush\VAPID::createVapidKeys()` in a private administrative environment. Set `WEB_PUSH_PUBLIC_KEY`, `WEB_PUSH_PRIVATE_KEY`, and `WEB_PUSH_SUBJECT` in the server environment. The subject must be a public HTTPS URL or `mailto:` contact. Keep the private key outside source control, logs, and application data. Keep the pair stable: changing it requires browsers to disable and re-enable notifications.
-
-Run the migration and keep the application's queue worker running. Without the key pair, the application keeps its existing page alerts and does not queue Web Push delivery. Nothing in this change configures a production environment.
-
-## Browser setup
-
-**On-site notifications** remain on by default. **Off-site notifications** are a separate switch, off by default, under Settings → Profile → Remotes. Each browser and account must explicitly opt in, even when the browser already has notification permission. Only this switch requests permission and activates system notifications. Failed enrollment restores the previous off-site choice, and blocked preference storage cannot activate delivery. On iPhone and iPad, install the site on the Home Screen and opt in from that app. Browser and OS permissions, Focus settings, and background delivery affect arrival; fully quitting a browser may prevent delivery until it runs again. Custom message sound plays only while the site is open.
-
-## Delivery and retention
-
-Delivery waits ten seconds, then rechecks unread state, conversation membership and history, request status, mutes, blocks, and whether Remotes is enabled. Messages older than five minutes are not sent. Requests expire at the provider after five minutes; a shared topic and notification tag replace bursts. Temporary network failures, rate limits, and server errors retry with a thirty-second delay, up to three attempts. The same generic notification may be replaced during retries.
-
-The database stores only an encrypted endpoint, its SHA-256 lookup hash, the owning user, and a row id for model identity and pagination. No browser encryption keys, authentication secrets, timestamps, device names, message payloads, or delivery history are stored. Only the server uses endpoints; exports include subscription ids and exclude delivery endpoints. Subscriptions are deleted when off-site notifications are disabled in that browser, that browser signs out, Remotes is disabled, the account is deleted, or the provider returns 404/410. Reusing the browser under another account associates its subscription only with the current account.
-
-Supported endpoint hosts are Google's FCM, Mozilla's push service, Apple's web push service, and Microsoft's `*.notify.windows.com` service. Registration rejects other destinations and custom ports; delivery never follows redirects.
-
-Native browser enrollment operations time out after ten seconds so a stalled browser service cannot leave the settings controls disabled indefinitely. Late completion of an abandoned native operation cannot continue into server registration.
From 12f62291238bfbf96ea20ab1b610c689cb616528 Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 25 Oct 2026 02:57:33 +0100
Subject: [PATCH 08/16] fix: limit push enrollments to twenty per account
---
KNOWLEDGE_BASE.md | 2 +-
.../Middleware/SerializePushSubscriptions.php | 30 +++++++++++++++++++
.../CreatePushSubscriptionRequest.php | 22 ++++++++++++++
app/Models/PushSubscription.php | 2 ++
resources/js/components/remotes-setting.tsx | 11 ++++---
resources/js/lib/remote-push.ts | 8 +++++
routes/web.php | 3 +-
.../PushSubscriptionControllerTest.php | 30 +++++++++++++++++++
tests/Unit/RemotePush.test.tsx | 10 +++++++
9 files changed, 112 insertions(+), 6 deletions(-)
create mode 100644 app/Http/Middleware/SerializePushSubscriptions.php
diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md
index 3ab484113..91bf20f78 100644
--- a/KNOWLEDGE_BASE.md
+++ b/KNOWLEDGE_BASE.md
@@ -331,7 +331,7 @@ Sources: [notifications page](resources/js/pages/notification/index.tsx), [notif
Remotes alerts are controlled under **Settings → Profile → Remotes**. **On-site notifications** and **Notification sound** are on by default. On-site alerts stay inside /dev/null and never ask for browser permission. Sound does not require notification permission and plays only while the site is running; the browser may require interaction before it can play.
-**Off-site notifications** are off by default. Turn that separate switch on to allow system notifications from this browser or installed PWA, including when closed. This is an explicit choice for each account and browser; an existing browser permission or an earlier on-site preference does not turn it on. On iPhone and iPad, add /dev/null to your Home Screen and turn the switch on from the installed app. The browser asks for permission only after you choose to enable off-site notifications. Declining permission or a failed enrollment leaves them off, and turning them off revokes delivery from that browser. On-site alerts remain independent.
+**Off-site notifications** are off by default. Turn that separate switch on to allow system notifications from this browser or installed PWA, including when closed. This is an explicit choice for each account and browser; an existing browser permission or an earlier on-site preference does not turn it on. On iPhone and iPad, add /dev/null to your Home Screen and turn the switch on from the installed app. The browser asks for permission only after you choose to enable off-site notifications. Declining permission or a failed enrollment leaves them off, and turning them off revokes delivery from that browser. On-site alerts remain independent. Up to 20 browsers or installed apps per account can have off-site notifications enabled. At the limit, existing enrollments stay active; turn off off-site notifications in one before enabling another.
Off-site alerts are silent, contain no message data, and open the Remotes inbox. Delivery depends on browser and operating-system permissions, background delivery, and Focus settings. A fully quit browser may prevent delivery. Subscriptions are removed on sign-out, account deletion, turning Remotes off, or when the push service reports that they expired. Delivery briefly waits and rechecks unread messages, membership, mutes, and blocks before sending.
diff --git a/app/Http/Middleware/SerializePushSubscriptions.php b/app/Http/Middleware/SerializePushSubscriptions.php
new file mode 100644
index 000000000..a2390faf0
--- /dev/null
+++ b/app/Http/Middleware/SerializePushSubscriptions.php
@@ -0,0 +1,30 @@
+user();
+ assert($user instanceof User);
+
+ // Hold the account lock across validation and creation, including concurrent browser enrollments.
+ return DB::transaction(function () use ($user, $request, $next): Response {
+ User::query()->whereKey($user->id)->lockForUpdate()->firstOrFail();
+
+ return $next($request);
+ });
+ }
+}
diff --git a/app/Http/Requests/CreatePushSubscriptionRequest.php b/app/Http/Requests/CreatePushSubscriptionRequest.php
index 84191cf2f..02410090d 100644
--- a/app/Http/Requests/CreatePushSubscriptionRequest.php
+++ b/app/Http/Requests/CreatePushSubscriptionRequest.php
@@ -4,9 +4,11 @@
namespace App\Http\Requests;
+use App\Models\PushSubscription;
use App\Models\User;
use App\Rules\PushEndpoint;
use Illuminate\Foundation\Http\FormRequest;
+use Illuminate\Validation\Validator;
final class CreatePushSubscriptionRequest extends FormRequest
{
@@ -25,4 +27,24 @@ public function rules(): array
{
return ['endpoint' => ['required', 'string', 'max:2048', new PushEndpoint]];
}
+
+ /**
+ * @return list
+ */
+ public function after(): array
+ {
+ return [function (Validator $validator): void {
+ if ($validator->errors()->isNotEmpty()) {
+ return;
+ }
+
+ $user = $this->user();
+ assert($user instanceof User);
+
+ if (! $user->pushSubscriptions()->where('endpoint_hash', hash('sha256', $this->string('endpoint')->toString()))->exists()
+ && $user->pushSubscriptions()->count() >= PushSubscription::MAX_PER_USER) {
+ $validator->errors()->add('endpoint', 'You have off-site notifications enabled in '.PushSubscription::MAX_PER_USER.' browsers or apps. Turn them off in one before enabling another.');
+ }
+ }];
+ }
}
diff --git a/app/Models/PushSubscription.php b/app/Models/PushSubscription.php
index 55a662bb4..23f7d00dd 100644
--- a/app/Models/PushSubscription.php
+++ b/app/Models/PushSubscription.php
@@ -26,6 +26,8 @@ final class PushSubscription extends Model
use HasUuids;
+ public const int MAX_PER_USER = 20;
+
/**
* @return array
*/
diff --git a/resources/js/components/remotes-setting.tsx b/resources/js/components/remotes-setting.tsx
index 21772d6d0..14b8948a8 100644
--- a/resources/js/components/remotes-setting.tsx
+++ b/resources/js/components/remotes-setting.tsx
@@ -8,6 +8,7 @@ import {
} from '@/hooks/use-chat';
import type { RemoteAlertChannel } from '@/lib/remote-notifications';
import { setRemoteAlerts } from '@/lib/remote-notifications';
+import { RemotePushValidationError } from '@/lib/remote-push';
import { cn } from '@/lib/utils';
/**
@@ -41,11 +42,13 @@ export function RemotesSetting() {
: 'Could not save notifications. Check your browser’s site permissions and storage settings.',
);
})
- .catch(() =>
+ .catch((error: unknown) =>
toast.error(
- enabled
- ? 'Could not enable off-site notifications. Check this browser’s permissions and try again.'
- : 'Could not turn off-site notifications off. Please try again.',
+ error instanceof RemotePushValidationError
+ ? error.message
+ : enabled
+ ? 'Could not enable off-site notifications. Check this browser’s permissions and try again.'
+ : 'Could not turn off-site notifications off. Please try again.',
),
)
.finally(() => setSavingAlerts(false));
diff --git a/resources/js/lib/remote-push.ts b/resources/js/lib/remote-push.ts
index b18da6109..2263a6e23 100644
--- a/resources/js/lib/remote-push.ts
+++ b/resources/js/lib/remote-push.ts
@@ -4,6 +4,8 @@ import {
store,
} from '@/actions/App/Http/Controllers/PushSubscriptionController';
+export class RemotePushValidationError extends Error {}
+
let active = false;
let pending = Promise.resolve();
@@ -44,6 +46,12 @@ async function request(
? {}
: { body: JSON.stringify({ endpoint }) }),
});
+ if (response.status === 422) {
+ const validation = (await response.json()) as {
+ errors: { endpoint: string[] };
+ };
+ throw new RemotePushValidationError(validation.errors.endpoint[0]);
+ }
if (!response.ok)
throw new Error('Could not save background notifications.');
return response;
diff --git a/routes/web.php b/routes/web.php
index ea7eac99b..3b1b6dc0a 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -122,6 +122,7 @@
use App\Http\Middleware\PreventPostingInTimeout;
use App\Http\Middleware\RememberIntendedUrl;
use App\Http\Middleware\RequireCommitRules;
+use App\Http\Middleware\SerializePushSubscriptions;
use App\Http\Middleware\ThrottleCommits;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
use Illuminate\Support\Facades\Route;
@@ -261,7 +262,7 @@
// Session...
Route::get('push-subscriptions', [PushSubscriptionController::class, 'index'])->name('push-subscription.index');
- Route::post('push-subscriptions', [PushSubscriptionController::class, 'store'])->middleware('throttle:30,1')->name('push-subscription.store');
+ Route::post('push-subscriptions', [PushSubscriptionController::class, 'store'])->middleware(['throttle:30,1', SerializePushSubscriptions::class])->name('push-subscription.store');
Route::delete('push-subscriptions', [PushSubscriptionController::class, 'destroy'])->name('push-subscription.destroy');
Route::post('logout', [SessionController::class, 'destroy'])
diff --git a/tests/Feature/Controllers/PushSubscriptionControllerTest.php b/tests/Feature/Controllers/PushSubscriptionControllerTest.php
index 46fbaa637..6e3ca3f7a 100644
--- a/tests/Feature/Controllers/PushSubscriptionControllerTest.php
+++ b/tests/Feature/Controllers/PushSubscriptionControllerTest.php
@@ -41,6 +41,36 @@
$this->assertModelMissing($subscription);
});
+it('limits new enrollments to twenty without replacing existing browsers', function (): void {
+ $user = User::factory()->create();
+ PushSubscription::factory()->count(19)->create(['user_id' => $user->id]);
+ $endpoint = 'https://fcm.googleapis.com/wp/twentieth';
+ $this->actingAs($user)->postJson(route('push-subscription.store'), ['endpoint' => $endpoint])->assertNoContent();
+ $ids = $user->pushSubscriptions()->pluck('id')->sort()->values()->all();
+ expect($ids)->toHaveCount(20);
+
+ $newEndpoint = 'https://fcm.googleapis.com/wp/twenty-first';
+ $this->postJson(route('push-subscription.store'), ['endpoint' => $newEndpoint])
+ ->assertUnprocessable()->assertJsonPath('errors.endpoint.0', 'You have off-site notifications enabled in 20 browsers or apps. Turn them off in one before enabling another.');
+ expect($user->pushSubscriptions()->pluck('id')->sort()->values()->all())->toBe($ids);
+
+ $this->postJson(route('push-subscription.store'), ['endpoint' => $endpoint])->assertNoContent();
+ expect($user->pushSubscriptions()->count())->toBe(20);
+ $this->deleteJson(route('push-subscription.destroy'), ['endpoint' => $endpoint])->assertNoContent();
+ $this->postJson(route('push-subscription.store'), ['endpoint' => $newEndpoint])->assertNoContent();
+ expect($user->pushSubscriptions()->count())->toBe(20);
+});
+
+it('applies the limit to the current account without transferring another accounts subscription on rejection', function (): void {
+ $full = User::factory()->create();
+ PushSubscription::factory()->count(20)->create(['user_id' => $full->id]);
+ $other = PushSubscription::factory()->create();
+ $this->actingAs($full)->postJson(route('push-subscription.store'), ['endpoint' => $other->endpoint])
+ ->assertUnprocessable()->assertJsonValidationErrors('endpoint');
+ expect($other->refresh()->user_id)->not->toBe($full->id);
+ $this->actingAs(User::query()->findOrFail($other->user_id))->postJson(route('push-subscription.store'), ['endpoint' => $other->endpoint])->assertNoContent();
+});
+
it('accepts supported browser endpoints', function (string $endpoint): void {
$this->actingAs(User::factory()->create())->postJson(route('push-subscription.store'), ['endpoint' => $endpoint])->assertNoContent();
})->with(['https://fcm.googleapis.com/wp/example', 'https://updates.push.services.mozilla.com/wpush/v2/example', 'https://web.push.apple.com/Qexample', 'https://wns2-example.notify.windows.com/w/?token=example']);
diff --git a/tests/Unit/RemotePush.test.tsx b/tests/Unit/RemotePush.test.tsx
index 52b53a75f..8751086ea 100644
--- a/tests/Unit/RemotePush.test.tsx
+++ b/tests/Unit/RemotePush.test.tsx
@@ -184,3 +184,13 @@ test('blocked preference storage cannot activate off-site delivery', async () =>
assert.equal(subscribe.mock.calls.length, 0);
assert.equal(save.mock.calls.length, 0);
});
+
+
+test('a full account reports the enrollment limit and leaves this browser opted out', async () => {
+ preferenceStorage();
+ const message = 'You have off-site notifications enabled in 20 browsers or apps. Turn them off in one before enabling another.';
+ save.mockImplementationOnce(async () => Response.json({errors: {endpoint: [message]}}, {status: 422}));
+ await assert.rejects(setRemoteAlerts('me', true, 'offsite'), {message});
+ assert.equal(remoteAlertsEnabled('me', 'offsite'), false);
+ assert.equal(unsubscribe.mock.calls.length, 1);
+});
From ab5f20b8ddf1f8c3818993fe8ae8c9d7069512f8 Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 25 Oct 2026 03:25:50 +0100
Subject: [PATCH 09/16] fix: retry only failed remote push subscriptions
---
app/Jobs/SendRemotePush.php | 26 ++++++----
tests/Unit/Jobs/SendRemotePushTest.php | 68 +++++++++++++++++++++++---
2 files changed, 79 insertions(+), 15 deletions(-)
diff --git a/app/Jobs/SendRemotePush.php b/app/Jobs/SendRemotePush.php
index f6451f8c5..09391b801 100644
--- a/app/Jobs/SendRemotePush.php
+++ b/app/Jobs/SendRemotePush.php
@@ -12,16 +12,20 @@
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Queue\Queueable;
-use Illuminate\Queue\Attributes\Tries;
use Illuminate\Support\Facades\Date;
-#[Tries(3)]
final class SendRemotePush implements ShouldQueue
{
use Queueable;
- public function __construct(public readonly int $messageId)
- {
+ /**
+ * @param list|null $subscriptionIds
+ */
+ public function __construct(
+ public readonly int $messageId,
+ public readonly ?array $subscriptionIds = null,
+ public readonly int $tries = 3,
+ ) {
//
}
@@ -43,7 +47,7 @@ public function handle(RemotePush $push): void
->whereHas('user.pushSubscriptions')
->with('user')->get();
- $retry = false;
+ $failed = [];
foreach ($participants as $participant) {
$user = $participant->user;
@@ -52,15 +56,19 @@ public function handle(RemotePush $push): void
continue;
}
- foreach (PushSubscription::query()->where('user_id', $user->id)->lazyById() as $subscription) {
+ foreach (PushSubscription::query()->where('user_id', $user->id)
+ ->when($this->subscriptionIds !== null, fn (Builder $query): Builder => $query->whereKey($this->subscriptionIds))
+ ->lazyById() as $subscription) {
if (! $push->send($subscription)) {
- $retry = true;
+ $failed[] = $subscription->id;
}
}
}
- if ($retry) {
- $this->release(30);
+ if ($failed !== [] && $this->attempts() < $this->tries) {
+ // A follow-up starts with a fresh queue attempt count, so carry the remaining budget.
+ dispatch(new self($this->messageId, $failed, $this->tries - $this->attempts())
+ ->onConnection($this->connection)->onQueue($this->queue))->delay(now()->addSeconds(30));
}
}
}
diff --git a/tests/Unit/Jobs/SendRemotePushTest.php b/tests/Unit/Jobs/SendRemotePushTest.php
index 9144d367c..0155463a9 100644
--- a/tests/Unit/Jobs/SendRemotePushTest.php
+++ b/tests/Unit/Jobs/SendRemotePushTest.php
@@ -11,9 +11,9 @@
use App\Models\PushSubscription;
use App\Models\User;
use App\Services\RemotePush;
-use Illuminate\Contracts\Queue\Job;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Request;
+use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Queue;
@@ -28,11 +28,12 @@
$this->participant = ConversationParticipant::factory()->create(['conversation_id' => $this->message->conversation_id, 'user_id' => $this->recipient->id]);
$this->subscription = PushSubscription::query()->create(['user_id' => $this->recipient->id, 'endpoint_hash' => hash('sha256', 'https://fcm.googleapis.com/wp/recipient'), 'endpoint' => 'https://fcm.googleapis.com/wp/recipient']);
$this->pushStatus = 201;
+ $this->pushStatuses = [];
$this->connectionFails = false;
- Http::fake(['https://fcm.googleapis.com/*' => function () {
+ Http::fake(['https://fcm.googleapis.com/*' => function (Request $request) {
throw_if($this->connectionFails, ConnectionException::class, 'unavailable');
- return Http::response('', $this->pushStatus);
+ return Http::response('', $this->pushStatuses[$request->url()] ?? $this->pushStatus);
}]);
});
@@ -105,14 +106,69 @@
it('retries temporary push failures without keeping or reporting the endpoint', function (int $status, bool $connectionFails): void {
$this->pushStatus = $status;
$this->connectionFails = $connectionFails;
- $queued = Mockery::mock(Job::class);
- $queued->shouldReceive('release')->once()->with(30);
+ Queue::fake();
$job = new SendRemotePush($this->message->id);
- $job->setJob($queued);
$job->handle(resolve(RemotePush::class));
$this->assertModelExists($this->subscription);
+ Queue::assertPushed(SendRemotePush::class, fn (SendRemotePush $retry): bool => $retry->messageId === $this->message->id
+ && $retry->subscriptionIds === [$this->subscription->id] && $retry->tries === 2 && $retry->delay->equalTo(now()->addSeconds(30)));
})->with([[500, false], [429, false], [201, true]]);
+it('retries only failed browsers across serialized queue deliveries', function (): void {
+ $this->recipient->pushSubscriptions()->create(['endpoint_hash' => 'second', 'endpoint' => 'https://fcm.googleapis.com/wp/second']);
+ $other = User::factory()->create();
+ ConversationParticipant::factory()->create(['conversation_id' => $this->message->conversation_id, 'user_id' => $other->id]);
+ $other->pushSubscriptions()->create(['endpoint_hash' => 'other', 'endpoint' => 'https://fcm.googleapis.com/wp/other']);
+ $this->pushStatuses['https://fcm.googleapis.com/wp/recipient'] = 500;
+ dispatch(new SendRemotePush($this->message->id)->onConnection('database')->onQueue('push-retries'));
+
+ $this->artisan('queue:work', ['connection' => 'database', '--queue' => 'push-retries', '--once' => true])->assertSuccessful();
+ Http::assertSentCount(3);
+
+ $this->pushStatuses['https://fcm.googleapis.com/wp/recipient'] = 201;
+ $other->pushSubscriptions()->create(['endpoint_hash' => 'new', 'endpoint' => 'https://fcm.googleapis.com/wp/new']);
+ $this->travel(31)->seconds();
+ $this->artisan('queue:work', ['connection' => 'database', '--queue' => 'push-retries', '--once' => true])->assertSuccessful();
+
+ Http::assertSentCount(4);
+ expect(Http::recorded()->filter(fn (array $record): bool => $record[0]->url() === 'https://fcm.googleapis.com/wp/recipient'))->toHaveCount(2);
+ $this->assertDatabaseCount('jobs', 0);
+ $this->assertDatabaseCount('failed_jobs', 0);
+});
+
+it('stops temporary retries within the original three-attempt budget', function (int $previousAttempts): void {
+ $this->pushStatus = 500;
+ dispatch(new SendRemotePush($this->message->id)->onConnection('database')->onQueue('push-retries'));
+ DB::table('jobs')->where('queue', 'push-retries')->update(['attempts' => $previousAttempts]);
+
+ for ($attempt = $previousAttempts; $attempt < 3; $attempt++) {
+ $this->artisan('queue:work', ['connection' => 'database', '--queue' => 'push-retries', '--once' => true])->assertSuccessful();
+ $this->travel(31)->seconds();
+ }
+
+ Http::assertSentCount(3 - $previousAttempts);
+ $this->assertDatabaseCount('jobs', 0);
+ $this->assertDatabaseCount('failed_jobs', 0);
+})->with([0, 1, 2]);
+
+it('rechecks eligibility and enrollment before a failed browser retry', function (string $change): void {
+ $this->pushStatus = 500;
+ dispatch(new SendRemotePush($this->message->id)->onConnection('database')->onQueue('push-retries'));
+ $this->artisan('queue:work', ['connection' => 'database', '--queue' => 'push-retries', '--once' => true])->assertSuccessful();
+
+ match ($change) {
+ 'read' => $this->participant->update(['read_through_id' => $this->message->id]),
+ 'revoked' => $this->subscription->delete(),
+ 'transferred' => $this->subscription->update(['user_id' => User::factory()->create()->id]),
+ };
+ $this->travel(31)->seconds();
+ $this->artisan('queue:work', ['connection' => 'database', '--queue' => 'push-retries', '--once' => true])->assertSuccessful();
+
+ Http::assertSentCount(1);
+ $this->assertDatabaseCount('jobs', 0);
+ $this->assertDatabaseCount('failed_jobs', 0);
+})->with(['read', 'revoked', 'transferred']);
+
it('uses Windows raw push headers for Microsoft Edge endpoints', function (): void {
Http::fake(['https://wns2-example.notify.windows.com/*' => Http::response('', 201)]);
$this->recipient->pushSubscriptions()->create(['endpoint_hash' => 'edge', 'endpoint' => 'https://wns2-example.notify.windows.com/w/?token=example']);
From ac0eb0de9089f77602e89bae033e96d1f048652c Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 25 Oct 2026 03:32:43 +0100
Subject: [PATCH 10/16] fix: prevent push enrollment from surviving revocation
---
app/Http/Middleware/SerializePushSubscriptions.php | 1 +
routes/web.php | 3 ++-
.../Controllers/PushSubscriptionControllerTest.php | 12 ++++++++++++
3 files changed, 15 insertions(+), 1 deletion(-)
diff --git a/app/Http/Middleware/SerializePushSubscriptions.php b/app/Http/Middleware/SerializePushSubscriptions.php
index a2390faf0..974fb140f 100644
--- a/app/Http/Middleware/SerializePushSubscriptions.php
+++ b/app/Http/Middleware/SerializePushSubscriptions.php
@@ -23,6 +23,7 @@ public function handle(Request $request, Closure $next): Response
// Hold the account lock across validation and creation, including concurrent browser enrollments.
return DB::transaction(function () use ($user, $request, $next): Response {
User::query()->whereKey($user->id)->lockForUpdate()->firstOrFail();
+ $user->refresh();
return $next($request);
});
diff --git a/routes/web.php b/routes/web.php
index 71b6c103b..63a257a80 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -267,10 +267,11 @@
// Session...
Route::get('push-subscriptions', [PushSubscriptionController::class, 'index'])->name('push-subscription.index');
- Route::post('push-subscriptions', [PushSubscriptionController::class, 'store'])->middleware(['throttle:30,1', SerializePushSubscriptions::class])->name('push-subscription.store');
+ Route::post('push-subscriptions', [PushSubscriptionController::class, 'store'])->middleware(['throttle:30,1', SerializePushSubscriptions::class])->block()->name('push-subscription.store');
Route::delete('push-subscriptions', [PushSubscriptionController::class, 'destroy'])->name('push-subscription.destroy');
Route::post('logout', [SessionController::class, 'destroy'])
+ ->block()
->name('logout');
// Banned, where a banned user is sent from everywhere else...
diff --git a/tests/Feature/Controllers/PushSubscriptionControllerTest.php b/tests/Feature/Controllers/PushSubscriptionControllerTest.php
index 6e3ca3f7a..eff1ba48a 100644
--- a/tests/Feature/Controllers/PushSubscriptionControllerTest.php
+++ b/tests/Feature/Controllers/PushSubscriptionControllerTest.php
@@ -86,6 +86,18 @@
$this->deleteJson(route('push-subscription.destroy'))->assertUnprocessable()->assertJsonValidationErrors('endpoint');
});
+it('uses current remotes consent when the authenticated user was loaded before a settings change', function (bool $enabled): void {
+ $user = User::factory()->create(['remotes_enabled' => ! $enabled]);
+ $this->actingAs($user);
+ User::query()->whereKey($user->id)->update(['remotes_enabled' => $enabled]);
+
+ $response = $this->postJson(route('push-subscription.store'), ['endpoint' => 'https://fcm.googleapis.com/wp/current-consent']);
+
+ $response->assertStatus($enabled ? 204 : 403);
+
+ expect($user->pushSubscriptions()->count())->toBe($enabled ? 1 : 0);
+})->with([true, false]);
+
it('removes only this browser on sign-out and all browsers when remotes are disabled', function (): void {
$user = User::factory()->create();
$this->actingAs($user)->postJson(route('push-subscription.store'), ['endpoint' => 'https://fcm.googleapis.com/wp/other'])->assertNoContent();
From 523e6e950cb1a47c0ebe846007a69d3e9b4053f0 Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 25 Oct 2026 03:46:05 +0100
Subject: [PATCH 11/16] fix: hold push session locks for the full request
---
app/Http/Middleware/SerializePushSessions.php | 33 +++++++++++++++++++
bootstrap/app.php | 3 ++
routes/web.php | 7 ++--
.../PushSubscriptionControllerTest.php | 6 ++--
4 files changed, 44 insertions(+), 5 deletions(-)
create mode 100644 app/Http/Middleware/SerializePushSessions.php
diff --git a/app/Http/Middleware/SerializePushSessions.php b/app/Http/Middleware/SerializePushSessions.php
new file mode 100644
index 000000000..617a6a618
--- /dev/null
+++ b/app/Http/Middleware/SerializePushSessions.php
@@ -0,0 +1,33 @@
+cookie(Config::string('session.cookie'));
+
+ if (! is_string($sessionId)) {
+ return $next($request);
+ }
+
+ // Run before StartSession and hold through its save; a cache lease can expire mid-enrollment.
+ return DB::transaction(function () use ($sessionId, $request, $next): Response {
+ DB::select('select pg_advisory_xact_lock(hashtextextended(?, 0))', [hash('sha256', 'remote-push-session:'.$sessionId)]);
+
+ return $next($request);
+ });
+ }
+}
diff --git a/bootstrap/app.php b/bootstrap/app.php
index 7d89b2908..bfc82a8a5 100644
--- a/bootstrap/app.php
+++ b/bootstrap/app.php
@@ -11,6 +11,7 @@
use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\PreventFraming;
use App\Http\Middleware\RedirectBannedUsers;
+use App\Http\Middleware\SerializePushSessions;
use App\Http\Middleware\StripNullBytes;
use App\Http\Middleware\UpdateLastSeenAt;
use App\Models\User;
@@ -26,6 +27,7 @@
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Middleware\ThrottleRequests;
+use Illuminate\Session\Middleware\StartSession;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
@@ -49,6 +51,7 @@
// Rate limits apply to the user authenticated by their SSH signature...
$middleware->prependToPriorityList(before: ThrottleRequests::class, prepend: AuthenticateSshSignature::class);
$middleware->prependToPriorityList(before: ThrottleRequests::class, prepend: AuthenticateSignedRequest::class);
+ $middleware->prependToPriorityList(before: StartSession::class, prepend: SerializePushSessions::class);
$middleware->web(append: [
HandleAppearance::class,
diff --git a/routes/web.php b/routes/web.php
index 63a257a80..bf5b0f4e4 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -123,6 +123,7 @@
use App\Http\Middleware\PreventPostingInTimeout;
use App\Http\Middleware\RememberIntendedUrl;
use App\Http\Middleware\RequireCommitRules;
+use App\Http\Middleware\SerializePushSessions;
use App\Http\Middleware\SerializePushSubscriptions;
use App\Http\Middleware\ThrottleCommits;
use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
@@ -267,11 +268,11 @@
// Session...
Route::get('push-subscriptions', [PushSubscriptionController::class, 'index'])->name('push-subscription.index');
- Route::post('push-subscriptions', [PushSubscriptionController::class, 'store'])->middleware(['throttle:30,1', SerializePushSubscriptions::class])->block()->name('push-subscription.store');
- Route::delete('push-subscriptions', [PushSubscriptionController::class, 'destroy'])->name('push-subscription.destroy');
+ Route::post('push-subscriptions', [PushSubscriptionController::class, 'store'])->middleware(['throttle:30,1', SerializePushSessions::class, SerializePushSubscriptions::class])->name('push-subscription.store');
+ Route::delete('push-subscriptions', [PushSubscriptionController::class, 'destroy'])->middleware(SerializePushSessions::class)->name('push-subscription.destroy');
Route::post('logout', [SessionController::class, 'destroy'])
- ->block()
+ ->middleware(SerializePushSessions::class)
->name('logout');
// Banned, where a banned user is sent from everywhere else...
diff --git a/tests/Feature/Controllers/PushSubscriptionControllerTest.php b/tests/Feature/Controllers/PushSubscriptionControllerTest.php
index eff1ba48a..09ccea5f5 100644
--- a/tests/Feature/Controllers/PushSubscriptionControllerTest.php
+++ b/tests/Feature/Controllers/PushSubscriptionControllerTest.php
@@ -5,6 +5,7 @@
use App\Models\PushSubscription;
use App\Models\User;
use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
beforeEach(function (): void {
config(['services.web_push.public_key' => 'public', 'services.web_push.private_key' => 'private']);
@@ -27,7 +28,8 @@
$first = User::factory()->create();
$second = User::factory()->create();
$endpoint = 'https://fcm.googleapis.com/wp/browser-one';
- $this->actingAs($first)->postJson(route('push-subscription.store'), ['endpoint' => $endpoint])->assertNoContent();
+ $this->actingAs($first)->withCredentials()->withCookie(config('session.cookie'), Str::random(40))
+ ->postJson(route('push-subscription.store'), ['endpoint' => $endpoint])->assertNoContent();
$subscription = PushSubscription::query()->sole();
expect($subscription->endpoint)->toBe($endpoint)
->and(DB::table('push_subscriptions')->value('endpoint'))->not->toContain($endpoint)
@@ -88,7 +90,7 @@
it('uses current remotes consent when the authenticated user was loaded before a settings change', function (bool $enabled): void {
$user = User::factory()->create(['remotes_enabled' => ! $enabled]);
- $this->actingAs($user);
+ $this->actingAs($user)->withCredentials()->withCookie(config('session.cookie'), Str::random(40));
User::query()->whereKey($user->id)->update(['remotes_enabled' => $enabled]);
$response = $this->postJson(route('push-subscription.store'), ['endpoint' => 'https://fcm.googleapis.com/wp/current-consent']);
From 71a9413242651930cff4a1379f1740f5821d6769 Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 25 Oct 2026 04:12:22 +0100
Subject: [PATCH 12/16] fix: bind push consent to the initiating account
---
.../CreatePushSubscriptionRequest.php | 10 +-
.../DeletePushSubscriptionRequest.php | 9 +-
resources/js/lib/remote-notifications.ts | 6 +-
resources/js/lib/remote-push.ts | 48 ++++++--
.../PushSubscriptionControllerTest.php | 69 +++++++----
tests/Unit/RemotePush.test.tsx | 115 +++++++++++++++---
6 files changed, 203 insertions(+), 54 deletions(-)
diff --git a/app/Http/Requests/CreatePushSubscriptionRequest.php b/app/Http/Requests/CreatePushSubscriptionRequest.php
index 02410090d..30834cdbb 100644
--- a/app/Http/Requests/CreatePushSubscriptionRequest.php
+++ b/app/Http/Requests/CreatePushSubscriptionRequest.php
@@ -7,17 +7,23 @@
use App\Models\PushSubscription;
use App\Models\User;
use App\Rules\PushEndpoint;
+use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
final class CreatePushSubscriptionRequest extends FormRequest
{
- public function authorize(): bool
+ public function authorize(): Response
{
$user = $this->user();
assert($user instanceof User);
- return $user->remotes_enabled && config('services.web_push.public_key') && config('services.web_push.private_key');
+ if ($this->input('user_id') !== $user->id) {
+ return Response::deny('Your signed-in account changed.');
+ }
+
+ return $user->remotes_enabled && config('services.web_push.public_key') && config('services.web_push.private_key')
+ ? Response::allow() : Response::deny();
}
/**
diff --git a/app/Http/Requests/DeletePushSubscriptionRequest.php b/app/Http/Requests/DeletePushSubscriptionRequest.php
index 181a06b2a..3b381d325 100644
--- a/app/Http/Requests/DeletePushSubscriptionRequest.php
+++ b/app/Http/Requests/DeletePushSubscriptionRequest.php
@@ -4,13 +4,18 @@
namespace App\Http\Requests;
+use App\Models\User;
+use Illuminate\Auth\Access\Response;
use Illuminate\Foundation\Http\FormRequest;
final class DeletePushSubscriptionRequest extends FormRequest
{
- public function authorize(): bool
+ public function authorize(): Response
{
- return true;
+ $user = $this->user();
+ assert($user instanceof User);
+
+ return $this->input('user_id') === $user->id ? Response::allow() : Response::deny('Your signed-in account changed.');
}
/**
diff --git a/resources/js/lib/remote-notifications.ts b/resources/js/lib/remote-notifications.ts
index 559d62864..c149ea86e 100644
--- a/resources/js/lib/remote-notifications.ts
+++ b/resources/js/lib/remote-notifications.ts
@@ -81,14 +81,14 @@ export async function setRemoteAlerts(
if (readStorage(preferenceKey(userId, channel)) !== 'on')
return false;
try {
- await syncRemotePush(true, true);
+ await syncRemotePush(userId, true, true);
} catch (error) {
writeStorage(preferenceKey(userId, channel), previous);
listeners.forEach((listener) => listener());
throw error;
}
} else {
- await syncRemotePush(false, true);
+ await syncRemotePush(userId, false, true);
}
}
@@ -114,7 +114,7 @@ export function createRemoteAlerts(
reading: () => string[],
onIncoming: (conversation: ConversationSummary) => void,
) {
- void syncRemotePush(remoteAlertsEnabled(userId, 'offsite')).catch(
+ void syncRemotePush(userId, remoteAlertsEnabled(userId, 'offsite')).catch(
() => undefined,
);
let latest: Record | null = null;
diff --git a/resources/js/lib/remote-push.ts b/resources/js/lib/remote-push.ts
index 2263a6e23..7011af39e 100644
--- a/resources/js/lib/remote-push.ts
+++ b/resources/js/lib/remote-push.ts
@@ -6,6 +6,8 @@ import {
export class RemotePushValidationError extends Error {}
+class RemotePushAccountChangedError extends Error {}
+
let active = false;
let pending = Promise.resolve();
@@ -24,6 +26,7 @@ export function remotePushSupported(): boolean {
async function request(
url: string,
+ userId: string,
method = 'GET',
endpoint?: string,
): Promise {
@@ -44,8 +47,13 @@ async function request(
},
...(endpoint === undefined
? {}
- : { body: JSON.stringify({ endpoint }) }),
+ : { body: JSON.stringify({ endpoint, user_id: userId }) }),
});
+ if (response.status === 403) {
+ const denied = (await response.json()) as { message: string };
+ if (denied.message === 'Your signed-in account changed.')
+ throw new RemotePushAccountChangedError(denied.message);
+ }
if (response.status === 422) {
const validation = (await response.json()) as {
errors: { endpoint: string[] };
@@ -76,6 +84,7 @@ function pushResult(operation: Promise): Promise {
/** Reuse an existing subscription on load; only a user gesture may create one. */
export function syncRemotePush(
+ userId: string,
enabled: boolean,
create = false,
): Promise {
@@ -90,7 +99,9 @@ export function syncRemotePush(
return;
let publicKey: string | null = null;
if (enabled) {
- const config = (await (await request(index.url())).json()) as {
+ const config = (await (
+ await request(index.url(), userId)
+ ).json()) as {
public_key: string | null;
};
publicKey = config.public_key;
@@ -137,26 +148,47 @@ export function syncRemotePush(
}
if (subscription) {
try {
- await request(store.url(), 'POST', subscription.endpoint);
+ await request(
+ store.url(),
+ userId,
+ 'POST',
+ subscription.endpoint,
+ );
} catch (error) {
+ if (error instanceof RemotePushAccountChangedError) {
+ active = false;
+ throw error;
+ }
if (create) {
active = false;
// A failed opt-in may have been saved before its response was lost.
- await request(
+ const accountChanged = await request(
destroy.url(),
+ userId,
'DELETE',
subscription.endpoint,
- ).catch(() => undefined);
- await pushResult(subscription.unsubscribe()).catch(
- () => undefined,
+ ).then(
+ () => false,
+ (cleanupError: unknown) =>
+ cleanupError instanceof
+ RemotePushAccountChangedError,
);
+ if (!accountChanged)
+ await pushResult(subscription.unsubscribe()).catch(
+ () => undefined,
+ );
}
throw error;
}
active = true;
}
} else if (subscription) {
- await request(destroy.url(), 'DELETE', subscription.endpoint);
+ await request(
+ destroy.url(),
+ userId,
+ 'DELETE',
+ subscription.endpoint,
+ );
await pushResult(subscription.unsubscribe());
active = false;
}
diff --git a/tests/Feature/Controllers/PushSubscriptionControllerTest.php b/tests/Feature/Controllers/PushSubscriptionControllerTest.php
index 09ccea5f5..2a3cc8cb1 100644
--- a/tests/Feature/Controllers/PushSubscriptionControllerTest.php
+++ b/tests/Feature/Controllers/PushSubscriptionControllerTest.php
@@ -18,10 +18,11 @@
});
it('exposes only the public key when delivery is configured', function (): void {
- $this->actingAs(User::factory()->create())->getJson(route('push-subscription.index'))->assertExactJson(['public_key' => 'public']);
+ $user = User::factory()->create();
+ $this->actingAs($user)->getJson(route('push-subscription.index'))->assertExactJson(['public_key' => 'public']);
config(['services.web_push.private_key' => null]);
$this->getJson(route('push-subscription.index'))->assertExactJson(['public_key' => null]);
- $this->postJson(route('push-subscription.store'), ['endpoint' => 'https://fcm.googleapis.com/wp/example'])->assertForbidden();
+ $this->postJson(route('push-subscription.store'), ['endpoint' => 'https://fcm.googleapis.com/wp/example', 'user_id' => $user->id])->assertForbidden();
});
it('stores an encrypted endpoint and transfers the browser to the current account without duplicating it', function (): void {
@@ -29,17 +30,17 @@
$second = User::factory()->create();
$endpoint = 'https://fcm.googleapis.com/wp/browser-one';
$this->actingAs($first)->withCredentials()->withCookie(config('session.cookie'), Str::random(40))
- ->postJson(route('push-subscription.store'), ['endpoint' => $endpoint])->assertNoContent();
+ ->postJson(route('push-subscription.store'), ['endpoint' => $endpoint, 'user_id' => $first->id])->assertNoContent();
$subscription = PushSubscription::query()->sole();
expect($subscription->endpoint)->toBe($endpoint)
->and(DB::table('push_subscriptions')->value('endpoint'))->not->toContain($endpoint)
->and($subscription->toArray())->toHaveKeys(['id'])->not->toHaveKeys(['endpoint', 'endpoint_hash', 'user_id']);
expect($first->load('pushSubscriptions')->toArray())->not->toHaveKey('push_subscriptions');
- $this->actingAs($second)->postJson(route('push-subscription.store'), ['endpoint' => $endpoint])->assertNoContent();
+ $this->actingAs($second)->postJson(route('push-subscription.store'), ['endpoint' => $endpoint, 'user_id' => $second->id])->assertNoContent();
expect(PushSubscription::query()->count())->toBe(1)->and($subscription->refresh()->user_id)->toBe($second->id);
- $this->actingAs($first)->deleteJson(route('push-subscription.destroy'), ['endpoint' => $endpoint])->assertNoContent();
+ $this->actingAs($first)->deleteJson(route('push-subscription.destroy'), ['endpoint' => $endpoint, 'user_id' => $first->id])->assertNoContent();
$this->assertModelExists($subscription);
- $this->actingAs($second)->deleteJson(route('push-subscription.destroy'), ['endpoint' => $endpoint])->assertNoContent();
+ $this->actingAs($second)->deleteJson(route('push-subscription.destroy'), ['endpoint' => $endpoint, 'user_id' => $second->id])->assertNoContent();
$this->assertModelMissing($subscription);
});
@@ -47,19 +48,19 @@
$user = User::factory()->create();
PushSubscription::factory()->count(19)->create(['user_id' => $user->id]);
$endpoint = 'https://fcm.googleapis.com/wp/twentieth';
- $this->actingAs($user)->postJson(route('push-subscription.store'), ['endpoint' => $endpoint])->assertNoContent();
+ $this->actingAs($user)->postJson(route('push-subscription.store'), ['endpoint' => $endpoint, 'user_id' => $user->id])->assertNoContent();
$ids = $user->pushSubscriptions()->pluck('id')->sort()->values()->all();
expect($ids)->toHaveCount(20);
$newEndpoint = 'https://fcm.googleapis.com/wp/twenty-first';
- $this->postJson(route('push-subscription.store'), ['endpoint' => $newEndpoint])
+ $this->postJson(route('push-subscription.store'), ['endpoint' => $newEndpoint, 'user_id' => $user->id])
->assertUnprocessable()->assertJsonPath('errors.endpoint.0', 'You have off-site notifications enabled in 20 browsers or apps. Turn them off in one before enabling another.');
expect($user->pushSubscriptions()->pluck('id')->sort()->values()->all())->toBe($ids);
- $this->postJson(route('push-subscription.store'), ['endpoint' => $endpoint])->assertNoContent();
+ $this->postJson(route('push-subscription.store'), ['endpoint' => $endpoint, 'user_id' => $user->id])->assertNoContent();
expect($user->pushSubscriptions()->count())->toBe(20);
- $this->deleteJson(route('push-subscription.destroy'), ['endpoint' => $endpoint])->assertNoContent();
- $this->postJson(route('push-subscription.store'), ['endpoint' => $newEndpoint])->assertNoContent();
+ $this->deleteJson(route('push-subscription.destroy'), ['endpoint' => $endpoint, 'user_id' => $user->id])->assertNoContent();
+ $this->postJson(route('push-subscription.store'), ['endpoint' => $newEndpoint, 'user_id' => $user->id])->assertNoContent();
expect($user->pushSubscriptions()->count())->toBe(20);
});
@@ -67,25 +68,28 @@
$full = User::factory()->create();
PushSubscription::factory()->count(20)->create(['user_id' => $full->id]);
$other = PushSubscription::factory()->create();
- $this->actingAs($full)->postJson(route('push-subscription.store'), ['endpoint' => $other->endpoint])
+ $this->actingAs($full)->postJson(route('push-subscription.store'), ['endpoint' => $other->endpoint, 'user_id' => $full->id])
->assertUnprocessable()->assertJsonValidationErrors('endpoint');
expect($other->refresh()->user_id)->not->toBe($full->id);
- $this->actingAs(User::query()->findOrFail($other->user_id))->postJson(route('push-subscription.store'), ['endpoint' => $other->endpoint])->assertNoContent();
+ $this->actingAs(User::query()->findOrFail($other->user_id))->postJson(route('push-subscription.store'), ['endpoint' => $other->endpoint, 'user_id' => $other->user_id])->assertNoContent();
});
it('accepts supported browser endpoints', function (string $endpoint): void {
- $this->actingAs(User::factory()->create())->postJson(route('push-subscription.store'), ['endpoint' => $endpoint])->assertNoContent();
+ $user = User::factory()->create();
+ $this->actingAs($user)->postJson(route('push-subscription.store'), ['endpoint' => $endpoint, 'user_id' => $user->id])->assertNoContent();
})->with(['https://fcm.googleapis.com/wp/example', 'https://updates.push.services.mozilla.com/wpush/v2/example', 'https://web.push.apple.com/Qexample', 'https://wns2-example.notify.windows.com/w/?token=example']);
it('rejects arbitrary destinations and malformed endpoints', function (mixed $endpoint): void {
- $this->actingAs(User::factory()->create())->postJson(route('push-subscription.store'), ['endpoint' => $endpoint])
+ $user = User::factory()->create();
+ $this->actingAs($user)->postJson(route('push-subscription.store'), ['endpoint' => $endpoint, 'user_id' => $user->id])
->assertUnprocessable()->assertJsonValidationErrors('endpoint');
})->with([null, 4, [['invalid']], '', 'http://fcm.googleapis.com/wp/a', 'https://127.0.0.1/push', 'https://notify.windows.com.evil.test/w/', 'https://evilnotify.windows.com/w/', 'https://fcm.googleapis.com.attacker.test/a', 'https://name@fcm.googleapis.com/wp/a', 'https://fcm.googleapis.com:444/wp/a', 'https://fcm.googleapis.com/wp/a#secret', 'https://fcm.googleapis.com/', 'https://fcm.googleapis.com', 'https://[', str_repeat('a', 2049)]);
it('validates deletion and refuses subscriptions while remotes are off', function (): void {
- $this->actingAs(User::factory()->create(['remotes_enabled' => false]));
- $this->postJson(route('push-subscription.store'), ['endpoint' => 'https://fcm.googleapis.com/wp/a'])->assertForbidden();
- $this->deleteJson(route('push-subscription.destroy'))->assertUnprocessable()->assertJsonValidationErrors('endpoint');
+ $user = User::factory()->create(['remotes_enabled' => false]);
+ $this->actingAs($user);
+ $this->postJson(route('push-subscription.store'), ['endpoint' => 'https://fcm.googleapis.com/wp/a', 'user_id' => $user->id])->assertForbidden();
+ $this->deleteJson(route('push-subscription.destroy'), ['user_id' => $user->id])->assertUnprocessable()->assertJsonValidationErrors('endpoint');
});
it('uses current remotes consent when the authenticated user was loaded before a settings change', function (bool $enabled): void {
@@ -93,7 +97,7 @@
$this->actingAs($user)->withCredentials()->withCookie(config('session.cookie'), Str::random(40));
User::query()->whereKey($user->id)->update(['remotes_enabled' => $enabled]);
- $response = $this->postJson(route('push-subscription.store'), ['endpoint' => 'https://fcm.googleapis.com/wp/current-consent']);
+ $response = $this->postJson(route('push-subscription.store'), ['endpoint' => 'https://fcm.googleapis.com/wp/current-consent', 'user_id' => $user->id]);
$response->assertStatus($enabled ? 204 : 403);
@@ -102,10 +106,33 @@
it('removes only this browser on sign-out and all browsers when remotes are disabled', function (): void {
$user = User::factory()->create();
- $this->actingAs($user)->postJson(route('push-subscription.store'), ['endpoint' => 'https://fcm.googleapis.com/wp/other'])->assertNoContent();
- $this->postJson(route('push-subscription.store'), ['endpoint' => 'https://fcm.googleapis.com/wp/current'])->assertNoContent();
+ $this->actingAs($user)->postJson(route('push-subscription.store'), ['endpoint' => 'https://fcm.googleapis.com/wp/other', 'user_id' => $user->id])->assertNoContent();
+ $this->postJson(route('push-subscription.store'), ['endpoint' => 'https://fcm.googleapis.com/wp/current', 'user_id' => $user->id])->assertNoContent();
$this->post(route('logout'))->assertRedirect();
expect($user->pushSubscriptions()->count())->toBe(1)->and($user->pushSubscriptions()->sole()->endpoint)->toEndWith('/other');
$this->actingAs($user)->putJson(route('user-remotes.update'), ['enabled' => false])->assertRedirect();
expect($user->pushSubscriptions()->count())->toBe(0);
});
+
+it('rejects push mutations from another account or missing consent identity', function (string $method, bool $missing): void {
+ $initiator = User::factory()->create();
+ $current = User::factory()->create();
+ $subscription = PushSubscription::factory()->create(['user_id' => $current->id]);
+ $payload = ['endpoint' => $subscription->endpoint, ...$missing ? [] : ['user_id' => $initiator->id]];
+
+ $this->actingAs($current)->{$method}(route($method === 'postJson' ? 'push-subscription.store' : 'push-subscription.destroy'), $payload)
+ ->assertForbidden()->assertJsonPath('message', 'Your signed-in account changed.');
+
+ expect($subscription->refresh()->user_id)->toBe($current->id);
+ $this->assertDatabaseCount('push_subscriptions', 1);
+})->with(['postJson', 'deleteJson'])->with([true, false]);
+
+it('reports a changed enrollment account before checking its Remotes availability', function (): void {
+ $initiator = User::factory()->create();
+ $current = User::factory()->create(['remotes_enabled' => false]);
+
+ $this->actingAs($current)->postJson(route('push-subscription.store'), ['endpoint' => 'https://fcm.googleapis.com/wp/account-switch', 'user_id' => $initiator->id])
+ ->assertForbidden()->assertJsonPath('message', 'Your signed-in account changed.');
+
+ $this->assertDatabaseCount('push_subscriptions', 0);
+});
diff --git a/tests/Unit/RemotePush.test.tsx b/tests/Unit/RemotePush.test.tsx
index 8751086ea..6e26dae52 100644
--- a/tests/Unit/RemotePush.test.tsx
+++ b/tests/Unit/RemotePush.test.tsx
@@ -18,31 +18,31 @@ beforeEach(() => {
vi.stubGlobal('navigator', { serviceWorker: { getRegistration: async () => registration, register: vi.fn(async () => registration), ready: Promise.resolve(registration) } });
vi.stubGlobal('fetch', async (url: string, options: RequestInit) => url.endsWith('/push-subscriptions') && options.method === 'GET'
? Response.json({ public_key: 'BA' }) : save(url, options));
- save.mockClear(); subscribe.mockClear(); unsubscribe.mockClear();
+ save.mockReset(); save.mockImplementation(async () => new Response(null, { status: 204 })); subscribe.mockClear(); unsubscribe.mockClear();
});
afterEach(() => {vi.unstubAllGlobals();vi.restoreAllMocks();vi.useRealTimers();});
test('does not subscribe or request permission on page load', async () => {
- await syncRemotePush(true);
+ await syncRemotePush('me', true);
assert.equal(subscribe.mock.calls.length, 0);
assert.equal(save.mock.calls.length, 0);
assert.equal(remotePushEnabled(), false);
});
-test('creates an opt-in subscription with only its endpoint sent to the account', async () => {
- await syncRemotePush(true, true);
+test('creates an opt-in subscription bound to its initiating account without encryption secrets', async () => {
+ await syncRemotePush('me', true, true);
assert.equal(remotePushEnabled(), true);
assert.equal(subscribe.mock.calls.length, 1);
const [, options] = save.mock.calls[0];
assert.equal(options.method, 'POST');
- assert.equal(options.body, JSON.stringify({ endpoint }));
+ assert.equal(options.body, JSON.stringify({ endpoint, user_id: 'me' }));
assert.equal((options.headers as Record)['X-XSRF-TOKEN'], 'csrf token');
});
test('reassociates an existing browser subscription without creating another', async () => {
subscription = { endpoint, unsubscribe };
- await syncRemotePush(true);
+ await syncRemotePush('me', true);
assert.equal(subscribe.mock.calls.length, 0);
assert.equal(save.mock.calls.length, 1);
assert.equal(remotePushEnabled(), true);
@@ -54,14 +54,14 @@ test('a transient passive registration failure preserves opt-in and recovers on
const enrolled = subscription;
save.mockImplementationOnce(async () => new Response(null, { status: 503 }));
- await assert.rejects(syncRemotePush(true));
+ await assert.rejects(syncRemotePush('me', true));
assert.equal(subscription, enrolled);
assert.equal(unsubscribe.mock.calls.length, 0);
assert.equal(save.mock.calls.filter(([, options]) => options.method === 'DELETE').length, 0);
assert.equal(remoteAlertsEnabled('me', 'offsite'), true);
assert.equal(remotePushEnabled(), true);
- await syncRemotePush(true);
+ await syncRemotePush('me', true);
assert.equal(save.mock.calls.at(-1)?.[1].method, 'POST');
assert.equal(subscribe.mock.calls.length, 1);
assert.equal(remotePushEnabled(), true);
@@ -70,7 +70,7 @@ test('a transient passive registration failure preserves opt-in and recovers on
test('turning notifications off revokes server delivery before unsubscribing', async () => {
subscription = { endpoint, unsubscribe };
- await syncRemotePush(false, true);
+ await syncRemotePush('me', false, true);
assert.equal(save.mock.calls[0][1].method, 'DELETE');
assert.equal(unsubscribe.mock.calls.length, 1);
assert.equal(remotePushEnabled(), false);
@@ -79,15 +79,15 @@ test('turning notifications off revokes server delivery before unsubscribing', a
test('failed server deletion keeps the subscription so disabling can be retried', async () => {
subscription = { endpoint, unsubscribe };
save.mockImplementationOnce(async () => new Response(null, { status: 503 }));
- await assert.rejects(syncRemotePush(false, true));
+ await assert.rejects(syncRemotePush('me', false, true));
assert.equal(unsubscribe.mock.calls.length, 0);
- await syncRemotePush(false, true);
+ await syncRemotePush('me', false, true);
assert.equal(unsubscribe.mock.calls.length, 1);
});
test('missing server configuration reports failure rather than enabling background delivery', async () => {
vi.stubGlobal('fetch', async () => Response.json({ public_key: null }));
- await assert.rejects(syncRemotePush(true, true));
+ await assert.rejects(syncRemotePush('me', true, true));
assert.equal(remotePushEnabled(), false);
assert.equal(subscribe.mock.calls.length, 0);
});
@@ -95,14 +95,14 @@ test('missing server configuration reports failure rather than enabling backgrou
test('registers a service worker before the first opt-in', async () => {
const register = vi.fn(async () => registration);
vi.stubGlobal('navigator', { serviceWorker: { getRegistration: async () => undefined, register, ready: Promise.resolve(registration) } });
- await syncRemotePush(true, true);
+ await syncRemotePush('me', true, true);
assert.equal(register.mock.calls.length, 1);
assert.equal(remotePushEnabled(), true);
});
test('unsupported browsers retain their page alert fallback', async () => {
vi.stubGlobal('window', { isSecureContext: true });
- await syncRemotePush(true, true);
+ await syncRemotePush('me', true, true);
assert.equal(save.mock.calls.length, 0);
});
@@ -120,11 +120,11 @@ test('page alerts can be re-enabled even when background delivery is unavailable
test('a stalled browser operation times out without saving and the next attempt can recover', async () => {
vi.useFakeTimers();
vi.spyOn(registration.pushManager, 'getSubscription').mockImplementationOnce(() => new Promise(() => {}));
- const rejected = assert.rejects(syncRemotePush(true, true), /browser did not respond/);
+ const rejected = assert.rejects(syncRemotePush('me', true, true), /browser did not respond/);
await vi.advanceTimersByTimeAsync(10_001);
await rejected;
assert.equal(save.mock.calls.length, 0);
- await syncRemotePush(true, true);
+ await syncRemotePush('me', true, true);
assert.equal(save.mock.calls.length, 1);
assert.equal(remotePushEnabled(), true);
});
@@ -132,11 +132,11 @@ test('a stalled browser operation times out without saving and the next attempt
test('does not query native subscriptions before permission or server configuration is ready', async () => {
const query = vi.spyOn(registration.pushManager, 'getSubscription');
vi.stubGlobal('Notification', {permission: 'default'});
- await syncRemotePush(true);
+ await syncRemotePush('me', true);
assert.equal(query.mock.calls.length, 0);
vi.stubGlobal('Notification', {permission: 'granted'});
vi.stubGlobal('fetch', async () => Response.json({public_key: null}));
- await assert.rejects(syncRemotePush(true, true));
+ await assert.rejects(syncRemotePush('me', true, true));
assert.equal(query.mock.calls.length, 0);
});
@@ -194,3 +194,82 @@ test('a full account reports the enrollment limit and leaves this browser opted
assert.equal(remoteAlertsEnabled('me', 'offsite'), false);
assert.equal(unsubscribe.mock.calls.length, 1);
});
+
+
+for (const phase of ['permission', 'subscription', 'queue'] as const) {
+ test(`an account switch during ${phase} cannot apply another account's consent`, async () => {
+ const storage = preferenceStorage();
+ let current = 'account-a';
+ let owner: string | null = null;
+ let resume!: () => void;
+ const gate = new Promise((resolve) => { resume = resolve; });
+ let blocker: Promise | undefined;
+ save.mockImplementation(async (_url, options) => {
+ const body = JSON.parse(options.body as string) as { user_id?: string };
+ if (body.user_id !== current) return Response.json({ message: 'Your signed-in account changed.' }, { status: 403 });
+ owner = current;
+ return new Response(null, { status: 204 });
+ });
+ if (phase === 'permission') {
+ vi.stubGlobal('Notification', { permission: 'granted', requestPermission: async () => { await gate; return 'granted'; } });
+ } else if (phase === 'subscription') {
+ subscribe.mockImplementationOnce(async () => { await gate; return subscription = { endpoint, unsubscribe }; });
+ } else {
+ vi.spyOn(navigator.serviceWorker, 'getRegistration').mockImplementationOnce(async () => { await gate; return undefined; });
+ blocker = setRemoteAlerts('blocker', false, 'offsite');
+ }
+ const enabling = setRemoteAlerts('account-a', true, 'offsite');
+ const rejected = assert.rejects(enabling);
+ if (phase === 'subscription') await vi.waitFor(() => assert.equal(subscribe.mock.calls.length, 1));
+ if (phase === 'queue') await vi.waitFor(() => assert.equal(storage.get('remote-alerts:account-a:offsite'), 'on'));
+ current = 'account-b';
+ resume();
+ if (blocker) await blocker;
+ await rejected;
+ const post = save.mock.calls.find(([, options]) => options.method === 'POST');
+ assert.equal(JSON.parse(post![1].body as string).user_id, 'account-a');
+ assert.equal(owner, null);
+ assert.equal(remoteAlertsEnabled('account-a', 'offsite'), false);
+ assert.equal(remoteAlertsEnabled('account-b', 'offsite'), false);
+ assert.deepEqual(save.mock.calls.map(([, options]) => options.method), ['POST']);
+ assert.equal(unsubscribe.mock.calls.length, 0);
+ });
+}
+
+test('a changed account rejection preserves the current accounts existing browser subscription', async () => {
+ const storage = preferenceStorage();
+ storage.set('remote-alerts:account-b:offsite', 'on');
+ subscription = { endpoint, unsubscribe };
+ const existing = subscription;
+ save.mockImplementation(async () => Response.json({ message: 'Your signed-in account changed.' }, { status: 403 }));
+ await assert.rejects(setRemoteAlerts('account-a', true, 'offsite'));
+ assert.equal(subscription, existing);
+ assert.equal(unsubscribe.mock.calls.length, 0);
+ assert.deepEqual(save.mock.calls.map(([, options]) => options.method), ['POST']);
+ assert.equal(remoteAlertsEnabled('account-a', 'offsite'), false);
+ assert.equal(remoteAlertsEnabled('account-b', 'offsite'), true);
+});
+
+test('failed enrollment cleanup cannot unsubscribe a changed account', async () => {
+ preferenceStorage();
+ subscription = { endpoint, unsubscribe };
+ save.mockImplementation(async (_url, options) => options.method === 'POST'
+ ? new Response(null, { status: 503 })
+ : Response.json({ message: 'Your signed-in account changed.' }, { status: 403 }));
+ await assert.rejects(setRemoteAlerts('account-a', true, 'offsite'));
+ assert.equal(unsubscribe.mock.calls.length, 0);
+ assert.deepEqual(save.mock.calls.map(([, options]) => JSON.parse(options.body as string).user_id), ['account-a', 'account-a']);
+});
+
+test('a stale account opt-out preserves the current accounts consent and native subscription', async () => {
+ const storage = preferenceStorage();
+ storage.set('remote-alerts:account-a:offsite', 'on');
+ storage.set('remote-alerts:account-b:offsite', 'on');
+ subscription = { endpoint, unsubscribe };
+ save.mockImplementation(async () => Response.json({ message: 'Your signed-in account changed.' }, { status: 403 }));
+ await assert.rejects(setRemoteAlerts('account-a', false, 'offsite'));
+ assert.equal(JSON.parse(save.mock.calls[0][1].body as string).user_id, 'account-a');
+ assert.equal(unsubscribe.mock.calls.length, 0);
+ assert.equal(remoteAlertsEnabled('account-a', 'offsite'), true);
+ assert.equal(remoteAlertsEnabled('account-b', 'offsite'), true);
+});
From 690d20240d961e40dccac87b8d3839a2839964ab Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 25 Oct 2026 04:28:22 +0100
Subject: [PATCH 13/16] fix: preserve shared push subscriptions across accounts
---
KNOWLEDGE_BASE.md | 4 +-
resources/js/lib/remote-notifications.ts | 4 +-
resources/js/lib/remote-push.ts | 46 ++++----
tests/Unit/RemotePush.test.tsx | 59 ++++++++--
tests/Unit/RemotePushAccountIsolation.test.ts | 111 ++++++++++++++++++
5 files changed, 190 insertions(+), 34 deletions(-)
create mode 100644 tests/Unit/RemotePushAccountIsolation.test.ts
diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md
index 87ef3ff56..989fd7b7b 100644
--- a/KNOWLEDGE_BASE.md
+++ b/KNOWLEDGE_BASE.md
@@ -332,9 +332,9 @@ Sources: [notifications page](resources/js/pages/notification/index.tsx), [notif
Remotes alerts are controlled under **Settings → Profile → Remotes**. **On-site notifications** and **Notification sound** are on by default. On-site alerts stay inside /dev/null and never ask for browser permission. Sound does not require notification permission and plays only while the site is running; the browser may require interaction before it can play.
-**Off-site notifications** are off by default. Turn that separate switch on to allow system notifications from this browser or installed PWA, including when closed. This is an explicit choice for each account and browser; an existing browser permission or an earlier on-site preference does not turn it on. On iPhone and iPad, add /dev/null to your Home Screen and turn the switch on from the installed app. The browser asks for permission only after you choose to enable off-site notifications. Declining permission or a failed enrollment leaves them off, and turning them off revokes delivery from that browser. On-site alerts remain independent. Up to 20 browsers or installed apps per account can have off-site notifications enabled. At the limit, existing enrollments stay active; turn off off-site notifications in one before enabling another.
+**Off-site notifications** are off by default. Turn that separate switch on to allow system notifications from this browser or installed PWA, including when closed. This is an explicit choice for each account and browser; an existing browser permission or an earlier on-site preference does not turn it on. On iPhone and iPad, add /dev/null to your Home Screen and turn the switch on from the installed app. The browser asks for permission only after you choose to enable off-site notifications. Declining permission or a rejected enrollment leaves them off. If /dev/null cannot confirm that a failed enrollment was removed, the switch stays on with an error so you can try turning it off again. Turning it off revokes that account's delivery registration; the browser permission and shared browser subscription can remain for reuse. On-site alerts remain independent. Up to 20 browsers or installed apps per account can have off-site notifications enabled. At the limit, existing enrollments stay active; turn off off-site notifications in one before enabling another.
-Off-site alerts are silent, contain no message data, and open the Remotes inbox. Delivery depends on browser and operating-system permissions, background delivery, and Focus settings. A fully quit browser may prevent delivery. Subscriptions are removed on sign-out, account deletion, turning Remotes off, or when the push service reports that they expired. Delivery briefly waits and rechecks unread messages, membership, mutes, and blocks before sending.
+Off-site alerts are silent, contain no message data, and open the Remotes inbox. Delivery depends on browser and operating-system permissions, background delivery, and Focus settings. A fully quit browser may prevent delivery. Delivery registrations are removed on sign-out, account deletion, turning Remotes off, or when the push service reports that they expired. Delivery briefly waits and rechecks unread messages, membership, mutes, and blocks before sending.
Choices stay saved for the signed-in account on that browser. Earlier combined alert opt-outs continue to apply to on-site alerts and sound until each is changed. Alerts never show message text. Only sound has a 10-second cooldown shared across remotes and tabs. The browser tab title shows the combined unread notification and remote message counts from navigation, such as **(2)**, caps the display at **(99+)** above 99, and removes the prefix when nothing remains unread. Counts do not depend on sound or off-site permission. While the site is open, alerts skip the remote you are actively reading in a visible, focused tab, whether on the Remotes page or in the dock. Messages in other remotes can alert while you browse any page. Existing unread messages do not trigger on-site alerts when you open the site.
diff --git a/resources/js/lib/remote-notifications.ts b/resources/js/lib/remote-notifications.ts
index c149ea86e..4ca4fce23 100644
--- a/resources/js/lib/remote-notifications.ts
+++ b/resources/js/lib/remote-notifications.ts
@@ -1,4 +1,5 @@
import {
+ RemotePushRevocationError,
remotePushEnabled,
remotePushSupported,
syncRemotePush,
@@ -83,7 +84,8 @@ export async function setRemoteAlerts(
try {
await syncRemotePush(userId, true, true);
} catch (error) {
- writeStorage(preferenceKey(userId, channel), previous);
+ if (!(error instanceof RemotePushRevocationError))
+ writeStorage(preferenceKey(userId, channel), previous);
listeners.forEach((listener) => listener());
throw error;
}
diff --git a/resources/js/lib/remote-push.ts b/resources/js/lib/remote-push.ts
index 7011af39e..249b6235c 100644
--- a/resources/js/lib/remote-push.ts
+++ b/resources/js/lib/remote-push.ts
@@ -4,9 +4,11 @@ import {
store,
} from '@/actions/App/Http/Controllers/PushSubscriptionController';
-export class RemotePushValidationError extends Error {}
+class RemotePushRejectedError extends Error {}
-class RemotePushAccountChangedError extends Error {}
+export class RemotePushValidationError extends RemotePushRejectedError {}
+
+export class RemotePushRevocationError extends RemotePushValidationError {}
let active = false;
let pending = Promise.resolve();
@@ -49,19 +51,19 @@ async function request(
? {}
: { body: JSON.stringify({ endpoint, user_id: userId }) }),
});
- if (response.status === 403) {
- const denied = (await response.json()) as { message: string };
- if (denied.message === 'Your signed-in account changed.')
- throw new RemotePushAccountChangedError(denied.message);
- }
if (response.status === 422) {
const validation = (await response.json()) as {
errors: { endpoint: string[] };
};
throw new RemotePushValidationError(validation.errors.endpoint[0]);
}
- if (!response.ok)
+ if (!response.ok) {
+ if (response.status >= 400 && response.status < 500)
+ throw new RemotePushRejectedError(
+ 'Could not save background notifications.',
+ );
throw new Error('Could not save background notifications.');
+ }
return response;
}
@@ -155,28 +157,25 @@ export function syncRemotePush(
subscription.endpoint,
);
} catch (error) {
- if (error instanceof RemotePushAccountChangedError) {
+ if (error instanceof RemotePushRejectedError) {
active = false;
throw error;
}
if (create) {
active = false;
// A failed opt-in may have been saved before its response was lost.
- const accountChanged = await request(
- destroy.url(),
- userId,
- 'DELETE',
- subscription.endpoint,
- ).then(
- () => false,
- (cleanupError: unknown) =>
- cleanupError instanceof
- RemotePushAccountChangedError,
- );
- if (!accountChanged)
- await pushResult(subscription.unsubscribe()).catch(
- () => undefined,
+ try {
+ await request(
+ destroy.url(),
+ userId,
+ 'DELETE',
+ subscription.endpoint,
+ );
+ } catch {
+ throw new RemotePushRevocationError(
+ 'Could not turn off-site notifications off. Please try again.',
);
+ }
}
throw error;
}
@@ -189,7 +188,6 @@ export function syncRemotePush(
'DELETE',
subscription.endpoint,
);
- await pushResult(subscription.unsubscribe());
active = false;
}
};
diff --git a/tests/Unit/RemotePush.test.tsx b/tests/Unit/RemotePush.test.tsx
index 6e26dae52..a4b4ea139 100644
--- a/tests/Unit/RemotePush.test.tsx
+++ b/tests/Unit/RemotePush.test.tsx
@@ -68,11 +68,13 @@ test('a transient passive registration failure preserves opt-in and recovers on
assert.equal(remoteAlertsEnabled('me', 'offsite'), true);
});
-test('turning notifications off revokes server delivery before unsubscribing', async () => {
+test('turning notifications off revokes server delivery while retaining the shared browser subscription', async () => {
subscription = { endpoint, unsubscribe };
+ const existing = subscription;
await syncRemotePush('me', false, true);
assert.equal(save.mock.calls[0][1].method, 'DELETE');
- assert.equal(unsubscribe.mock.calls.length, 1);
+ assert.equal(subscription, existing);
+ assert.equal(unsubscribe.mock.calls.length, 0);
assert.equal(remotePushEnabled(), false);
});
@@ -82,7 +84,7 @@ test('failed server deletion keeps the subscription so disabling can be retried'
await assert.rejects(syncRemotePush('me', false, true));
assert.equal(unsubscribe.mock.calls.length, 0);
await syncRemotePush('me', false, true);
- assert.equal(unsubscribe.mock.calls.length, 1);
+ assert.equal(unsubscribe.mock.calls.length, 0);
});
test('missing server configuration reports failure rather than enabling background delivery', async () => {
@@ -159,7 +161,7 @@ test('off-site delivery requires a separate explicit opt-in and turning it off r
assert.equal(await setRemoteAlerts('me', false, 'offsite'), true);
assert.equal(remoteAlertsEnabled('me', 'offsite'), false);
assert.equal(remoteAlertsEnabled('me'), true);
- assert.equal(unsubscribe.mock.calls.length, 1);
+ assert.equal(unsubscribe.mock.calls.length, 0);
assert.equal(save.mock.calls.at(-1)?.[1].method, 'DELETE');
});
@@ -175,7 +177,7 @@ test('denied permission and failed enrollment leave off-site notifications off',
assert.equal(remoteAlertsEnabled('me', 'offsite'), false);
assert.equal(storage.has('remote-alerts:me:offsite'), false);
assert.equal(save.mock.calls.at(-1)?.[1].method, 'DELETE');
- assert.equal(unsubscribe.mock.calls.length, 1);
+ assert.equal(unsubscribe.mock.calls.length, 0);
});
test('blocked preference storage cannot activate off-site delivery', async () => {
@@ -192,7 +194,7 @@ test('a full account reports the enrollment limit and leaves this browser opted
save.mockImplementationOnce(async () => Response.json({errors: {endpoint: [message]}}, {status: 422}));
await assert.rejects(setRemoteAlerts('me', true, 'offsite'), {message});
assert.equal(remoteAlertsEnabled('me', 'offsite'), false);
- assert.equal(unsubscribe.mock.calls.length, 1);
+ assert.equal(unsubscribe.mock.calls.length, 0);
});
@@ -256,11 +258,54 @@ test('failed enrollment cleanup cannot unsubscribe a changed account', async ()
save.mockImplementation(async (_url, options) => options.method === 'POST'
? new Response(null, { status: 503 })
: Response.json({ message: 'Your signed-in account changed.' }, { status: 403 }));
- await assert.rejects(setRemoteAlerts('account-a', true, 'offsite'));
+ await assert.rejects(setRemoteAlerts('account-a', true, 'offsite'), /Could not turn off-site notifications off/);
+ assert.equal(remoteAlertsEnabled('account-a', 'offsite'), true);
assert.equal(unsubscribe.mock.calls.length, 0);
assert.deepEqual(save.mock.calls.map(([, options]) => JSON.parse(options.body as string).user_id), ['account-a', 'account-a']);
});
+for (const status of [401, 403, 419, 429]) {
+ test(`a rejected enrollment (${status}) leaves consent off without deleting a shared registration`, async () => {
+ preferenceStorage();
+ subscription = { endpoint, unsubscribe };
+ const existing = subscription;
+ save.mockImplementationOnce(async () => new Response(null, { status }));
+ await assert.rejects(setRemoteAlerts('me', true, 'offsite'));
+ assert.equal(remoteAlertsEnabled('me', 'offsite'), false);
+ assert.equal(subscription, existing);
+ assert.deepEqual(save.mock.calls.map(([, options]) => options.method), ['POST']);
+ });
+}
+
+for (const failure of ['response', 'network'] as const) {
+ test(`an uncertain ${failure} enrollment keeps consent visible until server revocation succeeds`, async () => {
+ preferenceStorage();
+ let enrolled = false;
+ let revocationFails = true;
+ save.mockImplementation(async (_url, options) => {
+ if (options.method === 'POST') {
+ enrolled = true;
+ if (failure === 'network') throw new TypeError('Connection lost');
+ return new Response(null, { status: 503 });
+ }
+ if (revocationFails) return new Response(null, { status: 503 });
+ enrolled = false;
+ return new Response(null, { status: 204 });
+ });
+ await assert.rejects(setRemoteAlerts('me', true, 'offsite'), /Could not turn off-site notifications off/);
+ assert.equal(enrolled, true);
+ assert.equal(remoteAlertsEnabled('me', 'offsite'), true);
+ const existing = subscription;
+ assert.ok(existing);
+ revocationFails = false;
+ assert.equal(await setRemoteAlerts('me', false, 'offsite'), true);
+ assert.equal(enrolled, false);
+ assert.equal(remoteAlertsEnabled('me', 'offsite'), false);
+ assert.equal(subscription, existing);
+ assert.deepEqual(save.mock.calls.map(([, options]) => options.method), ['POST', 'DELETE', 'DELETE']);
+ });
+}
+
test('a stale account opt-out preserves the current accounts consent and native subscription', async () => {
const storage = preferenceStorage();
storage.set('remote-alerts:account-a:offsite', 'on');
diff --git a/tests/Unit/RemotePushAccountIsolation.test.ts b/tests/Unit/RemotePushAccountIsolation.test.ts
new file mode 100644
index 000000000..6a8d1be55
--- /dev/null
+++ b/tests/Unit/RemotePushAccountIsolation.test.ts
@@ -0,0 +1,111 @@
+import assert from 'node:assert/strict';
+import { existsSync, statSync } from 'node:fs';
+import { registerHooks } from 'node:module';
+import { test } from 'node:test';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+
+const resources = new URL('../../resources/js/', import.meta.url);
+
+// Separate module realms model tabs sharing an origin subscription and preference storage.
+registerHooks({
+ resolve(specifier, context, nextResolve) {
+ if (specifier.startsWith('@/') || specifier.startsWith('.')) {
+ const target = specifier.startsWith('@/')
+ ? new URL(specifier.slice(2), resources)
+ : new URL(specifier, context.parentURL);
+ const path = fileURLToPath(target);
+ const resolved = [path + '.ts', path + '/index.ts', path].find(
+ (candidate) => existsSync(candidate) && statSync(candidate).isFile(),
+ );
+ if (resolved) {
+ const realm = context.parentURL ? new URL(context.parentURL).search : '';
+ return nextResolve(pathToFileURL(resolved).href + realm, context);
+ }
+ }
+ return nextResolve(specifier, context);
+ },
+});
+
+for (const cleanup of [false, true]) {
+ void test(`a delayed successful ${cleanup ? 'enrollment cleanup' : 'opt-out'} preserves another tab's enrollment`, { timeout: 5_000 }, async () => {
+ const originals = new Map(['window', 'document', 'navigator', 'Notification', 'fetch'].map(
+ (key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
+ ));
+ const storage = new Map([
+ ['remote-alerts:account-a:offsite', 'on'],
+ ['remote-alerts:account-b:offsite', 'on'],
+ ]);
+ let unsubscribes = 0;
+ let subscription: { endpoint: string; unsubscribe(): Promise } | null = {
+ endpoint: 'https://fcm.googleapis.com/wp/shared-browser',
+ unsubscribe: async () => { unsubscribes++; subscription = null; return true; },
+ };
+ const originalSubscription = subscription;
+ const registration = { pushManager: {
+ getSubscription: async () => subscription,
+ subscribe: async () => { throw new Error('The existing subscription must be reused.'); },
+ } };
+ let current = 'account-a';
+ let owner: string | null = 'account-a';
+ let releaseDelete!: () => void;
+ let notifyDelete!: () => void;
+ const deletion = new Promise((resolve) => { notifyDelete = resolve; });
+ const response = new Promise((resolve) => { releaseDelete = resolve; });
+ let posts = 0;
+ const globals = {
+ window: { isSecureContext: true, PushManager: class {}, Notification: {}, localStorage: {
+ getItem: (key: string) => storage.get(key) ?? null,
+ setItem: (key: string, value: string) => storage.set(key, value),
+ removeItem: (key: string) => storage.delete(key),
+ } },
+ document: { cookie: 'XSRF-TOKEN=fixture' },
+ Notification: { permission: 'granted', requestPermission: async () => 'granted' },
+ navigator: { serviceWorker: { getRegistration: async () => registration } },
+ fetch: async (_url: string, options: RequestInit) => {
+ if (options.method === 'GET') return Response.json({ public_key: 'BA' });
+ assert.ok(typeof options.body === 'string');
+ const body = JSON.parse(options.body) as { user_id: string };
+ if (body.user_id !== current)
+ return Response.json({ message: 'Your signed-in account changed.' }, { status: 403 });
+ if (options.method === 'DELETE') {
+ if (owner === current) owner = null;
+ notifyDelete();
+ await response;
+ } else {
+ posts++;
+ if (cleanup && current === 'account-a') return new Response(null, { status: 503 });
+ owner = current;
+ }
+ return new Response(null, { status: 204 });
+ },
+ };
+ for (const [key, value] of Object.entries(globals))
+ Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
+ try {
+ const realm = cleanup ? 'cleanup' : 'optout';
+ const a = await import(new URL(`lib/remote-notifications.ts?tab=a-${realm}`, resources).href) as typeof import('../../resources/js/lib/remote-notifications');
+ const b = await import(new URL(`lib/remote-notifications.ts?tab=b-${realm}`, resources).href) as typeof import('../../resources/js/lib/remote-notifications');
+ const bPush = await import(new URL(`lib/remote-push.ts?tab=b-${realm}`, resources).href) as typeof import('../../resources/js/lib/remote-push');
+ const disabling = a.setRemoteAlerts('account-a', cleanup, 'offsite').then(() => 'resolved', () => 'rejected');
+ await deletion;
+ current = 'account-b';
+ await b.setRemoteAlerts('account-b', true, 'offsite');
+ assert.equal(owner, 'account-b');
+ releaseDelete();
+ assert.equal(await disabling, cleanup ? 'rejected' : 'resolved');
+ assert.equal(subscription, originalSubscription);
+ assert.equal(unsubscribes, 0);
+ assert.equal(b.remoteAlertsEnabled('account-b', 'offsite'), true);
+ const beforeReconcile = posts;
+ await bPush.syncRemotePush('account-b', true);
+ assert.equal(posts, beforeReconcile + 1);
+ assert.equal(owner, 'account-b');
+ assert.equal(bPush.remotePushEnabled(), true);
+ } finally {
+ for (const [key, descriptor] of originals) {
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
+ else Reflect.deleteProperty(globalThis, key);
+ }
+ }
+ });
+}
From 489816dcedbae0afe5e05637cc2430e5e267a086 Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 25 Oct 2026 04:46:44 +0100
Subject: [PATCH 14/16] test: generate Wayfinder routes before unit tests
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 29860b0e9..0fed34885 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
"lint": "NODE_OPTIONS='--experimental-strip-types' vp fmt resources/ && NODE_OPTIONS='--experimental-strip-types' vp lint --fix",
"test:lint": "NODE_OPTIONS='--experimental-strip-types' vp fmt --check resources/ && NODE_OPTIONS='--experimental-strip-types' vp lint",
"test:types": "tsc --noEmit && tsc --noEmit --project tests/tsconfig.json",
- "test:unit": "node --experimental-strip-types --test tests/Unit/*.test.ts"
+ "test:unit": "php artisan wayfinder:generate --with-form && node --experimental-strip-types --test tests/Unit/*.test.ts"
},
"dependencies": {
"@daypicker/react": "^10.0.2",
From bb6e48062b98a130bdc224c947623f5966b4309f Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 25 Oct 2026 04:46:44 +0100
Subject: [PATCH 15/16] fix: bound remote push delivery per recipient
---
app/Actions/CreateMessage.php | 5 +-
app/Jobs/SendRemotePush.php | 36 ++++++++------
tests/Unit/Jobs/SendRemotePushTest.php | 65 +++++++++++++++++++++-----
3 files changed, 78 insertions(+), 28 deletions(-)
diff --git a/app/Actions/CreateMessage.php b/app/Actions/CreateMessage.php
index 3ae4fdfa9..d45b63fa9 100644
--- a/app/Actions/CreateMessage.php
+++ b/app/Actions/CreateMessage.php
@@ -91,7 +91,10 @@ public function handle(User $sender, Conversation $conversation, string $body, s
self::advance($conversation, $message, unstash: true);
if (config('services.web_push.public_key') && config('services.web_push.private_key')) {
- dispatch(new SendRemotePush($message->id))->delay(now()->addSeconds(10))->afterCommit();
+ foreach (ConversationParticipant::query()->where('conversation_id', $conversation->id)
+ ->where('user_id', '!=', $sender->id)->whereHas('user.pushSubscriptions')->get(['user_id']) as $participant) {
+ dispatch(new SendRemotePush($message->id, $participant->user_id))->delay(now()->addSeconds(10))->afterCommit();
+ }
}
ConversationParticipant::query()
diff --git a/app/Jobs/SendRemotePush.php b/app/Jobs/SendRemotePush.php
index 09391b801..b30b6c9d4 100644
--- a/app/Jobs/SendRemotePush.php
+++ b/app/Jobs/SendRemotePush.php
@@ -12,8 +12,10 @@
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Foundation\Queue\Queueable;
+use Illuminate\Queue\Attributes\Timeout;
use Illuminate\Support\Facades\Date;
+#[Timeout(210)]
final class SendRemotePush implements ShouldQueue
{
use Queueable;
@@ -23,6 +25,7 @@ final class SendRemotePush implements ShouldQueue
*/
public function __construct(
public readonly int $messageId,
+ public readonly string $userId,
public readonly ?array $subscriptionIds = null,
public readonly int $tries = 3,
) {
@@ -38,36 +41,39 @@ public function handle(RemotePush $push): void
return;
}
- $participants = ConversationParticipant::query()->where('conversation_id', $message->conversation_id)
+ $participant = ConversationParticipant::query()->where('conversation_id', $message->conversation_id)
+ ->where('user_id', $this->userId)
->where('user_id', '!=', $message->user_id)
->where('visible_from_id', '<', $message->id)
->where('read_through_id', '<', $message->id)
->whereIn('status', config('remotes.requests') ? [RemoteStatus::Open, RemoteStatus::Fetched] : [RemoteStatus::Open])
->where(fn (Builder $query): Builder => $query->whereNull('muted_until_at')->orWhere('muted_until_at', '<=', now()))
->whereHas('user.pushSubscriptions')
- ->with('user')->get();
+ ->with('user')->first();
- $failed = [];
+ if (! $participant instanceof ConversationParticipant) {
+ return;
+ }
- foreach ($participants as $participant) {
- $user = $participant->user;
+ $user = $participant->user;
- if (! $user->remotes_enabled || $user->hasBlockWith($message->user) || $user->isMuting($message->user)) {
- continue;
- }
+ if (! $user->remotes_enabled || $user->hasBlockWith($message->user) || $user->isMuting($message->user)) {
+ return;
+ }
+
+ $failed = [];
- foreach (PushSubscription::query()->where('user_id', $user->id)
- ->when($this->subscriptionIds !== null, fn (Builder $query): Builder => $query->whereKey($this->subscriptionIds))
- ->lazyById() as $subscription) {
- if (! $push->send($subscription)) {
- $failed[] = $subscription->id;
- }
+ foreach (PushSubscription::query()->where('user_id', $user->id)
+ ->when($this->subscriptionIds !== null, fn (Builder $query): Builder => $query->whereKey($this->subscriptionIds))
+ ->orderBy('id')->limit(PushSubscription::MAX_PER_USER)->get() as $subscription) {
+ if (! $push->send($subscription)) {
+ $failed[] = $subscription->id;
}
}
if ($failed !== [] && $this->attempts() < $this->tries) {
// A follow-up starts with a fresh queue attempt count, so carry the remaining budget.
- dispatch(new self($this->messageId, $failed, $this->tries - $this->attempts())
+ dispatch(new self($this->messageId, $this->userId, $failed, $this->tries - $this->attempts())
->onConnection($this->connection)->onQueue($this->queue))->delay(now()->addSeconds(30));
}
}
diff --git a/tests/Unit/Jobs/SendRemotePushTest.php b/tests/Unit/Jobs/SendRemotePushTest.php
index 0155463a9..b4a515d6c 100644
--- a/tests/Unit/Jobs/SendRemotePushTest.php
+++ b/tests/Unit/Jobs/SendRemotePushTest.php
@@ -41,7 +41,7 @@
$this->recipient->pushSubscriptions()->create(['endpoint_hash' => 'second', 'endpoint' => 'https://fcm.googleapis.com/wp/second']);
ConversationParticipant::factory()->create(['conversation_id' => $this->message->conversation_id, 'user_id' => $this->message->user_id]);
$this->message->user->pushSubscriptions()->create(['endpoint_hash' => 'sender', 'endpoint' => 'https://fcm.googleapis.com/wp/sender']);
- resolve(SendRemotePush::class, ['messageId' => $this->message->id])->handle(resolve(RemotePush::class));
+ resolve(SendRemotePush::class, ['messageId' => $this->message->id, 'userId' => $this->recipient->id])->handle(resolve(RemotePush::class));
Http::assertSentCount(2);
Http::assertSent(fn (Request $request): bool => $request->body() === '' && str_starts_with($request->header('Authorization')[0], 'vapid t=') && $request->header('TTL') === ['300']);
});
@@ -53,7 +53,7 @@
$loaded[] = $user->id;
});
- new SendRemotePush($this->message->id)->handle(resolve(RemotePush::class));
+ new SendRemotePush($this->message->id, $this->recipient->id)->handle(resolve(RemotePush::class));
expect($loaded)->not->toContain($this->recipient->id);
Http::assertNothingSent();
@@ -76,20 +76,20 @@
'event' => $this->message->update(['event' => OrgEvent::Added]),
'unconfigured' => config(['services.web_push.private_key' => null]),
};
- new SendRemotePush($this->message->id)->handle(resolve(RemotePush::class));
+ new SendRemotePush($this->message->id, $this->recipient->id)->handle(resolve(RemotePush::class));
Http::assertNothingSent();
})->with(['read', 'history', 'muted', 'dropped', 'fetched', 'left', 'disabled', 'blocked', 'blocked-by', 'user-muted', 'deleted', 'old', 'event', 'unconfigured']);
it('allows expired mutes and enabled fetched requests', function (): void {
config(['remotes.requests' => true]);
$this->participant->update(['muted_until_at' => now(), 'status' => RemoteStatus::Fetched]);
- new SendRemotePush($this->message->id)->handle(resolve(RemotePush::class));
+ new SendRemotePush($this->message->id, $this->recipient->id)->handle(resolve(RemotePush::class));
Http::assertSentCount(1);
});
it('removes expired endpoints but keeps other subscriptions', function (int $status, bool $removed): void {
$this->pushStatus = $status;
- new SendRemotePush($this->message->id)->handle(resolve(RemotePush::class));
+ new SendRemotePush($this->message->id, $this->recipient->id)->handle(resolve(RemotePush::class));
expect(PushSubscription::query()->whereKey($this->subscription->id)->exists())->toBe(! $removed);
})->with([[404, true], [410, true], [403, false], [429, false], [500, false]]);
@@ -100,17 +100,56 @@
$message = $action->handle($this->message->user, $this->message->conversation, 'new message', $clientId, linkPreview: false);
$action->handle($this->message->user, $this->message->conversation, 'new message', $clientId, linkPreview: false);
Queue::assertPushed(SendRemotePush::class, 1);
- Queue::assertPushed(SendRemotePush::class, fn (SendRemotePush $job): bool => $job->messageId === $message->id && $job->afterCommit === true && $job->delay->equalTo(now()->addSeconds(10)));
+ Queue::assertPushed(SendRemotePush::class, fn (SendRemotePush $job): bool => $job->messageId === $message->id && $job->userId === $this->recipient->id && $job->afterCommit === true && $job->delay->equalTo(now()->addSeconds(10)));
+});
+
+it('queues each subscribed recipient independently with a bounded delivery timeout', function (): void {
+ $other = User::factory()->create();
+ ConversationParticipant::factory()->create(['conversation_id' => $this->message->conversation_id, 'user_id' => $other->id]);
+ PushSubscription::factory()->create(['user_id' => $other->id]);
+ ConversationParticipant::factory()->create(['conversation_id' => $this->message->conversation_id, 'user_id' => $this->message->user_id]);
+ PushSubscription::factory()->create(['user_id' => $this->message->user_id]);
+ $unsubscribed = User::factory()->create();
+ ConversationParticipant::factory()->create(['conversation_id' => $this->message->conversation_id, 'user_id' => $unsubscribed->id]);
+
+ $message = resolve(CreateMessage::class)->handle($this->message->user, $this->message->conversation, 'new message', Str::uuid()->toString(), linkPreview: false);
+
+ $jobs = DB::table('jobs')->get()->map(fn (object $job): array => json_decode($job->payload, true));
+ expect($jobs)->toHaveCount(2);
+ expect($jobs->pluck('timeout')->all())->toBe([210, 210]);
+ expect($jobs->map(fn (array $payload): string => unserialize($payload['data']['command'])->userId)->sort()->values()->all())
+ ->toBe(collect([$this->recipient->id, $other->id])->sort()->values()->all());
+ expect($jobs->map(fn (array $payload): int => unserialize($payload['data']['command'])->messageId)->all())->toBe([$message->id, $message->id]);
+});
+
+it('delivers twenty browsers per queued recipient without one recipient consuming the next job', function (): void {
+ $this->recipient->pushSubscriptions()->createMany(collect(range(1, 19))->map(fn (int $i): array => ['endpoint_hash' => 'first-'.$i, 'endpoint' => 'https://fcm.googleapis.com/wp/first-'.$i])->all());
+ $other = User::factory()->create();
+ ConversationParticipant::factory()->create(['conversation_id' => $this->message->conversation_id, 'user_id' => $other->id]);
+ $other->pushSubscriptions()->createMany(collect(range(1, 20))->map(fn (int $i): array => ['endpoint_hash' => 'other-'.$i, 'endpoint' => 'https://fcm.googleapis.com/wp/other-'.$i])->all());
+
+ resolve(CreateMessage::class)->handle($this->message->user, $this->message->conversation, 'new message', Str::uuid()->toString(), linkPreview: false);
+ $this->travel(11)->seconds();
+ $this->artisan('queue:work', ['connection' => 'database', '--once' => true])->assertSuccessful();
+
+ Http::assertSentCount(20);
+ $this->assertDatabaseCount('jobs', 1);
+
+ $this->artisan('queue:work', ['connection' => 'database', '--once' => true])->assertSuccessful();
+
+ Http::assertSentCount(40);
+ $this->assertDatabaseCount('jobs', 0);
+ $this->assertDatabaseCount('failed_jobs', 0);
});
it('retries temporary push failures without keeping or reporting the endpoint', function (int $status, bool $connectionFails): void {
$this->pushStatus = $status;
$this->connectionFails = $connectionFails;
Queue::fake();
- $job = new SendRemotePush($this->message->id);
+ $job = new SendRemotePush($this->message->id, $this->recipient->id);
$job->handle(resolve(RemotePush::class));
$this->assertModelExists($this->subscription);
- Queue::assertPushed(SendRemotePush::class, fn (SendRemotePush $retry): bool => $retry->messageId === $this->message->id
+ Queue::assertPushed(SendRemotePush::class, fn (SendRemotePush $retry): bool => $retry->messageId === $this->message->id && $retry->userId === $this->recipient->id
&& $retry->subscriptionIds === [$this->subscription->id] && $retry->tries === 2 && $retry->delay->equalTo(now()->addSeconds(30)));
})->with([[500, false], [429, false], [201, true]]);
@@ -120,7 +159,9 @@
ConversationParticipant::factory()->create(['conversation_id' => $this->message->conversation_id, 'user_id' => $other->id]);
$other->pushSubscriptions()->create(['endpoint_hash' => 'other', 'endpoint' => 'https://fcm.googleapis.com/wp/other']);
$this->pushStatuses['https://fcm.googleapis.com/wp/recipient'] = 500;
- dispatch(new SendRemotePush($this->message->id)->onConnection('database')->onQueue('push-retries'));
+ dispatch(new SendRemotePush($this->message->id, $this->recipient->id)->onConnection('database')->onQueue('push-retries'));
+ dispatch(new SendRemotePush($this->message->id, $other->id)->onConnection('database')->onQueue('push-other'));
+ $this->artisan('queue:work', ['connection' => 'database', '--queue' => 'push-other', '--once' => true])->assertSuccessful();
$this->artisan('queue:work', ['connection' => 'database', '--queue' => 'push-retries', '--once' => true])->assertSuccessful();
Http::assertSentCount(3);
@@ -138,7 +179,7 @@
it('stops temporary retries within the original three-attempt budget', function (int $previousAttempts): void {
$this->pushStatus = 500;
- dispatch(new SendRemotePush($this->message->id)->onConnection('database')->onQueue('push-retries'));
+ dispatch(new SendRemotePush($this->message->id, $this->recipient->id)->onConnection('database')->onQueue('push-retries'));
DB::table('jobs')->where('queue', 'push-retries')->update(['attempts' => $previousAttempts]);
for ($attempt = $previousAttempts; $attempt < 3; $attempt++) {
@@ -153,7 +194,7 @@
it('rechecks eligibility and enrollment before a failed browser retry', function (string $change): void {
$this->pushStatus = 500;
- dispatch(new SendRemotePush($this->message->id)->onConnection('database')->onQueue('push-retries'));
+ dispatch(new SendRemotePush($this->message->id, $this->recipient->id)->onConnection('database')->onQueue('push-retries'));
$this->artisan('queue:work', ['connection' => 'database', '--queue' => 'push-retries', '--once' => true])->assertSuccessful();
match ($change) {
@@ -172,6 +213,6 @@
it('uses Windows raw push headers for Microsoft Edge endpoints', function (): void {
Http::fake(['https://wns2-example.notify.windows.com/*' => Http::response('', 201)]);
$this->recipient->pushSubscriptions()->create(['endpoint_hash' => 'edge', 'endpoint' => 'https://wns2-example.notify.windows.com/w/?token=example']);
- new SendRemotePush($this->message->id)->handle(resolve(RemotePush::class));
+ new SendRemotePush($this->message->id, $this->recipient->id)->handle(resolve(RemotePush::class));
Http::assertSent(fn (Request $request): bool => str_contains($request->url(), '.notify.windows.com/') && $request->header('X-WNS-Type') === ['wns/raw'] && $request->header('Content-Type') === ['application/octet-stream'] && $request->header('Urgency') === [] && $request->body() === '');
});
From db19efd84f22517d6e04f4cd79d7a6be177cdb53 Mon Sep 17 00:00:00 2001
From: Mark
Date: Sun, 25 Oct 2026 04:54:46 +0100
Subject: [PATCH 16/16] fix: serialize browser push consent across tabs
---
resources/js/lib/remote-notifications.ts | 37 +--
resources/js/lib/remote-push.ts | 223 ++++++++++--------
tests/Unit/RemotePush.test.tsx | 84 +++++--
tests/Unit/RemotePushAccountIsolation.test.ts | 79 ++++++-
4 files changed, 272 insertions(+), 151 deletions(-)
diff --git a/resources/js/lib/remote-notifications.ts b/resources/js/lib/remote-notifications.ts
index 4ca4fce23..8898908ec 100644
--- a/resources/js/lib/remote-notifications.ts
+++ b/resources/js/lib/remote-notifications.ts
@@ -1,7 +1,7 @@
import {
- RemotePushRevocationError,
remotePushEnabled,
- remotePushSupported,
+ remotePushOptedIn,
+ setRemotePush,
syncRemotePush,
} from '@/lib/remote-push';
import { readStorage, writeStorage } from '@/lib/storage';
@@ -17,8 +17,7 @@ export function remoteAlertsEnabled(
userId: string,
channel: RemoteAlertChannel = 'notifications',
): boolean {
- if (channel === 'offsite')
- return readStorage(preferenceKey(userId, channel)) === 'on';
+ if (channel === 'offsite') return remotePushOptedIn(userId);
return (
(readStorage(preferenceKey(userId, channel)) ||
@@ -69,28 +68,10 @@ export async function setRemoteAlerts(
channel: RemoteAlertChannel = 'notifications',
): Promise {
if (channel === 'offsite') {
- if (enabled) {
- if (!remotePushSupported())
- throw new Error(
- 'Off-site notifications are not supported here.',
- );
- const permission = await Notification.requestPermission();
- if (permission !== 'granted')
- throw new Error('Notification permission was not granted.');
- const previous = readStorage(preferenceKey(userId, channel));
- writeStorage(preferenceKey(userId, channel), 'on');
- if (readStorage(preferenceKey(userId, channel)) !== 'on')
- return false;
- try {
- await syncRemotePush(userId, true, true);
- } catch (error) {
- if (!(error instanceof RemotePushRevocationError))
- writeStorage(preferenceKey(userId, channel), previous);
- listeners.forEach((listener) => listener());
- throw error;
- }
- } else {
- await syncRemotePush(userId, false, true);
+ try {
+ return await setRemotePush(userId, enabled);
+ } finally {
+ listeners.forEach((listener) => listener());
}
}
@@ -116,9 +97,7 @@ export function createRemoteAlerts(
reading: () => string[],
onIncoming: (conversation: ConversationSummary) => void,
) {
- void syncRemotePush(userId, remoteAlertsEnabled(userId, 'offsite')).catch(
- () => undefined,
- );
+ void syncRemotePush(userId).catch(() => undefined);
let latest: Record | null = null;
let stopped = false;
let lastSoundAt = 0;
diff --git a/resources/js/lib/remote-push.ts b/resources/js/lib/remote-push.ts
index 249b6235c..b96d181a4 100644
--- a/resources/js/lib/remote-push.ts
+++ b/resources/js/lib/remote-push.ts
@@ -3,15 +3,20 @@ import {
index,
store,
} from '@/actions/App/Http/Controllers/PushSubscriptionController';
+import { readStorage, writeStorage } from '@/lib/storage';
class RemotePushRejectedError extends Error {}
export class RemotePushValidationError extends RemotePushRejectedError {}
-export class RemotePushRevocationError extends RemotePushValidationError {}
+class RemotePushRevocationError extends RemotePushValidationError {}
let active = false;
-let pending = Promise.resolve();
+const preferenceKey = (userId: string) => `remote-alerts:${userId}:offsite`;
+
+export function remotePushOptedIn(userId: string): boolean {
+ return readStorage(preferenceKey(userId)) === 'on';
+}
export function remotePushEnabled(): boolean {
return active;
@@ -22,6 +27,7 @@ export function remotePushSupported(): boolean {
window.isSecureContext &&
'Notification' in window &&
'PushManager' in window &&
+ 'locks' in navigator &&
'serviceWorker' in navigator
);
}
@@ -84,114 +90,139 @@ function pushResult(operation: Promise): Promise {
);
}
+/** Recheck consent after acquiring the same origin-wide lock used by explicit choices. */
+export function syncRemotePush(userId: string): Promise {
+ if (!remotePushSupported()) return Promise.resolve();
+ return navigator.locks.request(
+ 'remote-push',
+ { signal: AbortSignal.timeout(10_000) },
+ () => updateRemotePush(userId, remotePushOptedIn(userId)),
+ );
+}
+
+export async function setRemotePush(
+ userId: string,
+ enabled: boolean,
+): Promise {
+ if (!remotePushSupported())
+ throw new Error('Off-site notifications are not supported here.');
+ if (enabled) {
+ const permission = await Notification.requestPermission();
+ if (permission !== 'granted')
+ throw new Error('Notification permission was not granted.');
+ }
+
+ // Keep preference commits inside the lock so another tab cannot enroll after a successful opt-out.
+ return navigator.locks.request(
+ 'remote-push',
+ { signal: AbortSignal.timeout(10_000) },
+ async () => {
+ const key = preferenceKey(userId);
+ const previous = readStorage(key);
+ if (enabled) {
+ writeStorage(key, 'on');
+ if (!remotePushOptedIn(userId)) return false;
+ try {
+ await updateRemotePush(userId, true, true);
+ } catch (error) {
+ if (!(error instanceof RemotePushRevocationError))
+ writeStorage(key, previous);
+ throw error;
+ }
+ } else {
+ await updateRemotePush(userId, false, true);
+ writeStorage(key, 'off');
+ }
+ return remotePushOptedIn(userId) === enabled;
+ },
+ );
+}
+
/** Reuse an existing subscription on load; only a user gesture may create one. */
-export function syncRemotePush(
+async function updateRemotePush(
userId: string,
enabled: boolean,
create = false,
): Promise {
- const sync = async () => {
- if (
- !window.isSecureContext ||
- !('serviceWorker' in navigator) ||
- !('PushManager' in window)
- )
- return;
- if (Notification.permission !== 'granted' && (!create || enabled))
+ if (Notification.permission !== 'granted' && (!create || enabled)) return;
+ let publicKey: string | null = null;
+ if (enabled) {
+ const config = (await (await request(index.url(), userId)).json()) as {
+ public_key: string | null;
+ };
+ publicKey = config.public_key;
+ if (!publicKey) {
+ active = false;
+ if (create)
+ throw new Error('Background notifications are unavailable.');
return;
- let publicKey: string | null = null;
- if (enabled) {
- const config = (await (
- await request(index.url(), userId)
- ).json()) as {
- public_key: string | null;
- };
- publicKey = config.public_key;
- if (!publicKey) {
- active = false;
- if (create)
- throw new Error(
- 'Background notifications are unavailable.',
- );
- return;
- }
}
- let registration = await pushResult(
- navigator.serviceWorker.getRegistration(),
+ }
+ let registration = await pushResult(
+ navigator.serviceWorker.getRegistration(),
+ );
+ if (!registration && create && enabled) {
+ await pushResult(
+ navigator.serviceWorker.register('/sw.js', {
+ scope: '/',
+ updateViaCache: 'none',
+ }),
);
- if (!registration && create && enabled) {
- await pushResult(
- navigator.serviceWorker.register('/sw.js', {
- scope: '/',
- updateViaCache: 'none',
+ registration = await pushResult(navigator.serviceWorker.ready);
+ }
+ if (!registration) return;
+ let subscription = await pushResult(
+ registration.pushManager.getSubscription(),
+ );
+ if (!subscription) active = false;
+ if (enabled && publicKey) {
+ if (!subscription && create) {
+ const key = publicKey.replace(/-/g, '+').replace(/_/g, '/');
+ subscription = await pushResult(
+ registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: Uint8Array.from(
+ atob(key.padEnd(Math.ceil(key.length / 4) * 4, '=')),
+ (char) => char.charCodeAt(0),
+ ),
}),
);
- registration = await pushResult(navigator.serviceWorker.ready);
}
- if (!registration) return;
- let subscription = await pushResult(
- registration.pushManager.getSubscription(),
- );
- if (!subscription) active = false;
- if (enabled && publicKey) {
- if (!subscription && create) {
- const key = publicKey.replace(/-/g, '+').replace(/_/g, '/');
- subscription = await pushResult(
- registration.pushManager.subscribe({
- userVisibleOnly: true,
- applicationServerKey: Uint8Array.from(
- atob(
- key.padEnd(Math.ceil(key.length / 4) * 4, '='),
- ),
- (char) => char.charCodeAt(0),
- ),
- }),
+ if (subscription) {
+ try {
+ await request(
+ store.url(),
+ userId,
+ 'POST',
+ subscription.endpoint,
);
- }
- if (subscription) {
- try {
- await request(
- store.url(),
- userId,
- 'POST',
- subscription.endpoint,
- );
- } catch (error) {
- if (error instanceof RemotePushRejectedError) {
- active = false;
- throw error;
- }
- if (create) {
- active = false;
- // A failed opt-in may have been saved before its response was lost.
- try {
- await request(
- destroy.url(),
- userId,
- 'DELETE',
- subscription.endpoint,
- );
- } catch {
- throw new RemotePushRevocationError(
- 'Could not turn off-site notifications off. Please try again.',
- );
- }
- }
+ } catch (error) {
+ if (error instanceof RemotePushRejectedError) {
+ active = false;
throw error;
}
- active = true;
+ if (create) {
+ active = false;
+ // A failed opt-in may have been saved before its response was lost.
+ try {
+ await request(
+ destroy.url(),
+ userId,
+ 'DELETE',
+ subscription.endpoint,
+ );
+ } catch {
+ throw new RemotePushRevocationError(
+ 'Could not turn off-site notifications off. Please try again.',
+ );
+ }
+ }
+ throw error;
}
- } else if (subscription) {
- await request(
- destroy.url(),
- userId,
- 'DELETE',
- subscription.endpoint,
- );
- active = false;
+ active = true;
}
- };
- const result = pending.then(sync);
- pending = result.catch(() => undefined);
- return result;
+ } else if (subscription) {
+ await request(destroy.url(), userId, 'DELETE', subscription.endpoint);
+ active = false;
+ }
}
diff --git a/tests/Unit/RemotePush.test.tsx b/tests/Unit/RemotePush.test.tsx
index a4b4ea139..076e26155 100644
--- a/tests/Unit/RemotePush.test.tsx
+++ b/tests/Unit/RemotePush.test.tsx
@@ -1,7 +1,7 @@
import { remoteAlertsEnabled, setRemoteAlerts } from '../../resources/js/lib/remote-notifications';
import assert from 'node:assert/strict';
import { afterEach, beforeEach, test, vi } from 'vite-plus/test';
-import { remotePushEnabled, syncRemotePush } from '../../resources/js/lib/remote-push';
+import { remotePushEnabled, setRemotePush, syncRemotePush } from '../../resources/js/lib/remote-push';
const endpoint = 'https://fcm.googleapis.com/wp/browser';
const save = vi.fn(async (_url: string, _options: RequestInit) => new Response(null, { status: 204 }));
@@ -12,10 +12,14 @@ const registration = { pushManager: { getSubscription: async () => subscription,
beforeEach(() => {
subscription = null;
+ let pending: Promise = Promise.resolve();
+ const locks = { request: (_name: string, _options: LockOptions, run: () => Promise) => { const result = pending.then(run); pending = result.catch(() => undefined); return result; } };
vi.stubGlobal('window', { isSecureContext: true, PushManager: class {}, Notification: {} });
+ const storage = preferenceStorage();
+ storage.set('remote-alerts:me:offsite', 'on');
vi.stubGlobal('Notification', { permission: 'granted', requestPermission: vi.fn(async () => 'granted') });
vi.stubGlobal('document', { cookie: 'XSRF-TOKEN=csrf%20token' });
- vi.stubGlobal('navigator', { serviceWorker: { getRegistration: async () => registration, register: vi.fn(async () => registration), ready: Promise.resolve(registration) } });
+ vi.stubGlobal('navigator', { locks, serviceWorker: { getRegistration: async () => registration, register: vi.fn(async () => registration), ready: Promise.resolve(registration) } });
vi.stubGlobal('fetch', async (url: string, options: RequestInit) => url.endsWith('/push-subscriptions') && options.method === 'GET'
? Response.json({ public_key: 'BA' }) : save(url, options));
save.mockReset(); save.mockImplementation(async () => new Response(null, { status: 204 })); subscribe.mockClear(); unsubscribe.mockClear();
@@ -24,14 +28,14 @@ beforeEach(() => {
afterEach(() => {vi.unstubAllGlobals();vi.restoreAllMocks();vi.useRealTimers();});
test('does not subscribe or request permission on page load', async () => {
- await syncRemotePush('me', true);
+ await syncRemotePush('me');
assert.equal(subscribe.mock.calls.length, 0);
assert.equal(save.mock.calls.length, 0);
assert.equal(remotePushEnabled(), false);
});
test('creates an opt-in subscription bound to its initiating account without encryption secrets', async () => {
- await syncRemotePush('me', true, true);
+ await setRemotePush('me', true);
assert.equal(remotePushEnabled(), true);
assert.equal(subscribe.mock.calls.length, 1);
const [, options] = save.mock.calls[0];
@@ -42,7 +46,7 @@ test('creates an opt-in subscription bound to its initiating account without enc
test('reassociates an existing browser subscription without creating another', async () => {
subscription = { endpoint, unsubscribe };
- await syncRemotePush('me', true);
+ await syncRemotePush('me');
assert.equal(subscribe.mock.calls.length, 0);
assert.equal(save.mock.calls.length, 1);
assert.equal(remotePushEnabled(), true);
@@ -54,14 +58,14 @@ test('a transient passive registration failure preserves opt-in and recovers on
const enrolled = subscription;
save.mockImplementationOnce(async () => new Response(null, { status: 503 }));
- await assert.rejects(syncRemotePush('me', true));
+ await assert.rejects(syncRemotePush('me'));
assert.equal(subscription, enrolled);
assert.equal(unsubscribe.mock.calls.length, 0);
assert.equal(save.mock.calls.filter(([, options]) => options.method === 'DELETE').length, 0);
assert.equal(remoteAlertsEnabled('me', 'offsite'), true);
assert.equal(remotePushEnabled(), true);
- await syncRemotePush('me', true);
+ await syncRemotePush('me');
assert.equal(save.mock.calls.at(-1)?.[1].method, 'POST');
assert.equal(subscribe.mock.calls.length, 1);
assert.equal(remotePushEnabled(), true);
@@ -71,7 +75,7 @@ test('a transient passive registration failure preserves opt-in and recovers on
test('turning notifications off revokes server delivery while retaining the shared browser subscription', async () => {
subscription = { endpoint, unsubscribe };
const existing = subscription;
- await syncRemotePush('me', false, true);
+ await setRemotePush('me', false);
assert.equal(save.mock.calls[0][1].method, 'DELETE');
assert.equal(subscription, existing);
assert.equal(unsubscribe.mock.calls.length, 0);
@@ -81,30 +85,30 @@ test('turning notifications off revokes server delivery while retaining the shar
test('failed server deletion keeps the subscription so disabling can be retried', async () => {
subscription = { endpoint, unsubscribe };
save.mockImplementationOnce(async () => new Response(null, { status: 503 }));
- await assert.rejects(syncRemotePush('me', false, true));
+ await assert.rejects(setRemotePush('me', false));
assert.equal(unsubscribe.mock.calls.length, 0);
- await syncRemotePush('me', false, true);
+ await setRemotePush('me', false);
assert.equal(unsubscribe.mock.calls.length, 0);
});
test('missing server configuration reports failure rather than enabling background delivery', async () => {
vi.stubGlobal('fetch', async () => Response.json({ public_key: null }));
- await assert.rejects(syncRemotePush('me', true, true));
+ await assert.rejects(setRemotePush('me', true));
assert.equal(remotePushEnabled(), false);
assert.equal(subscribe.mock.calls.length, 0);
});
test('registers a service worker before the first opt-in', async () => {
const register = vi.fn(async () => registration);
- vi.stubGlobal('navigator', { serviceWorker: { getRegistration: async () => undefined, register, ready: Promise.resolve(registration) } });
- await syncRemotePush('me', true, true);
+ vi.stubGlobal('navigator', { locks: navigator.locks, serviceWorker: { getRegistration: async () => undefined, register, ready: Promise.resolve(registration) } });
+ await setRemotePush('me', true);
assert.equal(register.mock.calls.length, 1);
assert.equal(remotePushEnabled(), true);
});
-test('unsupported browsers retain their page alert fallback', async () => {
+test('unsupported browsers reject off-site enrollment and retain their page alert fallback', async () => {
vi.stubGlobal('window', { isSecureContext: true });
- await syncRemotePush('me', true, true);
+ await assert.rejects(setRemotePush('me', true), /not supported/);
assert.equal(save.mock.calls.length, 0);
});
@@ -122,11 +126,11 @@ test('page alerts can be re-enabled even when background delivery is unavailable
test('a stalled browser operation times out without saving and the next attempt can recover', async () => {
vi.useFakeTimers();
vi.spyOn(registration.pushManager, 'getSubscription').mockImplementationOnce(() => new Promise(() => {}));
- const rejected = assert.rejects(syncRemotePush('me', true, true), /browser did not respond/);
+ const rejected = assert.rejects(setRemotePush('me', true), /browser did not respond/);
await vi.advanceTimersByTimeAsync(10_001);
await rejected;
assert.equal(save.mock.calls.length, 0);
- await syncRemotePush('me', true, true);
+ await setRemotePush('me', true);
assert.equal(save.mock.calls.length, 1);
assert.equal(remotePushEnabled(), true);
});
@@ -134,11 +138,11 @@ test('a stalled browser operation times out without saving and the next attempt
test('does not query native subscriptions before permission or server configuration is ready', async () => {
const query = vi.spyOn(registration.pushManager, 'getSubscription');
vi.stubGlobal('Notification', {permission: 'default'});
- await syncRemotePush('me', true);
+ await syncRemotePush('me');
assert.equal(query.mock.calls.length, 0);
vi.stubGlobal('Notification', {permission: 'granted'});
vi.stubGlobal('fetch', async () => Response.json({public_key: null}));
- await assert.rejects(syncRemotePush('me', true, true));
+ await assert.rejects(setRemotePush('me', true));
assert.equal(query.mock.calls.length, 0);
});
@@ -187,6 +191,41 @@ test('blocked preference storage cannot activate off-site delivery', async () =>
assert.equal(save.mock.calls.length, 0);
});
+test('a failed browser lock preserves consent and a later opt-out can retry', async () => {
+ subscription = { endpoint, unsubscribe };
+ const lock = vi.spyOn(navigator.locks, 'request');
+ lock.mockRejectedValueOnce(new DOMException('Lock wait expired', 'AbortError'));
+ await assert.rejects(setRemoteAlerts('me', false, 'offsite'));
+ assert.equal(remoteAlertsEnabled('me', 'offsite'), true);
+ assert.equal(save.mock.calls.length, 0);
+
+ assert.equal(await setRemoteAlerts('me', false, 'offsite'), true);
+ assert.equal(remoteAlertsEnabled('me', 'offsite'), false);
+ assert.equal(save.mock.calls[0][1].method, 'DELETE');
+});
+
+test('browsers without shared locking cannot enroll off-site delivery', async () => {
+ Reflect.deleteProperty(navigator, 'locks');
+ await assert.rejects(setRemoteAlerts('me', true, 'offsite'), /not supported/);
+ assert.equal(subscribe.mock.calls.length, 0);
+ assert.equal(save.mock.calls.length, 0);
+});
+
+test('passive work queued behind opt-out reads the committed off preference', async () => {
+ subscription = { endpoint, unsubscribe };
+ let resume!: () => void;
+ const gate = new Promise((resolve) => { resume = resolve; });
+ const query = vi.spyOn(registration.pushManager, 'getSubscription').mockImplementationOnce(async () => { await gate; return subscription; });
+ const disabling = setRemoteAlerts('me', false, 'offsite');
+ await vi.waitFor(() => assert.equal(query.mock.calls.length, 1));
+ const passive = syncRemotePush('me');
+ resume();
+ await Promise.all([disabling, passive]);
+
+ assert.equal(remoteAlertsEnabled('me', 'offsite'), false);
+ assert.deepEqual(save.mock.calls.map(([, options]) => options.method), ['DELETE', 'DELETE']);
+});
+
test('a full account reports the enrollment limit and leaves this browser opted out', async () => {
preferenceStorage();
@@ -200,12 +239,13 @@ test('a full account reports the enrollment limit and leaves this browser opted
for (const phase of ['permission', 'subscription', 'queue'] as const) {
test(`an account switch during ${phase} cannot apply another account's consent`, async () => {
- const storage = preferenceStorage();
+ preferenceStorage();
let current = 'account-a';
let owner: string | null = null;
let resume!: () => void;
const gate = new Promise((resolve) => { resume = resolve; });
let blocker: Promise | undefined;
+ let blocked = false;
save.mockImplementation(async (_url, options) => {
const body = JSON.parse(options.body as string) as { user_id?: string };
if (body.user_id !== current) return Response.json({ message: 'Your signed-in account changed.' }, { status: 403 });
@@ -217,13 +257,13 @@ for (const phase of ['permission', 'subscription', 'queue'] as const) {
} else if (phase === 'subscription') {
subscribe.mockImplementationOnce(async () => { await gate; return subscription = { endpoint, unsubscribe }; });
} else {
- vi.spyOn(navigator.serviceWorker, 'getRegistration').mockImplementationOnce(async () => { await gate; return undefined; });
+ vi.spyOn(navigator.serviceWorker, 'getRegistration').mockImplementationOnce(async () => { blocked = true; await gate; return undefined; });
blocker = setRemoteAlerts('blocker', false, 'offsite');
}
const enabling = setRemoteAlerts('account-a', true, 'offsite');
const rejected = assert.rejects(enabling);
if (phase === 'subscription') await vi.waitFor(() => assert.equal(subscribe.mock.calls.length, 1));
- if (phase === 'queue') await vi.waitFor(() => assert.equal(storage.get('remote-alerts:account-a:offsite'), 'on'));
+ if (phase === 'queue') await vi.waitFor(() => assert.equal(blocked, true));
current = 'account-b';
resume();
if (blocker) await blocker;
diff --git a/tests/Unit/RemotePushAccountIsolation.test.ts b/tests/Unit/RemotePushAccountIsolation.test.ts
index 6a8d1be55..c4a56cc46 100644
--- a/tests/Unit/RemotePushAccountIsolation.test.ts
+++ b/tests/Unit/RemotePushAccountIsolation.test.ts
@@ -3,6 +3,7 @@ import { existsSync, statSync } from 'node:fs';
import { registerHooks } from 'node:module';
import { test } from 'node:test';
import { fileURLToPath, pathToFileURL } from 'node:url';
+import { locks } from 'node:worker_threads';
const resources = new URL('../../resources/js/', import.meta.url);
@@ -60,7 +61,7 @@ for (const cleanup of [false, true]) {
} },
document: { cookie: 'XSRF-TOKEN=fixture' },
Notification: { permission: 'granted', requestPermission: async () => 'granted' },
- navigator: { serviceWorker: { getRegistration: async () => registration } },
+ navigator: { locks, serviceWorker: { getRegistration: async () => registration } },
fetch: async (_url: string, options: RequestInit) => {
if (options.method === 'GET') return Response.json({ public_key: 'BA' });
assert.ok(typeof options.body === 'string');
@@ -89,15 +90,17 @@ for (const cleanup of [false, true]) {
const disabling = a.setRemoteAlerts('account-a', cleanup, 'offsite').then(() => 'resolved', () => 'rejected');
await deletion;
current = 'account-b';
- await b.setRemoteAlerts('account-b', true, 'offsite');
- assert.equal(owner, 'account-b');
+ const enrolling = b.setRemoteAlerts('account-b', true, 'offsite');
+ await new Promise((resolve) => setImmediate(resolve));
releaseDelete();
+ await enrolling;
+ assert.equal(owner, 'account-b');
assert.equal(await disabling, cleanup ? 'rejected' : 'resolved');
assert.equal(subscription, originalSubscription);
assert.equal(unsubscribes, 0);
assert.equal(b.remoteAlertsEnabled('account-b', 'offsite'), true);
const beforeReconcile = posts;
- await bPush.syncRemotePush('account-b', true);
+ await bPush.syncRemotePush('account-b');
assert.equal(posts, beforeReconcile + 1);
assert.equal(owner, 'account-b');
assert.equal(bPush.remotePushEnabled(), true);
@@ -109,3 +112,71 @@ for (const cleanup of [false, true]) {
}
});
}
+
+for (const phase of ['native', 'request'] as const) void test(`a stopped passive watcher awaiting ${phase} cannot re-enroll after another tab opts out with all channels off`, { timeout: 5_000 }, async () => {
+ const originals = new Map(['window', 'document', 'navigator', 'Notification', 'Audio', 'fetch'].map(
+ (key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const,
+ ));
+ const storage = new Map([
+ ['remote-alerts:me:offsite', 'on'],
+ ['remote-alerts:me:notifications', 'off'],
+ ['remote-alerts:me:sound', 'off'],
+ ]);
+ let owner: string | null = 'me';
+ let resume!: () => void;
+ let nativeStarted!: () => void;
+ let postCompleted!: () => void;
+ const native = new Promise((resolve) => { nativeStarted = resolve; });
+ const held = new Promise((resolve) => { resume = resolve; });
+ const posted = new Promise((resolve) => { postCompleted = resolve; });
+ let hold = true;
+ const events = new EventTarget();
+ const globals = {
+ window: Object.assign(new EventTarget(), { isSecureContext: true, PushManager: class {}, Notification: {}, setInterval, clearInterval, localStorage: {
+ getItem: (key: string) => storage.get(key) ?? null,
+ setItem: (key: string, value: string) => storage.set(key, value),
+ removeItem: (key: string) => storage.delete(key),
+ } }),
+ document: Object.assign(events, { cookie: '' }),
+ Notification: { permission: 'granted', requestPermission: async () => 'granted' },
+ Audio: class { pause() {} },
+ navigator: { locks, serviceWorker: { getRegistration: async () => ({ pushManager: { getSubscription: async () => {
+ if (phase === 'native' && hold) { hold = false; nativeStarted(); await held; }
+ return { endpoint: 'https://fcm.googleapis.com/wp/shared', unsubscribe: async () => true };
+ } } }) } },
+ fetch: async (_url: string, options: RequestInit) => {
+ if (options.method === 'GET') return Response.json({ public_key: 'BA' });
+ if (phase === 'request' && options.method === 'POST') { nativeStarted(); await held; }
+ owner = options.method === 'POST' ? 'me' : null;
+ if (options.method === 'POST') postCompleted();
+ return new Response(null, { status: 204 });
+ },
+ };
+ for (const [key, value] of Object.entries(globals))
+ Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
+ let watcher: ReturnType | undefined;
+ try {
+ const a = await import(new URL(`lib/remote-notifications.ts?tab=stale-a-${phase}`, resources).href) as typeof import('../../resources/js/lib/remote-notifications');
+ const b = await import(new URL(`lib/remote-notifications.ts?tab=stale-b-${phase}`, resources).href) as typeof import('../../resources/js/lib/remote-notifications');
+ watcher = a.createRemoteAlerts('me', () => '/remotes', () => [], () => {});
+ await native;
+ const disabling = b.setRemoteAlerts('me', false, 'offsite');
+ await new Promise((resolve) => setImmediate(resolve));
+ watcher.stop();
+ resume();
+ await posted;
+ await disabling;
+ await new Promise((resolve) => setImmediate(resolve));
+ assert.equal(b.remoteAlertsEnabled('me', 'offsite'), false);
+ assert.equal(b.remoteAlertsEnabled('me', 'notifications'), false);
+ assert.equal(b.remoteAlertsEnabled('me', 'sound'), false);
+ assert.equal(owner, null);
+ } finally {
+ resume();
+ watcher?.stop();
+ for (const [key, descriptor] of originals) {
+ if (descriptor) Object.defineProperty(globalThis, key, descriptor);
+ else Reflect.deleteProperty(globalThis, key);
+ }
+ }
+});