Skip to content

feat(vaults): vault invite links, and an explicit Share verb (#68) - #226

Merged
kipavy merged 1 commit into
devfrom
feat/vault-join-links
Sep 8, 2026
Merged

kipavy merged 1 commit into
devfrom
feat/vault-join-links

Conversation

@kipavy

@kipavy kipavy commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

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, and notification is already navigate class 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-join route in the confirm trust class, coded against the server half already on main (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:

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 (g, k) and a test pins that. The secret rides in the fragment of the https form, so it never reaches a web server log, a CDN or a Referer.

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 getTeamVaultKey 404s → awaiting_key, which #70 already renders as a real waiting state, plus an awaitingKey inbox entry and a teamDataManager retry. Observed live after redeeming:

Waiting for access — Waiting for @rapid-nimbus-6491 to grant access — they need to come online once.

Status contract

Read off the server (the API contract did not pin it) and matched on a JoinGrantError code, never a message — a translated string is not a protocol:

status meaning
404 wrong id or wrong secret (deliberately indistinguishable)
410 revoked or expired
409 exhausted
402 seat cap (enforced at redeem; existing seat-holders exempt)
400 (redeem) redeemer has published no public key

owner is never offered as a link role — the server answers 400. max_uses clamps to [1,500] and expires_in_secs to [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 !teamId branch 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. MembersPage keeps 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.

  1. Four dead buttons in a shipped surface. VaultShareSheet — which renders live as the vault-header popover — passed () => {} for onRemove, onRevoke, onGrantKey and onCopyInviteLink while PeopleList rendered a button for each. All four are now wired.
  2. The share popover was blank for every invited member. The sheet resolved vaultId only 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.
  3. "Awaiting key" was the viewer's own status, applied to everyone. 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 from getVaultKeyHolders, and says nothing while that is unknown.
  4. "Grant now" failed silently for a non-key-holder. distributeKeyToNewMember returns 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."
  5. Row layout collapsed at the popover's width — an untruncated handle beside chips and actions squeezed into a three-line column, and an email address into five.
  6. The Links form pushed Create below the 320px fold. It now folds behind "New link", and the surface allows 480 — it carries three tabs now, not a short member list.
  7. Expiry read "6 days left" on a link minted seconds ago with a 7-day life. The unit still floors (hours never inflate to days), 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-expiry refresh nor the 401 retry, so an audit page opened on a stale token failed instead of recovering. All four now use services/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:

role=owner -> 400    manager/editor/member/connect-only -> 201
max_uses 99999 -> 500       expires_in_secs 1 -> +60s
preview wrong secret -> 404   wrong grant id -> 404   good -> 200
redeem keyless -> 400   redeem -> 200   re-redeem as member -> 200 (no use spent)
then exhausted -> 409       revoke -> 204   preview after revoke -> 410

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

  • An exhausted grant stays in GET /grants: the server's list_live filters 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.
  • Unrelated and pre-existing: switching accounts in-session leaves the owner unable to unwrap her own team vault key ("Couldn't load team vault") — the server returns the wrapped key 200 and the DB is consistent, so it is the local vault key after a switch. A fresh sign-in resolves it. Nothing in this branch touches key derivation; worth its own issue.

🤖 Generated with Claude Code

https://claude.ai/code/session_014gY5k7XbeR5zoTXdzsuWxF

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
kipavy force-pushed the feat/vault-join-links branch from 7d181e9 to bf0bb70 Compare September 8, 2026 10:54
@kipavy
kipavy merged commit 7b550a8 into dev Sep 8, 2026
4 checks passed
@kipavy
kipavy deleted the feat/vault-join-links branch September 8, 2026 10:57
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>
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