Transfer or release a device's push subscription when it changes accounts (#932) - #667
Conversation
A device keeps one push endpoint or FCM token across the accounts that sign in on it. Before, a second account got PUSH_ENDPOINT_OWNED_BY_OTHER_USER, the client rotated the endpoint, and the first account kept a dead row that counted toward its five-device cap. Now the row moves to the registering account when the request proves it holds the device: a Web Push request must present the stored p256dh key and auth secret, and an FCM request must present the stored token with the FCM sentinel. A request without that proof still gets the old error, so an account cannot take or remove another account's device. Refs thomasluizon/orbit-tickets#932 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unsubscribe only removed a row the caller owned. When a browser or Android device stayed registered to a previous account and the account now signed in turned push off, the client dropped the endpoint locally while the previous account kept listing and counting a dead device. Unsubscribe now takes the device's p256dh and auth, which both clients already send, and removes another account's row only when they prove control of the device, using the same check as the subscribe transfer. Without that proof the row stays with its owner and the call still returns success, so it does not reveal who owns an endpoint. Refs thomasluizon/orbit-tickets#932 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
Important
The browser account-switch path still leaves the previous account's subscription behind, so this change does not resolve the reported browser behavior without a coordinated client update.
Reviewed changes in the two commits at ecb9a832 across the API, domain entity, chat tool, and unit tests:
- Registration transfer: A matching Web Push key pair or FCM token can move an existing endpoint to the signed-in account, enforcing that account's device cap.
- Subscription release: Unsubscribe forwards device credentials and can remove a previous account's row when those credentials match.
- Coverage: Handler, domain, controller, tool, and SQLite account-switch tests exercise these paths and rejection of mismatched Web Push credentials.
⚠️ Browser account switches never reach the transfer path
Both existing web registration flows unsubscribe the browser's old subscription and create a new endpoint before calling the API. The new account therefore never presents the old endpoint or its keys to ClaimExisting; the old account's row remains and still counts toward the device cap, while the web prompt skips registration entirely if it sees an existing subscription. Please coordinate the acknowledged client follow-up with this rollout rather than treating the server change alone as a fix for browser account switches.
Technical details
# Browser registration does not claim the existing row
## Affected sites
- `apps/web/hooks/use-push-notification-preferences.ts:127-145` in `orbit-ui-mobile` discards the existing subscription before posting the replacement.
- `apps/web/components/ui/push-prompt.tsx:63-67,116-133` skips a prompt for an existing subscription and otherwise rotates the endpoint before registration.
- `src/Orbit.Application/Notifications/Commands/SubscribePushCommand.cs:34-39,75-84` can transfer only when the posted endpoint matches the previously stored row.
## Required outcome
- Ensure the browser presents the existing endpoint and keys under the new account before rotating it, and that account changes trigger registration even when the browser already has a subscription. Sequence or explicitly track the dependent client rollout so the original browser device-cap and stale-owner behavior is not presented as resolved prematurely.GPT Sol | 𝕏
|
Disposition of the review body finding (browser account switches never reach the transfer path): filed as thomasluizon/orbit-tickets#994. This pull request is the API half of thomasluizon/orbit-tickets#932 and does not claim to fix the browser flow on its own. #994 is the dependent web rollout: it sends the existing subscription (endpoint and keys) to unsubscribe before onboarding rotates it and on sign-out, and a scope comment added today covers the two paths this review names: the push prompt that skips or rotates an existing subscription after an account change ( |
|
@pullfrog review |
There was a problem hiding this comment.
Important
A delayed unsubscribe from the previous account can remove the newly claimed account's push subscription.
Reviewed changes in the two commits at ecb9a832 across the API, application, domain, and tests:
- Registration transfer: Matching browser credentials or an FCM token transfer an existing row and enforce the destination account's device cap.
- Subscription release: The HTTP endpoint and chat tool forward credentials so a different account can remove the row.
- Coverage: Handler, domain, controller, and SQLite tests exercise transfers, releases, and mismatched browser keys.
Note: 1 inline comment(s) dropped because they did not anchor to lines inside the PR diff:
src/Orbit.Application/Notifications/Commands/UnsubscribePushCommand.cs:44(RIGHT) — line 44 (RIGHT) is not inside a diff hunk
GPT Sol | 𝕏
|
Disposition of the second review's finding (a delayed unsubscribe from the previous account can remove the newly claimed row): fixed in a69c7ba. An unsubscribe from an account that does not own the row now changes nothing unless it sets the new optional |
There was a problem hiding this comment.
ℹ️ No new findings in this delta. The existing concurrency thread remains open because the opt-in change does not protect a delete that read the row before another account claimed it.
Reviewed changes since the prior Pullfrog review at ecb9a832:
- Explicit cross-account release: Added
releaseOtherAccountto the unsubscribe request and command, defaulting ordinary sign-out to owner-only release. - Tool forwarding: Propagated the opt-in flag and device credentials through the HTTP, MCP, and chat-tool paths.
- Regression coverage: Added legacy payload, opt-in validation, and delayed sign-out tests for Web Push and FCM.
GPT Sol | 𝕏
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes since the prior Pullfrog review at a69c7ba8:
- Owner-bound deletion: Replaced tracked removal with an immediate delete constrained by subscription ID and the owner observed during unsubscribe, preserving a newer account's claim if a transfer happens after the read.
- Concurrency coverage: Added SQLite interleaving tests for browser and Android subscriptions, with both ordinary sign-out and explicit cross-account release.
GPT Sol | 𝕏
|
* Delete the deprecated POST /oauth/google route (#957) (#664) The MCP authorize page moved to the authorization-code redirect through /oauth/google/start and /oauth/google/callback, so the One Tap tokeninfo route kept no caller in either app or in any documented client. It also created users through a raw repository call instead of an application command. Remove the route, its GoogleAuthRequest record, FindOrCreateGoogleUserAsync, the now orphaned user repository and HTTP client factory dependencies, the OAuthController.GoogleAuth catalog entry and the GoogleTokenAudienceMismatch error, and regenerate openapi.json. The route stayed deprecated on main, so oasdiff reports the removal as api-path-removed-with-deprecation at INFO and the breaking-change gate passes. Co-authored-by: Claude Opus 5 <noreply@anthropic.com> (cherry picked from commit e596ee1) * chore: run the Pullfrog reviewer on the Codex harness with gpt-6.1-sol (#668) * chore: run the Pullfrog reviewer on the Codex harness with gpt-6.1-sol The Codex step now sets PULLFROG_AGENT=codex and passes openai/gpt-6.1-sol as a raw model specifier, because no published Pullfrog alias resolves to it yet. The Claude fallback moves to claude-opus-5-5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * chore: keep the Pullfrog reviewer on the opencode harness at the model's default effort The Codex CLI harness disables multi_agent, which drops the reviewfrog specialist sub-agent, and a raw model specifier has no effort rung, so the effort input was never applied. The review step keeps openai/gpt-6.1-sol on the default opencode harness, where OpenAI's documented default effort is medium. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit c95d548) * Transfer or release a device's push subscription when it changes accounts (#932) (#667) * Transfer a device's push subscription when another account registers it A device keeps one push endpoint or FCM token across the accounts that sign in on it. Before, a second account got PUSH_ENDPOINT_OWNED_BY_OTHER_USER, the client rotated the endpoint, and the first account kept a dead row that counted toward its five-device cap. Now the row moves to the registering account when the request proves it holds the device: a Web Push request must present the stored p256dh key and auth secret, and an FCM request must present the stored token with the FCM sentinel. A request without that proof still gets the old error, so an account cannot take or remove another account's device. Refs thomasluizon/orbit-tickets#932 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let the account on a device release its prior account's push row Unsubscribe only removed a row the caller owned. When a browser or Android device stayed registered to a previous account and the account now signed in turned push off, the client dropped the endpoint locally while the previous account kept listing and counting a dead device. Unsubscribe now takes the device's p256dh and auth, which both clients already send, and removes another account's row only when they prove control of the device, using the same check as the subscribe transfer. Without that proof the row stays with its owner and the call still returns success, so it does not reveal who owns an endpoint. Refs thomasluizon/orbit-tickets#932 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: require explicit cross-account push subscription release * fix: bind push unsubscribe deletion to the observed owner --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> (cherry picked from commit ce78ccb) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>




Fixes thomasluizon/orbit-tickets#932.
Problem
A browser keeps one push endpoint, and an Android install keeps one FCM token, across the accounts that sign in on it. When a second account registered that endpoint,
SubscribePushCommandreturnedPUSH_ENDPOINT_OWNED_BY_OTHER_USER. The client then rotated the endpoint and registered the new one, and the first account kept a row for a device that no longer received its pushes.UnsubscribePushCommandremoved only the caller's own row, so the new account could not release it either. Each switch added one dead row to the first account's device list and moved it toward the five-device cap.Change
PushSubscription.MatchesCredentials(p256dh, auth)decides whether a request proves control of the device. A Web Push row needs the storedp256dhkey andauthsecret, compared in fixed time. An FCM row needs the FCM sentinel, because the caller already matched the row by its token.PushSubscription.TransferTo(userId)moves the row to another account and gives it a freshCreatedAtUtc.SubscribePushCommand: when the endpoint belongs to another account and the request proves control, the row moves to the caller. The caller's oldest rows beyond the cap are evicted, never the moved row. Without proof, the command still returnsPUSH_ENDPOINT_OWNED_BY_OTHER_USER.UnsubscribePushCommandnow carriesP256dhandAuth. The controller forwards them from the body it already requires, and theunsubscribe_pushchat tool forwards them when given. The owner can release its own row as before. Another account can release the row only with the same proof. Without proof the call changes nothing and still returns 200, so it does not reveal who owns an endpoint.Repeated switches now move one row between accounts, so neither account grows toward the cap. The request and response shapes do not change, so
orbit-ui-mobileneeds no contract change. Its existingPUSH_ENDPOINT_OWNED_BY_OTHER_USERrecovery still applies to a request without proof.Test evidence
Part 1, subscribe transfer.
dotnet test tests/Orbit.Application.Tests --no-build --filter "FullyQualifiedName~SubscribePushCommandHandlerTests.Handle_ExistingEndpointDifferentUser_RejectsToPreventHijack"passed (1 of 1). It sends different keys, so it never exercised the same device under a second account.PushSubscriptionTestsinOrbit.Domain.Testspasses 18 of 18.Part 2, unsubscribe release.
dotnet test tests/Orbit.Application.Tests --no-build --filter "FullyQualifiedName~UnsubscribePushCommandHandlerTests"passed (2 of 2).P256dhandAuthadded to the command and the handler unchanged:The mocked handler tests are not the proof for this part. The mocked repository ignores the query predicate, so before the fix
Handle_OtherAccountsRowWithItsDeviceKeys_RemovesAndSavespassed and bothHandle_OtherAccountsRowWithoutItsDeviceKeys_LeavesItInPlacecases failed withReceivedCallsException. That is a mock artifact. The SQLite test above runs the real predicate and shows the defect.3. After the fix,
UnsubscribePushCommandHandlerTestspasses 6 of 6 andPushSubscriptionAccountSwitchTestspasses 6 of 6.Whole suite,
dotnet test Orbit.slnx --no-build:node tools/check-suppression-allowlist.mjs,node tools/check-timeless.mjs --base origin/main,node tools/check-dashes.mjs --files <changed files>,node tools/check-root-allowlist.mjsandnode tools/arch-map.mjsexit 0.dotnet format Orbit.slnx --verify-no-changeson the changed files exits 0.dotnet test tests/Orbit.Infrastructure.Tests --filter FullyQualifiedName~PushSubscriptionAccountSwitchTestspassed all six existing tests with the defect present.dotnet test tests/Orbit.Infrastructure.Tests --filter FullyQualifiedName~PreviousAccountsDelayedUnsubscribefailed both web and Android cases: B’s expected count was 1, actual 0.dotnet test tests/Orbit.Infrastructure.Tests --filter 'FullyQualifiedName~PushSubscriptionAccountSwitchTests|FullyQualifiedName~NotificationControllerTests|FullyQualifiedName~NotificationToolsTests' --no-restore.dotnet test tests/Orbit.Application.Tests --filter 'FullyQualifiedName~NotificationPushCommand|FullyQualifiedName~UnsubscribePushCommand|FullyQualifiedName~ProfileNotificationCalendarToolTests' --no-restore.dotnet build Orbit.slnx: zero errors.dotnet test: 7,022 passed, zero failures.EF Core 10.0.12’s installed nuspec supplied the revision used to verify immediate deletion semantics and the interceptor’s command-source mapping.
Assumptions
p256dhkey plusauthsecret. The endpoint alone is not enough, because the push sender writes it to warning logs. Rejected alternative: accept a matching endpoint alone.fcmin both key fields, the API never returns a token, and logs keep only a 20-character preview. Rejected alternative: a device-held secret for Android, which needs a client and wire change.CreatedAtUtc, so it counts as the new account's newest device and is not the first one evicted at the cap. Rejected alternative: keep the first account's registration time.PUSH_ENDPOINT_OWNED_BY_OTHER_USER, which reveals that another account owns the endpoint.main, whereGET api/notifications/subscriptionsdoes not exist yet (api#649 added it toredesign/main). Its handler counts rows byUserId, so the tests assert that row count directly. Rejected alternative: targetredesign/main.PushSubscriptionAccountSwitchTestsruns both handlers over the sharedSqliteOrbitDbContextFactorywith the real repository and unique endpoint index, the same pattern asIdempotencyBehaviorDbTests. Rejected alternative: mock-only coverage, which cannot show a per-account count.unsubscribe_pushchat tool forwardsp256dhandauth, which its schema already declares. Rejected alternative: leave the tool on the endpoint alone.subscribeToPushNotifications, which drops the browser's endpoint before it registers, and web sign-out does not release it. Both are client work, filed as thomasluizon/orbit-tickets#994. Rejected alternative: edit a second repository in this pull request.🤖 Generated with Claude Code
UnsubscribeRequestinstead of adding unsubscribe-specific fields toSubscribeRequest.Local, which excludes entities marked for deletion.Manual steps
environment=productionandbranch=mainafter merge. Proof: successful workflow and Render deployment.releaseOtherAccount: truewith device credentials toPOST /api/notifications/unsubscribe; ordinary sign-out must omit it. Proof: explicit cleanup removes the prior account’s row, while delayed ordinary sign-out preserves the new claim.Once the change reaches
main, dispatch GitHub Actions Release API inthomasluizon/orbit-api, using workflow refmain,environment=productionandbranch=main. A successful workflow verifies API health and that Render serves the released commit.No new configuration, secrets, migration or backfill is required.