Skip to content

feat!: render handles instead of display names - #124

Merged
kipavy merged 11 commits into
devfrom
feat/remove-display-name
Aug 16, 2026
Merged

feat!: render handles instead of display names#124
kipavy merged 11 commits into
devfrom
feat/remove-display-name

Conversation

@kipavy

@kipavy kipavy commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Client half of the users.display_name removal. Pairs with VoltiusApp/server#5.

The security fix

Every call site passed the user's own email address as the WebSocket display_name query 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 from users.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 independent getCurrentUserEmail() forward. Review confirmed there is no seventh: new WebSocket( appears exactly once in the client, reached only via openWebSocket, which has exactly two production callers.

teamSessionStore.test.ts now asserts the exact set of string arguments reaching openWebSocket, so an identity string reintroduced at any position fails the test.

What changed

  • Participant carries handle; the client reads handle, never the compatibility alias
  • handleInitials() gives two-letter avatars — a single leading letter collapsed to ~20 values across the generated-handle wordlist. MultiplayerBar's duplicate slice(0, 2) implementation folded into it
  • Display-name editor, updateDisplayName, getCurrentDisplayName, fetchAndCacheDisplayName and their i18n strings deleted. "display_name" stays in the accountCacheKeys purge list until 0.27, to clear the key from devices that cached one
  • RecentPerson drops display_name; a version: 1 + migrate drops legacy empty-handle rows
  • ~40 files renamed to handle. Three wire keys keep their names and change only their value: the display_name alias, invited_by_display_name, inviter_display_name. The plugin API keeps displayName — third-party bundles read it

TeamMember.handle stays optional so an older self-hosted server cannot white-screen the app; avatarColor and handleInitials are guarded at the helper.

Testing

453 files / 3402 tests, tsc --noEmit clean, run on a quiet worktree.

kipavy added 11 commits August 15, 2026 17:55
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.
@kipavy
kipavy merged commit 7c172c8 into dev Aug 16, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant