Repository navigation
feat(vaults): vault invite links, and an explicit Share verb (#68) - #226
Merged
Merged
Conversation
Closes the two remaining bullets of #68. ## Vault invite links **Addressed link.** The pending-invitation row's link button copies `voltius://notification?n=invite:<id>`, which lands the invitee on their own inbox entry where Accept and Decline already are. No new route and no new server work: the entry exists, and `notification` is already `navigate` class because the link carries no capability — the invitation itself is what confers anything, and it is already bound to one user. **Open link.** New `vault-join` route in the `confirm` trust class, against the server half already on main (migration 038, `src/team_join_grants.rs`). A new Links tab in the share sheet mints, lists and revokes grants; the sheet names the team, the role and the inviter before Join does anything. A link can never carry vault access. Vault keys are wrapped per member with X25519, so a grant confers membership only and the sheet says so: "You'll join straight away, but the vault stays locked until someone who already has its key is online." `account_id` is the KDF salt and appears in no link — the codec has exactly two parameters, and a test pins that. The secret rides in the fragment of the https form, so it never reaches a web server log. The keyless window from #41 needs no new state: a redeemer lands in `awaiting_key`, which #70 already renders as "Waiting for access — waiting for @owner to grant access", plus an inbox entry and a retry. The status contract was read off the server and is matched on a `JoinGrantError` code, never on a message: 404 wrong id or secret, 410 revoked or expired, 409 exhausted, 402 seat cap, 400 on redeem for a redeemer with no published key. ## Explicit Share verb The vault header's bare `+`, tooltipped "Invite member", already opened the share sheet. It now says Share. That is the whole verb — no second entry point and no second surface to keep in step. A sidebar context-menu entry was considered and dropped: the rail has no context menu, and a popover anchored away from the click reads as a glitch. It is now shown for a private vault too. The sheet's `!teamId` branch *is* the conversion consent gate, so hiding the verb until a team existed put it everywhere except the one place it matters most. ## Bugs found by driving the running app against a live server 1. **Four dead buttons.** `VaultShareSheet` passed `() => {}` for `onRemove`, `onRevoke`, `onGrantKey` and `onCopyInviteLink` while `PeopleList` rendered a button for each. Every one is now wired. 2. **The share popover was blank for any invited member.** The sheet resolved `vaultId` only against local vaults, but the sidebar's standalone-team entry passes a *team* id — so everyone without a local vault got nothing at all. 3. **"Awaiting key" was read from the viewer's own vault status** and applied to the whole roster, so a reader who was waiting saw everyone as waiting. It now comes per member from `getVaultKeyHolders`, and says nothing while unknown. 4. **"Grant now" failed silently for a non-key-holder.** `distributeKeyToNewMember` returns quietly when this device cannot unwrap the key — right for the background reconcile, wrong for a button. It now says so. 5. Row layout: an untruncated handle beside chips and actions collapsed into a three-line column at the popover's width, and an email into five. 6. The Links form pushed Create below the popover's 320px fold; it folds away behind "New link", and the surface now allows 480. 7. Expiry read "6 days left" on a link minted seconds ago with a 7-day life. The unit still floors, but the count rounds, and the exact instant is a tooltip. ## DRY The authenticated-fetch shape existed four times — `sync`, `teamVaultSync`, `teamService`, `auditService` — and had drifted: `auditService`'s copy did neither the pre-refresh nor the 401 retry, so an audit page opened on a stale token failed instead of recovering. All four now use `services/authFetch`. Member removal, invitation revocation and the post-join refresh were likewise extracted so the Members page and the share sheet share one implementation. Verified live against an isolated `voltius-server` + `postgres:16-alpine`, not prod: every status above observed on the wire, and the whole flow driven in the running app — convert, mint, redeem as a second account, land in `awaiting_key`, grant the key, watch the row clear, then revoke the link. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014gY5k7XbeR5zoTXdzsuWxF
kipavy
force-pushed
the
feat/vault-join-links
branch
from
September 8, 2026 10:54
7d181e9 to
bf0bb70
Compare
This was referenced Sep 8, 2026
kipavy
added a commit
that referenced
this pull request
Sep 8, 2026
The #226 code narrated itself: block headers restating what the next lines do, multi-line rationale that belongs in the commit message, and JSDoc on obvious test helpers. It also matched the dense commenting of the surrounding vault-share files, which is not the house style to copy. What stays is the load-bearing kind only, one or two lines each: why the key-holder check happens in the button path rather than in distributeKeyToNewMember, why the expiry unit floors but the count rounds, why vaultId can be a team id, why fetchAuthRateLimited is not folded into fetchAuthJson, and that a link carries membership and never the vault key. No behaviour change; 1381 tests across the touched suites pass. Claude-Session: https://claude.ai/code/session_014gY5k7XbeR5zoTXdzsuWxF Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.
Closes the two remaining bullets of #68.
1. Vault invite links
Addressed link. The pending-invitation row's link button copies
voltius://notification?n=invite:<id>, landing the invitee on their own inbox entry where Accept and Decline already are. No new route, no new server work — the entry exists, andnotificationis alreadynavigateclass because the link carries no capability at all: the invitation row is what confers anything, and it is already bound to one user.Open link. A new
vault-joinroute in theconfirmtrust class, coded against the server half already onmain(migration 038,src/team_join_grants.rs). A Links tab in the share sheet mints, lists and revokes grants.🔴 A link can never carry vault access. Vault keys are wrapped per member with X25519, so a grant confers membership only — and the confirm sheet says so rather than letting a joiner assume otherwise:
account_idis the KDF salt and appears in no link: the codec has exactly two parameters (g,k) and a test pins that. The secret rides in the fragment of thehttpsform, so it never reaches a web server log, a CDN or aReferer.The secret is returned by the mint call and never again — only its sha256 is stored. So a grant listed back from the server has no Copy button; it says "Link no longer viewable — revoke it and make a new one." rather than offering a button that would produce a broken link.
The #41 keyless window: already handled, nothing new needed
A redeemer becomes a member with no key, and
getTeamVaultKey404s →awaiting_key, which #70 already renders as a real waiting state, plus anawaitingKeyinbox entry and ateamDataManagerretry. Observed live after redeeming:Status contract
Read off the server (the API contract did not pin it) and matched on a
JoinGrantErrorcode, never a message — a translated string is not a protocol:owneris never offered as a link role — the server answers 400.max_usesclamps to [1,500] andexpires_in_secsto [60, 2592000]; the form mirrors both, so a link's printed terms are never a lie.2. The explicit Share verb — narrow, no new sheet
The vault header's bare
+, tooltipped "Invite member", already opened the share sheet. It now says Share. That is the whole verb: no second entry point, no second surface to keep in step. A sidebar context-menu entry was considered and dropped — the 4.75rem rail has no context menu today, and a popover anchored away from the click reads as a glitch.It is now also shown for a private vault. The sheet's
!teamIdbranch is the conversion consent gate, so hiding the verb until a team already existed put it everywhere except the one place it matters most: pressing Share on a private vault now opens the cost list before anything converts.The reverted tabbed sheet was not rebuilt.
MembersPagekeeps its bulk role actions, member detail panel, role filter, Roles button, seats meter and person-first invite-to-session.Bugs found by driving the running app against a live server
None of these were in the issue; all were invisible to the test suite.
VaultShareSheet— which renders live as the vault-header popover — passed() => {}foronRemove,onRevoke,onGrantKeyandonCopyInviteLinkwhilePeopleListrendered a button for each. All four are now wired.vaultIdonly against local vaults, but the sidebar's standalone-team entry passes a team id — so anyone without a local vault (i.e. every invitee) opened the sheet and got nothing.statusByTeamId[teamId] === "awaiting_key"marked the whole roster, so a reader who was waiting saw every member as waiting — and would have been offered "Grant now" against people who already had a key. It now comes per member fromgetVaultKeyHolders, and says nothing while that is unknown.distributeKeyToNewMemberreturns quietly when this device cannot unwrap the key — correct for the background reconcile, wrong for a button someone pressed. It now reports "You don't hold this vault's key, so you can't grant it."DRY
The authenticated-fetch shape existed four times —
sync,teamVaultSync,teamService,auditService— and had drifted:auditService's copy did neither the pre-expiry refresh nor the 401 retry, so an audit page opened on a stale token failed instead of recovering. All four now useservices/authFetch, behaviour-preserving per call site. Member removal, invitation revocation and the post-join refresh were likewise extracted, so the Members page and the share sheet share one implementation instead of a third copy.Verification
Live, against an isolated
voltius-server:latest+postgres:16-alpine— never prod. Every status above observed on the wire:And the whole flow driven in the running app with two real accounts: convert a private vault through the consent gate → mint an editor link → redeem it as a second account → land in
awaiting_key→ grant the key → watch the row clear (confirmed in the DB) → revoke the link.Typecheck clean; the suite passes in chunks (services + i18n 1112, stores/hooks/utils/plugins 1863, components 806+571, ≈5,800 tests).
Notes for review
GET /grants: the server'slist_livefilters on revocation and expiry, not on uses. The tab is honest about it ("0 uses left of 1") rather than hiding it, and it can be revoked.🤖 Generated with Claude Code
https://claude.ai/code/session_014gY5k7XbeR5zoTXdzsuWxF