Skip to content

feat: unified invite flow — knock any Voltius user (server) - #4

Merged
kipavy merged 22 commits into
mainfrom
feat/unified-invite-flow
Aug 15, 2026
Merged

feat: unified invite flow — knock any Voltius user (server)#4
kipavy merged 22 commits into
mainfrom
feat/unified-invite-flow

Conversation

@kipavy

@kipavy kipavy commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Server half of the unified invite flow: a host can now find any Voltius user — teammate or not — by @handle or full email address and knock on them, and the recipient joins in one tap or declines.

Pairs with VoltiusApp/voltius#122 (client). This one must land and deploy first: the client degrades safely against an old server (search simply returns fewer rows), but the client half is inert without these endpoints.

What it adds

  • A handle namespace (migration 035). Every user gets one at registration and existing users are backfilled, so "copy my address" works for a free account without exposing an email. Pro and above may claim a custom handle, which is what makes them fuzzy-searchable. Handles are never recycled — released ones move to retired_handles permanently, so a remembered @kevin can never become a stranger wearing that name. A reserved list rejects vendor names and their near-variants (voltius-support, adm1n).
  • A rewritten /v1/users/search. Teammates match fuzzily on name and email; anyone with a custom handle matches fuzzily on that handle; everyone else resolves only on a full email address or an exact handle. Email-substring matching is deleted — it was an enumeration oracle, and deleting it beats rate-limiting it. public_key is removed from the response; the endpoint gains its own limiter.
  • Stranger knocks in grant_invitee, gated on the recipient's opt-out, their block list, and a per-sender budget (STRANGER_KNOCK_RATE_LIMIT, default 20/hour).
  • Name redaction: an unaccepted stranger sees no session name, no participant list and no participant count. A mis-aimed invite leaks a handle, never a hostname.
  • Decline and un-invite, both clearing all three pieces — durable row, wrapped key, and the in-memory admission set. Decline blocks the sender for 7 days silently, or permanently on request.
  • GET /v1/users/:id/public-key, because key material must be read fresh at wrap time and a stranger shares no team roster to read it from.

Two things worth a reviewer's attention

Blocks and opt-outs are indistinguishable from success, by design — a sender must learn nothing. That is why suppression writes no grant, and why suppressed_invites exists: without it the host's own invitee_ids would reveal the outcome by omission. A suppressed knock therefore occupies a guest seat exactly as a real pending invite does; un-invite frees it. A residual timing difference remains (the granted path genuinely writes rows) and is documented rather than padded.

Migration 035's backfill was quadratic and measured 96s at 20k users inside the startup transaction, with ACCESS EXCLUSIVE on users — the server would have been down for the duration. Creating the unique index before the backfill brings it to 4.3s at the same size. Timings were measured, not estimated. The migration has never been released, but any developer database that applied an earlier revision of it must be dropped or sqlx will refuse to start.

Behaviour change on a shipped surface

The Members invite panel calls the same search endpoint, so it gains handle lookups and loses email-substring matching. It is only harder than before when the invitee has no custom handle and the inviter does not know their full email address; anyone on Pro or above who claimed a handle is easier to invite than before.

Verification

236 tests pass against a real Postgres, clippy clean at -D warnings. Verified live in a two-account run against a throwaway stack: the knock arrives with no session name, decline writes nothing on the next knock, un-invite returns 403 to a non-host and 204 to the host with rows and key gone, and the redaction holds on the wire and not merely in SQL.

Known limit

Sock puppets are not solved: accounts are free and blocks are per-user-id. The escalation lever, if abuse appears, is restricting knocks to handle-holders or paid accounts. Knocks are logged to tracing rather than audit_logs, which requires a team_id a stranger knock does not have.

kipavy added 22 commits August 15, 2026 01:08
… invitee list

Adds suppressed_invites so a blocked/opted-out stranger still occupies a
seat in the host's own invitee_ids, and dedupes the direct-session-with-
teammate test fixture across its pre-existing copies.
GET /v1/teams/:team_id/members never returned a user's handle, so
teammate rows in the unified People tab would be the only ones
without an @handle under the display name. Add handle to TeamMember
and its query. handle_is_custom is left out to match
UserSearchResult, which already omits it — nothing consumes it.
…ts revoke

revoke_grants_for_departed_member cleared the invitee row, the wrapped
key and the in-memory admission entry but left suppressed_invites
behind. That row exists so a host can't tell a decline/block apart
from a real pending invite — surviving it kept a guest seat occupied
with nothing to free it after the member left.

Both revoke paths now derive their side-table deletes from one list
(GRANT_SIDE_TABLES) so a future table can't drift out of one of them
again; terminal_session_invitees itself stays bespoke per path since
the bulk form's selection query differs (set-scoped with a teammate
check vs. a plain pair delete).
Duplicated verbatim between routes::users and routes::teams tests,
with the teams.rs copy's own comment admitting it was copied from the
first. test_support already carries the other shared test fixtures.
035's per-row uniqueness probe ran without an index, so each of N candidates
sequentially scanned a users table that gained a non-NULL handle every
iteration — quadratic, inside the one transaction sqlx holds ACCESS EXCLUSIVE
on users for, i.e. server downtime. Measured on a scratch database: 1.70s at
2,000 users but 140.66s at 20,000.

Creating the unique index first is legal on an all-NULL column (NULLs are not
indexed for uniqueness) and turns the probe into an index lookup. Same 20,000
rows, same 20,000 distinct handles: 2.72s.
…ed one

An expired trial keeps subscription_tier = 'pro' and is downgraded only by
entitlement::effective_tier, which /v1/auth/me applies and the handle claim did
not. A lapsed account was shown the free-tier upsell and could still claim a
custom handle by calling the endpoint directly — permanently, since
handle_is_custom never reverts.

terminal.rs already had this exact read as owner_effective_tier; promote it to
entitlement::effective_tier_for_user rather than let a third tier check exist.
The test database is persistent and accumulates users, so an unchecked
generate_handle eventually collides on the handle unique index.
A stranger knock is the one surface a recipient reads before granting terminal
access, and the client built its inviter name from a participant's
display_name — which arrives in the sender's own WebSocket query string. A
sender could connect to their own session as "Voltius Support" and knock under
that name, walking straight past the reserved-handle list that refuses
@voltius-support at claim time.

visible_sessions now joins users on the caller's own invited_by and returns
invited_by_handle: a server-owned value, which is what makes it trustworthy.
It is caller-supplied, unbounded and rendered in participant lists. Control
characters are refused outright — no real client sends them — and length is
capped at 64 characters, which is truncated rather than refused since a merely
long name is plausible input.
The session-end cleanup looped over a hardcoded pair of tables, stating in its
own comment the invariant it broke: a soft end never fires ON DELETE CASCADE,
so every per-invitee table must be cleared explicitly. suppressed_invites was a
third, and its rows survived forever — a durable record that one user blocked
or opted out of another, which is exactly the social graph D9 refused to create.

Derived from GRANT_SIDE_TABLES so all three deletion sites read one list.
D7 promises a mis-aimed invite leaks a handle, never a hostname.
visible_sessions redacted connection_name, and then list_active_sessions
attached the live participant list and headcount to the same row — every
current participant's display name, to someone who has not accepted. Blank both
when the name is redacted. host_public_key stays: it is inert and plausibly
needed before joining.
The opt-out short-circuited the block query, so granted, opted-out and
blocked knocks each cost a different number of round trips — a live run
measured 0/20 granted knocks below the median suppressed one. Both reads
now always run and the decision is taken afterwards.
@kipavy
kipavy merged commit 215d7ce into main Aug 15, 2026
2 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