feat: unified invite flow — knock any Voltius user (server) - #4
Merged
Conversation
… 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.
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.
Server half of the unified invite flow: a host can now find any Voltius user — teammate or not — by
@handleor 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
retired_handlespermanently, so a remembered@kevincan never become a stranger wearing that name. A reserved list rejects vendor names and their near-variants (voltius-support,adm1n)./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_keyis removed from the response; the endpoint gains its own limiter.grant_invitee, gated on the recipient's opt-out, their block list, and a per-sender budget (STRANGER_KNOCK_RATE_LIMIT, default 20/hour).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_invitesexists: without it the host's owninvitee_idswould 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 EXCLUSIVEonusers— 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
tracingrather thanaudit_logs, which requires ateam_ida stranger knock does not have.