Summary
When an invitee accepts a team-vault invitation while the only key-holder (the inviter) is offline, they become a full team member but never receive the wrapped team-vault key. Their client then silently polls GET /v1/teams/{id}/vault-key → 404 in a loop with no user-facing explanation — the shared vault just fails to open. It only self-heals if/when the inviter next comes online.
This was found during an end-to-end test with two real accounts against a live server.
How team-vault key distribution works today
The team-vault key (random 32-byte key, X25519-wrapped per member) is only distributed in two places:
- At team creation —
initTeamVaultKey() wraps the key for whatever members exist at that moment (src/services/teamVaultSync.ts:179).
- Reactively — the sync loop's
team_members: SSE handler wraps the key for newly-seen members (src/services/sync.ts:785-802), run by clients that already hold the key.
The reactive path computes "new members" as a diff against the client's current membersByTeam:
// src/services/sync.ts:787-801
const prevMemberIds = new Set((useTeamStore.getState().membersByTeam[teamId] ?? []).map(m => m.user_id));
await Promise.all([ loadTeams(), loadMembers(teamId), loadRoles(teamId) ]);
const newMembers = (membersByTeam[teamId] ?? []).filter(m => !prevMemberIds.has(m.user_id) && m.public_key);
if (newMembers.length > 0) {
const { distributeKeyToNewMember } = await import("@/services/teamVaultSync");
await Promise.allSettled(newMembers.map(m => distributeKeyToNewMember(teamId, m.user_id, m.public_key)));
}
The gap
There is no reconciliation for already-existing members who lack a key. Distribution is purely event-driven and requires a key-holder to be online at the moment the membership event fires. Two consequences:
- The adder never self-distributes.
addMemberById calls loadMembers() (src/stores/teamStore.ts:88), so by the time the adder's own team_members: event arrives, the new member is already in membersByTeam → newMembers is empty → skipped. In a team where the inviter is the only key-holder (i.e. the first invite to a freshly-created shared vault), nobody distributes at add-time.
- Async acceptance loses the event. If the invitee accepts later, while the inviter is offline, the
team_members: event is never delivered to a key-holder. On the inviter's next login there's no re-check for keyless members, other than the incidental stale-membersByTeam-vs-fresh diff (which is what accidentally rescued the test case ~40s after the inviter reconnected).
Reproduction
- Account A (business) creates/shares a vault → team vault; A holds the only key.
- A invites B, then closes the app (or just goes offline).
- B accepts the invitation → B is now a
team_member, but team_vault_keys has no row for B.
- B opens the shared vault → it silently fails; B's client polls
GET vault-key → 404 indefinitely.
- B stays locked out until A happens to reopen the app and re-sync.
Evidence (from the E2E run)
team_vault_keys had only A after B accepted; server logged repeated Vault key not found for user <B> → 404.
- When A reconnected, A's client did
PUT …/vault-key … key_count=2 and B's next fetch logged Vault key fetched; the shared vault then materialised for B.
Impact
- Correctness: async invite-acceptance (invitee accepts hours later) is a completely normal flow, and it leaves the invitee locked out with no recovery path until the inviter returns.
- UX: even in the transient online case there's a window where the vault silently fails with a tight
404 poll loop and zero user feedback.
Suggested fixes
- UI/UX (minimum): when a member has joined a team vault but has no key yet, show a clear "Waiting for a vault owner to grant you access" state instead of a blank/failed vault + silent
404 loop. Back off the poll.
- Reconciliation (better): on a key-holder's login / team load, detect members present in
team_members but absent from team_vault_keys and (re)distribute to them — not just event-diff newcomers.
- Optionally server-assisted: let the server flag "N members awaiting keys" so a key-holder client knows to run distribution proactively.
Notes
- Prod team features are currently gated behind unavailable subscriptions, so this is not user-facing yet — but it should be resolved before those subscriptions go live.
- Found via a two-account E2E harness (isolated server + two app instances). The rest of the team-vault crypto handoff and live multiplayer/presence/control all worked correctly end-to-end.
Summary
When an invitee accepts a team-vault invitation while the only key-holder (the inviter) is offline, they become a full team member but never receive the wrapped team-vault key. Their client then silently polls
GET /v1/teams/{id}/vault-key → 404in a loop with no user-facing explanation — the shared vault just fails to open. It only self-heals if/when the inviter next comes online.This was found during an end-to-end test with two real accounts against a live server.
How team-vault key distribution works today
The team-vault key (random 32-byte key, X25519-wrapped per member) is only distributed in two places:
initTeamVaultKey()wraps the key for whatever members exist at that moment (src/services/teamVaultSync.ts:179).team_members:SSE handler wraps the key for newly-seen members (src/services/sync.ts:785-802), run by clients that already hold the key.The reactive path computes "new members" as a diff against the client's current
membersByTeam:The gap
There is no reconciliation for already-existing members who lack a key. Distribution is purely event-driven and requires a key-holder to be online at the moment the membership event fires. Two consequences:
addMemberByIdcallsloadMembers()(src/stores/teamStore.ts:88), so by the time the adder's ownteam_members:event arrives, the new member is already inmembersByTeam→newMembersis empty → skipped. In a team where the inviter is the only key-holder (i.e. the first invite to a freshly-created shared vault), nobody distributes at add-time.team_members:event is never delivered to a key-holder. On the inviter's next login there's no re-check for keyless members, other than the incidental stale-membersByTeam-vs-fresh diff (which is what accidentally rescued the test case ~40s after the inviter reconnected).Reproduction
team_member, butteam_vault_keyshas no row for B.GET vault-key → 404indefinitely.Evidence (from the E2E run)
team_vault_keyshad only A after B accepted; server logged repeatedVault key not found for user <B>→404.PUT …/vault-key … key_count=2and B's next fetch loggedVault key fetched; the shared vault then materialised for B.Impact
404poll loop and zero user feedback.Suggested fixes
404loop. Back off the poll.team_membersbut absent fromteam_vault_keysand (re)distribute to them — not just event-diff newcomers.Notes