From cedb04f9fd02cdc894069de6968834ba5d1f88e8 Mon Sep 17 00:00:00 2001 From: Mark Date: Sun, 25 Oct 2026 02:59:54 +0200 Subject: [PATCH 01/16] feat: deliver remotes notifications with web push --- .env.example | 4 + KNOWLEDGE_BASE.md | 2 +- PRODUCT.md | 4 +- app/Actions/CreateExport.php | 5 + app/Actions/CreateMessage.php | 5 + app/Actions/CreatePushSubscription.php | 20 + app/Actions/DeletePushSubscription.php | 16 + app/Actions/UpdateUserRemotes.php | 4 + .../PushSubscriptionController.php | 40 ++ app/Http/Controllers/SessionController.php | 9 +- .../CreatePushSubscriptionRequest.php | 28 + .../DeletePushSubscriptionRequest.php | 23 + app/Jobs/SendRemotePush.php | 65 ++ app/Models/PushSubscription.php | 41 ++ app/Models/User.php | 9 + app/Rules/PushEndpoint.php | 31 + app/Services/RemotePush.php | 45 ++ composer.json | 1 + composer.lock | 614 +++++++++++++++++- config/services.php | 6 + .../factories/PushSubscriptionFactory.php | 29 + ...120000_create_push_subscriptions_table.php | 21 + docs/web-push.md | 21 + phpunit.xml | 2 + public/sw.js | 12 +- resources/js/components/remotes-setting.tsx | 45 +- resources/js/hooks/use-chat.ts | 18 + resources/js/lib/remote-notifications.ts | 20 +- resources/js/lib/remote-push.ts | 111 ++++ routes/web.php | 5 + .../ConversationMessageControllerTest.php | 2 +- .../Controllers/ExportControllerTest.php | 9 + .../PushSubscriptionControllerTest.php | 67 ++ tests/Unit/Actions/DeleteUserTest.php | 8 + tests/Unit/Jobs/SendRemotePushTest.php | 107 +++ tests/Unit/RemotePush.test.tsx | 101 +++ tests/Unit/RemotePushWorker.test.ts | 62 ++ tests/draft-images.config.ts | 1 + tests/tsconfig.json | 1 + 39 files changed, 1591 insertions(+), 23 deletions(-) create mode 100644 app/Actions/CreatePushSubscription.php create mode 100644 app/Actions/DeletePushSubscription.php create mode 100644 app/Http/Controllers/PushSubscriptionController.php create mode 100644 app/Http/Requests/CreatePushSubscriptionRequest.php create mode 100644 app/Http/Requests/DeletePushSubscriptionRequest.php create mode 100644 app/Jobs/SendRemotePush.php create mode 100644 app/Models/PushSubscription.php create mode 100644 app/Rules/PushEndpoint.php create mode 100644 app/Services/RemotePush.php create mode 100644 database/factories/PushSubscriptionFactory.php create mode 100644 database/migrations/2026_10_25_120000_create_push_subscriptions_table.php create mode 100644 docs/web-push.md create mode 100644 resources/js/lib/remote-push.ts create mode 100644 tests/Feature/Controllers/PushSubscriptionControllerTest.php create mode 100644 tests/Unit/Jobs/SendRemotePushTest.php create mode 100644 tests/Unit/RemotePush.test.tsx create mode 100644 tests/Unit/RemotePushWorker.test.ts diff --git a/.env.example b/.env.example index 71a4d6b48..ae94f08c7 100644 --- a/.env.example +++ b/.env.example @@ -93,3 +93,7 @@ VITE_APP_NAME="${APP_NAME}" REMOTES_REQUESTS=false REMOTES_EVERYONE=false + +WEB_PUSH_SUBJECT=https://dev-null.sh +WEB_PUSH_PUBLIC_KEY= +WEB_PUSH_PRIVATE_KEY= diff --git a/KNOWLEDGE_BASE.md b/KNOWLEDGE_BASE.md index d81ae1a83..5ab3a4354 100644 --- a/KNOWLEDGE_BASE.md +++ b/KNOWLEDGE_BASE.md @@ -329,7 +329,7 @@ Pending replies, quotes, and mentions wait for approval before notifying others. Sources: [notifications page](resources/js/pages/notification/index.tsx), [notification filtering](app/Queries/NotificationBuilder.php), [grouping](app/Queries/NotificationGroupsQuery.php), [moderation notifications](app/Actions/UpdateCommitStatuses.php). -Remotes can also show **browser notifications** and play a **notification sound**, controlled independently under **Remotes** in your account's **Settings → Profile**. Both are on by default. Choose **Allow notifications** there if your browser still needs permission for banners; the site never requests permission automatically on page load. Sound does not require notification permission and can stay on when banners are off. Each choice stays saved for the signed-in account on that browser, and an earlier opt-out from combined alerts keeps both off until you change them. Alerts work only while the site is open, including background tabs where the browser allows polling; they are not push notifications after closing the site. Browsers may suspend background tabs or block sound until you interact with the page. Alerts never show message text. Only the sound has a 10-second cooldown shared across remotes and tabs; notification banners continue updating during a burst. The browser tab title shows the combined unread notification and remote message counts from the navigation, such as **(2)**, caps the display at **(99+)** above 99, and removes the prefix when nothing remains unread. The count does not depend on sound or notification permission. Alerts skip only 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, including a different remote or the Remotes inbox. Switching away lets new messages alert again, without replaying messages suppressed while you were reading that remote. Existing unread messages do not trigger alerts when you open the site. +Remotes can also show **browser notifications** and play a **notification sound**, controlled independently under **Remotes** in your account's **Settings → Profile**. Both are on by default. Choose **Allow notifications** there if your browser still needs permission for banners; the site never requests permission automatically on page load. Sound does not require notification permission and can stay on when banners are off. Each choice stays saved for the signed-in account on that browser, and an earlier opt-out from combined alerts keeps both off until you change them. Choose **Allow notifications** to also enable Web Push on a supported browser. Push alerts can arrive when the site or installed app is closed; on iPhone and iPad, install /dev/null on the Home Screen and allow notifications from that app. Delivery depends on browser and operating-system permissions, background delivery, and Focus settings. Push alerts are silent, contain no message data, and open the Remotes inbox. Sound still plays only while the site is running. Subscriptions belong to this browser and account and are removed on sign-out, account deletion, or turning Remotes off. Turning Notifications off disables push on this browser. Expired subscriptions are removed when the push service reports them. Browsers may suspend background tabs or block sound until you interact with the page. Alerts never show message text. Only the sound has a 10-second cooldown shared across remotes and tabs; notification banners continue updating during a burst. The browser tab title shows the combined unread notification and remote message counts from the navigation, such as **(2)**, caps the display at **(99+)** above 99, and removes the prefix when nothing remains unread. The count does not depend on sound or notification permission. Push delivery briefly waits and rechecks unread messages, membership, mutes, and blocks before sending. While the site is open, alerts skip only 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, including a different remote or the Remotes inbox. Switching away lets new messages alert again, without replaying messages suppressed while you were reading that remote. Existing unread messages do not trigger alerts when you open the site. With **Notifications** enabled, a new remote alert alternates the browser tab title between its normal title and **Alice in dev/null core sent you a message**, or **Alice sent you a message** for a direct remote. It switches every second for three normal/message cycles, then keeps the message title until you view the receiving remote. All already-open tabs for your account share the alert through the browser's native BroadcastChannel API; opening that remote in a visible, focused tab restores its alert titles to the normal titles and unread counts everywhere. Focusing an unrelated page or a different remote does not dismiss it. A newer alert updates the sender without restarting the blinking, and the sound cooldown does not delay it. This uses the browser's native page title and works even without permission for notification banners. Message text is never included, and muted or actively read remotes do not trigger it. diff --git a/PRODUCT.md b/PRODUCT.md index 34df477bc..eb1470e0d 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -36,7 +36,7 @@ What exists, one line each (details in the knowledge base): - **Engagement:** follows, stars, saved commits, pins, mentions, and pastéis (an opt-in daily allowance of pastries). - **Profiles:** mirrored from GitHub, with an activity graph, streaks, achievements, a Unix rank, and a live marker while you stream on YouTube. - **Remotes:** private one-to-one and group messages (orgs). -- **Notifications:** in-app activity, plus independently controlled browser and sound alerts for Remotes while the site is open, enabled by default; only browser banners require notification permission. +- **Notifications:** in-app activity, plus independently controlled browser and sound alerts for Remotes with opt-in Web Push delivery when the site or installed app is closed; sound plays while the site is open. - **Search:** developers by name, commits by meaning. - **Emails:** an occasional update the admins write to everyone, or to a filtered audience, with one-click unsubscribe. Never per-activity notifications. - **Safety:** mute, block, reports, and moderation. @@ -51,7 +51,7 @@ Rules every feature must keep: - **Store as little as possible.** Assume the database has leaked: no data without a feature that needs it, sensitive content encrypted, no secrets stored, and everything about a person erased with their account and included in their export (see `.ai/guidelines/data.blade.php`). The one deliberate exception is moderation: bans and timeouts are kept by GitHub account after deletion, holding only the GitHub id, reason, and dates, so deleting an account and signing up again can't escape or reset them. - **Same rules on every surface.** Web, mobile API, terminal, and Action share validation, moderation, visibility, and rate limits. -Deliberately excluded: sign-up or identity outside GitHub, editing commits, and email or push notifications. +Deliberately excluded: sign-up or identity outside GitHub, editing commits, and email activity notifications. ## Brand Commitments diff --git a/app/Actions/CreateExport.php b/app/Actions/CreateExport.php index 269e48405..b4ad5b244 100644 --- a/app/Actions/CreateExport.php +++ b/app/Actions/CreateExport.php @@ -12,6 +12,7 @@ use App\Models\Message; use App\Models\Poll; use App\Models\PollOption; +use App\Models\PushSubscription; use App\Models\User; use App\Models\UserTimeout; use App\Queries\CommitBuilder; @@ -48,6 +49,7 @@ * remote_mutes: array, * notifications: Generator>, * ssh_keys: array, + * push_subscriptions: array, * devices: array, * timeouts: array, * sessions: Collection @@ -139,6 +141,9 @@ public function handle(User $user): array ->all(), 'notifications' => $this->notifications($user), 'ssh_keys' => $user->sshKeys()->pluck('fingerprint')->all(), + 'push_subscriptions' => $user->pushSubscriptions()->get()->map(fn (PushSubscription $subscription): array => [ + 'id' => $subscription->id, + ])->all(), 'devices' => $user->devices()->oldest('approved_at')->get()->map(fn (Device $device): array => [ 'name' => $device->name, 'fingerprint' => $device->fingerprint, diff --git a/app/Actions/CreateMessage.php b/app/Actions/CreateMessage.php index 8eed2413b..3ae4fdfa9 100644 --- a/app/Actions/CreateMessage.php +++ b/app/Actions/CreateMessage.php @@ -5,6 +5,7 @@ namespace App\Actions; use App\Enums\RemoteStatus; +use App\Jobs\SendRemotePush; use App\Jobs\UnfurlLink; use App\Models\Conversation; use App\Models\ConversationParticipant; @@ -89,6 +90,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(); + } + ConversationParticipant::query() ->where('conversation_id', $conversation->id) ->where('user_id', $sender->id) diff --git a/app/Actions/CreatePushSubscription.php b/app/Actions/CreatePushSubscription.php new file mode 100644 index 000000000..65e006859 --- /dev/null +++ b/app/Actions/CreatePushSubscription.php @@ -0,0 +1,20 @@ +updateOrCreate( + ['endpoint_hash' => hash('sha256', $endpoint)], + ['user_id' => $user->id, 'endpoint' => $endpoint], + ); + } +} diff --git a/app/Actions/DeletePushSubscription.php b/app/Actions/DeletePushSubscription.php new file mode 100644 index 000000000..1e18e1795 --- /dev/null +++ b/app/Actions/DeletePushSubscription.php @@ -0,0 +1,16 @@ +where('user_id', $user->id)->where('endpoint_hash', $endpointHash)->delete(); + } +} diff --git a/app/Actions/UpdateUserRemotes.php b/app/Actions/UpdateUserRemotes.php index 34666dbe5..1920940b9 100644 --- a/app/Actions/UpdateUserRemotes.php +++ b/app/Actions/UpdateUserRemotes.php @@ -11,5 +11,9 @@ public function handle(User $user, bool $enabled): void { $user->update(['remotes_enabled' => $enabled]); + + if (! $enabled) { + $user->pushSubscriptions()->delete(); + } } } diff --git a/app/Http/Controllers/PushSubscriptionController.php b/app/Http/Controllers/PushSubscriptionController.php new file mode 100644 index 000000000..416893b16 --- /dev/null +++ b/app/Http/Controllers/PushSubscriptionController.php @@ -0,0 +1,40 @@ +json([ + 'public_key' => config('services.web_push.private_key') ? config('services.web_push.public_key') : null, + ])->header('Cache-Control', 'private, no-store'); + } + + public function store(CreatePushSubscriptionRequest $request, #[CurrentUser] User $user, CreatePushSubscription $action): Response + { + $subscription = $action->handle($user, $request->string('endpoint')->toString()); + $request->session()->put('push_endpoint_hash', $subscription->endpoint_hash); + + return response()->noContent(); + } + + public function destroy(DeletePushSubscriptionRequest $request, #[CurrentUser] User $user, DeletePushSubscription $action): Response + { + $action->handle($user, hash('sha256', $request->string('endpoint')->toString())); + $request->session()->forget('push_endpoint_hash'); + + return response()->noContent(); + } +} diff --git a/app/Http/Controllers/SessionController.php b/app/Http/Controllers/SessionController.php index e2f63697b..8725d47a1 100644 --- a/app/Http/Controllers/SessionController.php +++ b/app/Http/Controllers/SessionController.php @@ -5,9 +5,11 @@ namespace App\Http\Controllers; use App\Actions\CreateTomFriendship; +use App\Actions\DeletePushSubscription; use App\Actions\UpdateOrCreateUser; use App\Jobs\ImportGitHubFollowsJob; use App\Jobs\SyncGitHubSshKeysJob; +use App\Models\User; use App\Services\GitHubAccountScreening; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; @@ -68,8 +70,13 @@ public function store(Request $request, UpdateOrCreateUser $action, CreateTomFri return redirect()->intended(route('log.show', $user->log, absolute: false)); } - public function destroy(Request $request): RedirectResponse + public function destroy(Request $request, DeletePushSubscription $deletePushSubscription): RedirectResponse { + $user = $request->user(); + assert($user instanceof User); + $endpointHash = $request->session()->get('push_endpoint_hash'); + $deletePushSubscription->handle($user, is_string($endpointHash) ? $endpointHash : ''); + Auth::guard('web')->logout(); $request->session()->invalidate(); diff --git a/app/Http/Requests/CreatePushSubscriptionRequest.php b/app/Http/Requests/CreatePushSubscriptionRequest.php new file mode 100644 index 000000000..84191cf2f --- /dev/null +++ b/app/Http/Requests/CreatePushSubscriptionRequest.php @@ -0,0 +1,28 @@ +user(); + assert($user instanceof User); + + return $user->remotes_enabled && config('services.web_push.public_key') && config('services.web_push.private_key'); + } + + /** + * @return array> + */ + public function rules(): array + { + return ['endpoint' => ['required', 'string', 'max:2048', new PushEndpoint]]; + } +} diff --git a/app/Http/Requests/DeletePushSubscriptionRequest.php b/app/Http/Requests/DeletePushSubscriptionRequest.php new file mode 100644 index 000000000..181a06b2a --- /dev/null +++ b/app/Http/Requests/DeletePushSubscriptionRequest.php @@ -0,0 +1,23 @@ +> + */ + public function rules(): array + { + return ['endpoint' => ['required', 'string', 'max:2048']]; + } +} diff --git a/app/Jobs/SendRemotePush.php b/app/Jobs/SendRemotePush.php new file mode 100644 index 000000000..05223c035 --- /dev/null +++ b/app/Jobs/SendRemotePush.php @@ -0,0 +1,65 @@ +find($this->messageId); + + if (! $message instanceof Message || $message->event !== null || $message->created_at->lt(Date::now()->subMinutes(5)) + || ! config('services.web_push.public_key') || ! config('services.web_push.private_key')) { + return; + } + + $participants = ConversationParticipant::query()->where('conversation_id', $message->conversation_id) + ->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())) + ->with('user')->get(); + + $retry = false; + + foreach ($participants as $participant) { + $user = $participant->user; + + if (! $user->remotes_enabled || $user->hasBlockWith($message->user) || $user->isMuting($message->user)) { + continue; + } + + foreach (PushSubscription::query()->where('user_id', $user->id)->lazyById() as $subscription) { + if (! $push->send($subscription)) { + $retry = true; + } + } + } + + if ($retry) { + $this->release(30); + } + } +} diff --git a/app/Models/PushSubscription.php b/app/Models/PushSubscription.php new file mode 100644 index 000000000..55a662bb4 --- /dev/null +++ b/app/Models/PushSubscription.php @@ -0,0 +1,41 @@ + */ + use HasFactory; + + use HasUuids; + + /** + * @return array + */ + public function casts(): array + { + return [ + 'id' => 'string', + 'user_id' => 'string', + 'endpoint_hash' => 'string', + 'endpoint' => 'encrypted', + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 4360ca6ed..db64ec385 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -92,6 +92,7 @@ 'lane_color', 'pasteis_mode', 'remotes_enabled', + 'pushSubscriptions', 'remotes_from', 'pull_receipts', 'pasteis_received', @@ -242,6 +243,14 @@ public function sshKeys(): HasMany return $this->hasMany(SshKey::class); } + /** + * @return HasMany + */ + public function pushSubscriptions(): HasMany + { + return $this->hasMany(PushSubscription::class); + } + /** * @return HasMany */ diff --git a/app/Rules/PushEndpoint.php b/app/Rules/PushEndpoint.php new file mode 100644 index 000000000..27e5977d8 --- /dev/null +++ b/app/Rules/PushEndpoint.php @@ -0,0 +1,31 @@ + Config::string('services.web_push.subject'), + 'publicKey' => Config::string('services.web_push.public_key'), + 'privateKey' => Config::string('services.web_push.private_key'), + ]); + $endpoint = $subscription->endpoint; + $headers = VAPID::getVapidHeaders( + 'https://'.parse_url($endpoint, PHP_URL_HOST), + $auth['subject'], $auth['publicKey'], $auth['privateKey'], ContentEncoding::aes128gcm, + ); + + $windows = str_ends_with((string) parse_url($endpoint, PHP_URL_HOST), '.notify.windows.com'); + + // An empty authenticated push needs no browser encryption secret or message data. + try { + $response = Http::withHeaders([...$headers, 'TTL' => '300', 'Topic' => 'devnull-remotes', ...$windows ? ['X-WNS-Type' => 'wns/raw'] : ['Urgency' => 'normal']]) + ->withOptions(['allow_redirects' => false])->timeout(10)->withBody('', 'application/octet-stream')->post($endpoint); + } catch (ConnectionException) { + return false; + } + + if (in_array($response->status(), [404, 410], true)) { + $subscription->delete(); + } + + return ! $response->serverError() && $response->status() !== 429; + } +} diff --git a/composer.json b/composer.json index 7398bb672..13b6d5152 100644 --- a/composer.json +++ b/composer.json @@ -18,6 +18,7 @@ "laravel/socialite": "^5.31.0", "laravel/wayfinder": "^0.1.23", "league/flysystem-aws-s3-v3": "^3.35.3", + "minishlink/web-push": "^11.0", "nunomaduro/essentials": "^1.2.0", "phpseclib/phpseclib": "^4.0.2", "resend/resend-php": "^1.17", diff --git a/composer.lock b/composer.lock index df440131b..80c8c1c69 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f6112468a2fda879fb903b4c4f7c1c3d", + "content-hash": "169e47b41d0e981a7238dc3cb9e0b900", "packages": [ { "name": "aws/aws-crt-php", @@ -2761,6 +2761,85 @@ ], "time": "2026-10-16T15:19:33+00:00" }, + { + "name": "minishlink/web-push", + "version": "v11.0.0", + "source": { + "type": "git", + "url": "https://github.com/web-push-libs/web-push-php.git", + "reference": "f8410afb73486ab895bb4792b879bdca62025b47" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-push-libs/web-push-php/zipball/f8410afb73486ab895bb4792b879bdca62025b47", + "reference": "f8410afb73486ab895bb4792b879bdca62025b47", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-json": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "php": ">=8.2", + "php-http/discovery": "^1.19", + "php-http/httplug": "^2.4", + "psr/http-client": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1|^2.0", + "psr/log": "^2.0|^3.0", + "spomky-labs/base64url": "^2.0.4", + "symfony/polyfill-php83": "^1.33", + "web-token/jwt-library": "^3.4.9|^4.0.6" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^v3.92.2", + "guzzlehttp/guzzle": "^7.9.2", + "guzzlehttp/psr7": "^2.7", + "php-http/guzzle7-adapter": "^1.1", + "phpstan/phpstan": "^2.1.33", + "phpstan/phpstan-deprecation-rules": "^2.0", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^11.5.46|^12.5.2", + "symfony/polyfill-iconv": "^1.33" + }, + "suggest": { + "ext-bcmath": "Optional for performance.", + "ext-gmp": "Optional for performance.", + "php-http/guzzle7-adapter": "Enables concurrent sending via WebPush::flushPooled() if you use Guzzle." + }, + "type": "library", + "autoload": { + "psr-4": { + "Minishlink\\WebPush\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Louis Lagrange", + "email": "lagrange.louis@gmail.com", + "homepage": "https://github.com/Minishlink" + } + ], + "description": "Web Push library for PHP", + "homepage": "https://github.com/web-push-libs/web-push-php", + "keywords": [ + "Push API", + "WebPush", + "notifications", + "push", + "web" + ], + "support": { + "issues": "https://github.com/web-push-libs/web-push-php/issues", + "source": "https://github.com/web-push-libs/web-push-php/tree/v11.0.0" + }, + "time": "2026-07-23T16:10:05+00:00" + }, { "name": "monolog/monolog", "version": "3.12.1", @@ -3430,6 +3509,194 @@ }, "time": "2025-09-24T15:06:41+00:00" }, + { + "name": "php-http/discovery", + "version": "1.20.0", + "source": { + "type": "git", + "url": "https://github.com/php-http/discovery.git", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d", + "reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d", + "shasum": "" + }, + "require": { + "composer-plugin-api": "^1.0|^2.0", + "php": "^7.1 || ^8.0" + }, + "conflict": { + "nyholm/psr7": "<1.0", + "zendframework/zend-diactoros": "*" + }, + "provide": { + "php-http/async-client-implementation": "*", + "php-http/client-implementation": "*", + "psr/http-client-implementation": "*", + "psr/http-factory-implementation": "*", + "psr/http-message-implementation": "*" + }, + "require-dev": { + "composer/composer": "^1.0.2|^2.0", + "graham-campbell/phpspec-skip-example-extension": "^5.0", + "php-http/httplug": "^1.0 || ^2.0", + "php-http/message-factory": "^1.0", + "phpspec/phpspec": "^5.1 || ^6.1 || ^7.3", + "sebastian/comparator": "^3.0.5 || ^4.0.8", + "symfony/phpunit-bridge": "^6.4.4 || ^7.0.1" + }, + "type": "composer-plugin", + "extra": { + "class": "Http\\Discovery\\Composer\\Plugin", + "plugin-optional": true + }, + "autoload": { + "psr-4": { + "Http\\Discovery\\": "src/" + }, + "exclude-from-classmap": [ + "src/Composer/Plugin.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations", + "homepage": "http://php-http.org", + "keywords": [ + "adapter", + "client", + "discovery", + "factory", + "http", + "message", + "psr17", + "psr7" + ], + "support": { + "issues": "https://github.com/php-http/discovery/issues", + "source": "https://github.com/php-http/discovery/tree/1.20.0" + }, + "time": "2024-10-02T11:20:13+00:00" + }, + { + "name": "php-http/httplug", + "version": "2.4.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/httplug.git", + "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/httplug/zipball/5cad731844891a4c282f3f3e1b582c46839d22f4", + "reference": "5cad731844891a4c282f3f3e1b582c46839d22f4", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0", + "php-http/promise": "^1.1", + "psr/http-client": "^1.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.1 || ^5.0 || ^6.0", + "phpspec/phpspec": "^5.1 || ^6.0 || ^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eric GELOEN", + "email": "geloen.eric@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "HTTPlug, the HTTP client abstraction for PHP", + "homepage": "http://httplug.io", + "keywords": [ + "client", + "http" + ], + "support": { + "issues": "https://github.com/php-http/httplug/issues", + "source": "https://github.com/php-http/httplug/tree/2.4.1" + }, + "time": "2024-09-23T11:39:58+00:00" + }, + { + "name": "php-http/promise", + "version": "1.3.1", + "source": { + "type": "git", + "url": "https://github.com/php-http/promise.git", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-http/promise/zipball/fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "reference": "fc85b1fba37c169a69a07ef0d5a8075770cc1f83", + "shasum": "" + }, + "require": { + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "friends-of-phpspec/phpspec-code-coverage": "^4.3.2 || ^6.3", + "phpspec/phpspec": "^5.1.2 || ^6.2 || ^7.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Http\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joel Wurtz", + "email": "joel.wurtz@gmail.com" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com" + } + ], + "description": "Promise used for asynchronous HTTP requests", + "homepage": "http://httplug.io", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/php-http/promise/issues", + "source": "https://github.com/php-http/promise/tree/1.3.1" + }, + "time": "2024-03-15T13:55:21+00:00" + }, { "name": "phpoption/phpoption", "version": "1.10.0", @@ -4297,6 +4564,181 @@ }, "time": "2026-10-20T03:25:21+00:00" }, + { + "name": "spomky-labs/base64url", + "version": "v2.0.4", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/base64url.git", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "reference": "7752ce931ec285da4ed1f4c5aa27e45e097be61d", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "require-dev": { + "phpstan/extension-installer": "^1.0", + "phpstan/phpstan": "^0.11|^0.12", + "phpstan/phpstan-beberlei-assert": "^0.11|^0.12", + "phpstan/phpstan-deprecation-rules": "^0.11|^0.12", + "phpstan/phpstan-phpunit": "^0.11|^0.12", + "phpstan/phpstan-strict-rules": "^0.11|^0.12" + }, + "type": "library", + "autoload": { + "psr-4": { + "Base64Url\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky-Labs/base64url/contributors" + } + ], + "description": "Base 64 URL Safe Encoding/Decoding PHP Library", + "homepage": "https://github.com/Spomky-Labs/base64url", + "keywords": [ + "base64", + "rfc4648", + "safe", + "url" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/base64url/issues", + "source": "https://github.com/Spomky-Labs/base64url/tree/v2.0.4" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2020-11-03T09:10:25+00:00" + }, + { + "name": "spomky-labs/pki-framework", + "version": "1.6.3", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/pki-framework.git", + "reference": "792e909d4e387adffe3c4f404451c7d57a3d2022" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/pki-framework/zipball/792e909d4e387adffe3c4f404451c7d57a3d2022", + "reference": "792e909d4e387adffe3c4f404451c7d57a3d2022", + "shasum": "" + }, + "require": { + "brick/math": "^0.10|^0.11|^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20|^1.0", + "ext-mbstring": "*", + "php": ">=8.1" + }, + "require-dev": { + "ekino/phpstan-banned-code": "^1.0|^2.0|^3.0", + "ext-gmp": "*", + "ext-openssl": "*", + "infection/infection": "^0.28|^0.29|^0.31", + "php-parallel-lint/php-parallel-lint": "^1.3", + "phpstan/extension-installer": "^1.3|^2.0", + "phpstan/phpstan": "^1.8|^2.0", + "phpstan/phpstan-deprecation-rules": "^1.0|^2.0", + "phpstan/phpstan-phpunit": "^1.1|^2.0", + "phpstan/phpstan-strict-rules": "^1.3|^2.0", + "phpunit/phpunit": "^10.1|^11.0|^12.0", + "rector/rector": "^1.0|^2.0", + "roave/security-advisories": "dev-latest", + "symfony/string": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symplify/easy-coding-standard": "^12.0 || ^13.0" + }, + "suggest": { + "ext-bcmath": "For better performance (or GMP)", + "ext-gmp": "For better performance (or BCMath)", + "ext-openssl": "For OpenSSL based cyphering", + "ext-sodium": "To verify Ed25519 signatures where the OpenSSL extension has no EdDSA" + }, + "type": "library", + "autoload": { + "psr-4": { + "SpomkyLabs\\Pki\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Joni Eskelinen", + "email": "jonieske@gmail.com", + "role": "Original developer" + }, + { + "name": "Florent Morselli", + "email": "florent.morselli@spomky-labs.com", + "role": "Spomky-Labs PKI Framework developer" + } + ], + "description": "A PHP framework for managing Public Key Infrastructures. It comprises X.509 public key certificates, attribute certificates, certification requests and certification path validation.", + "homepage": "https://github.com/spomky-labs/pki-framework", + "keywords": [ + "DER", + "Private Key", + "ac", + "algorithm identifier", + "asn.1", + "asn1", + "attribute certificate", + "certificate", + "certification request", + "cryptography", + "csr", + "decrypt", + "ec", + "encrypt", + "pem", + "pkcs", + "public key", + "rsa", + "sign", + "signature", + "verify", + "x.509", + "x.690", + "x509", + "x690" + ], + "support": { + "issues": "https://github.com/Spomky-Labs/pki-framework/issues", + "source": "https://github.com/Spomky-Labs/pki-framework/tree/1.6.3" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-09-12T19:02:49+00:00" + }, { "name": "symfony/clock", "version": "v8.1.9", @@ -5943,6 +6385,86 @@ ], "time": "2026-09-25T15:50:05+00:00" }, + { + "name": "symfony/polyfill-php83", + "version": "v1.43.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "reference": "80ccff923a8d61f73ebe9da0aa94e25232f004ce", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.43.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-09-25T15:50:05+00:00" + }, { "name": "symfony/polyfill-php84", "version": "v1.43.0", @@ -7358,6 +7880,96 @@ } ], "time": "2026-04-26T05:33:54+00:00" + }, + { + "name": "web-token/jwt-library", + "version": "4.2.3", + "source": { + "type": "git", + "url": "https://github.com/web-token/jwt-library.git", + "reference": "db962c3ad8bc91c531a49dd373f0a641fc507bfd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/web-token/jwt-library/zipball/db962c3ad8bc91c531a49dd373f0a641fc507bfd", + "reference": "db962c3ad8bc91c531a49dd373f0a641fc507bfd", + "shasum": "" + }, + "require": { + "brick/math": "^0.12|^0.13|^0.14|^0.15|^0.16|^0.17|^0.18|^0.19|^0.20|^1.0", + "php": ">=8.2", + "psr/clock": "^1.0", + "spomky-labs/pki-framework": "^1.2.1", + "symfony/deprecation-contracts": "^2.5|^3.0" + }, + "conflict": { + "spomky-labs/jose": "*" + }, + "suggest": { + "ext-bcmath": "GMP or BCMath is highly recommended to improve the library performance", + "ext-gmp": "GMP or BCMath is highly recommended to improve the library performance", + "ext-openssl": "For key management (creation, optimization, etc.) and some algorithms (AES, RSA, ECDSA, etc.)", + "ext-sodium": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "paragonie/sodium_compat": "Sodium is required for OKP key creation, EdDSA signature algorithm and ECDH-ES key encryption with OKP keys", + "spomky-labs/aes-key-wrap": "For all Key Wrapping algorithms (AxxxKW, AxxxGCMKW, PBES2-HSxxx+AyyyKW...)", + "symfony/console": "Needed to use console commands", + "symfony/http-client": "To enable JKU/X5U support." + }, + "type": "library", + "autoload": { + "psr-4": { + "Jose\\Component\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/web-token/jwt-framework/contributors" + } + ], + "description": "JWT library", + "homepage": "https://github.com/web-token", + "keywords": [ + "JOSE", + "JWE", + "JWK", + "JWKSet", + "JWS", + "Jot", + "RFC7515", + "RFC7516", + "RFC7517", + "RFC7518", + "RFC7519", + "RFC7520", + "bundle", + "jwa", + "jwt", + "symfony" + ], + "support": { + "issues": "https://github.com/web-token/jwt-library/issues", + "source": "https://github.com/web-token/jwt-library/tree/4.2.3" + }, + "funding": [ + { + "url": "https://github.com/Spomky", + "type": "github" + }, + { + "url": "https://www.patreon.com/FlorentMorselli", + "type": "patreon" + } + ], + "time": "2026-09-12T18:50:35+00:00" } ], "packages-dev": [ diff --git a/config/services.php b/config/services.php index c834f80bc..5aa7c8951 100644 --- a/config/services.php +++ b/config/services.php @@ -4,6 +4,12 @@ return [ + 'web_push' => [ + 'subject' => env('WEB_PUSH_SUBJECT', 'https://dev-null.sh'), + 'public_key' => env('WEB_PUSH_PUBLIC_KEY'), + 'private_key' => env('WEB_PUSH_PRIVATE_KEY'), + ], + /* |-------------------------------------------------------------------------- | Third Party Services diff --git a/database/factories/PushSubscriptionFactory.php b/database/factories/PushSubscriptionFactory.php new file mode 100644 index 000000000..5593f9648 --- /dev/null +++ b/database/factories/PushSubscriptionFactory.php @@ -0,0 +1,29 @@ + + */ +final class PushSubscriptionFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $endpoint = 'https://fcm.googleapis.com/wp/'.fake()->uuid(); + + return [ + 'user_id' => User::factory(), + 'endpoint' => $endpoint, + 'endpoint_hash' => hash('sha256', $endpoint), + ]; + } +} diff --git a/database/migrations/2026_10_25_120000_create_push_subscriptions_table.php b/database/migrations/2026_10_25_120000_create_push_subscriptions_table.php new file mode 100644 index 000000000..e8cf56934 --- /dev/null +++ b/database/migrations/2026_10_25_120000_create_push_subscriptions_table.php @@ -0,0 +1,21 @@ +uuid('id')->primary(); + $table->foreignUuid('user_id')->constrained()->cascadeOnDelete(); + $table->char('endpoint_hash', 64)->unique(); + $table->text('endpoint'); + $table->index('user_id'); + }); + } +}; diff --git a/docs/web-push.md b/docs/web-push.md new file mode 100644 index 000000000..714fc6ce9 --- /dev/null +++ b/docs/web-push.md @@ -0,0 +1,21 @@ +# 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 + +Choose **Allow notifications** in Settings → Profile → Remotes. Each browser must opt in. 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 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. diff --git a/phpunit.xml b/phpunit.xml index 9ca6d4aab..d59b95bbe 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -37,6 +37,8 @@ + + diff --git a/public/sw.js b/public/sw.js index 9f4536fc3..8c26c3f17 100644 --- a/public/sw.js +++ b/public/sw.js @@ -19,6 +19,16 @@ const assetCache = `assets-${version}`; const offlineUrl = '/offline.html'; const offlineFiles = [offlineUrl, '/favicon.ico', '/favicon.svg']; +self.addEventListener('push', (event) => { + event.waitUntil(self.registration.showNotification('/dev/null · Remotes', { + body: 'You have a new message in Remotes.', + icon: '/icon-192.png', + tag: 'devnull-remotes', + silent: true, + data: { url: '/remotes' }, + })); +}); + self.addEventListener('notificationclick', (event) => { event.notification.close(); const url = new URL(event.notification.data?.url ?? '/', self.location.origin); @@ -26,7 +36,7 @@ self.addEventListener('notificationclick', (event) => { if (url.origin !== self.location.origin) return; event.waitUntil(self.clients.matchAll({ type: 'window' }).then(async (clients) => { - const client = clients[0]; + const client = clients.find((client) => new URL(client.url).pathname.startsWith('/remotes')) ?? clients[0]; if (client) { await client.navigate(url.href); diff --git a/resources/js/components/remotes-setting.tsx b/resources/js/components/remotes-setting.tsx index ec10e686d..dbdc8e727 100644 --- a/resources/js/components/remotes-setting.tsx +++ b/resources/js/components/remotes-setting.tsx @@ -6,6 +6,8 @@ import { Button } from '@/components/ui/button'; import { useRemoteAlertsEnabled, useRemoteNotificationPermission, + useRemoteNotificationSupport, + useRemotePushEnabled, } from '@/hooks/use-chat'; import type { RemoteAlertChannel } from '@/lib/remote-notifications'; import { setRemoteAlerts } from '@/lib/remote-notifications'; @@ -21,12 +23,16 @@ export function RemotesSetting() { const alertsEnabled = useRemoteAlertsEnabled(auth.user?.id ?? null); const soundEnabled = useRemoteAlertsEnabled(auth.user?.id ?? null, 'sound'); const hasPermission = useRemoteNotificationPermission(); + const supported = useRemoteNotificationSupport(); + const pushEnabled = useRemotePushEnabled(); + const [savingAlerts, setSavingAlerts] = useState(false); const saveAlerts = ( enabled: boolean, channel: RemoteAlertChannel = 'notifications', ) => { - if (auth.user === null) return; + if (auth.user === null || savingAlerts) return; + setSavingAlerts(true); void setRemoteAlerts(auth.user.id, enabled, channel) .then((saved) => { if (!saved) @@ -38,9 +44,12 @@ export function RemotesSetting() { }) .catch(() => toast.error( - 'Could not enable notifications. Check your browser’s site settings.', + enabled + ? 'Background notifications could not be enabled. Alerts can still appear while the site is open.' + : 'Could not turn notifications off. Please try again.', ), - ); + ) + .finally(() => setSavingAlerts(false)); }; const toggle = () => { @@ -119,18 +128,30 @@ export function RemotesSetting() { id="remote-alerts-description" className="text-sm text-muted-foreground" > - On this browser, while the site is open. No message - previews. + Notifications on supported browsers, even when the site + or installed app is closed. No message previews. +

+

+ On iPhone and iPad, add /dev/null to your Home Screen + and allow notifications from the installed app.

+
+
+

+ Off-site notifications +

+

+ Send notifications to this browser or installed app, + including when closed. No message previews. +

+

+ On iPhone and iPad, add /dev/null to your Home Screen + and turn this on from the installed app. +

+
+ +

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); + } + } +});