feat!: render handles instead of display names - #124
Merged
Conversation
All five call sites passed getCurrentUserEmail() as the display_name query parameter, and the server echoed it to every participant — so a stranger admitted by knock learned everyone's real email address. The server resolves the name from users.handle now, so the client sends nothing. The Participant type's display_name field is renamed to handle to match, and every in-repo consumer of it (MultiplayerBar, ShareMenu, TeamSessions' avatar stack, teamInbox's participant-name lookups, the sharing plugin domain) is updated to read the server-resolved handle instead.
- teamInbox.ts: the stale comment claimed participant display names still arrived over the WebSocket query string; that leak is gone, so rewrite it around invited_by_handle being the sole authoritative source for a knock's identity. - multiplayerService.ws.test.ts: the regression guard's not.toContain checks let a raw unencoded @ through and didn't say what they don't cover; switch to an exact URL match (catches any future parameter) and note that the store call sites are guarded separately by their own call-signature tests. - teamSessionStore.test.ts: stop reaching into openWebSocket's mock args by position (args[4]); find the callbacks object by its onParticipantList shape so a future signature reorder doesn't silently repoint the test. - MembersPage.InviteToSession.test.tsx: update the last old-shape Participant fixtures (display_name) to handle. - teamSessionStore.directInvite.test.ts: drop the now-dead getCurrentUserEmail mock; the store no longer imports it. - Remove the now-unreferenced hosts.teamSessions.meFallback key from all four locales (en/fr/ru/zh).
…ession guard The prior fix-up's comment claimed teamSessionStore.test.ts and teamInbox.test.ts already asserted attachAsHost/joinSession never forward an identity string into openWebSocket. They didn't: teamSessionStore.test.ts only extracted callbacks via mockImplementation, with no toHaveBeenCalledWith anywhere, and teamInbox.test.ts asserted one level removed — joinSession's own public signature, not what it forwards. Add real guards for both paths: assertOpenWebSocketArgsCarryNoIdentity finds the callbacks object by its onParticipantList shape (not by index) and checks every string-typed argument against the expected, non-identity set, so a re-introduced email/display_name anywhere in the call fails immediately. Covers startSharing's attachAsHost and joinSession. Also close the sent-frames gap the review flagged as neither fixed nor acknowledged: sendOutput/sendInput/requestControl/grantControl/revokeControl never serialize an identity string, verified directly.
A single leading letter collapses to 20 possible values over the generated handle wordlist, so every merry-* user rendered the same glyph. MultiplayerBar had its own slice(0, 2) initials that disagreed; both go through handleInitials now.
There is no endpoint left to call. The handle control is the account section's identity field. getCurrentDisplayName and fetchAndCacheDisplayName had no production callers and go with it.
No migration and no handler version: project() already runs on replaceAll, which is the path the E2EE sync blob and the import UI both take, so a stale field from a device that has not updated is discarded on arrival. PeopleTab.tsx and teamSharing.ts read RecentPerson.display_name; both now fall back to handle, since that is all a Recent row carries.
Clicking a teammate row in this file's PeopleTab exercises the real, unmocked recentPeopleStore, which persists across tests within the file. The roster fixture had no handle field, so a successful invite cached an empty handle into Recent — and PeopleTab now renders that handle, not a display name, so later queries for "alice" found an empty row instead. Fixes: give the roster/carol fixtures real handle-shaped values instead of human names (the standard the removal plan sets — a name like "Alice" hides truncation/initials problems a real handle exposes), query for those handles, and reset the real store in beforeEach so a write in one test can't leak into the next.
Members, vaults, the vault header, the people tab and the notification inbox all read handle. invited_by_display_name keeps its key — the server still sends it; only the value is a handle now.
TeamMember.handle became required, removing the only tolerance for a self-hosted server without migration 035 — a throw here white-screens the app since there is no error boundary. Restore handle?: string and make every consumer (avatarColor, handleInitials, MembersPage sort/ search/self-card, PeopleTab, teamSharing, the plugin domain) safe against a missing or empty handle instead. Also: drop legacy voltius-recent-people rows with an empty handle on rehydration (project() never ran on read, so a pre-0.26 row rendered as a bare '@' and could never match a search); fix two comments that mis-stated invited_by_display_name/inviter_display_name as having a handle key beside them when the field name is the only alias there is; delete the now-dead getMyEmail; and stop MembersPage's self-card from fetching /v1/auth/me on every mount and losing the offline email fallback, sourcing the handle from the already-loaded member list with a keychain read as fallback.
Finding 3: onRehydrateStorage's callback closed over useRecentPeopleStore, but zustand's persist runs hydrate() synchronously inside create() for sync storage (toThenable resolves inline, not via microtask) — so the callback fired while the module's own const was still in the temporal dead zone, threw, and was silently swallowed by hydrate()'s catch. Moved the drop to version: 1 + migrate, which runs as a pure data transform with no closure over the store binding, and also re-persists the cleaned list. Rewrote the test to exercise real module-load hydration (vi.resetModules + dynamic import) instead of a manual persist.rehydrate() call after the module is already initialized — confirmed the new test fails against the previous onRehydrateStorage implementation and passes against this one. Finding 6: extracted the keychain-handle-with-getMe-fallback read that SidebarAccountButton.tsx had (and MembersPage.tsx's self-card was missing) into one shared getMyHandle() in services/account.ts, used by both. Fixes two problems: MembersPage's keychain miss resolved to null forever (the skeleton's loading state), so any account without a cached handle showed a permanently pulsing skeleton instead of degrading to the you label; and the myMember?.handle half of the old merge was dead code, since myHandle is only read in the branch where teamId is null and members is always [].
refreshAccountInfo awaited getMyHandle() inside its Promise.all, so openDropdown blocked for up to 10s whenever the keychain had no cached handle and getMe() had to hit the network. Fire handle resolution separately so the dropdown opens on local reads alone. Also close the second door on the empty-handle bug: replaceAll now filters empty-handle rows like remember does, so a peer's synced list or an old export can't reinstate one.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Client half of the
users.display_nameremoval. Pairs with VoltiusApp/server#5.The security fix
Every call site passed the user's own email address as the WebSocket
display_namequery parameter, and the server echoed it to every participant — so a stranger admitted by a knock invite learned every participant's real email. The client now sends nothing; the server resolves the name fromusers.handle.The plan named four call sites. A pre-flight scan found a fifth (
teamSessionJoin.ts, the shared join path) and the implementer found a sixth —teamSessionStore.attachAsHost, the host path, with its own independentgetCurrentUserEmail()forward. Review confirmed there is no seventh:new WebSocket(appears exactly once in the client, reached only viaopenWebSocket, which has exactly two production callers.teamSessionStore.test.tsnow asserts the exact set of string arguments reachingopenWebSocket, so an identity string reintroduced at any position fails the test.What changed
Participantcarrieshandle; the client readshandle, never the compatibility aliashandleInitials()gives two-letter avatars — a single leading letter collapsed to ~20 values across the generated-handle wordlist.MultiplayerBar's duplicateslice(0, 2)implementation folded into itupdateDisplayName,getCurrentDisplayName,fetchAndCacheDisplayNameand their i18n strings deleted."display_name"stays in theaccountCacheKeyspurge list until 0.27, to clear the key from devices that cached oneRecentPersondropsdisplay_name; aversion: 1+migratedrops legacy empty-handle rowshandle. Three wire keys keep their names and change only their value: thedisplay_namealias,invited_by_display_name,inviter_display_name. The plugin API keepsdisplayName— third-party bundles read itTeamMember.handlestays optional so an older self-hosted server cannot white-screen the app;avatarColorandhandleInitialsare guarded at the helper.Testing
453 files / 3402 tests,
tsc --noEmitclean, run on a quiet worktree.