fix(users): support the intended Bungie platforms during first sign-in - #749
Conversation
Steam-first accounts could not sign in. getCurrentUser() returns the membership that owns cross-saved data - Steam (3) for most PC players - while anonymousUserSchema validated membershipType as min(1).max(2), so createAnonymousUser rejected a valid account and the OAuth callback 500'd. Define the supported platforms once in helpers/bungie.membershipTypes.js (1 Xbox, 2 PSN, 3 Steam, 6 Epic) and validate both user schemas against it. userSchema was previously unbounded, so the two now agree. Fix two adjacent defects on the same path. #getPreferredMembership destructured memberships[0], throwing a TypeError on an account with no Destiny memberships rather than reaching the 404 the route already implements; it also read crossSaveOverride off index 0 and fell back to index 0, which can return a membership that does not own the data. It now uses the rule already proven in twilio/mms.service.js and resolves undefined when nothing is playable. Also recognise a player who moves their cross-save owner. That changes both the platform membershipId and the membershipType, so sign-in missed the existing record and created a second document in a second Cosmos partition, stranding the registration on the first. Fall back to the Bungie.net membership id, then move the record rather than update it: membershipType is the partition key and is fixed for a document's life. Widen the anonymous displayName ceiling to 32 for Steam persona names. Closes #718 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf
chrispaskvan
left a comment
There was a problem hiding this comment.
The #718 fix itself looks right: one list of supported platforms, the same cross-save selection rule as mms.service.js, and an empty membership list now reaching the 404. The Bungie-id fallback is the right key for a returning player. The main concern is what movePlatform leaves behind when its delete fails (inline).
Predates this PR, but now more likely: when signIn finds the user and the platform hasn't changed, updateUser looks the record up by the new displayName + membershipType (users/user.service.js:973). Steam persona names can change, so a renamed Steam player isn't found and sign-in throws. This PR brings in more Steam users, so it's probably worth its own issue.
Review feedback on #749. A failed delete in movePlatform left two documents sharing an id, and every cross-partition lookup threw `more than 1 document found` on them. That took out inbound STOP handling (twilio.controller.js), the consent gate, SMS sign-in and the notification path - for as long as the stale copy survived, which nothing bounded. "Visible and recoverable" understated it. Reduce a multi-document result through #oneDocument: a shared id is a failed move, so read the newest _ts and log loudly. Two documents with different ids are a real duplicate and still throw. getConsentByPhoneNumber projects id and _ts so the same rule reaches the gate that decides whether an SMS goes out. Retry the delete itself, since correct-despite-a-duplicate is a fallback rather than the resting state. A 404 means it is already gone. Treat a conflicting create as a move a concurrent sign-in already made and carry on to the delete, instead of 500ing the second of two OAuth callbacks. getUserByBungieMembershipId resolves undefined for a missing id rather than rejecting: it is a fallback, and rejecting turned a brand-new user whose token lacked the field into a failed sign-in. The existing "more than one document" fixtures returned the same object twice, which Cosmos cannot do - ids are unique per partition. They now use genuinely distinct documents. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf
chrispaskvan
left a comment
There was a problem hiding this comment.
Re-review of a3d6830. All three earlier comments are addressed, and the replies match the code. Two smaller issues in the new code are inline. Neither blocks merging.
Review feedback on #749. The previous commit resolved a duplicate by taking the newest _ts, which assumed nothing writes to the superseded copy. Two things do: a session predating the move still carries the old displayName and membershipType, and user.routes.js writes through both; and a player moving cross save back lands on the old copy by its platform membershipId. Either makes the stale copy the newest, so every lookup starts returning the consent it never received - a STOP undone, and texts going out again. movePlatform now marks the old copy with movedTo before creating its replacement. That write is on the old copy's own partition, so a failure stops the move with nothing duplicated. Every lookup filters marked documents out, which is a fact about the record rather than a race with whatever wrote last. getUserByBungieMembershipId is the exception and still sees them, because that is the path signIn recovers through: a move whose successor was never created would otherwise leave the player invisible. signIn clears the mark when the platform turns out to be unchanged. A conflicting create now replaces the target instead of skipping it, which covers a player moving back to a platform whose superseded copy is still present as well as two concurrent callbacks. Retry the delete only on statuses that can clear on their own (408, 429, 449, 5xx) with a 100ms base, rather than spending eight seconds of an OAuth callback on a 403. The delete-failure specs use fake timers, taking the suite from 8.7s back to 4.6s. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf
chrispaskvan
left a comment
There was a problem hiding this comment.
Re-review of 27a98e2. Both earlier comments are fixed as suggested: marking the old copy before creating the new one replaces the timestamp rule, and the retry now only retries temporary errors, with fake timers in the tests. Two gaps in the new flow and one nit are inline.
| */ | ||
| if (documents.length > 1) { | ||
| throw new Error( | ||
| `more than 1 document found for bungie.membership_id ${bungieMembershipId}`, |
There was a problem hiding this comment.
Medium: moving back after a failed delete makes every sign-in 500, and nothing fixes it. Take a move from Xbox (1) to Steam (3) that left a marked Xbox copy A (the delete failed) and a live Steam copy B. When the player moves cross-save back to Xbox:
getUserByMembershipId(xbox id)finds only A, which is marked, so#oneLiveDocumentfilters it out.signInfalls back to this lookup, which is unfiltered. It finds both A and B and throws here.
The replace-on-conflict branch in movePlatform that the reply says handles move-back is never reached from signIn. Only the movePlatform unit test exercises it.
Suggested fix: return the single live document if there is one, fall back to the single marked one only when nothing is live (the recovery case this lookup exists for), and throw otherwise. B is then returned, its platform differs from Xbox, and the move and replace run as intended. A signIn controller test with [A marked, B live] would catch this.
There was a problem hiding this comment.
You're right, and the sharper part of this is that my previous reply was wrong: I claimed the replace-on-conflict branch handled move-back, and as you say signIn can't reach it — only the movePlatform unit test could. Writing a test at the wrong level let me believe a path was covered when nothing exercised it.
Fixed in 5936ae2, exactly as you suggested: getUserByBungieMembershipId filters to live documents and uses those when any exist, falls back to the full set only when nothing is live (the interrupted-move case it exists for), and throws otherwise. Moving back then returns B, its platform differs from Xbox, and the move and replace run as intended.
Two tests, at both levels this time. The service one asserts live wins over a superseded copy sharing the id. The controller one drives signIn end to end with [A marked, B live] and Bungie reporting Xbox as owner again — it asserts the sign-in resolves, the Xbox record comes back with no mark on partition 1, and the Steam copy is deleted from partition 3. Reverting the fix fails 3 tests, including that one.
🤖 Reviewed with Claude Code · https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf
| * and once it lands, no lookup will return that copy again however many | ||
| * times it is written to afterwards. | ||
| */ | ||
| await this.documents.updateDocument( |
There was a problem hiding this comment.
Low: two sign-ins at once 500 again, now at this step. storedUser carries its _etag, so updateDocument sends it as IfMatch. If two callbacks read A, the first one marks it and changes its etag, and the second gets a 412 and throws, so the 409 handling below is never reached. Marking is safe to repeat, so dropping _etag from this write (or treating a 412 here as "someone else is already moving it") would fix it.
There was a problem hiding this comment.
Confirmed and fixed in 5936ae2 — updateDocument sets IfMatch whenever the document carries an _etag, and storedUser always does.
Took the first of your two options: the mark is built from the already-destructured carried, which has the system properties stripped, so it goes unconditionally. Marking is idempotent, so there's nothing a precondition would protect. Treating a 412 as "someone else is moving it" would have worked too, but it needs the reader to know why a conditional write is there at all, and the answer would have been "no reason".
Test asserts the mark write carries no _etag.
🤖 Reviewed with Claude Code · https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf
| let created; | ||
|
|
||
| try { | ||
| created = await this.documents.createDocument(userCollectionId, moved); |
There was a problem hiding this comment.
Nit: if this create fails with anything other than a 409, the method throws before cacheService.deleteUser. The cache then keeps serving the unmarked copy for its lifetime, so display-name lookups on the old identity still return it. Writes are safe, because the stale _etag makes them fail with a 412 rather than remove the mark, but those reads are wrong. Clearing the cache right after the mark lands would close this.
There was a problem hiding this comment.
Fixed in 5936ae2 — the cache clear now runs directly after the mark lands, so it happens before anything that can throw.
Your read of the write path is right, and it's worth keeping in the comment: a stale cached _etag makes writes fail their precondition rather than silently removing the mark, so the exposure was reads only. The comment now says the clear is there because everything below it can throw and a cached copy outlives the failure by an hour.
Test drives a create failure and asserts cacheService.deleteUser was still called.
🤖 Reviewed with Claude Code · https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf
Review feedback on #749. getUserByBungieMembershipId saw superseded copies on purpose, so signIn could finish an interrupted move. After a failed delete both copies carry the same Bungie id, so it threw - and since that is the lookup signIn falls through to when the platform membershipId no longer matches, a player moving cross save back could never sign in again. The replace-on-conflict branch meant to handle exactly that was unreachable from signIn; only its unit test reached it. It now prefers the live copy and returns a superseded one only when nothing live matches, which is the interrupted-move case it exists for. A controller test drives signIn with both copies present. Mark without the etag. It was sent as IfMatch, so of two sign-ins arriving together the second failed its precondition on the mark the first had just written. Marking is idempotent, so the write does not need one. Clear the cache directly after the mark rather than at the end. Everything between can throw, and a cached copy outlives the failure by an hour holding the record unmarked, so display-name lookups on the old identity kept resolving to something no query returns. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf
|
Re-review of 5936ae2: all three comments from the last round are fixed, and this looks ready to merge from my side.
Optional follow-up, not blocking: the mark replaces the whole document with the copy read at sign-in. A write (a STOP, say) that lands on the old copy in the few milliseconds between that read and the mark would be overwritten. The new document is built from the same copy, so this narrow window predates these changes. A Cosmos patch that only adds Verified locally: the four changed spec files pass (174 tests, including the six new ones). CI is green apart from Snyk Code's test-limit check. |
|
Thanks for tracing Xbox → Steam → Xbox through — that's the path my unit test couldn't reach, so it's the one I most wanted a second pair of eyes on. On the optional follow-up: you're right about the window, and I'd add one thing that changes where it belongs. The catch with putting it alongside #748: if that lands, I've noted the interaction on #748 so whoever picks that up knows this exists. Not merging — that's yours to call. 🤖 Reviewed with Claude Code · https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf |
Closes #718.
The bug
DestinyService.getCurrentUser()returns the membership that owns cross-saved data — Steam (3) for most PC players — whileanonymousUserSchemavalidatedmembershipTypeasmin(1).max(2). Steam-first accounts were rejected atcreateAnonymousUserand the OAuth callback 500'd.Two adjacent defects sat on the same path:
#getPreferredMembershipdestructuredmemberships[0], so an account with no Destiny memberships threw aTypeErrorinstead of reaching the 404 the route already implements.crossSaveOverrideoff index 0 with an index-0 fallback, which can return a membership that does not own the data.Changes
helpers/bungie.membershipTypes.js(new) — the supported platforms in one place: 1 Xbox, 2 PSN, 3 Steam, 6 Epic. Excludes 0 None, 4 Blizzard (migrated to Steam in 2019), 5 Stadia (retired 2023), 10 Demon, 254 BungieNext. Both user schemas validate against it;userSchema.membershipTypewas previously unboundedz.number().int(), so the two now agree. TypingCurrentUser.membershipTypeas the same union is what makes the services agree at compile time — the exact class of mismatch this issue was.destiny/destiny.service.js— selection now uses the rule already proven intwilio/mms.service.js:crossSaveOverride === membershipType || !crossSaveOverride. Resolvesundefinedfor an empty membership list or an unsupported owner, logging which. Callers already handled a falsy result;destiny.controller.jsandmcp/mcp.routes.jspropagate the| undefined.Cross-save owner changes — moving the membership that owns cross-saved data changes both the platform
membershipIdand themembershipType, so sign-in missed the existing record and created a second document in a second Cosmos partition, stranding the registration (phone number, notifications, consent) on the first. Silently.getUserByBungieMembershipIdlooks the player up onbungie.membership_id, the Bungie.net id that survives the change — already stored, already on the session atauthentication.controller.js:55, never queried. Finding the record is not enough, though:/membershipTypeis the Cosmos partition key and is fixed for a document's life, somovePlatformrecreates the record under the new platform and removes the old copy, carrying the registration across. Create first, delete second — a failure between the two leaves a visible duplicate rather than losing the registration.displayNameceiling widened 16 → 32 for Steam persona names;min(3)kept.Verification
pnpm test971 passed,pnpm typecheckclean,pnpm swaggerleavesopenapi.jsonunchanged.Every new test was mutation-checked against the pre-fix source:
membershipType: z.number().int().min(1).max(2)const membership = memberships[0]const [{ crossSaveOverride }] = membershipsdeletebeforecreateinmovePlatformmovePlatformsignInNotes for review
users/user.service.js:365reportssuppressions/unusedon thebiome-ignoreabove the dead#deleteUser. Biome counts the bare identifierdeleteUserinmovePlatform'sthis.cacheService.deleteUser(storedUser)as a use of the private member, so the rule stops firing and its suppression reads as pointless — verified by rewriting the call as a computed access, which makes the warning vanish. Removing the suppression would make the file depend on a biome false-negative, and deleting#deleteUserbelongs in its own change.biome checkexits 0.bungie.membership_idand will not match the fallback. They acquire one on their next ordinary sign-in, so it closes itself without a backfill.vitest.config.jsis left alone rather than ratcheted mid-fix.movePlatformexists only because/membershipTypeis the partition key. A stable key deletes it.twilio/mms.service.jsanddestiny/destiny.service.jsnow hold two copies of the cross-save rule — worth collapsing into the new helper, as its own change.🤖 Generated with Claude Code
https://claude.ai/code/session_018iD3V7Zyugg3atXVsogTBf