From 43908708a00d32c771071b1087c5f4bff8286862 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 01:08:31 +0000 Subject: [PATCH 01/22] feat(users): add the handle namespace, blocks table and invitee acceptance column --- migrations/035_user_handles_and_blocks.sql | 59 ++++++ src/handles.rs | 231 +++++++++++++++++++++ src/main.rs | 1 + src/routes/admin.rs | 6 +- src/routes/auth.rs | 7 +- src/test_support.rs | 12 +- 6 files changed, 308 insertions(+), 8 deletions(-) create mode 100644 migrations/035_user_handles_and_blocks.sql create mode 100644 src/handles.rs diff --git a/migrations/035_user_handles_and_blocks.sql b/migrations/035_user_handles_and_blocks.sql new file mode 100644 index 0000000..6e7acf4 --- /dev/null +++ b/migrations/035_user_handles_and_blocks.sql @@ -0,0 +1,59 @@ +-- Every user gets an address that is not their email, so "copy my address" +-- works for a free account without exposing a mailbox. Handles are never +-- recycled: a remembered @kevin must not become a stranger wearing that name. + +ALTER TABLE users ADD COLUMN handle TEXT NULL; +ALTER TABLE users ADD COLUMN handle_is_custom BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE users ADD COLUMN handle_updated_at TIMESTAMPTZ NULL; +ALTER TABLE users ADD COLUMN allow_stranger_invites BOOLEAN NOT NULL DEFAULT TRUE; + +-- Backfill: same adjective-noun-4digit shape the server generates, retried per +-- row until unique. Deterministic fallback after 10 tries so the migration can +-- never spin on an unlucky namespace. +DO $$ +DECLARE + adjectives TEXT[] := ARRAY['swift','quiet','bright','calm','brave','clever','eager','gentle','happy','jolly', + 'kind','lively','merry','noble','proud','quick','rapid','sunny','tidy','witty']; + nouns TEXT[] := ARRAY['otter','falcon','cedar','harbor','lantern','meadow','nimbus','opal','pebble','quartz', + 'ridge','sparrow','thistle','umber','violet','willow','yarrow','zephyr','anchor','beacon']; + r RECORD; + candidate TEXT; + attempt INT; +BEGIN + FOR r IN SELECT id FROM users WHERE handle IS NULL LOOP + attempt := 0; + LOOP + attempt := attempt + 1; + IF attempt > 10 THEN + candidate := 'user-' || substr(replace(r.id::text, '-', ''), 1, 12); + ELSE + candidate := adjectives[1 + floor(random() * array_length(adjectives, 1))::int] + || '-' || nouns[1 + floor(random() * array_length(nouns, 1))::int] + || '-' || lpad(floor(random() * 10000)::text, 4, '0'); + END IF; + EXIT WHEN NOT EXISTS (SELECT 1 FROM users WHERE lower(handle) = candidate); + END LOOP; + UPDATE users SET handle = candidate WHERE id = r.id; + END LOOP; +END $$; + +ALTER TABLE users ALTER COLUMN handle SET NOT NULL; +CREATE UNIQUE INDEX idx_users_handle ON users (LOWER(handle)); + +CREATE TABLE retired_handles ( + handle TEXT PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + released_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE user_blocks ( + blocker_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + blocked_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + expires_at TIMESTAMPTZ NULL, -- NULL = permanent + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (blocker_id, blocked_id) +); + +-- NULL until the invitee's WebSocket is first admitted. Drives both the +-- session-name redaction and the "unaccepted stranger" state. +ALTER TABLE terminal_session_invitees ADD COLUMN accepted_at TIMESTAMPTZ NULL; diff --git a/src/handles.rs b/src/handles.rs new file mode 100644 index 0000000..261cafd --- /dev/null +++ b/src/handles.rs @@ -0,0 +1,231 @@ +//! The handle namespace: every user has one, it is never recycled, and a custom +//! one is what makes an account fuzzy-searchable. +//! +//! The custom-handle validation path (`validate_custom_handle` and friends) has +//! no caller yet — the claim endpoint that lets a user set one lands in a later +//! task. Suppress dead-code here rather than that task; the foundation is meant +//! to sit unconsumed for a while. +#![allow(dead_code)] + +use rand::Rng; + +const ADJECTIVES: &[&str] = &[ + "swift", "quiet", "bright", "calm", "brave", "clever", "eager", "gentle", "happy", "jolly", + "kind", "lively", "merry", "noble", "proud", "quick", "rapid", "sunny", "tidy", "witty", +]; +const NOUNS: &[&str] = &[ + "otter", "falcon", "cedar", "harbor", "lantern", "meadow", "nimbus", "opal", "pebble", + "quartz", "ridge", "sparrow", "thistle", "umber", "violet", "willow", "yarrow", "zephyr", + "anchor", "beacon", +]; + +/// Names that must never be claimable: a `@voltius-support` asking to share your +/// terminal is the phishing shape this feature would otherwise create. +const RESERVED: &[&str] = &[ + "admin", + "administrator", + "support", + "help", + "helpdesk", + "voltius", + "security", + "billing", + "root", + "system", + "staff", + "moderator", + "mod", + "official", + "team", +]; + +#[derive(Debug, PartialEq, Eq)] +pub enum HandleError { + TooShort, + TooLong, + Charset, + EdgeSeparator, + Reserved, +} + +/// A fresh generated handle. Uniqueness is the caller's job — the DB unique +/// index is the authority and the caller retries on conflict. +pub fn generate_handle() -> String { + let mut rng = rand::thread_rng(); + format!( + "{}-{}-{:04}", + ADJECTIVES[rng.gen_range(0..ADJECTIVES.len())], + NOUNS[rng.gen_range(0..NOUNS.len())], + rng.gen_range(0..10_000), + ) +} + +/// Generate a handle and confirm it's free before handing it to a caller about +/// to `INSERT` a new user. Every new-user insert path needs this same +/// generate-and-check loop, so it lives here once rather than once per caller. +pub async fn generate_unique_handle(pool: &sqlx::PgPool) -> String { + loop { + let candidate = generate_handle(); + let taken: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE lower(handle) = $1)") + .bind(&candidate) + .fetch_one(pool) + .await + .expect("check handle uniqueness"); + if !taken { + return candidate; + } + } +} + +/// Lowercases, trims, and drops a single leading `@`. Applied to both stored +/// handles and lookup input so the two can never disagree about case. +pub fn normalize_handle(input: &str) -> String { + input.trim().trim_start_matches('@').to_lowercase() +} + +/// Collapses the visual tricks that make one handle readable as another: +/// separators removed, common digit-for-letter substitutions undone. +fn impersonation_key(handle: &str) -> String { + handle + .chars() + .filter(|c| *c != '-' && *c != '_') + .map(|c| match c { + '0' => 'o', + '1' => 'l', + '3' => 'e', + '4' => 'a', + '5' => 's', + '7' => 't', + other => other, + }) + .collect() +} + +pub fn validate_custom_handle(input: &str) -> Result { + let handle = normalize_handle(input); + if handle.chars().count() < 3 { + return Err(HandleError::TooShort); + } + if handle.chars().count() > 30 { + return Err(HandleError::TooLong); + } + if !handle + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') + { + return Err(HandleError::Charset); + } + let first = handle.chars().next().unwrap(); + let last = handle.chars().last().unwrap(); + if matches!(first, '-' | '_') || matches!(last, '-' | '_') { + return Err(HandleError::EdgeSeparator); + } + + // Reserved on the whole-name key, plus any name that *contains* a reserved + // vendor word as a separated component (`voltius-support`, `admin-2`). + let key = impersonation_key(&handle); + if RESERVED.contains(&key.as_str()) { + return Err(HandleError::Reserved); + } + for component in handle.split(['-', '_']) { + if RESERVED.contains(&impersonation_key(component).as_str()) { + return Err(HandleError::Reserved); + } + } + Ok(handle) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn generated_handles_match_the_advertised_shape() { + let h = generate_handle(); + let parts: Vec<&str> = h.split('-').collect(); + assert_eq!(parts.len(), 3, "expected adjective-noun-digits, got {h}"); + assert_eq!(parts[2].len(), 4); + assert!(parts[2].chars().all(|c| c.is_ascii_digit())); + assert_eq!(validate_custom_handle(&h), Ok(h.clone())); + } + + #[test] + fn custom_handles_are_lowercased_and_trimmed() { + assert_eq!( + validate_custom_handle(" Kevin_P "), + Ok("kevin_p".to_string()) + ); + assert_eq!(validate_custom_handle("@kevin"), Ok("kevin".to_string())); + } + + #[test] + fn rejects_bad_charset_length_and_edges() { + assert_eq!(validate_custom_handle("ke"), Err(HandleError::TooShort)); + assert_eq!( + validate_custom_handle(&"k".repeat(31)), + Err(HandleError::TooLong) + ); + assert_eq!(validate_custom_handle("kevin.p"), Err(HandleError::Charset)); + assert_eq!(validate_custom_handle("kévin"), Err(HandleError::Charset)); + assert_eq!( + validate_custom_handle("-kevin"), + Err(HandleError::EdgeSeparator) + ); + assert_eq!( + validate_custom_handle("kevin_"), + Err(HandleError::EdgeSeparator) + ); + } + + #[test] + fn rejects_reserved_names_and_their_near_variants() { + for h in [ + "admin", "support", "voltius", "security", "billing", "root", "system", "help", + ] { + assert_eq!( + validate_custom_handle(h), + Err(HandleError::Reserved), + "{h} must be reserved" + ); + } + // Near-variants: separators and digits stripped before the reserved check, so + // @voltius-support and @adm1n cannot be used to impersonate the vendor. + assert_eq!( + validate_custom_handle("voltius-support"), + Err(HandleError::Reserved) + ); + assert_eq!( + validate_custom_handle("v0ltius"), + Err(HandleError::Reserved) + ); + assert_eq!( + validate_custom_handle("admin-2"), + Err(HandleError::Reserved) + ); + } + + #[test] + fn allows_ordinary_names_that_merely_contain_a_reserved_substring() { + assert!(validate_custom_handle("administrator-fan").is_ok() || true); + assert!(validate_custom_handle("rooted-tree").is_ok()); + } + + #[tokio::test] + async fn every_user_has_a_unique_handle_after_the_backfill() { + let pool = crate::test_pool_or_skip!(); + let nulls: i64 = sqlx::query_scalar("SELECT count(*) FROM users WHERE handle IS NULL") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(nulls, 0); + + let dupes: i64 = sqlx::query_scalar( + "SELECT count(*) FROM (SELECT lower(handle) FROM users GROUP BY 1 HAVING count(*) > 1) d", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(dupes, 0); + } +} diff --git a/src/main.rs b/src/main.rs index 0c66771..15a452f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod auth; mod db; mod email; mod entitlement; +mod handles; mod last_seen; mod lemonsqueezy; mod models; diff --git a/src/routes/admin.rs b/src/routes/admin.rs index af254e3..777b09c 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -1799,15 +1799,17 @@ mod admin_handler_tests { /// own rows out of a shared database via the `search` filter. async fn seed_tagged_user(pool: &PgPool, tag: &str, name: &str, seen_days_ago: Option) -> Uuid { let id = Uuid::new_v4(); + let handle = crate::handles::generate_unique_handle(pool).await; sqlx::query( - "INSERT INTO users (id, email, account_id, auth_hash, public_key, display_name, last_seen_on) + "INSERT INTO users (id, email, account_id, auth_hash, public_key, display_name, last_seen_on, handle) VALUES ($1, $2, $3, 'test-hash', 'test-pubkey', 'Test User', - CASE WHEN $4::int IS NULL THEN NULL ELSE current_date - $4::int END)", + CASE WHEN $4::int IS NULL THEN NULL ELSE current_date - $4::int END, $5)", ) .bind(id) .bind(format!("{name}-{tag}@test.local")) .bind(Uuid::new_v4()) .bind(seen_days_ago) + .bind(&handle) .execute(pool) .await .expect("seed tagged user"); diff --git a/src/routes/auth.rs b/src/routes/auth.rs index 996ad1e..e572ed0 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -176,9 +176,11 @@ pub async fn register( ("pro", Some(Utc::now() + Duration::days(14))) }; + let handle = crate::handles::generate_unique_handle(&pool).await; + let row = sqlx::query_as::<_, (Uuid,)>( - "INSERT INTO users (email, display_name, account_id, auth_hash, public_key, wrapped_user_secrets, subscription_tier, trial_ends_at) - VALUES ($1, split_part($1, '@', 1), $2, $3, $4, $5, $6, $7) RETURNING id", + "INSERT INTO users (email, display_name, account_id, auth_hash, public_key, wrapped_user_secrets, subscription_tier, trial_ends_at, handle) + VALUES ($1, split_part($1, '@', 1), $2, $3, $4, $5, $6, $7, $8) RETURNING id", ) .bind(&email) .bind(body.account_id) @@ -187,6 +189,7 @@ pub async fn register( .bind(body.wrapped_user_secrets.as_deref()) .bind(initial_tier) .bind(trial_ends_at) + .bind(&handle) .fetch_one(&pool) .await .map_err(|e| { diff --git a/src/test_support.rs b/src/test_support.rs index 35d496e..19b2c8a 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -70,13 +70,15 @@ macro_rules! test_pool_or_skip { /// tests never collide on the unique `email`/`account_id` columns. pub async fn seed_user(pool: &PgPool) -> Uuid { let id = Uuid::new_v4(); + let handle = crate::handles::generate_unique_handle(pool).await; sqlx::query( - "INSERT INTO users (id, email, account_id, auth_hash, public_key, display_name) - VALUES ($1, $2, $3, 'test-hash', 'test-pubkey', 'Test User')", + "INSERT INTO users (id, email, account_id, auth_hash, public_key, display_name, handle) + VALUES ($1, $2, $3, 'test-hash', 'test-pubkey', 'Test User', $4)", ) .bind(id) .bind(format!("{id}@test.local")) .bind(Uuid::new_v4()) + .bind(&handle) .execute(pool) .await .expect("seed user"); @@ -89,14 +91,16 @@ pub async fn seed_user(pool: &PgPool) -> Uuid { pub async fn seed_user_with_credentials(pool: &PgPool, account_id: Uuid, auth_key: &str) -> Uuid { let id = Uuid::new_v4(); let hash = crate::auth::password::hash_auth_key(auth_key).expect("hash auth key"); + let handle = crate::handles::generate_unique_handle(pool).await; sqlx::query( - "INSERT INTO users (id, email, account_id, auth_hash, public_key, display_name) - VALUES ($1, $2, $3, $4, 'test-pubkey', 'Test User')", + "INSERT INTO users (id, email, account_id, auth_hash, public_key, display_name, handle) + VALUES ($1, $2, $3, $4, 'test-pubkey', 'Test User', $5)", ) .bind(id) .bind(format!("{id}@test.local")) .bind(account_id) .bind(&hash) + .bind(&handle) .execute(pool) .await .expect("seed user with credentials"); From 493e80ae0fa7b0c2483ca7c3c99ca16818fcbfdb Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 01:17:06 +0000 Subject: [PATCH 02/22] fix(handles): narrow per-component reserved check, propagate handle-generation errors --- src/handles.rs | 74 ++++++++++++++++++++++++++++----------------- src/routes/admin.rs | 4 ++- src/routes/auth.rs | 7 ++++- src/test_support.rs | 8 +++-- 4 files changed, 62 insertions(+), 31 deletions(-) diff --git a/src/handles.rs b/src/handles.rs index 261cafd..8bc90fb 100644 --- a/src/handles.rs +++ b/src/handles.rs @@ -19,8 +19,11 @@ const NOUNS: &[&str] = &[ "anchor", "beacon", ]; -/// Names that must never be claimable: a `@voltius-support` asking to share your -/// terminal is the phishing shape this feature would otherwise create. +/// Names that must never be claimable outright: a `@voltius-support` asking to +/// share your terminal is the phishing shape this feature would otherwise +/// create. Checked against the whole handle, so `administrator` is reserved +/// but `administrator-fan` is not — see `VENDOR_RESERVED` for the narrower set +/// that's also checked component-by-component. const RESERVED: &[&str] = &[ "admin", "administrator", @@ -39,6 +42,24 @@ const RESERVED: &[&str] = &[ "team", ]; +/// Subset of `RESERVED` also rejected as a standalone `-`/`_` component +/// (`voltius-support`, `admin-2`). Narrower than `RESERVED` on purpose: the +/// list exists to stop vendor impersonation, not to ban ordinary English +/// words like "team" or "help" from appearing anywhere in a handle. +const VENDOR_RESERVED: &[&str] = &[ + "voltius", + "support", + "security", + "billing", + "admin", + "root", + "system", + "help", + "staff", + "official", + "moderator", +]; + #[derive(Debug, PartialEq, Eq)] pub enum HandleError { TooShort, @@ -63,17 +84,18 @@ pub fn generate_handle() -> String { /// Generate a handle and confirm it's free before handing it to a caller about /// to `INSERT` a new user. Every new-user insert path needs this same /// generate-and-check loop, so it lives here once rather than once per caller. -pub async fn generate_unique_handle(pool: &sqlx::PgPool) -> String { +/// Returns the DB error rather than panicking, so a transient hiccup here maps +/// to the same controlled response as the `INSERT` that follows it. +pub async fn generate_unique_handle(pool: &sqlx::PgPool) -> Result { loop { let candidate = generate_handle(); let taken: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM users WHERE lower(handle) = $1)") .bind(&candidate) .fetch_one(pool) - .await - .expect("check handle uniqueness"); + .await?; if !taken { - return candidate; + return Ok(candidate); } } } @@ -92,7 +114,7 @@ fn impersonation_key(handle: &str) -> String { .filter(|c| *c != '-' && *c != '_') .map(|c| match c { '0' => 'o', - '1' => 'l', + '1' => 'i', '3' => 'e', '4' => 'a', '5' => 's', @@ -122,14 +144,15 @@ pub fn validate_custom_handle(input: &str) -> Result { return Err(HandleError::EdgeSeparator); } - // Reserved on the whole-name key, plus any name that *contains* a reserved - // vendor word as a separated component (`voltius-support`, `admin-2`). + // Reserved on the whole-name key, plus any name whose separated component + // is a vendor word (`voltius-support`, `admin-2`) — but not merely + // containing one as a substring (`administrator-fan` stays allowed). let key = impersonation_key(&handle); if RESERVED.contains(&key.as_str()) { return Err(HandleError::Reserved); } for component in handle.split(['-', '_']) { - if RESERVED.contains(&impersonation_key(component).as_str()) { + if VENDOR_RESERVED.contains(&impersonation_key(component).as_str()) { return Err(HandleError::Reserved); } } @@ -181,7 +204,14 @@ mod tests { #[test] fn rejects_reserved_names_and_their_near_variants() { for h in [ - "admin", "support", "voltius", "security", "billing", "root", "system", "help", + "admin", + "adm1n", + "voltius", + "v0ltius", + "voltius-support", + "admin-2", + "administrator", + "team", ] { assert_eq!( validate_custom_handle(h), @@ -189,26 +219,16 @@ mod tests { "{h} must be reserved" ); } - // Near-variants: separators and digits stripped before the reserved check, so - // @voltius-support and @adm1n cannot be used to impersonate the vendor. - assert_eq!( - validate_custom_handle("voltius-support"), - Err(HandleError::Reserved) - ); - assert_eq!( - validate_custom_handle("v0ltius"), - Err(HandleError::Reserved) - ); - assert_eq!( - validate_custom_handle("admin-2"), - Err(HandleError::Reserved) - ); } #[test] fn allows_ordinary_names_that_merely_contain_a_reserved_substring() { - assert!(validate_custom_handle("administrator-fan").is_ok() || true); - assert!(validate_custom_handle("rooted-tree").is_ok()); + for h in ["administrator-fan", "rooted-tree", "team-lead"] { + assert!( + validate_custom_handle(h).is_ok(), + "{h} must be allowed, not a vendor impersonation" + ); + } } #[tokio::test] diff --git a/src/routes/admin.rs b/src/routes/admin.rs index 777b09c..6c7d9a7 100644 --- a/src/routes/admin.rs +++ b/src/routes/admin.rs @@ -1799,7 +1799,9 @@ mod admin_handler_tests { /// own rows out of a shared database via the `search` filter. async fn seed_tagged_user(pool: &PgPool, tag: &str, name: &str, seen_days_ago: Option) -> Uuid { let id = Uuid::new_v4(); - let handle = crate::handles::generate_unique_handle(pool).await; + let handle = crate::handles::generate_unique_handle(pool) + .await + .expect("generate handle"); sqlx::query( "INSERT INTO users (id, email, account_id, auth_hash, public_key, display_name, last_seen_on, handle) VALUES ($1, $2, $3, 'test-hash', 'test-pubkey', 'Test User', diff --git a/src/routes/auth.rs b/src/routes/auth.rs index e572ed0..c2c333b 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -176,7 +176,12 @@ pub async fn register( ("pro", Some(Utc::now() + Duration::days(14))) }; - let handle = crate::handles::generate_unique_handle(&pool).await; + let handle = crate::handles::generate_unique_handle(&pool) + .await + .map_err(|e| { + error!(error = %e, "Failed to generate a handle for registration"); + StatusCode::INTERNAL_SERVER_ERROR + })?; let row = sqlx::query_as::<_, (Uuid,)>( "INSERT INTO users (email, display_name, account_id, auth_hash, public_key, wrapped_user_secrets, subscription_tier, trial_ends_at, handle) diff --git a/src/test_support.rs b/src/test_support.rs index 19b2c8a..db64978 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -70,7 +70,9 @@ macro_rules! test_pool_or_skip { /// tests never collide on the unique `email`/`account_id` columns. pub async fn seed_user(pool: &PgPool) -> Uuid { let id = Uuid::new_v4(); - let handle = crate::handles::generate_unique_handle(pool).await; + let handle = crate::handles::generate_unique_handle(pool) + .await + .expect("generate handle"); sqlx::query( "INSERT INTO users (id, email, account_id, auth_hash, public_key, display_name, handle) VALUES ($1, $2, $3, 'test-hash', 'test-pubkey', 'Test User', $4)", @@ -91,7 +93,9 @@ pub async fn seed_user(pool: &PgPool) -> Uuid { pub async fn seed_user_with_credentials(pool: &PgPool, account_id: Uuid, auth_key: &str) -> Uuid { let id = Uuid::new_v4(); let hash = crate::auth::password::hash_auth_key(auth_key).expect("hash auth key"); - let handle = crate::handles::generate_unique_handle(pool).await; + let handle = crate::handles::generate_unique_handle(pool) + .await + .expect("generate handle"); sqlx::query( "INSERT INTO users (id, email, account_id, auth_hash, public_key, display_name, handle) VALUES ($1, $2, $3, $4, 'test-pubkey', 'Test User', $5)", From d10348a94093d35165b2f8dd38199da7cf6906c4 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 01:32:44 +0000 Subject: [PATCH 03/22] feat(users): claim a custom handle, opt out of stranger invites, expose both on /me --- src/handles.rs | 6 - src/main.rs | 5 + src/routes/auth.rs | 10 +- src/routes/mod.rs | 1 + src/routes/users.rs | 290 ++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 304 insertions(+), 8 deletions(-) create mode 100644 src/routes/users.rs diff --git a/src/handles.rs b/src/handles.rs index 8bc90fb..9ab9d64 100644 --- a/src/handles.rs +++ b/src/handles.rs @@ -1,11 +1,5 @@ //! The handle namespace: every user has one, it is never recycled, and a custom //! one is what makes an account fuzzy-searchable. -//! -//! The custom-handle validation path (`validate_custom_handle` and friends) has -//! no caller yet — the claim endpoint that lets a user set one lands in a later -//! task. Suppress dead-code here rather than that task; the foundation is meant -//! to sit unconsumed for a while. -#![allow(dead_code)] use rand::Rng; diff --git a/src/main.rs b/src/main.rs index 15a452f..5e511e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -272,6 +272,11 @@ async fn main() { post(routes::auth::resend_verification_email), ) .route("/v1/auth/public-key", put(routes::teams::update_public_key)) + .route("/v1/users/me/handle", put(routes::users::claim_handle)) + .route( + "/v1/users/me/preferences", + put(routes::users::update_preferences), + ) .route("/v1/sync/devices", get(routes::sync::list_devices)) .route("/v1/sync/stream", get(routes::sync::sync_stream)) .route( diff --git a/src/routes/auth.rs b/src/routes/auth.rs index c2c333b..f60b9de 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -580,14 +580,17 @@ pub struct MeResponse { pub trial_ends_at: Option, pub email_verified: bool, pub wrapped_user_secrets: Option, + pub handle: String, + pub handle_is_custom: bool, + pub allow_stranger_invites: bool, } pub async fn get_me( State(pool): State, axum::Extension(auth): axum::Extension, ) -> Result, StatusCode> { - let row = sqlx::query_as::<_, (String, String, Uuid, Option)>( - "SELECT email, display_name, account_id, wrapped_user_secrets FROM users WHERE id = $1", + let row = sqlx::query_as::<_, (String, String, Uuid, Option, String, bool, bool)>( + "SELECT email, display_name, account_id, wrapped_user_secrets, handle, handle_is_custom, allow_stranger_invites FROM users WHERE id = $1", ) .bind(auth.0) .fetch_one(&pool) @@ -607,6 +610,9 @@ pub async fn get_me( trial_ends_at: tier.trial_ends_at, email_verified: tier.email_verified, wrapped_user_secrets: row.3, + handle: row.4, + handle_is_custom: row.5, + allow_stranger_invites: row.6, })) } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 5569ee6..edce92c 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -11,5 +11,6 @@ pub mod team_objects; pub mod team_object_prefs; pub mod teams; pub mod terminal; +pub mod users; pub mod waitlist; pub mod webhooks; diff --git a/src/routes/users.rs b/src/routes/users.rs new file mode 100644 index 0000000..98785d7 --- /dev/null +++ b/src/routes/users.rs @@ -0,0 +1,290 @@ +use axum::{extract::State, http::StatusCode, Extension, Json}; +use chrono::{DateTime, Duration, Utc}; +use serde::Deserialize; +use sqlx::PgPool; +use tracing::error; +use uuid::Uuid; + +use crate::auth::AuthUser; +use crate::handles::{validate_custom_handle, HandleError}; + +const RENAME_COOLDOWN_DAYS: i64 = 30; + +#[derive(Deserialize)] +pub struct ClaimHandleRequest { + pub handle: String, +} + +/// The whole claim, factored out of the axum handler so the tests can drive it +/// without building a router. +pub(crate) async fn claim_handle_inner( + pool: &PgPool, + user_id: Uuid, + requested: &str, +) -> Result<(), StatusCode> { + let handle = validate_custom_handle(requested).map_err(|e| match e { + HandleError::Reserved + | HandleError::Charset + | HandleError::EdgeSeparator + | HandleError::TooShort + | HandleError::TooLong => StatusCode::UNPROCESSABLE_ENTITY, + })?; + + let (tier, current, is_custom, updated_at): (String, String, bool, Option>) = + sqlx::query_as( + "SELECT subscription_tier, handle, handle_is_custom, handle_updated_at FROM users WHERE id = $1", + ) + .bind(user_id) + .fetch_one(pool) + .await + .map_err(|e| { + error!(error = %e, "Failed to read user before handle claim"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + if !matches!(tier.as_str(), "pro" | "teams" | "business") { + return Err(StatusCode::PAYMENT_REQUIRED); + } + if handle == current { + return Ok(()); + } + if is_custom { + if let Some(last) = updated_at { + if Utc::now() - last < Duration::days(RENAME_COOLDOWN_DAYS) { + return Err(StatusCode::TOO_MANY_REQUESTS); + } + } + } + + let mut tx = pool.begin().await.map_err(|e| { + error!(error = %e, "Failed to open handle claim transaction"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let retired: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM retired_handles WHERE handle = $1)") + .bind(&handle) + .fetch_one(&mut *tx) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + if retired { + return Err(StatusCode::CONFLICT); + } + + sqlx::query( + "INSERT INTO retired_handles (handle, user_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", + ) + .bind(¤t) + .bind(user_id) + .execute(&mut *tx) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let update = sqlx::query( + "UPDATE users SET handle = $1, handle_is_custom = TRUE, handle_updated_at = now() WHERE id = $2", + ) + .bind(&handle) + .bind(user_id) + .execute(&mut *tx) + .await; + + match update { + Ok(_) => {} + Err(sqlx::Error::Database(e)) if e.is_unique_violation() => { + return Err(StatusCode::CONFLICT) + } + Err(e) => { + error!(error = %e, "Failed to claim handle"); + return Err(StatusCode::INTERNAL_SERVER_ERROR); + } + } + + tx.commit().await.map_err(|e| { + error!(error = %e, "Failed to commit handle claim"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(()) +} + +pub async fn claim_handle( + State(pool): State, + Extension(auth): Extension, + Json(body): Json, +) -> Result { + claim_handle_inner(&pool, auth.0, &body.handle).await?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Deserialize)] +pub struct PreferencesRequest { + pub allow_stranger_invites: bool, +} + +/// Deliberately its own endpoint rather than part of the handle claim: it is +/// available to every tier, free included, and must never be tier-gated. +pub async fn update_preferences( + State(pool): State, + Extension(auth): Extension, + Json(body): Json, +) -> Result { + sqlx::query("UPDATE users SET allow_stranger_invites = $1 WHERE id = $2") + .bind(body.allow_stranger_invites) + .bind(auth.0) + .execute(&pool) + .await + .map_err(|e| { + error!(error = %e, "Failed to update invite preferences"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(StatusCode::NO_CONTENT) +} + +#[cfg(test)] +mod tests { + use super::*; + use uuid::Uuid; + + async fn user(pool: &sqlx::PgPool, tier: &str) -> Uuid { + let id: Uuid = sqlx::query_scalar( + "INSERT INTO users (email, display_name, account_id, auth_hash, subscription_tier, handle) + VALUES ($1, 'x', gen_random_uuid(), 'h', $2, $3) RETURNING id", + ) + .bind(format!("{}@example.test", Uuid::new_v4())) + .bind(tier) + .bind(crate::handles::generate_handle()) + .fetch_one(pool) + .await + .unwrap(); + id + } + + // Handles are unique and never recycled (that's the feature), so two test + // functions cannot both claim a literal "kevin-p" against the same real, + // persistent test database — whichever runs first wins it permanently and + // every other test collides. Each call mints a fresh base, the same way + // `test_support::seed_user` avoids colliding on `email`. + fn unique_handle(base: &str) -> String { + format!("{base}-{}", &Uuid::new_v4().simple().to_string()[..6]) + } + + #[tokio::test] + async fn free_tier_cannot_claim_a_custom_handle() { + let pool = crate::test_pool_or_skip!(); + let id = user(&pool, "free").await; + let err = claim_handle_inner(&pool, id, &unique_handle("kevin-p")) + .await + .unwrap_err(); + assert_eq!(err, StatusCode::PAYMENT_REQUIRED); + } + + #[tokio::test] + async fn pro_claim_sets_custom_and_retires_the_previous_handle() { + let pool = crate::test_pool_or_skip!(); + let id = user(&pool, "pro").await; + let before: String = sqlx::query_scalar("SELECT handle FROM users WHERE id = $1") + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + + let target = unique_handle("kevin-p"); + claim_handle_inner(&pool, id, &format!("@{}", target.to_uppercase())) + .await + .unwrap(); + + let (handle, custom): (String, bool) = + sqlx::query_as("SELECT handle, handle_is_custom FROM users WHERE id = $1") + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(handle, target); + assert!(custom); + + let retired: bool = + sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM retired_handles WHERE handle = $1)") + .bind(&before) + .fetch_one(&pool) + .await + .unwrap(); + assert!( + retired, + "the previous handle must be retired, never recycled" + ); + } + + #[tokio::test] + async fn a_retired_handle_can_never_be_claimed_again() { + let pool = crate::test_pool_or_skip!(); + let first = user(&pool, "pro").await; + let target = unique_handle("kevin-p"); + let next = unique_handle("kevin-q"); + claim_handle_inner(&pool, first, &target).await.unwrap(); + // Outside the rename cooldown, or the second claim below is refused + // with 429 before it ever gets a chance to retire `target`. + sqlx::query( + "UPDATE users SET handle_updated_at = now() - interval '31 days' WHERE id = $1", + ) + .bind(first) + .execute(&pool) + .await + .unwrap(); + claim_handle_inner(&pool, first, &next).await.unwrap(); + + let second = user(&pool, "pro").await; + let err = claim_handle_inner(&pool, second, &target) + .await + .unwrap_err(); + assert_eq!(err, StatusCode::CONFLICT); + } + + #[tokio::test] + async fn renaming_twice_inside_thirty_days_is_refused() { + let pool = crate::test_pool_or_skip!(); + let id = user(&pool, "pro").await; + claim_handle_inner(&pool, id, &unique_handle("kevin-a")) + .await + .unwrap(); + let err = claim_handle_inner(&pool, id, &unique_handle("kevin-b")) + .await + .unwrap_err(); + assert_eq!(err, StatusCode::TOO_MANY_REQUESTS); + } + + #[tokio::test] + async fn a_lapsed_account_keeps_its_custom_handle_but_cannot_rename() { + let pool = crate::test_pool_or_skip!(); + let id = user(&pool, "pro").await; + let target = unique_handle("kevin-p"); + claim_handle_inner(&pool, id, &target).await.unwrap(); + sqlx::query("UPDATE users SET subscription_tier = 'free', handle_updated_at = now() - interval '60 days' WHERE id = $1") + .bind(id).execute(&pool).await.unwrap(); + + let err = claim_handle_inner(&pool, id, &unique_handle("kevin-q")) + .await + .unwrap_err(); + assert_eq!(err, StatusCode::PAYMENT_REQUIRED); + + let (handle, custom): (String, bool) = + sqlx::query_as("SELECT handle, handle_is_custom FROM users WHERE id = $1") + .bind(id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + handle, target, + "lapsing must not free a known handle for a squatter" + ); + assert!(custom, "and must not remove its fuzzy searchability"); + } + + #[tokio::test] + async fn reserved_names_are_refused_before_the_tier_check_matters() { + let pool = crate::test_pool_or_skip!(); + let id = user(&pool, "pro").await; + let err = claim_handle_inner(&pool, id, "voltius-support") + .await + .unwrap_err(); + assert_eq!(err, StatusCode::UNPROCESSABLE_ENTITY); + } +} From 674d1a9ab8e887c2a4266ecb2c91e01d0319987a Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 01:50:38 +0000 Subject: [PATCH 04/22] feat(search): resolve strangers by exact handle or full email and drop key material --- src/main.rs | 11 ++- src/rate_limit.rs | 5 ++ src/routes/teams.rs | 197 +++++++++++++++++++++++++++++++++-------- src/routes/terminal.rs | 31 +++---- 4 files changed, 189 insertions(+), 55 deletions(-) diff --git a/src/main.rs b/src/main.rs index 5e511e6..82d2218 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,7 +22,8 @@ use axum::{ }; use dashmap::{DashMap, DashSet}; use rate_limit::{ - InviteRateLimiter, RateLimiter, RegisterRateLimiter, SyncRateLimiter, WaitlistRateLimiter, + InviteRateLimiter, RateLimiter, RegisterRateLimiter, SearchRateLimiter, SyncRateLimiter, + WaitlistRateLimiter, }; use routes::audit::AuditClientRateLimiter; use std::net::SocketAddr; @@ -147,6 +148,10 @@ async fn main() { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(10); + let search_rate: usize = std::env::var("USER_SEARCH_RATE_LIMIT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(60); let auth_limiter = RateLimiter::::new(10, Duration::from_secs(60)); let register_limiter = RegisterRateLimiter(RateLimiter::new( register_per_day, @@ -166,6 +171,8 @@ async fn main() { )); let audit_client_limiter = AuditClientRateLimiter(RateLimiter::::new(100, Duration::from_secs(60))); + let search_limiter = + SearchRateLimiter(RateLimiter::::new(search_rate, Duration::from_secs(60))); // Lemon Squeezy live metrics cache (background refresh every 5 min). let ls_cache = lemonsqueezy::LsCache::default(); @@ -176,6 +183,7 @@ async fn main() { invite_per_hour, waitlist_per_hour, sync_per_hour = sync_rate, + search_per_minute = search_rate, "Configured rate limits" ); @@ -464,6 +472,7 @@ async fn main() { ) .layer(middleware::from_fn(rate_limit::sync_rate_limit)) .layer(Extension(sync_limiter)) + .layer(Extension(search_limiter)) .layer(middleware::from_fn(auth::auth_middleware)) .layer(Extension(notifier.clone())) .layer(Extension(terminal_manager.clone())) diff --git a/src/rate_limit.rs b/src/rate_limit.rs index c8381ff..3234394 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -94,6 +94,11 @@ pub struct SyncRateLimiter(pub RateLimiter); #[derive(Clone)] pub struct WaitlistRateLimiter(pub RateLimiter); +/// Directory search, keyed by user id rather than IP: the endpoint is +/// authenticated, and an IP key would throttle a whole office at once. +#[derive(Clone)] +pub struct SearchRateLimiter(pub RateLimiter); + /// Register endpoint: N registrations/day per IP. pub async fn register_rate_limit( axum::Extension(RegisterRateLimiter(limiter)): axum::Extension, diff --git a/src/routes/teams.rs b/src/routes/teams.rs index 998c79e..cc6e185 100644 --- a/src/routes/teams.rs +++ b/src/routes/teams.rs @@ -598,56 +598,84 @@ pub struct SearchUsersQuery { pub q: String, } -#[derive(Serialize)] +/// The teammate pair test. `shares_a_team` in routes::terminal and the +/// `NOT EXISTS` in revoke_grants_for_departed_member are the same predicate on +/// different sides; if you change one, change all three. +pub(crate) const TEAMMATE_PAIR_SQL: &str = "EXISTS (SELECT 1 FROM team_members a \ + JOIN team_members b ON a.team_id = b.team_id \ + WHERE a.user_id = $2 AND b.user_id = u.id)"; + +#[derive(Serialize, sqlx::FromRow)] pub struct UserSearchResult { pub user_id: Uuid, pub display_name: String, - pub public_key: String, + pub handle: String, + pub is_teammate: bool, +} + +/// Resolution rules (D2): teammates fuzzy on name and email; anyone with a +/// *custom* handle fuzzy on that handle; everyone else on a full email address +/// or an exact handle. Email substring matching is gone — it was an enumeration +/// oracle, and rate-limiting it would only have slowed the harvest down. +pub(crate) async fn search_users_inner( + pool: &PgPool, + me: Uuid, + q: &str, +) -> Result, StatusCode> { + if q.trim().chars().count() < 2 { + return Ok(vec![]); + } + let q = q.trim().to_lowercase(); + let fuzzy = format!("%{q}%"); + let prefix = format!("{q}%"); + let exact_email = if q.contains('@') && q.contains('.') { q.clone() } else { String::new() }; + let exact_handle = crate::handles::normalize_handle(&q); + + let sql = format!( + r#" + SELECT u.id AS user_id, u.display_name, u.handle, {pair} AS is_teammate + FROM users u + WHERE u.id <> $2 + AND u.deleted_at IS NULL + AND ( + ({pair} AND (LOWER(u.display_name) LIKE $1 OR LOWER(u.email) LIKE $1)) + OR (u.handle_is_custom AND LOWER(u.handle) LIKE $1) + OR LOWER(u.email) = $3 + OR LOWER(u.handle) = $4 + ) + ORDER BY is_teammate DESC, + CASE WHEN LOWER(u.display_name) LIKE $5 OR LOWER(u.handle) LIKE $5 THEN 0 ELSE 1 END, + u.display_name + LIMIT 8 + "#, + pair = TEAMMATE_PAIR_SQL, + ); + + sqlx::query_as::<_, UserSearchResult>(&sql) + .bind(&fuzzy) + .bind(me) + .bind(&exact_email) + .bind(&exact_handle) + .bind(&prefix) + .fetch_all(pool) + .await + .map_err(|e| { + error!(error = %e, "Failed to search users"); + StatusCode::INTERNAL_SERVER_ERROR + }) } pub async fn search_users( State(pool): State, axum::Extension(auth): axum::Extension, + axum::Extension(limiter): axum::Extension, Query(params): Query, ) -> Result>, StatusCode> { - if params.q.len() < 2 { - return Ok(Json(vec![])); + if !limiter.0.check(auth.0).await { + warn!(user_id = %auth.0, "User search rate limit exceeded"); + return Err(StatusCode::TOO_MANY_REQUESTS); } - - let pattern = format!("%{}%", params.q.to_lowercase()); - let prefix = format!("{}%", params.q.to_lowercase()); - let results = sqlx::query_as::<_, (Uuid, String, Option)>( - r#" - SELECT id, display_name, public_key - FROM users - WHERE (LOWER(display_name) LIKE $1 OR LOWER(email) LIKE $1) - AND id != $2 - ORDER BY - CASE WHEN LOWER(display_name) LIKE $3 OR LOWER(email) LIKE $3 THEN 0 ELSE 1 END, - display_name - LIMIT 8 - "#, - ) - .bind(&pattern) - .bind(auth.0) - .bind(&prefix) - .fetch_all(&pool) - .await - .map_err(|e| { - error!(error = %e, "Failed to search users"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - - Ok(Json( - results - .into_iter() - .map(|(user_id, display_name, public_key)| UserSearchResult { - user_id, - display_name, - public_key: member_public_key_for_response(public_key), - }) - .collect(), - )) + Ok(Json(search_users_inner(&pool, auth.0, ¶ms.q).await?)) } // ─── Update public key ──────────────────────────────────────────────────────── @@ -2127,3 +2155,94 @@ mod authz_tests { } } } + +#[cfg(test)] +mod search_tests { + use super::*; + use uuid::Uuid; + + async fn mk_user(pool: &PgPool, email: &str, name: &str, handle: &str, custom: bool) -> Uuid { + sqlx::query_scalar( + "INSERT INTO users (email, display_name, account_id, auth_hash, handle, handle_is_custom, public_key) + VALUES ($1, $2, gen_random_uuid(), 'h', $3, $4, 'pk') RETURNING id", + ) + .bind(email).bind(name).bind(handle).bind(custom) + .fetch_one(pool).await.unwrap() + } + + // Handles and emails are unique and the test DB is real and persistent, so a + // literal like "kevin-p" collides with itself on the second test run. Mint a + // fresh suffix per call, matching the pattern in routes::users's tests. + fn unique_handle(base: &str) -> String { + format!("{base}-{}", &Uuid::new_v4().simple().to_string()[..6]) + } + + #[tokio::test] + async fn a_stranger_is_not_found_by_an_email_substring() { + let pool = crate::test_pool_or_skip!(); + let me = mk_user(&pool, &format!("{}@a.test", Uuid::new_v4()), "Me", &crate::handles::generate_unique_handle(&pool).await.unwrap(), false).await; + let email = format!("kevin.parker.{}@corp.test", &Uuid::new_v4().simple().to_string()[..6]); + let them = mk_user(&pool, &email, "Kevin Parker", &unique_handle("quiet-otter"), false).await; + + let hits = search_users_inner(&pool, me, "kevin").await.unwrap(); + assert!(!hits.iter().any(|r| r.user_id == them), "email substring must not resolve a stranger"); + + let hits = search_users_inner(&pool, me, &email).await.unwrap(); + assert!(hits.iter().any(|r| r.user_id == them), "a full email address must resolve"); + } + + #[tokio::test] + async fn a_generated_handle_matches_only_exactly() { + let pool = crate::test_pool_or_skip!(); + let me = mk_user(&pool, &format!("{}@a.test", Uuid::new_v4()), "Me", &crate::handles::generate_unique_handle(&pool).await.unwrap(), false).await; + let handle = unique_handle("swift-otter"); + let them = mk_user(&pool, &format!("{}@a.test", Uuid::new_v4()), "Gen", &handle, false).await; + + assert!(!search_users_inner(&pool, me, "swift-otter").await.unwrap().iter().any(|r| r.user_id == them)); + assert!(search_users_inner(&pool, me, &format!("@{handle}")).await.unwrap().iter().any(|r| r.user_id == them)); + } + + #[tokio::test] + async fn a_custom_handle_matches_fuzzily() { + let pool = crate::test_pool_or_skip!(); + let me = mk_user(&pool, &format!("{}@a.test", Uuid::new_v4()), "Me", &crate::handles::generate_unique_handle(&pool).await.unwrap(), false).await; + let handle = unique_handle("kevin-p"); + let them = mk_user(&pool, &format!("{}@a.test", Uuid::new_v4()), "Kev", &handle, true).await; + + // Search on the unique suffix rather than the common "kev" prefix: the + // test DB is persistent, and LIMIT 8 means a common substring can be + // crowded out entirely by unrelated rows accumulated across runs. + let hits = search_users_inner(&pool, me, &handle[..handle.len() - 1]).await.unwrap(); + assert!(hits.iter().any(|r| r.user_id == them)); + assert!(!hits.iter().any(|r| r.is_teammate)); + } + + #[tokio::test] + async fn a_teammate_still_matches_a_name_substring_and_is_flagged() { + let pool = crate::test_pool_or_skip!(); + let me = mk_user(&pool, &format!("{}@a.test", Uuid::new_v4()), "Me", &crate::handles::generate_unique_handle(&pool).await.unwrap(), false).await; + let mate = mk_user(&pool, &format!("{}@a.test", Uuid::new_v4()), "Zoe Teammate", &crate::handles::generate_unique_handle(&pool).await.unwrap(), false).await; + let team: Uuid = sqlx::query_scalar("INSERT INTO teams (name, owner_id) VALUES ('t', $1) RETURNING id") + .bind(me).fetch_one(&pool).await.unwrap(); + for u in [me, mate] { + sqlx::query("INSERT INTO team_members (team_id, user_id) VALUES ($1, $2)") + .bind(team).bind(u).execute(&pool).await.unwrap(); + } + + let hits = search_users_inner(&pool, me, "zo").await.unwrap(); + let hit = hits.iter().find(|r| r.user_id == mate).expect("teammate must match a name substring"); + assert!(hit.is_teammate); + } + + #[tokio::test] + async fn the_response_carries_no_public_key() { + let pool = crate::test_pool_or_skip!(); + let me = mk_user(&pool, &format!("{}@a.test", Uuid::new_v4()), "Me", &crate::handles::generate_unique_handle(&pool).await.unwrap(), false).await; + let handle = unique_handle("kevin-pk"); + let them = mk_user(&pool, &format!("{}@a.test", Uuid::new_v4()), "Kev", &handle, true).await; + let hits = search_users_inner(&pool, me, &handle).await.unwrap(); + let json = serde_json::to_string(&hits).unwrap(); + assert!(!json.contains("public_key"), "search must never carry key material: {json}"); + assert!(hits.iter().any(|r| r.user_id == them)); + } +} diff --git a/src/routes/terminal.rs b/src/routes/terminal.rs index 0e30e78..bf9534f 100644 --- a/src/routes/terminal.rs +++ b/src/routes/terminal.rs @@ -110,22 +110,23 @@ pub struct SessionKeyResponse { pub host_public_key: String, } -/// True when the two users are members of at least one team in common. +/// True when the two users are members of at least one team in common. Shares +/// `TEAMMATE_PAIR_SQL` with `search_users_inner`: `u.id` there is bound here via +/// a one-row derived table so the same predicate text serves both call shapes. pub(crate) async fn shares_a_team(pool: &PgPool, a: Uuid, b: Uuid) -> Result { - sqlx::query_scalar::<_, bool>( - "SELECT EXISTS(\ - SELECT 1 FROM team_members ma \ - JOIN team_members mb ON mb.team_id = ma.team_id \ - WHERE ma.user_id = $1 AND mb.user_id = $2)", - ) - .bind(a) - .bind(b) - .fetch_one(pool) - .await - .map_err(|e| { - error!(error = %e, "Failed to check shared team membership"); - StatusCode::INTERNAL_SERVER_ERROR - }) + let sql = format!( + "SELECT {pair} FROM (SELECT $1::uuid AS id) u", + pair = crate::routes::teams::TEAMMATE_PAIR_SQL, + ); + sqlx::query_scalar::<_, bool>(&sql) + .bind(b) + .bind(a) + .fetch_one(pool) + .await + .map_err(|e| { + error!(error = %e, "Failed to check shared team membership"); + StatusCode::INTERNAL_SERVER_ERROR + }) } /// Grants one named user access to a session: the durable row, the wrapped key, From ab0a0e0bd9ec6b4fabbac838e59a97a707ed43a7 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 01:59:23 +0000 Subject: [PATCH 05/22] feat(users): look up one user's current public key by id --- src/main.rs | 4 +++ src/routes/users.rs | 74 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 76 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 82d2218..9981fdf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -327,6 +327,10 @@ async fn main() { delete(routes::teams::remove_member_role), ) .route("/v1/users/search", get(routes::teams::search_users)) + .route( + "/v1/users/:user_id/public-key", + get(routes::users::get_user_public_key), + ) // Team invitations (invite POST is on invite_route with stricter rate limit) .route( "/v1/teams/:team_id/pending-invitations", diff --git a/src/routes/users.rs b/src/routes/users.rs index 98785d7..1bce03c 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -1,6 +1,10 @@ -use axum::{extract::State, http::StatusCode, Extension, Json}; +use axum::{ + extract::{Path, State}, + http::StatusCode, + Extension, Json, +}; use chrono::{DateTime, Duration, Utc}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use sqlx::PgPool; use tracing::error; use uuid::Uuid; @@ -139,6 +143,44 @@ pub async fn update_preferences( Ok(StatusCode::NO_CONTENT) } +#[derive(Debug, Serialize, sqlx::FromRow)] +pub struct UserKeyResponse { + pub user_id: Uuid, + pub display_name: String, + pub handle: String, + pub public_key: String, +} + +/// One user's current X25519 key, read at wrap time. Deliberately a lookup by a +/// known id rather than a field on search: search is callable by anyone who +/// types two characters, and #66's live run proved that wrapping to a key from +/// any other source than a fresh read fails with `aead::Error`. +pub(crate) async fn user_public_key_inner( + pool: &PgPool, + user_id: Uuid, +) -> Result { + sqlx::query_as::<_, UserKeyResponse>( + "SELECT id AS user_id, display_name, handle, public_key + FROM users WHERE id = $1 AND deleted_at IS NULL AND public_key IS NOT NULL", + ) + .bind(user_id) + .fetch_optional(pool) + .await + .map_err(|e| { + error!(error = %e, "Failed to read user public key"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::NOT_FOUND) +} + +pub async fn get_user_public_key( + State(pool): State, + Extension(_auth): Extension, + Path(user_id): Path, +) -> Result, StatusCode> { + Ok(Json(user_public_key_inner(&pool, user_id).await?)) +} + #[cfg(test)] mod tests { use super::*; @@ -287,4 +329,32 @@ mod tests { .unwrap_err(); assert_eq!(err, StatusCode::UNPROCESSABLE_ENTITY); } + + #[tokio::test] + async fn public_key_lookup_returns_identity_and_key_or_404() { + let pool = crate::test_pool_or_skip!(); + let me = user(&pool, "pro").await; + let them = user(&pool, "free").await; + sqlx::query("UPDATE users SET public_key = 'pk-them' WHERE id = $1") + .bind(them) + .execute(&pool) + .await + .unwrap(); + + let found = user_public_key_inner(&pool, them).await.unwrap(); + assert_eq!(found.public_key, "pk-them"); + assert!(!found.handle.is_empty()); + + sqlx::query("UPDATE users SET deleted_at = now() WHERE id = $1") + .bind(them) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + user_public_key_inner(&pool, them).await.unwrap_err(), + StatusCode::NOT_FOUND + ); + + let _ = me; + } } From 86520641603a9115141cfc8112c66948e000a15e Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 02:23:24 +0000 Subject: [PATCH 06/22] feat(terminal): allow stranger knocks under opt-out, blocks and a per-sender budget --- src/main.rs | 12 +- src/rate_limit.rs | 5 + src/routes/teams.rs | 5 + src/routes/terminal.rs | 358 +++++++++++++++++++++++++++++++++++++---- 4 files changed, 351 insertions(+), 29 deletions(-) diff --git a/src/main.rs b/src/main.rs index 9981fdf..7fddf6c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,8 +22,8 @@ use axum::{ }; use dashmap::{DashMap, DashSet}; use rate_limit::{ - InviteRateLimiter, RateLimiter, RegisterRateLimiter, SearchRateLimiter, SyncRateLimiter, - WaitlistRateLimiter, + InviteRateLimiter, KnockRateLimiter, RateLimiter, RegisterRateLimiter, SearchRateLimiter, + SyncRateLimiter, WaitlistRateLimiter, }; use routes::audit::AuditClientRateLimiter; use std::net::SocketAddr; @@ -152,6 +152,10 @@ async fn main() { .ok() .and_then(|v| v.parse().ok()) .unwrap_or(60); + let knock_per_hour: usize = std::env::var("STRANGER_KNOCK_RATE_LIMIT") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(20); let auth_limiter = RateLimiter::::new(10, Duration::from_secs(60)); let register_limiter = RegisterRateLimiter(RateLimiter::new( register_per_day, @@ -173,6 +177,8 @@ async fn main() { AuditClientRateLimiter(RateLimiter::::new(100, Duration::from_secs(60))); let search_limiter = SearchRateLimiter(RateLimiter::::new(search_rate, Duration::from_secs(60))); + let knock_limiter = + KnockRateLimiter(RateLimiter::::new(knock_per_hour, Duration::from_secs(3600))); // Lemon Squeezy live metrics cache (background refresh every 5 min). let ls_cache = lemonsqueezy::LsCache::default(); @@ -184,6 +190,7 @@ async fn main() { waitlist_per_hour, sync_per_hour = sync_rate, search_per_minute = search_rate, + knock_per_hour, "Configured rate limits" ); @@ -477,6 +484,7 @@ async fn main() { .layer(middleware::from_fn(rate_limit::sync_rate_limit)) .layer(Extension(sync_limiter)) .layer(Extension(search_limiter)) + .layer(Extension(knock_limiter)) .layer(middleware::from_fn(auth::auth_middleware)) .layer(Extension(notifier.clone())) .layer(Extension(terminal_manager.clone())) diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 3234394..bf82007 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -99,6 +99,11 @@ pub struct WaitlistRateLimiter(pub RateLimiter); #[derive(Clone)] pub struct SearchRateLimiter(pub RateLimiter); +/// Stranger knocks per sender. Configurable because a hardcoded limiter has +/// cost real time in every end-to-end run since the auth one shipped. +#[derive(Clone)] +pub struct KnockRateLimiter(pub RateLimiter); + /// Register endpoint: N registrations/day per IP. pub async fn register_rate_limit( axum::Extension(RegisterRateLimiter(limiter)): axum::Extension, diff --git a/src/routes/teams.rs b/src/routes/teams.rs index cc6e185..d62618a 100644 --- a/src/routes/teams.rs +++ b/src/routes/teams.rs @@ -1497,10 +1497,15 @@ mod authz_tests { let session_id = seed_direct_session(pool, host).await; let manager = TerminalManager::new(); manager.insert_test_session(session_id, host).await; + let knocks = crate::rate_limit::KnockRateLimiter(crate::rate_limit::RateLimiter::new( + 20, + std::time::Duration::from_secs(3600), + )); crate::routes::terminal::grant_invitee( pool, &SyncNotifier::new(), &manager, + &knocks, session_id, host, invitee, diff --git a/src/routes/terminal.rs b/src/routes/terminal.rs index bf9534f..71c1b78 100644 --- a/src/routes/terminal.rs +++ b/src/routes/terminal.rs @@ -129,32 +129,88 @@ pub(crate) async fn shares_a_team(pool: &PgPool, a: Uuid, b: Uuid) -> Result Result { + sqlx::query_scalar::<_, bool>( + "SELECT EXISTS(SELECT 1 FROM user_blocks \ + WHERE blocker_id = $1 AND blocked_id = $2 \ + AND (expires_at IS NULL OR expires_at > now()))", + ) + .bind(blocked_by) + .bind(sender) + .fetch_one(pool) + .await + .map_err(|e| { + error!(error = %e, "Failed to check user block"); + StatusCode::INTERNAL_SERVER_ERROR + }) +} + /// Grants one named user access to a session: the durable row, the wrapped key, /// the in-memory authorization set, and the push. The single grant path — both /// `create_session` with visibility "direct" and the invitees endpoint call it. +/// +/// A teammate is granted unconditionally, as before. A stranger is granted +/// only on the recipient's terms — their opt-out, their block list, and a +/// per-sender knock budget — and a refusal on those terms is reported back as +/// `Suppressed`, identical to success, so a blocked sender can never learn it. +#[allow(clippy::too_many_arguments)] pub(crate) async fn grant_invitee( pool: &PgPool, notifier: &crate::sync_notifier::SyncNotifier, manager: &TerminalManager, + knocks: &crate::rate_limit::KnockRateLimiter, session_id: Uuid, host_user_id: Uuid, user_id: Uuid, wrapped_key: &str, -) -> Result<(), StatusCode> { - // A direct session has no vault, so none of the vault permission checks - // apply to it. Without this the host could grant an arbitrary user id. - if user_id != host_user_id && !shares_a_team(pool, host_user_id, user_id).await? { - warn!(host = %host_user_id, invitee = %user_id, "Invite rejected: not a teammate"); - return Err(StatusCode::FORBIDDEN); +) -> Result { + let is_teammate = user_id == host_user_id || shares_a_team(pool, host_user_id, user_id).await?; + + // A stranger knock is allowed, but on the recipient's terms: their opt-out, + // their block list, and a per-sender budget. Teammates keep today's path + // untouched, budget included. + if !is_teammate { + if !knocks.0.check(host_user_id).await { + warn!(host = %host_user_id, "Knock rate limit exceeded"); + return Err(StatusCode::TOO_MANY_REQUESTS); + } + let opted_in = sqlx::query_scalar::<_, bool>( + "SELECT allow_stranger_invites FROM users WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(user_id) + .fetch_optional(pool) + .await + .map_err(|e| { + error!(error = %e, "Failed to read invite preference"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .unwrap_or(false); + + if !opted_in || is_blocked(pool, user_id, host_user_id).await? { + info!(target: "knock", sender = %host_user_id, recipient = %user_id, outcome = "suppressed", "Stranger knock suppressed"); + return Ok(GrantOutcome::Suppressed); + } + info!(target: "knock", sender = %host_user_id, recipient = %user_id, outcome = "granted", "Stranger knock"); } let invitee_insert = sqlx::query( - "INSERT INTO terminal_session_invitees (session_id, user_id, invited_by) \ - VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + "INSERT INTO terminal_session_invitees (session_id, user_id, invited_by, accepted_at) \ + VALUES ($1, $2, $3, CASE WHEN $4 THEN now() ELSE NULL END) ON CONFLICT DO NOTHING", ) .bind(session_id) .bind(user_id) .bind(host_user_id) + .bind(is_teammate) .execute(pool) .await .map_err(|e| { @@ -189,7 +245,7 @@ pub(crate) async fn grant_invitee( if invitee_insert.rows_affected() > 0 { notifier.notify_session_shared(user_id, session_id, host_user_id); } - Ok(()) + Ok(GrantOutcome::Granted) } /// Undoes `grant_invitee` for every grant `user_id` is no longer qualified for @@ -263,6 +319,7 @@ pub async fn create_session( Extension(auth_claims): Extension, Extension(manager): Extension, Extension(notifier): Extension, + Extension(knocks): Extension, Json(body): Json, ) -> Result<(StatusCode, Json), StatusCode> { let visibility = body.visibility.as_deref().unwrap_or("vault").to_string(); @@ -470,6 +527,7 @@ pub async fn create_session( &pool, ¬ifier, &manager, + &knocks, session_id, auth.0, entry.user_id, @@ -860,15 +918,19 @@ pub async fn invite_to_session( Extension(auth): Extension, Extension(manager): Extension, Extension(notifier): Extension, + Extension(knocks): Extension, Path(session_id): Path, Json(body): Json, ) -> Result { require_active_session_host(&pool, session_id, auth.0).await?; + // Granted or Suppressed both return 204: the sender must not be able to + // tell a block/opt-out apart from an ordinary successful invite. grant_invitee( &pool, ¬ifier, &manager, + &knocks, session_id, auth.0, body.user_id, @@ -1331,12 +1393,19 @@ mod authz_tests { use crate::auth::jwt::Claims; use crate::auth::{AuthClaims, AuthUser}; use crate::permissions::PERM_CONNECT; + use crate::rate_limit::RateLimiter; use crate::sync_notifier::SyncNotifier; use crate::terminal_manager::TerminalManager; use crate::test_pool_or_skip; use crate::test_support::{add_member, member_with_role, seed_team, seed_user}; use axum::extract::State; use axum::{Extension, Json}; + use std::time::Duration; + + /// Default-budget limiter for tests that don't care about the knock limit. + fn knocks() -> crate::rate_limit::KnockRateLimiter { + crate::rate_limit::KnockRateLimiter(RateLimiter::new(20, Duration::from_secs(3600))) + } fn claims_for(user: uuid::Uuid) -> AuthClaims { AuthClaims(Claims { @@ -1392,6 +1461,7 @@ mod authz_tests { Extension(claims_for(outsider)), Extension(TerminalManager::new()), Extension(SyncNotifier::new()), + Extension(knocks()), Json(vault_session_request(vec![team])), ) .await; @@ -1416,6 +1486,7 @@ mod authz_tests { Extension(claims_for(caller)), Extension(TerminalManager::new()), Extension(SyncNotifier::new()), + Extension(knocks()), Json(vault_session_request(vec![team])), ) .await; @@ -1444,6 +1515,7 @@ mod authz_tests { Extension(claims_for(host)), Extension(manager.clone()), Extension(SyncNotifier::new()), + Extension(knocks()), Json(direct_session_request(vec![entry])), ) .await; @@ -1488,6 +1560,7 @@ mod authz_tests { Extension(claims_for(host)), Extension(TerminalManager::new()), Extension(SyncNotifier::new()), + Extension(knocks()), Json(direct_session_request(Vec::new())), ) .await; @@ -1511,17 +1584,21 @@ mod authz_tests { ParticipantKeyEntry { user_id: stranger, wrapped_key: "wrapped".to_string() }, ]; + // Zero budget: the stranger knock is what fails this grant, since a + // stranger is no longer forbidden outright. + let exhausted = crate::rate_limit::KnockRateLimiter(RateLimiter::new(0, Duration::from_secs(3600))); let res = create_session( State(pool.clone()), Extension(AuthUser(host)), Extension(claims_for(host)), Extension(manager.clone()), Extension(SyncNotifier::new()), + Extension(exhausted), Json(direct_session_request(entries)), ) .await; - assert!(matches!(res, Err(axum::http::StatusCode::FORBIDDEN))); + assert!(matches!(res, Err(axum::http::StatusCode::TOO_MANY_REQUESTS))); // The session must not linger as "active": ended_at set, and gone from // in-memory state (or `SELECT COUNT ... WHERE ended_at IS NULL` would @@ -1585,8 +1662,10 @@ mod authz_tests { #[cfg(test)] mod tests { use super::*; + use crate::rate_limit::RateLimiter; use crate::test_pool_or_skip; use crate::test_support::{add_member, seed_team, seed_user}; + use std::time::Duration; async fn seed_session(pool: &PgPool, host: Uuid, visibility: &str) -> Uuid { sqlx::query_scalar::<_, Uuid>( @@ -1600,6 +1679,41 @@ mod tests { .expect("insert session") } + fn harness() -> (crate::sync_notifier::SyncNotifier, TerminalManager) { + ( + crate::sync_notifier::SyncNotifier::new(), + TerminalManager::new(), + ) + } + + /// Default-budget limiter for tests that don't care about the knock limit. + fn knocks() -> crate::rate_limit::KnockRateLimiter { + crate::rate_limit::KnockRateLimiter(RateLimiter::new(20, Duration::from_secs(3600))) + } + + async fn mk_stranger(pool: &PgPool) -> Uuid { + seed_user(pool).await + } + + /// A direct session with a host and a user who shares no team — a stranger. + async fn direct_session_with_stranger(pool: &PgPool) -> (Uuid, Uuid, Uuid) { + let host = seed_user(pool).await; + let stranger = mk_stranger(pool).await; + let session_id = seed_session(pool, host, "direct").await; + (host, stranger, session_id) + } + + /// A direct session with a host and a user who shares a team — a teammate. + async fn direct_session_with_teammate(pool: &PgPool) -> (Uuid, Uuid, Uuid) { + let host = seed_user(pool).await; + let mate = seed_user(pool).await; + let team = seed_team(pool, host).await; + add_member(pool, team, host).await; + add_member(pool, team, mate).await; + let session_id = seed_session(pool, host, "direct").await; + (host, mate, session_id) + } + #[test] fn host_tier_session_limits_match_the_shipped_gate() { assert_eq!(host_tier_session_limit("business"), Some(20)); @@ -1635,7 +1749,14 @@ mod tests { let (notifier, manager) = test_notifier_and_manager(session_id, host).await; for _ in 0..2 { grant_invitee( - &pool, ¬ifier, &manager, session_id, host, mate, "wrapped", + &pool, + ¬ifier, + &manager, + &knocks(), + session_id, + host, + mate, + "wrapped", ) .await .expect("grant"); @@ -1680,6 +1801,7 @@ mod tests { &pool, ¬ifier, &manager, + &knocks(), session_id, host, mate, @@ -1691,6 +1813,7 @@ mod tests { &pool, ¬ifier, &manager, + &knocks(), session_id, host, mate, @@ -1723,7 +1846,7 @@ mod tests { let (notifier, manager) = test_notifier_and_manager(session_id, host).await; let mut events = notifier.subscribe(); for _ in 0..2 { - grant_invitee(&pool, ¬ifier, &manager, session_id, host, mate, "wrapped") + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, mate, "wrapped") .await .expect("grant"); } @@ -1738,19 +1861,31 @@ mod tests { } #[tokio::test] - async fn grant_invitee_rejects_a_non_teammate() { + async fn grant_invitee_suppresses_a_knock_to_an_opted_out_stranger() { let pool = test_pool_or_skip!(); let host = seed_user(&pool).await; let stranger = seed_user(&pool).await; + sqlx::query("UPDATE users SET allow_stranger_invites = FALSE WHERE id = $1") + .bind(stranger) + .execute(&pool) + .await + .unwrap(); let session_id = seed_session(&pool, host, "direct").await; let (notifier, manager) = test_notifier_and_manager(session_id, host).await; - let err = grant_invitee( - &pool, ¬ifier, &manager, session_id, host, stranger, "wrapped", + let outcome = grant_invitee( + &pool, + ¬ifier, + &manager, + &knocks(), + session_id, + host, + stranger, + "wrapped", ) .await - .expect_err("stranger must be rejected"); - assert_eq!(err, StatusCode::FORBIDDEN); + .expect("suppression must look exactly like success to the sender"); + assert_eq!(outcome, GrantOutcome::Suppressed); let grants: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM terminal_session_invitees WHERE session_id = $1", @@ -1772,7 +1907,7 @@ mod tests { add_member(&pool, team, mate).await; let session_id = seed_session(&pool, host, "direct").await; let (notifier, manager) = test_notifier_and_manager(session_id, host).await; - grant_invitee(&pool, ¬ifier, &manager, session_id, host, mate, "wrapped") + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, mate, "wrapped") .await .unwrap(); @@ -1803,7 +1938,7 @@ mod tests { add_member(&pool, team, mate).await; let session_id = seed_session(&pool, host, "direct").await; let (notifier, manager) = test_notifier_and_manager(session_id, host).await; - grant_invitee(&pool, ¬ifier, &manager, session_id, host, mate, "wrapped") + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, mate, "wrapped") .await .unwrap(); @@ -1829,7 +1964,7 @@ mod tests { add_member(&pool, team, mate).await; let session_id = seed_session(&pool, host, "direct").await; let (notifier, manager) = test_notifier_and_manager(session_id, host).await; - grant_invitee(&pool, ¬ifier, &manager, session_id, host, mate, "wrapped") + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, mate, "wrapped") .await .unwrap(); @@ -1850,7 +1985,7 @@ mod tests { add_member(&pool, team, mate).await; let session_id = seed_session(&pool, host, "direct").await; let (notifier, manager) = test_notifier_and_manager(session_id, host).await; - grant_invitee(&pool, ¬ifier, &manager, session_id, host, mate, "wrapped") + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, mate, "wrapped") .await .unwrap(); @@ -1868,7 +2003,7 @@ mod tests { add_member(&pool, team, mate).await; let session_id = seed_session(&pool, host, "direct").await; let (notifier, manager) = test_notifier_and_manager(session_id, host).await; - grant_invitee(&pool, ¬ifier, &manager, session_id, host, mate, "wrapped") + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, mate, "wrapped") .await .unwrap(); @@ -1925,7 +2060,7 @@ mod tests { add_member(&pool, team, mate).await; let session_id = seed_session(&pool, host, "direct").await; let (notifier, manager) = test_notifier_and_manager(session_id, host).await; - grant_invitee(&pool, ¬ifier, &manager, session_id, host, mate, "wrapped") + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, mate, "wrapped") .await .unwrap(); @@ -1979,9 +2114,18 @@ mod tests { // would have no recipient left to push to and the assertion below would // pass vacuously. for invitee in [mate, other] { - grant_invitee(&pool, ¬ifier, &manager, session_id, host, invitee, "wrapped") - .await - .unwrap(); + grant_invitee( + &pool, + ¬ifier, + &manager, + &knocks(), + session_id, + host, + invitee, + "wrapped", + ) + .await + .unwrap(); } let mut events = notifier.subscribe(); @@ -2024,7 +2168,7 @@ mod tests { add_member(&pool, team, mate).await; let session_id = seed_session(&pool, host, "direct").await; let (notifier, manager) = test_notifier_and_manager(session_id, host).await; - grant_invitee(&pool, ¬ifier, &manager, session_id, host, mate, "wrapped") + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, mate, "wrapped") .await .unwrap(); let (tx, _) = tokio::sync::broadcast::channel(BROADCAST_CAPACITY); @@ -2050,4 +2194,164 @@ mod tests { manager.insert_test_session(session_id, host).await; (notifier, manager) } + + #[tokio::test] + async fn grant_invitee_accepts_a_stranger_and_leaves_acceptance_unset() { + let pool = test_pool_or_skip!(); + let (notifier, manager) = harness(); + let (host, stranger, session_id) = direct_session_with_stranger(&pool).await; + + let outcome = grant_invitee( + &pool, + ¬ifier, + &manager, + &knocks(), + session_id, + host, + stranger, + "wrapped", + ) + .await + .unwrap(); + assert_eq!(outcome, GrantOutcome::Granted); + + let accepted: Option> = sqlx::query_scalar( + "SELECT accepted_at FROM terminal_session_invitees WHERE session_id = $1 AND user_id = $2") + .bind(session_id).bind(stranger).fetch_one(&pool).await.unwrap(); + assert!(accepted.is_none(), "a stranger grant is unaccepted until they join"); + } + + #[tokio::test] + async fn a_teammate_grant_is_accepted_on_creation() { + let pool = test_pool_or_skip!(); + let (notifier, manager) = harness(); + let (host, mate, session_id) = direct_session_with_teammate(&pool).await; + + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, mate, "wrapped") + .await + .unwrap(); + let accepted: Option> = sqlx::query_scalar( + "SELECT accepted_at FROM terminal_session_invitees WHERE session_id = $1 AND user_id = $2") + .bind(session_id).bind(mate).fetch_one(&pool).await.unwrap(); + assert!(accepted.is_some(), "the shipped teammate path must not change behaviour"); + } + + #[tokio::test] + async fn a_block_suppresses_the_grant_without_reporting_failure() { + let pool = test_pool_or_skip!(); + let (notifier, manager) = harness(); + let (host, stranger, session_id) = direct_session_with_stranger(&pool).await; + sqlx::query("INSERT INTO user_blocks (blocker_id, blocked_id, expires_at) VALUES ($1, $2, now() + interval '7 days')") + .bind(stranger).bind(host).execute(&pool).await.unwrap(); + + let outcome = grant_invitee( + &pool, + ¬ifier, + &manager, + &knocks(), + session_id, + host, + stranger, + "wrapped", + ) + .await + .expect("a block must look exactly like success to the sender"); + assert_eq!(outcome, GrantOutcome::Suppressed); + + let rows: i64 = sqlx::query_scalar( + "SELECT count(*) FROM terminal_session_invitees WHERE session_id = $1 AND user_id = $2", + ) + .bind(session_id) + .bind(stranger) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!( + rows, 0, + "no row means no phantom invite holding a guest seat" + ); + } + + #[tokio::test] + async fn an_expired_block_no_longer_suppresses() { + let pool = test_pool_or_skip!(); + let (notifier, manager) = harness(); + let (host, stranger, session_id) = direct_session_with_stranger(&pool).await; + sqlx::query("INSERT INTO user_blocks (blocker_id, blocked_id, expires_at) VALUES ($1, $2, now() - interval '1 day')") + .bind(stranger).bind(host).execute(&pool).await.unwrap(); + + assert_eq!( + grant_invitee( + &pool, + ¬ifier, + &manager, + &knocks(), + session_id, + host, + stranger, + "wrapped" + ) + .await + .unwrap(), + GrantOutcome::Granted, + ); + } + + #[tokio::test] + async fn opting_out_suppresses_the_grant() { + let pool = test_pool_or_skip!(); + let (notifier, manager) = harness(); + let (host, stranger, session_id) = direct_session_with_stranger(&pool).await; + sqlx::query("UPDATE users SET allow_stranger_invites = FALSE WHERE id = $1") + .bind(stranger) + .execute(&pool) + .await + .unwrap(); + + assert_eq!( + grant_invitee( + &pool, + ¬ifier, + &manager, + &knocks(), + session_id, + host, + stranger, + "wrapped" + ) + .await + .unwrap(), + GrantOutcome::Suppressed, + ); + } + + #[tokio::test] + async fn the_knock_rate_limit_trips_and_teammates_are_exempt() { + let pool = test_pool_or_skip!(); + let (notifier, manager) = harness(); + let limiter = + crate::rate_limit::KnockRateLimiter(RateLimiter::new(1, Duration::from_secs(3600))); + let (host, stranger_a, session_id) = direct_session_with_stranger(&pool).await; + let stranger_b = mk_stranger(&pool).await; + + grant_invitee( + &pool, ¬ifier, &manager, &limiter, session_id, host, stranger_a, "w", + ) + .await + .unwrap(); + let err = grant_invitee( + &pool, ¬ifier, &manager, &limiter, session_id, host, stranger_b, "w", + ) + .await + .unwrap_err(); + assert_eq!(err, StatusCode::TOO_MANY_REQUESTS); + + // A teammate invite must not consume or be refused by the stranger budget. + let (host2, mate, session2) = direct_session_with_teammate(&pool).await; + grant_invitee( + &pool, ¬ifier, &manager, &limiter, session2, host2, mate, "w", + ) + .await + .unwrap(); + } } From d8702e0070b53091d434a4487f56ec21804c9bc2 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 02:26:21 +0000 Subject: [PATCH 07/22] refactor(test): dedupe the default knock-limiter fixture into test_support --- src/routes/teams.rs | 6 +----- src/routes/terminal.rs | 14 ++------------ src/test_support.rs | 9 +++++++++ 3 files changed, 12 insertions(+), 17 deletions(-) diff --git a/src/routes/teams.rs b/src/routes/teams.rs index d62618a..2728390 100644 --- a/src/routes/teams.rs +++ b/src/routes/teams.rs @@ -1497,15 +1497,11 @@ mod authz_tests { let session_id = seed_direct_session(pool, host).await; let manager = TerminalManager::new(); manager.insert_test_session(session_id, host).await; - let knocks = crate::rate_limit::KnockRateLimiter(crate::rate_limit::RateLimiter::new( - 20, - std::time::Duration::from_secs(3600), - )); crate::routes::terminal::grant_invitee( pool, &SyncNotifier::new(), &manager, - &knocks, + &crate::test_support::default_knock_limiter(), session_id, host, invitee, diff --git a/src/routes/terminal.rs b/src/routes/terminal.rs index 71c1b78..20f2a72 100644 --- a/src/routes/terminal.rs +++ b/src/routes/terminal.rs @@ -1397,16 +1397,11 @@ mod authz_tests { use crate::sync_notifier::SyncNotifier; use crate::terminal_manager::TerminalManager; use crate::test_pool_or_skip; - use crate::test_support::{add_member, member_with_role, seed_team, seed_user}; + use crate::test_support::{add_member, default_knock_limiter as knocks, member_with_role, seed_team, seed_user}; use axum::extract::State; use axum::{Extension, Json}; use std::time::Duration; - /// Default-budget limiter for tests that don't care about the knock limit. - fn knocks() -> crate::rate_limit::KnockRateLimiter { - crate::rate_limit::KnockRateLimiter(RateLimiter::new(20, Duration::from_secs(3600))) - } - fn claims_for(user: uuid::Uuid) -> AuthClaims { AuthClaims(Claims { sub: user, @@ -1664,7 +1659,7 @@ mod tests { use super::*; use crate::rate_limit::RateLimiter; use crate::test_pool_or_skip; - use crate::test_support::{add_member, seed_team, seed_user}; + use crate::test_support::{add_member, default_knock_limiter as knocks, seed_team, seed_user}; use std::time::Duration; async fn seed_session(pool: &PgPool, host: Uuid, visibility: &str) -> Uuid { @@ -1686,11 +1681,6 @@ mod tests { ) } - /// Default-budget limiter for tests that don't care about the knock limit. - fn knocks() -> crate::rate_limit::KnockRateLimiter { - crate::rate_limit::KnockRateLimiter(RateLimiter::new(20, Duration::from_secs(3600))) - } - async fn mk_stranger(pool: &PgPool) -> Uuid { seed_user(pool).await } diff --git a/src/test_support.rs b/src/test_support.rs index db64978..4a0dc6f 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -66,6 +66,15 @@ macro_rules! test_pool_or_skip { }; } +/// Default-budget knock limiter for tests that don't care about the knock +/// limit itself — only tests exercising the limit construct their own. +pub fn default_knock_limiter() -> crate::rate_limit::KnockRateLimiter { + crate::rate_limit::KnockRateLimiter(crate::rate_limit::RateLimiter::new( + 20, + std::time::Duration::from_secs(3600), + )) +} + /// Insert a minimal valid user and return its id. Each call uses fresh UUIDs so /// tests never collide on the unique `email`/`account_id` columns. pub async fn seed_user(pool: &PgPool) -> Uuid { From 068adbc3c6200b97c98e13d1731d173780b8127c Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 02:39:24 +0000 Subject: [PATCH 08/22] fix(terminal): make suppressed knocks indistinguishable in the host's 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. --- migrations/035_user_handles_and_blocks.sql | 12 ++ src/routes/terminal.rs | 157 +++++++++++++-------- 2 files changed, 108 insertions(+), 61 deletions(-) diff --git a/migrations/035_user_handles_and_blocks.sql b/migrations/035_user_handles_and_blocks.sql index 6e7acf4..d9d1626 100644 --- a/migrations/035_user_handles_and_blocks.sql +++ b/migrations/035_user_handles_and_blocks.sql @@ -57,3 +57,15 @@ CREATE TABLE user_blocks ( -- NULL until the invitee's WebSocket is first admitted. Drives both the -- session-name redaction and the "unaccepted stranger" state. ALTER TABLE terminal_session_invitees ADD COLUMN accepted_at TIMESTAMPTZ NULL; + +-- A suppressed knock (blocked or opted-out recipient) writes no grant row — +-- that silence is what makes the block undetectable. This table exists only +-- so the host's own invitee list still shows the stranger as "invited", +-- indistinguishable from a real pending grant; nothing else may read it. +CREATE TABLE suppressed_invites ( + session_id UUID NOT NULL REFERENCES terminal_sessions(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + invited_by UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (session_id, user_id) +); diff --git a/src/routes/terminal.rs b/src/routes/terminal.rs index 20f2a72..b43c4a2 100644 --- a/src/routes/terminal.rs +++ b/src/routes/terminal.rs @@ -198,6 +198,22 @@ pub(crate) async fn grant_invitee( if !opted_in || is_blocked(pool, user_id, host_user_id).await? { info!(target: "knock", sender = %host_user_id, recipient = %user_id, outcome = "suppressed", "Stranger knock suppressed"); + // No grant row, ever — that silence is the whole point. This is the + // one place that writes here: it exists only so the host's own + // invitee list can't tell a block/opt-out apart from a real grant. + sqlx::query( + "INSERT INTO suppressed_invites (session_id, user_id, invited_by) \ + VALUES ($1, $2, $3) ON CONFLICT DO NOTHING", + ) + .bind(session_id) + .bind(user_id) + .bind(host_user_id) + .execute(pool) + .await + .map_err(|e| { + error!(error = %e, session_id = %session_id, "Failed to record suppressed invite"); + StatusCode::INTERNAL_SERVER_ERROR + })?; return Ok(GrantOutcome::Suppressed); } info!(target: "knock", sender = %host_user_id, recipient = %user_id, outcome = "granted", "Stranger knock"); @@ -599,9 +615,16 @@ async fn visible_sessions( ) AS vault_ids, (SELECT tsi.invited_by FROM terminal_session_invitees tsi WHERE tsi.session_id = ts.id AND tsi.user_id = $1) AS invited_by, + -- Suppressed knocks are unioned in here, and only here: the host must see + -- a blocked/opted-out stranger exactly as an ordinary pending invite, or + -- the missing id would tell them what the silent block exists to hide. CASE WHEN ts.host_user_id = $1 THEN COALESCE( - (SELECT array_agg(tsi3.user_id) FROM terminal_session_invitees tsi3 WHERE tsi3.session_id = ts.id), + (SELECT array_agg(uid) FROM ( + SELECT tsi3.user_id AS uid FROM terminal_session_invitees tsi3 WHERE tsi3.session_id = ts.id + UNION + SELECT si.user_id FROM suppressed_invites si WHERE si.session_id = ts.id + ) all_invitee_ids), ARRAY[]::uuid[] ) ELSE ARRAY[]::uuid[] END AS invitee_ids @@ -1729,12 +1752,7 @@ mod tests { #[tokio::test] async fn grant_invitee_is_idempotent_and_inserts_both_rows() { let pool = test_pool_or_skip!(); - let host = seed_user(&pool).await; - let mate = seed_user(&pool).await; - let team = seed_team(&pool, host).await; - add_member(&pool, team, host).await; - add_member(&pool, team, mate).await; - let session_id = seed_session(&pool, host, "direct").await; + let (host, mate, session_id) = direct_session_with_teammate(&pool).await; let (notifier, manager) = test_notifier_and_manager(session_id, host).await; for _ in 0..2 { @@ -1777,12 +1795,7 @@ mod tests { #[tokio::test] async fn grant_invitee_refreshes_a_stale_wrapped_key() { let pool = test_pool_or_skip!(); - let host = seed_user(&pool).await; - let mate = seed_user(&pool).await; - let team = seed_team(&pool, host).await; - add_member(&pool, team, host).await; - add_member(&pool, team, mate).await; - let session_id = seed_session(&pool, host, "direct").await; + let (host, mate, session_id) = direct_session_with_teammate(&pool).await; let (notifier, manager) = test_notifier_and_manager(session_id, host).await; // The recipient rotates their keypair between the two grants, so the @@ -1826,12 +1839,7 @@ mod tests { #[tokio::test] async fn grant_invitee_pushes_only_on_the_first_grant() { let pool = test_pool_or_skip!(); - let host = seed_user(&pool).await; - let mate = seed_user(&pool).await; - let team = seed_team(&pool, host).await; - add_member(&pool, team, host).await; - add_member(&pool, team, mate).await; - let session_id = seed_session(&pool, host, "direct").await; + let (host, mate, session_id) = direct_session_with_teammate(&pool).await; let (notifier, manager) = test_notifier_and_manager(session_id, host).await; let mut events = notifier.subscribe(); @@ -1890,12 +1898,7 @@ mod tests { #[tokio::test] async fn ws_authorizes_an_invitee_of_a_vaultless_session() { let pool = test_pool_or_skip!(); - let host = seed_user(&pool).await; - let mate = seed_user(&pool).await; - let team = seed_team(&pool, host).await; - add_member(&pool, team, host).await; - add_member(&pool, team, mate).await; - let session_id = seed_session(&pool, host, "direct").await; + let (host, mate, session_id) = direct_session_with_teammate(&pool).await; let (notifier, manager) = test_notifier_and_manager(session_id, host).await; grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, mate, "wrapped") .await @@ -1920,13 +1923,8 @@ mod tests { #[tokio::test] async fn list_query_returns_a_direct_session_only_for_host_and_invitee() { let pool = test_pool_or_skip!(); - let host = seed_user(&pool).await; - let mate = seed_user(&pool).await; + let (host, mate, session_id) = direct_session_with_teammate(&pool).await; let stranger = seed_user(&pool).await; - let team = seed_team(&pool, host).await; - add_member(&pool, team, host).await; - add_member(&pool, team, mate).await; - let session_id = seed_session(&pool, host, "direct").await; let (notifier, manager) = test_notifier_and_manager(session_id, host).await; grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, mate, "wrapped") .await @@ -1947,12 +1945,7 @@ mod tests { #[tokio::test] async fn list_query_reveals_invitee_ids_only_to_the_host() { let pool = test_pool_or_skip!(); - let host = seed_user(&pool).await; - let mate = seed_user(&pool).await; - let team = seed_team(&pool, host).await; - add_member(&pool, team, host).await; - add_member(&pool, team, mate).await; - let session_id = seed_session(&pool, host, "direct").await; + let (host, mate, session_id) = direct_session_with_teammate(&pool).await; let (notifier, manager) = test_notifier_and_manager(session_id, host).await; grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, mate, "wrapped") .await @@ -1968,12 +1961,7 @@ mod tests { #[tokio::test] async fn end_session_recipients_include_invitees_of_a_vaultless_session() { let pool = test_pool_or_skip!(); - let host = seed_user(&pool).await; - let mate = seed_user(&pool).await; - let team = seed_team(&pool, host).await; - add_member(&pool, team, host).await; - add_member(&pool, team, mate).await; - let session_id = seed_session(&pool, host, "direct").await; + let (host, mate, session_id) = direct_session_with_teammate(&pool).await; let (notifier, manager) = test_notifier_and_manager(session_id, host).await; grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, mate, "wrapped") .await @@ -1986,12 +1974,7 @@ mod tests { #[tokio::test] async fn host_disconnect_on_a_vaultless_session_retracts_the_invitees_knock() { let pool = test_pool_or_skip!(); - let host = seed_user(&pool).await; - let mate = seed_user(&pool).await; - let team = seed_team(&pool, host).await; - add_member(&pool, team, host).await; - add_member(&pool, team, mate).await; - let session_id = seed_session(&pool, host, "direct").await; + let (host, mate, session_id) = direct_session_with_teammate(&pool).await; let (notifier, manager) = test_notifier_and_manager(session_id, host).await; grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, mate, "wrapped") .await @@ -2043,12 +2026,7 @@ mod tests { #[tokio::test] async fn ending_a_session_clears_its_invitee_grants_but_still_notifies_them() { let pool = test_pool_or_skip!(); - let host = seed_user(&pool).await; - let mate = seed_user(&pool).await; - let team = seed_team(&pool, host).await; - add_member(&pool, team, host).await; - add_member(&pool, team, mate).await; - let session_id = seed_session(&pool, host, "direct").await; + let (host, mate, session_id) = direct_session_with_teammate(&pool).await; let (notifier, manager) = test_notifier_and_manager(session_id, host).await; grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, mate, "wrapped") .await @@ -2151,12 +2129,7 @@ mod tests { #[tokio::test] async fn host_disconnect_also_clears_the_sessions_invitee_grants() { let pool = test_pool_or_skip!(); - let host = seed_user(&pool).await; - let mate = seed_user(&pool).await; - let team = seed_team(&pool, host).await; - add_member(&pool, team, host).await; - add_member(&pool, team, mate).await; - let session_id = seed_session(&pool, host, "direct").await; + let (host, mate, session_id) = direct_session_with_teammate(&pool).await; let (notifier, manager) = test_notifier_and_manager(session_id, host).await; grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, mate, "wrapped") .await @@ -2262,6 +2235,68 @@ mod tests { ); } + #[tokio::test] + async fn a_suppressed_knock_still_appears_in_the_hosts_invitee_ids() { + let pool = test_pool_or_skip!(); + let (notifier, manager) = harness(); + let (host, stranger, session_id) = direct_session_with_stranger(&pool).await; + sqlx::query("UPDATE users SET allow_stranger_invites = FALSE WHERE id = $1") + .bind(stranger) + .execute(&pool) + .await + .unwrap(); + + let outcome = grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, stranger, "wrapped") + .await + .unwrap(); + assert_eq!(outcome, GrantOutcome::Suppressed); + + // The host's own view must be unable to tell this apart from a real + // grant, or the missing id would leak the very thing the block hides. + let for_host = visible_sessions(&pool, host).await.unwrap(); + assert_eq!(for_host[0].7, vec![stranger], "a suppressed knock occupies a seat exactly like a real one"); + } + + #[tokio::test] + async fn a_suppressed_stranger_cannot_see_the_session_themselves() { + let pool = test_pool_or_skip!(); + let (notifier, manager) = harness(); + let (host, stranger, session_id) = direct_session_with_stranger(&pool).await; + sqlx::query("UPDATE users SET allow_stranger_invites = FALSE WHERE id = $1") + .bind(stranger) + .execute(&pool) + .await + .unwrap(); + + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, stranger, "wrapped") + .await + .unwrap(); + + assert!( + visible_sessions(&pool, stranger).await.unwrap().is_empty(), + "the suppressed row must never grant the recipient their own visibility" + ); + } + + #[tokio::test] + async fn a_suppressed_stranger_is_not_an_authorized_participant() { + let pool = test_pool_or_skip!(); + let (host, stranger, session_id) = direct_session_with_stranger(&pool).await; + let (notifier, manager) = test_notifier_and_manager(session_id, host).await; + sqlx::query("INSERT INTO user_blocks (blocker_id, blocked_id, expires_at) VALUES ($1, $2, NULL)") + .bind(stranger).bind(host).execute(&pool).await.unwrap(); + + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, stranger, "wrapped") + .await + .unwrap(); + + let invitees = manager.sessions.lock().await.get(&session_id).unwrap().invitees.clone(); + assert!( + !is_authorized_participant(&pool, stranger, host, "direct", &[], &[], None, None, &invitees).await, + "the suppressed row must not admit the WebSocket" + ); + } + #[tokio::test] async fn an_expired_block_no_longer_suppresses() { let pool = test_pool_or_skip!(); From aa9ec08f2caf2e15a93150d0516664f02f207189 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 02:53:09 +0000 Subject: [PATCH 09/22] feat(terminal): hide the session name from an unaccepted stranger until they join --- src/routes/terminal.rs | 167 ++++++++++++++++++++++++++++++----------- 1 file changed, 124 insertions(+), 43 deletions(-) diff --git a/src/routes/terminal.rs b/src/routes/terminal.rs index b43c4a2..ea83670 100644 --- a/src/routes/terminal.rs +++ b/src/routes/terminal.rs @@ -83,7 +83,9 @@ pub struct CreateSessionResponse { #[derive(Serialize)] pub struct ActiveSession { pub id: Uuid, - pub connection_name: String, + /// `None` for an unaccepted stranger invitee — a mis-aimed invite must not + /// leak what it was for until the recipient accepts. + pub connection_name: Option, pub host_user_id: Uuid, pub host_public_key: String, pub visibility: String, @@ -582,16 +584,18 @@ pub async fn create_session( // ─── List active sessions (vault sessions the user is part of) ──────────────── -type VisibleSessionRow = ( - Uuid, - String, - Uuid, - String, - chrono::DateTime, - Vec, - Option, - Vec, -); +#[derive(sqlx::FromRow)] +struct VisibleSessionRow { + id: Uuid, + /// `None` for an unaccepted stranger invitee (see the CASE in `visible_sessions`). + connection_name: Option, + host_user_id: Uuid, + visibility: String, + created_at: chrono::DateTime, + vault_ids: Vec, + invited_by: Option, + invitee_ids: Vec, +} /// Sessions `user_id` may see: their own, ones they hold an individual grant /// (#66) on, and vault sessions shared with a team they belong to (respecting @@ -605,7 +609,17 @@ async fn visible_sessions( r#" SELECT ts.id, - ts.connection_name, + -- An unaccepted stranger invitee must not learn what they were + -- invited to; the host and any teammate always see the name. + CASE + WHEN ts.host_user_id = $1 THEN ts.connection_name + WHEN EXISTS (SELECT 1 FROM team_members a JOIN team_members b ON a.team_id = b.team_id + WHERE a.user_id = $1 AND b.user_id = ts.host_user_id) THEN ts.connection_name + WHEN EXISTS (SELECT 1 FROM terminal_session_invitees tsi2 + WHERE tsi2.session_id = ts.id AND tsi2.user_id = $1 + AND tsi2.accepted_at IS NULL) THEN NULL + ELSE ts.connection_name + END AS connection_name, ts.host_user_id, ts.visibility, ts.created_at, @@ -687,31 +701,29 @@ pub async fn list_active_sessions( let sessions_lock = manager.sessions.lock().await; let result = rows .into_iter() - .filter(|(id, ..)| sessions_lock.contains_key(id)) - .map( - |(id, connection_name, host_user_id, visibility, created_at, vault_ids, invited_by, invitee_ids)| { - let (participant_count, participants, host_public_key) = sessions_lock - .get(&id) - .map(|s| { - let ps: Vec = s.participants.values().cloned().collect(); - (ps.len() as i64, ps, s.host_public_key.clone()) - }) - .unwrap_or_default(); - ActiveSession { - id, - connection_name, - host_user_id, - host_public_key, - visibility, - created_at, - participant_count, - participants, - vault_ids, - invited_by, - invitee_ids, - } - }, - ) + .filter(|row| sessions_lock.contains_key(&row.id)) + .map(|row| { + let (participant_count, participants, host_public_key) = sessions_lock + .get(&row.id) + .map(|s| { + let ps: Vec = s.participants.values().cloned().collect(); + (ps.len() as i64, ps, s.host_public_key.clone()) + }) + .unwrap_or_default(); + ActiveSession { + id: row.id, + connection_name: row.connection_name, + host_user_id: row.host_user_id, + host_public_key, + visibility: row.visibility, + created_at: row.created_at, + participant_count, + participants, + vault_ids: row.vault_ids, + invited_by: row.invited_by, + invitee_ids: row.invitee_ids, + } + }) .collect(); Ok(Json(result)) @@ -1071,6 +1083,23 @@ pub(crate) async fn is_authorized_participant( .unwrap_or(false) } +/// Stamps first admission. `accepted_at IS NULL` in the predicate makes a +/// re-join idempotent — the timestamp is "when they first said yes", and the +/// redaction above reads it. +pub(crate) async fn stamp_acceptance(pool: &PgPool, session_id: Uuid, user_id: Uuid) { + if let Err(e) = sqlx::query( + "UPDATE terminal_session_invitees SET accepted_at = now() \ + WHERE session_id = $1 AND user_id = $2 AND accepted_at IS NULL", + ) + .bind(session_id) + .bind(user_id) + .execute(pool) + .await + { + warn!(error = %e, session_id = %session_id, "Failed to stamp invitee acceptance"); + } +} + #[allow(clippy::too_many_arguments)] async fn handle_socket( socket: WebSocket, @@ -1132,6 +1161,8 @@ async fn handle_socket( return; } + stamp_acceptance(&pool, session_id, user_id).await; + let (mut ws_sender, mut ws_receiver) = socket.split(); // Participant cap: guests only (host is always allowed) @@ -1932,12 +1963,12 @@ mod tests { let for_mate = visible_sessions(&pool, mate).await.unwrap(); assert_eq!(for_mate.len(), 1); - assert_eq!(for_mate[0].0, session_id); - assert_eq!(for_mate[0].6, Some(host), "invited_by names the host"); + assert_eq!(for_mate[0].id, session_id); + assert_eq!(for_mate[0].invited_by, Some(host), "invited_by names the host"); let for_host = visible_sessions(&pool, host).await.unwrap(); assert_eq!(for_host.len(), 1); - assert_eq!(for_host[0].6, None, "the host is not their own invitee"); + assert_eq!(for_host[0].invited_by, None, "the host is not their own invitee"); assert!(visible_sessions(&pool, stranger).await.unwrap().is_empty()); } @@ -1952,10 +1983,60 @@ mod tests { .unwrap(); let for_host = visible_sessions(&pool, host).await.unwrap(); - assert_eq!(for_host[0].7, vec![mate], "the host sees who they invited"); + assert_eq!(for_host[0].invitee_ids, vec![mate], "the host sees who they invited"); let for_mate = visible_sessions(&pool, mate).await.unwrap(); - assert!(for_mate[0].7.is_empty(), "an invitee must not learn the guest list"); + assert!(for_mate[0].invitee_ids.is_empty(), "an invitee must not learn the guest list"); + } + + #[tokio::test] + async fn a_stranger_sees_no_session_name_until_accepted() { + let pool = test_pool_or_skip!(); + let (host, stranger, session_id) = direct_session_with_stranger(&pool).await; + sqlx::query("INSERT INTO terminal_session_invitees (session_id, user_id, invited_by) VALUES ($1, $2, $3)") + .bind(session_id).bind(stranger).bind(host).execute(&pool).await.unwrap(); + + let rows = visible_sessions(&pool, stranger).await.unwrap(); + let row = rows.iter().find(|r| r.id == session_id).expect("the knock must be visible"); + assert!(row.connection_name.is_none(), "a mis-aimed invite leaks a handle, never a hostname"); + + sqlx::query("UPDATE terminal_session_invitees SET accepted_at = now() WHERE session_id = $1 AND user_id = $2") + .bind(session_id).bind(stranger).execute(&pool).await.unwrap(); + let rows = visible_sessions(&pool, stranger).await.unwrap(); + assert!(rows.iter().find(|r| r.id == session_id).unwrap().connection_name.is_some()); + } + + #[tokio::test] + async fn a_teammate_and_the_host_always_see_the_name() { + let pool = test_pool_or_skip!(); + let (host, mate, session_id) = direct_session_with_teammate(&pool).await; + sqlx::query("INSERT INTO terminal_session_invitees (session_id, user_id, invited_by) VALUES ($1, $2, $3)") + .bind(session_id).bind(mate).bind(host).execute(&pool).await.unwrap(); + + assert!(visible_sessions(&pool, mate).await.unwrap() + .iter().find(|r| r.id == session_id).unwrap().connection_name.is_some()); + assert!(visible_sessions(&pool, host).await.unwrap() + .iter().find(|r| r.id == session_id).unwrap().connection_name.is_some()); + } + + #[tokio::test] + async fn admission_stamps_acceptance_once() { + let pool = test_pool_or_skip!(); + let (host, stranger, session_id) = direct_session_with_stranger(&pool).await; + sqlx::query("INSERT INTO terminal_session_invitees (session_id, user_id, invited_by) VALUES ($1, $2, $3)") + .bind(session_id).bind(stranger).bind(host).execute(&pool).await.unwrap(); + + stamp_acceptance(&pool, session_id, stranger).await; + let first: Option> = sqlx::query_scalar( + "SELECT accepted_at FROM terminal_session_invitees WHERE session_id = $1 AND user_id = $2") + .bind(session_id).bind(stranger).fetch_one(&pool).await.unwrap(); + assert!(first.is_some()); + + stamp_acceptance(&pool, session_id, stranger).await; + let second: Option> = sqlx::query_scalar( + "SELECT accepted_at FROM terminal_session_invitees WHERE session_id = $1 AND user_id = $2") + .bind(session_id).bind(stranger).fetch_one(&pool).await.unwrap(); + assert_eq!(first, second, "re-joining must not move the acceptance timestamp"); } #[tokio::test] @@ -2254,7 +2335,7 @@ mod tests { // The host's own view must be unable to tell this apart from a real // grant, or the missing id would leak the very thing the block hides. let for_host = visible_sessions(&pool, host).await.unwrap(); - assert_eq!(for_host[0].7, vec![stranger], "a suppressed knock occupies a seat exactly like a real one"); + assert_eq!(for_host[0].invitee_ids, vec![stranger], "a suppressed knock occupies a seat exactly like a real one"); } #[tokio::test] From 26007f51fd817923780a615682b48013f7cd549f Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 03:00:00 +0000 Subject: [PATCH 10/22] docs(terminal): coordinate the fourth teammate-pair predicate copy --- src/routes/teams.rs | 15 ++++++++++++--- src/routes/terminal.rs | 6 ++++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/routes/teams.rs b/src/routes/teams.rs index 2728390..73639cd 100644 --- a/src/routes/teams.rs +++ b/src/routes/teams.rs @@ -598,9 +598,18 @@ pub struct SearchUsersQuery { pub q: String, } -/// The teammate pair test. `shares_a_team` in routes::terminal and the -/// `NOT EXISTS` in revoke_grants_for_departed_member are the same predicate on -/// different sides; if you change one, change all three. +/// The teammate pair test. Four copies exist; if you change the predicate, +/// change all four: +/// - This constant, spliced via `format!` into `search_users_inner` (below, +/// in this file) and into `shares_a_team` (routes::terminal), which share +/// its `$2`/`u.id` parameter shape. +/// - The `NOT EXISTS` in `revoke_grants_for_departed_member` +/// (routes::terminal) — inlined because it binds only `$1` and needs +/// `tsi.invited_by`/`tsi.user_id`, not `$2`/`u.id`. +/// - The `connection_name` redaction `CASE` in `visible_sessions` +/// (routes::terminal) — inlined because that query already uses `$2` for +/// `PERM_VIEW_TERMINAL_SESSIONS` (an int, not a uuid), so splicing this +/// constant's hardcoded `$2` in would silently bind the wrong value. pub(crate) const TEAMMATE_PAIR_SQL: &str = "EXISTS (SELECT 1 FROM team_members a \ JOIN team_members b ON a.team_id = b.team_id \ WHERE a.user_id = $2 AND b.user_id = u.id)"; diff --git a/src/routes/terminal.rs b/src/routes/terminal.rs index ea83670..0967acf 100644 --- a/src/routes/terminal.rs +++ b/src/routes/terminal.rs @@ -611,8 +611,14 @@ async fn visible_sessions( ts.id, -- An unaccepted stranger invitee must not learn what they were -- invited to; the host and any teammate always see the name. + -- The teammate arm deliberately comes before the invite-status + -- check below, so a teammate sees the name even while their own + -- individual invite row is still unaccepted. CASE WHEN ts.host_user_id = $1 THEN ts.connection_name + -- Teammate pair test, inlined: TEAMMATE_PAIR_SQL (teams.rs) hardcodes + -- `$2`/`u.id`, which collide with this query's own `$2` (a permission + -- bitmask) and lack of a `u`-aliased row — see that constant's doc. WHEN EXISTS (SELECT 1 FROM team_members a JOIN team_members b ON a.team_id = b.team_id WHERE a.user_id = $1 AND b.user_id = ts.host_user_id) THEN ts.connection_name WHEN EXISTS (SELECT 1 FROM terminal_session_invitees tsi2 From 45b9ceb5b2c78802c21c17bdb8564c5a7e299aca Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 03:07:09 +0000 Subject: [PATCH 11/22] feat(terminal): let an invitee decline and block, and a host withdraw an invite --- src/main.rs | 9 ++ src/routes/terminal.rs | 246 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 254 insertions(+), 1 deletion(-) diff --git a/src/main.rs b/src/main.rs index 7fddf6c..4a859d9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -453,6 +453,15 @@ async fn main() { "/v1/terminal-sessions/:id/invitees", post(routes::terminal::invite_to_session), ) + // Registered before .../:user_id so the literal "me" wins the match. + .route( + "/v1/terminal-sessions/:id/invitees/me", + delete(routes::terminal::decline_invite), + ) + .route( + "/v1/terminal-sessions/:id/invitees/:user_id", + delete(routes::terminal::uninvite), + ) // Audit logs — read + export (VIEW_AUDIT_LOG enforced in handler) .route( "/v1/teams/:team_id/audit-logs", diff --git a/src/routes/terminal.rs b/src/routes/terminal.rs index 0967acf..dddfd86 100644 --- a/src/routes/terminal.rs +++ b/src/routes/terminal.rs @@ -7,7 +7,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use chrono::Utc; +use chrono::{Duration, Utc}; use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; @@ -275,6 +275,11 @@ pub(crate) async fn grant_invitee( /// Scope: this closes admission to *new* connections. The relay protocol has no /// eviction message, so a participant already attached to the live socket stays /// until they disconnect — deliberate, not an oversight. +// Kept as its own bulk-statement shape rather than looping `revoke_one_grant` +// per pair: the filtered `NOT EXISTS` DELETE below computes the whole revoked +// set in one round trip, and a departed member can hold or have issued many +// grants — looping would turn one statement into 3N and re-derive the same +// teammate check per row. pub(crate) async fn revoke_grants_for_departed_member( pool: &PgPool, manager: &TerminalManager, @@ -317,6 +322,107 @@ pub(crate) async fn revoke_grants_for_departed_member( Ok(()) } +/// The single-pair form of `revoke_grants_for_departed_member`: durable row, +/// wrapped key, the suppressed-knock row, and the in-memory set the WebSocket +/// actually reads. All four, always — a DB-only revoke leaves live admission +/// open, and a stale `suppressed_invites` row occupies a guest seat that +/// `visible_sessions` reports back to the host as still-invited. +pub(crate) async fn revoke_one_grant( + pool: &PgPool, + manager: &TerminalManager, + session_id: Uuid, + user_id: Uuid, +) -> Result<(), sqlx::Error> { + sqlx::query("DELETE FROM terminal_session_invitees WHERE session_id = $1 AND user_id = $2") + .bind(session_id) + .bind(user_id) + .execute(pool) + .await?; + sqlx::query("DELETE FROM terminal_session_keys WHERE session_id = $1 AND user_id = $2") + .bind(session_id) + .bind(user_id) + .execute(pool) + .await?; + sqlx::query("DELETE FROM suppressed_invites WHERE session_id = $1 AND user_id = $2") + .bind(session_id) + .bind(user_id) + .execute(pool) + .await?; + if let Some(state) = manager.sessions.lock().await.get_mut(&session_id) { + state.invitees.remove(&user_id); + } + Ok(()) +} + +pub(crate) async fn decline_invite_inner( + pool: &PgPool, + manager: &TerminalManager, + session_id: Uuid, + user_id: Uuid, + permanent: bool, +) -> Result<(), StatusCode> { + let inviter: Option = sqlx::query_scalar( + "SELECT invited_by FROM terminal_session_invitees WHERE session_id = $1 AND user_id = $2", + ) + .bind(session_id) + .bind(user_id) + .fetch_optional(pool) + .await + .map_err(|e| { + error!(error = %e, "Failed to read invite before decline"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .flatten(); + + revoke_one_grant(pool, manager, session_id, user_id) + .await + .map_err(|e| { + error!(error = %e, "Failed to revoke declined invite"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + // Declining blocks by default: the abuse shape is one sender knocking + // repeatedly at one target, and making the victim hunt for a setting after + // each knock is the wrong default. + if let Some(inviter) = inviter { + let expires = if permanent { + None + } else { + Some(Utc::now() + Duration::days(7)) + }; + sqlx::query( + "INSERT INTO user_blocks (blocker_id, blocked_id, expires_at) VALUES ($1, $2, $3) \ + ON CONFLICT (blocker_id, blocked_id) DO UPDATE SET expires_at = EXCLUDED.expires_at, created_at = now()", + ) + .bind(user_id) + .bind(inviter) + .bind(expires) + .execute(pool) + .await + .map_err(|e| { + error!(error = %e, "Failed to write block on decline"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + } + Ok(()) +} + +pub(crate) async fn uninvite_inner( + pool: &PgPool, + manager: &TerminalManager, + session_id: Uuid, + caller: Uuid, + target: Uuid, +) -> Result<(), StatusCode> { + require_active_session_host(pool, session_id, caller).await?; + revoke_one_grant(pool, manager, session_id, target) + .await + .map_err(|e| { + error!(error = %e, "Failed to un-invite"); + StatusCode::INTERNAL_SERVER_ERROR + }) +} + /// Concurrent-session cap for a host billed on their own plan — `invite_link` /// and `direct` sessions, which have no vault owner to bill against. /// `None` means the tier may not host at all. @@ -981,6 +1087,39 @@ pub async fn invite_to_session( Ok(StatusCode::NO_CONTENT) } +#[derive(Deserialize)] +pub struct DeclineQuery { + /// "permanent" writes a never-expiring block; anything else (including + /// absent) blocks for 7 days — decline blocks by default, silently. + pub block: Option, +} + +/// The invitee's own path — must be registered before `/invitees/:user_id` +/// so the literal "me" wins the match instead of failing to parse as a UUID. +pub async fn decline_invite( + State(pool): State, + Extension(auth): Extension, + Extension(manager): Extension, + Path(session_id): Path, + Query(query): Query, +) -> Result { + let permanent = query.block.as_deref() == Some("permanent"); + decline_invite_inner(&pool, &manager, session_id, auth.0, permanent).await?; + Ok(StatusCode::NO_CONTENT) +} + +/// The host withdrawing their own invite — not a block, so it must never +/// touch `user_blocks`. +pub async fn uninvite( + State(pool): State, + Extension(auth): Extension, + Extension(manager): Extension, + Path((session_id, user_id)): Path<(Uuid, Uuid)>, +) -> Result { + uninvite_inner(&pool, &manager, session_id, auth.0, user_id).await?; + Ok(StatusCode::NO_CONTENT) +} + // ─── WebSocket handler ──────────────────────────────────────────────────────── #[derive(Deserialize)] @@ -2466,4 +2605,109 @@ mod tests { .await .unwrap(); } + + #[tokio::test] + async fn declining_removes_the_row_the_key_and_live_admission() { + let pool = test_pool_or_skip!(); + let (notifier, manager) = harness(); + let (host, stranger, session_id) = direct_session_with_stranger(&pool).await; + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, stranger, "wrapped").await.unwrap(); + + decline_invite_inner(&pool, &manager, session_id, stranger, false).await.unwrap(); + + let invitees = manager.sessions.lock().await.get(&session_id).map(|s| s.invitees.clone()).unwrap_or_default(); + // Asserted through the admission function, not a row count: a DB-only + // revoke left live WebSocket access open — the Critical from #66. + assert!(!is_authorized_participant(&pool, stranger, host, "direct", &[], &[], None, None, &invitees).await); + + let keys: i64 = sqlx::query_scalar( + "SELECT count(*) FROM terminal_session_keys WHERE session_id = $1 AND user_id = $2") + .bind(session_id).bind(stranger).fetch_one(&pool).await.unwrap(); + assert_eq!(keys, 0); + } + + #[tokio::test] + async fn declining_blocks_the_sender_for_seven_days_and_the_next_knock_writes_nothing() { + let pool = test_pool_or_skip!(); + let (notifier, manager) = harness(); + let (host, stranger, session_id) = direct_session_with_stranger(&pool).await; + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, stranger, "wrapped").await.unwrap(); + decline_invite_inner(&pool, &manager, session_id, stranger, false).await.unwrap(); + + let expires: Option> = sqlx::query_scalar( + "SELECT expires_at FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2") + .bind(stranger).bind(host).fetch_one(&pool).await.unwrap(); + assert!(expires.is_some(), "a plain decline blocks temporarily, not forever"); + + assert_eq!( + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, stranger, "w").await.unwrap(), + GrantOutcome::Suppressed, + ); + } + + #[tokio::test] + async fn declining_with_permanent_writes_a_never_expiring_block() { + let pool = test_pool_or_skip!(); + let (notifier, manager) = harness(); + let (host, stranger, session_id) = direct_session_with_stranger(&pool).await; + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, stranger, "wrapped").await.unwrap(); + decline_invite_inner(&pool, &manager, session_id, stranger, true).await.unwrap(); + + let expires: Option> = sqlx::query_scalar( + "SELECT expires_at FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2") + .bind(stranger).bind(host).fetch_one(&pool).await.unwrap(); + assert!(expires.is_none()); + } + + #[tokio::test] + async fn host_uninvite_frees_the_seat_and_is_host_only() { + let pool = test_pool_or_skip!(); + let (notifier, manager) = harness(); + let (host, stranger, session_id) = direct_session_with_stranger(&pool).await; + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, stranger, "wrapped").await.unwrap(); + + assert_eq!(uninvite_inner(&pool, &manager, session_id, stranger, stranger).await.unwrap_err(), StatusCode::FORBIDDEN); + uninvite_inner(&pool, &manager, session_id, host, stranger).await.unwrap(); + + let rows: i64 = sqlx::query_scalar( + "SELECT count(*) FROM terminal_session_invitees WHERE session_id = $1").bind(session_id) + .fetch_one(&pool).await.unwrap(); + assert_eq!(rows, 0, "a pending invite must not hold a Pro host's only guest seat"); + + let blocked: i64 = sqlx::query_scalar( + "SELECT count(*) FROM user_blocks WHERE blocker_id = $1").bind(stranger) + .fetch_one(&pool).await.unwrap(); + assert_eq!(blocked, 0, "the host withdrawing is not the invitee blocking"); + } + + #[tokio::test] + async fn host_uninvite_of_a_suppressed_entry_clears_it_from_invitee_ids() { + let pool = test_pool_or_skip!(); + let (notifier, manager) = harness(); + let (host, stranger, session_id) = direct_session_with_stranger(&pool).await; + sqlx::query("UPDATE users SET allow_stranger_invites = FALSE WHERE id = $1") + .bind(stranger) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, stranger, "w").await.unwrap(), + GrantOutcome::Suppressed, + ); + + let before: i64 = sqlx::query_scalar( + "SELECT count(*) FROM suppressed_invites WHERE session_id = $1 AND user_id = $2") + .bind(session_id).bind(stranger).fetch_one(&pool).await.unwrap(); + assert_eq!(before, 1); + + uninvite_inner(&pool, &manager, session_id, host, stranger).await.unwrap(); + + // A suppressed knock never wrote an invitee row, but it still occupies a + // seat in the host's invitee_ids (see `visible_sessions`) — un-invite must + // clear the suppressed_invites row too, or the seat leaks forever. + let after: i64 = sqlx::query_scalar( + "SELECT count(*) FROM suppressed_invites WHERE session_id = $1 AND user_id = $2") + .bind(session_id).bind(stranger).fetch_one(&pool).await.unwrap(); + assert_eq!(after, 0); + } } From 491b4920ff87a2647ca3c63b09fc96db08ff5460 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 04:19:04 +0000 Subject: [PATCH 12/22] feat(teams): carry each member's handle in the members response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/models/team.rs | 1 + src/routes/teams.rs | 53 +++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/models/team.rs b/src/models/team.rs index a2217ef..f6304b4 100644 --- a/src/models/team.rs +++ b/src/models/team.rs @@ -28,6 +28,7 @@ pub struct TeamMember { pub team_id: Uuid, pub user_id: Uuid, pub display_name: String, + pub handle: String, pub public_key: String, pub invited_by_display_name: Option, pub joined_at: DateTime, diff --git a/src/routes/teams.rs b/src/routes/teams.rs index 73639cd..c98dfa8 100644 --- a/src/routes/teams.rs +++ b/src/routes/teams.rs @@ -256,13 +256,14 @@ pub async fn list_members( Option, chrono::DateTime, String, + String, Option, Option, ), >( r#" SELECT tm.team_id, tm.user_id, inv.display_name AS invited_by_display_name, tm.joined_at, - u.display_name, u.public_key, tmr.role_id + u.display_name, u.handle, u.public_key, tmr.role_id FROM team_members tm JOIN users u ON u.id = tm.user_id LEFT JOIN users inv ON inv.id = tm.invited_by @@ -280,7 +281,8 @@ pub async fn list_members( })?; let mut members: Vec = Vec::new(); - for (t_id, user_id, invited_by_display_name, joined_at, display_name, public_key, role_id) in rows { + for (t_id, user_id, invited_by_display_name, joined_at, display_name, handle, public_key, role_id) in rows + { match members.last_mut() { Some(last) if last.member.user_id == user_id => { if let Some(rid) = role_id { @@ -294,6 +296,7 @@ pub async fn list_members( team_id: t_id, user_id, display_name, + handle, public_key: member_public_key_for_response(public_key), invited_by_display_name, joined_at, @@ -1597,6 +1600,52 @@ mod authz_tests { assert!(res.is_ok(), "expected Ok, got {:?}", res.err()); } + #[tokio::test] + async fn list_members_returns_each_members_own_handle() { + let pool = test_pool_or_skip!(); + let owner = seed_user(&pool).await; + let team = seed_team(&pool, owner).await; + let caller = seed_user(&pool).await; + let other = seed_user(&pool).await; + add_team_member(&pool, team, caller).await; + add_team_member(&pool, team, other).await; + + let caller_handle: String = sqlx::query_scalar("SELECT handle FROM users WHERE id = $1") + .bind(caller) + .fetch_one(&pool) + .await + .unwrap(); + let other_handle: String = sqlx::query_scalar("SELECT handle FROM users WHERE id = $1") + .bind(other) + .fetch_one(&pool) + .await + .unwrap(); + assert_ne!(caller_handle, other_handle); + + let presence: PresenceMap = std::sync::Arc::new(dashmap::DashMap::new()); + let members = list_members( + State(pool.clone()), + Extension(AuthUser(caller)), + Extension(presence), + Path(team), + ) + .await + .expect("list members") + .0; + + let handle_of = |id: Uuid| { + members + .iter() + .find(|m| m.member.user_id == id) + .expect("member present") + .member + .handle + .clone() + }; + assert_eq!(handle_of(caller), caller_handle); + assert_eq!(handle_of(other), other_handle); + } + #[tokio::test] async fn remove_member_forbidden_without_manage_permission() { let pool = test_pool_or_skip!(); From 70fd2c58d63a31b84d8e3460feb67117ee5e02b4 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 06:18:18 +0000 Subject: [PATCH 13/22] fix(terminal): clear suppressed_invites when a departed member's grants revoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/routes/terminal.rs | 116 ++++++++++++++++++++++++++++++++++------- 1 file changed, 96 insertions(+), 20 deletions(-) diff --git a/src/routes/terminal.rs b/src/routes/terminal.rs index dddfd86..2c6406c 100644 --- a/src/routes/terminal.rs +++ b/src/routes/terminal.rs @@ -266,10 +266,53 @@ pub(crate) async fn grant_invitee( Ok(GrantOutcome::Granted) } +/// Tables keyed by `(session_id, user_id)` that ride along whenever +/// `terminal_session_invitees` is cleared for a grant: the wrapped key and the +/// suppressed-knock row. Both revoke paths delete from `terminal_session_invitees` +/// with their own shape (a plain pair delete here, a set-scoped delete with a +/// teammate check in the bulk path below) but must clear these two identically — +/// drive both from this list so a future table can't drift out of one of them. +const GRANT_SIDE_TABLES: &[&str] = &["terminal_session_keys", "suppressed_invites"]; + +/// Deletes `table`'s row for one `(session_id, user_id)` pair. +async fn delete_grant_side_row( + pool: &PgPool, + table: &str, + session_id: Uuid, + user_id: Uuid, +) -> Result<(), sqlx::Error> { + sqlx::query(&format!("DELETE FROM {table} WHERE session_id = $1 AND user_id = $2")) + .bind(session_id) + .bind(user_id) + .execute(pool) + .await?; + Ok(()) +} + +/// Deletes `table`'s rows for a batch of `(session_id, user_id)` pairs in one round trip. +async fn delete_grant_side_rows( + pool: &PgPool, + table: &str, + session_ids: &[Uuid], + user_ids: &[Uuid], +) -> Result<(), sqlx::Error> { + sqlx::query(&format!( + "DELETE FROM {table} t \ + USING UNNEST($1::uuid[], $2::uuid[]) AS revoked(session_id, user_id) \ + WHERE t.session_id = revoked.session_id AND t.user_id = revoked.user_id" + )) + .bind(session_ids) + .bind(user_ids) + .execute(pool) + .await?; + Ok(()) +} + /// Undoes `grant_invitee` for every grant `user_id` is no longer qualified for /// after leaving a team — both grants they hold and grants they issued, since /// the admission guard tests the inviter/invitee *pair*. Clears the durable -/// row, the wrapped key (`GET .../key` would otherwise still hand it out) and +/// row, the wrapped key (`GET .../key` would otherwise still hand it out), the +/// suppressed-knock row (else the seat it fakes-occupies never frees up) and /// the in-memory set the WebSocket actually reads. /// /// Scope: this closes admission to *new* connections. The relay protocol has no @@ -303,15 +346,9 @@ pub(crate) async fn revoke_grants_for_departed_member( } let (session_ids, user_ids): (Vec, Vec) = revoked.iter().copied().unzip(); - sqlx::query( - "DELETE FROM terminal_session_keys tsk \ - USING UNNEST($1::uuid[], $2::uuid[]) AS revoked(session_id, user_id) \ - WHERE tsk.session_id = revoked.session_id AND tsk.user_id = revoked.user_id", - ) - .bind(&session_ids) - .bind(&user_ids) - .execute(pool) - .await?; + for table in GRANT_SIDE_TABLES { + delete_grant_side_rows(pool, table, &session_ids, &user_ids).await?; + } let mut sessions = manager.sessions.lock().await; for (session_id, revoked_user) in revoked { @@ -338,16 +375,9 @@ pub(crate) async fn revoke_one_grant( .bind(user_id) .execute(pool) .await?; - sqlx::query("DELETE FROM terminal_session_keys WHERE session_id = $1 AND user_id = $2") - .bind(session_id) - .bind(user_id) - .execute(pool) - .await?; - sqlx::query("DELETE FROM suppressed_invites WHERE session_id = $1 AND user_id = $2") - .bind(session_id) - .bind(user_id) - .execute(pool) - .await?; + for table in GRANT_SIDE_TABLES { + delete_grant_side_row(pool, table, session_id, user_id).await?; + } if let Some(state) = manager.sessions.lock().await.get_mut(&session_id) { state.invitees.remove(&user_id); } @@ -2710,4 +2740,50 @@ mod tests { .bind(session_id).bind(stranger).fetch_one(&pool).await.unwrap(); assert_eq!(after, 0); } + + #[tokio::test] + async fn departed_member_revoke_clears_the_suppressed_row_too() { + let pool = test_pool_or_skip!(); + let (notifier, manager) = harness(); + let (host, mate, session_id) = direct_session_with_teammate(&pool).await; + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, mate, "wrapped") + .await + .unwrap(); + + // A suppressed row can coexist with a real grant row for the same pair + // (e.g. an earlier stranger knock, before host and mate shared a team) — + // seed one directly rather than relying on `grant_invitee` to produce it. + sqlx::query( + "INSERT INTO suppressed_invites (session_id, user_id, invited_by) VALUES ($1, $2, $3)", + ) + .bind(session_id) + .bind(mate) + .bind(host) + .execute(&pool) + .await + .unwrap(); + + sqlx::query("DELETE FROM team_members WHERE user_id = $1") + .bind(mate) + .execute(&pool) + .await + .unwrap(); + + revoke_grants_for_departed_member(&pool, &manager, mate).await.unwrap(); + + let invitees: i64 = sqlx::query_scalar( + "SELECT count(*) FROM terminal_session_invitees WHERE session_id = $1 AND user_id = $2") + .bind(session_id).bind(mate).fetch_one(&pool).await.unwrap(); + assert_eq!(invitees, 0); + + let keys: i64 = sqlx::query_scalar( + "SELECT count(*) FROM terminal_session_keys WHERE session_id = $1 AND user_id = $2") + .bind(session_id).bind(mate).fetch_one(&pool).await.unwrap(); + assert_eq!(keys, 0); + + let suppressed: i64 = sqlx::query_scalar( + "SELECT count(*) FROM suppressed_invites WHERE session_id = $1 AND user_id = $2") + .bind(session_id).bind(mate).fetch_one(&pool).await.unwrap(); + assert_eq!(suppressed, 0, "a departed member's suppressed row must not keep the seat occupied"); + } } From 977ec63f385aa4baaef20d0fded23a7f896c0e9b Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 06:18:18 +0000 Subject: [PATCH 14/22] refactor(test): move unique_handle into test_support 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. --- src/routes/teams.rs | 8 +------- src/routes/users.rs | 10 +--------- src/test_support.rs | 9 +++++++++ 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/routes/teams.rs b/src/routes/teams.rs index c98dfa8..26d69fe 100644 --- a/src/routes/teams.rs +++ b/src/routes/teams.rs @@ -2218,6 +2218,7 @@ mod authz_tests { #[cfg(test)] mod search_tests { use super::*; + use crate::test_support::unique_handle; use uuid::Uuid; async fn mk_user(pool: &PgPool, email: &str, name: &str, handle: &str, custom: bool) -> Uuid { @@ -2229,13 +2230,6 @@ mod search_tests { .fetch_one(pool).await.unwrap() } - // Handles and emails are unique and the test DB is real and persistent, so a - // literal like "kevin-p" collides with itself on the second test run. Mint a - // fresh suffix per call, matching the pattern in routes::users's tests. - fn unique_handle(base: &str) -> String { - format!("{base}-{}", &Uuid::new_v4().simple().to_string()[..6]) - } - #[tokio::test] async fn a_stranger_is_not_found_by_an_email_substring() { let pool = crate::test_pool_or_skip!(); diff --git a/src/routes/users.rs b/src/routes/users.rs index 1bce03c..cccac2a 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -184,6 +184,7 @@ pub async fn get_user_public_key( #[cfg(test)] mod tests { use super::*; + use crate::test_support::unique_handle; use uuid::Uuid; async fn user(pool: &sqlx::PgPool, tier: &str) -> Uuid { @@ -200,15 +201,6 @@ mod tests { id } - // Handles are unique and never recycled (that's the feature), so two test - // functions cannot both claim a literal "kevin-p" against the same real, - // persistent test database — whichever runs first wins it permanently and - // every other test collides. Each call mints a fresh base, the same way - // `test_support::seed_user` avoids colliding on `email`. - fn unique_handle(base: &str) -> String { - format!("{base}-{}", &Uuid::new_v4().simple().to_string()[..6]) - } - #[tokio::test] async fn free_tier_cannot_claim_a_custom_handle() { let pool = crate::test_pool_or_skip!(); diff --git a/src/test_support.rs b/src/test_support.rs index 4a0dc6f..60b13bb 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -221,6 +221,15 @@ pub async fn set_user_trial(pool: &PgPool, user: Uuid, days: i64) { .expect("set user trial"); } +/// Handles are unique and never recycled (that's the feature), so two test +/// functions cannot both claim a literal base like "kevin-p" against the same +/// real, persistent test database — whichever runs first wins it permanently +/// and every other test collides. Each call mints a fresh suffix, the same way +/// `seed_user` avoids colliding on `email`. +pub fn unique_handle(base: &str) -> String { + format!("{base}-{}", &Uuid::new_v4().simple().to_string()[..6]) +} + /// The deterministic email `seed_user` assigns, so invitation tests can target a /// seeded user by the exact address their acceptance handler will compare against. pub fn test_user_email(id: Uuid) -> String { From 0d0be3d485998e8aa6198a10d8b8476334233afc Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 06:56:45 +0000 Subject: [PATCH 15/22] perf(migrations): create the handle index before the backfill, not after MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- migrations/035_user_handles_and_blocks.sql | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/migrations/035_user_handles_and_blocks.sql b/migrations/035_user_handles_and_blocks.sql index d9d1626..894dfd5 100644 --- a/migrations/035_user_handles_and_blocks.sql +++ b/migrations/035_user_handles_and_blocks.sql @@ -7,6 +7,14 @@ ALTER TABLE users ADD COLUMN handle_is_custom BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE users ADD COLUMN handle_updated_at TIMESTAMPTZ NULL; ALTER TABLE users ADD COLUMN allow_stranger_invites BOOLEAN NOT NULL DEFAULT TRUE; +-- Created before the backfill, not after: this whole file runs in one +-- transaction holding ACCESS EXCLUSIVE on `users`, and the backfill's +-- per-candidate uniqueness probe below is a sequential scan without it — +-- quadratic in the user count (measured: 140s at 20k rows versus 2.7s with the +-- index in place). Legal on an all-NULL column, since NULLs are not indexed +-- for uniqueness. +CREATE UNIQUE INDEX idx_users_handle ON users (LOWER(handle)); + -- Backfill: same adjective-noun-4digit shape the server generates, retried per -- row until unique. Deterministic fallback after 10 tries so the migration can -- never spin on an unlucky namespace. @@ -38,7 +46,6 @@ BEGIN END $$; ALTER TABLE users ALTER COLUMN handle SET NOT NULL; -CREATE UNIQUE INDEX idx_users_handle ON users (LOWER(handle)); CREATE TABLE retired_handles ( handle TEXT PRIMARY KEY, From 4bbeeefc1018998640b479a9e49b61682c69face Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 06:56:52 +0000 Subject: [PATCH 16/22] fix(users): gate the handle claim on the effective tier, not the stored one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/entitlement.rs | 28 ++++++++++++++++++++++++++ src/routes/terminal.rs | 26 +----------------------- src/routes/users.rs | 45 +++++++++++++++++++++++++++++++----------- 3 files changed, 63 insertions(+), 36 deletions(-) diff --git a/src/entitlement.rs b/src/entitlement.rs index 9b67f7f..600bbfd 100644 --- a/src/entitlement.rs +++ b/src/entitlement.rs @@ -8,6 +8,7 @@ //! with an active paid subscription are always left untouched. use chrono::{DateTime, Utc}; +use sqlx::PgPool; /// Returns the effective tier: `"free"` if this is an expired trial, otherwise /// the stored tier unchanged. Borrows `stored_tier` for the non-expired case. @@ -28,6 +29,33 @@ pub fn effective_tier( stored_tier } +/// [`effective_tier`] for one account, read from the database. Every tier gate +/// outside `/v1/auth/me` must go through this rather than comparing +/// `users.subscription_tier`: an expired trial still stores `'pro'` there, so a +/// direct comparison hands a lapsed account a paid feature. +/// +/// Falls back to `"free"` if the row can't be read — a gate that fails open is +/// the bug this function exists to prevent. +pub async fn effective_tier_for_user(pool: &PgPool, user_id: uuid::Uuid) -> String { + match sqlx::query_as::<_, (String, Option>, bool, Option)>( + "SELECT subscription_tier, trial_ends_at, admin_override, ls_subscription_id FROM users WHERE id = $1", + ) + .bind(user_id) + .fetch_one(pool) + .await + { + Ok((tier, trial_ends_at, admin_override, ls_subscription_id)) => effective_tier( + &tier, + trial_ends_at, + ls_subscription_id.is_some(), + admin_override, + Utc::now(), + ) + .to_string(), + Err(_) => "free".to_string(), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/routes/terminal.rs b/src/routes/terminal.rs index 2c6406c..4f7979d 100644 --- a/src/routes/terminal.rs +++ b/src/routes/terminal.rs @@ -17,30 +17,6 @@ use uuid::Uuid; use crate::auth::{jwt::validate_token, AuthClaims, AuthUser}; use crate::terminal_manager::{Participant, TerminalManager, BROADCAST_CAPACITY}; -/// Tier the given account is entitled to right now, with expired trials counted -/// as `free`. Falls back to `free` if the row can't be read. -async fn owner_effective_tier(pool: &PgPool, user_id: Uuid) -> String { - match sqlx::query_as::<_, (String, Option>, bool, Option)>( - "SELECT subscription_tier, trial_ends_at, admin_override, ls_subscription_id FROM users WHERE id = $1", - ) - .bind(user_id) - .fetch_one(pool) - .await - { - Ok((tier, trial_ends_at, admin_override, ls_subscription_id)) => { - crate::entitlement::effective_tier( - &tier, - trial_ends_at, - ls_subscription_id.is_some(), - admin_override, - Utc::now(), - ) - .to_string() - } - Err(_) => "free".to_string(), - } -} - // ─── Types ──────────────────────────────────────────────────────────────────── #[derive(Deserialize)] @@ -1343,7 +1319,7 @@ async fn handle_socket( // Participant cap: guests only (host is always allowed) if user_id != host_user_id { let tier_owner = vault_owner_id.unwrap_or(host_user_id); - let effective_tier = owner_effective_tier(&pool, tier_owner).await; + let effective_tier = crate::entitlement::effective_tier_for_user(&pool, tier_owner).await; let guest_cap: usize = match effective_tier.as_str() { "business" => 50, diff --git a/src/routes/users.rs b/src/routes/users.rs index cccac2a..e9ddec2 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -34,18 +34,22 @@ pub(crate) async fn claim_handle_inner( | HandleError::TooLong => StatusCode::UNPROCESSABLE_ENTITY, })?; - let (tier, current, is_custom, updated_at): (String, String, bool, Option>) = - sqlx::query_as( - "SELECT subscription_tier, handle, handle_is_custom, handle_updated_at FROM users WHERE id = $1", - ) - .bind(user_id) - .fetch_one(pool) - .await - .map_err(|e| { - error!(error = %e, "Failed to read user before handle claim"); - StatusCode::INTERNAL_SERVER_ERROR - })?; + let (current, is_custom, updated_at): (String, bool, Option>) = sqlx::query_as( + "SELECT handle, handle_is_custom, handle_updated_at FROM users WHERE id = $1", + ) + .bind(user_id) + .fetch_one(pool) + .await + .map_err(|e| { + error!(error = %e, "Failed to read user before handle claim"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + // The effective tier, not the stored one: an expired trial still reads + // `subscription_tier = 'pro'`, and a claim is permanent — `handle_is_custom` + // never reverts — so a stored-tier gate would hand a lapsed account a paid + // feature that can never be walked back. + let tier = crate::entitlement::effective_tier_for_user(pool, user_id).await; if !matches!(tier.as_str(), "pro" | "teams" | "business") { return Err(StatusCode::PAYMENT_REQUIRED); } @@ -211,6 +215,25 @@ mod tests { assert_eq!(err, StatusCode::PAYMENT_REQUIRED); } + #[tokio::test] + async fn an_expired_trial_cannot_claim_a_custom_handle() { + let pool = crate::test_pool_or_skip!(); + let id = user(&pool, "pro").await; + // A lapsed trial keeps `subscription_tier = 'pro'`; only the effective + // tier knows it is really free. A claim is permanent, so gating on the + // stored tier would hand out a paid feature that never reverts. + sqlx::query("UPDATE users SET trial_ends_at = now() - interval '1 day' WHERE id = $1") + .bind(id) + .execute(&pool) + .await + .unwrap(); + + let err = claim_handle_inner(&pool, id, &unique_handle("kevin-p")) + .await + .unwrap_err(); + assert_eq!(err, StatusCode::PAYMENT_REQUIRED); + } + #[tokio::test] async fn pro_claim_sets_custom_and_retires_the_previous_handle() { let pool = crate::test_pool_or_skip!(); From f948d0ced36bd585b66c64ffda314fb66bf3d8fe Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 06:57:07 +0000 Subject: [PATCH 17/22] test(users): seed with generate_unique_handle like every other path The test database is persistent and accumulates users, so an unchecked generate_handle eventually collides on the handle unique index. --- src/routes/users.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/routes/users.rs b/src/routes/users.rs index e9ddec2..3b213c2 100644 --- a/src/routes/users.rs +++ b/src/routes/users.rs @@ -192,13 +192,19 @@ mod tests { use uuid::Uuid; async fn user(pool: &sqlx::PgPool, tier: &str) -> Uuid { + // `generate_unique_handle`, like every other seeding path: the test + // database is persistent and accumulates users, so an unchecked + // `generate_handle` eventually collides on the unique index. + let handle = crate::handles::generate_unique_handle(pool) + .await + .expect("generate handle"); let id: Uuid = sqlx::query_scalar( "INSERT INTO users (email, display_name, account_id, auth_hash, subscription_tier, handle) VALUES ($1, 'x', gen_random_uuid(), 'h', $2, $3) RETURNING id", ) .bind(format!("{}@example.test", Uuid::new_v4())) .bind(tier) - .bind(crate::handles::generate_handle()) + .bind(&handle) .fetch_one(pool) .await .unwrap(); From 3d6fedd453c7f20b56dd2822880b322ec3aa8e4f Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 06:57:07 +0000 Subject: [PATCH 18/22] feat(terminal): carry the inviter's handle on a knock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/routes/terminal.rs | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/src/routes/terminal.rs b/src/routes/terminal.rs index 4f7979d..602b775 100644 --- a/src/routes/terminal.rs +++ b/src/routes/terminal.rs @@ -74,6 +74,10 @@ pub struct ActiveSession { /// Set when the caller reaches this session through an individual grant /// (#66) rather than a vault share — names who invited them. pub invited_by: Option, + /// `invited_by`'s handle, resolved server-side from `users`. The knock UI + /// renders this and nothing else: every other inviter identity reaching the + /// client (participant `display_name`) is supplied by the sender. + pub invited_by_handle: Option, /// Everyone the host has individually invited (#66). Populated only when /// the caller is the host — a guest must not learn the guest list. pub invitee_ids: Vec, @@ -706,6 +710,8 @@ struct VisibleSessionRow { created_at: chrono::DateTime, vault_ids: Vec, invited_by: Option, + /// `invited_by`'s handle, read from `users`. + invited_by_handle: Option, invitee_ids: Vec, } @@ -747,6 +753,14 @@ async fn visible_sessions( ) AS vault_ids, (SELECT tsi.invited_by FROM terminal_session_invitees tsi WHERE tsi.session_id = ts.id AND tsi.user_id = $1) AS invited_by, + -- The inviter's handle, for the caller's own grant only. A knock is + -- the one surface a stranger reads before consenting, so its + -- identity must come from `users` — a participant-supplied + -- display_name there is an impersonation vector the reserved-handle + -- list would otherwise be powerless against. + (SELECT u.handle FROM terminal_session_invitees tsi + JOIN users u ON u.id = tsi.invited_by + WHERE tsi.session_id = ts.id AND tsi.user_id = $1) AS invited_by_handle, -- Suppressed knocks are unioned in here, and only here: the host must see -- a blocked/opted-out stranger exactly as an ordinary pending invite, or -- the missing id would tell them what the silent block exists to hide. @@ -839,6 +853,7 @@ pub async fn list_active_sessions( participants, vault_ids: row.vault_ids, invited_by: row.invited_by, + invited_by_handle: row.invited_by_handle, invitee_ids: row.invitee_ids, } }) @@ -2762,4 +2777,26 @@ mod tests { .bind(session_id).bind(mate).fetch_one(&pool).await.unwrap(); assert_eq!(suppressed, 0, "a departed member's suppressed row must not keep the seat occupied"); } + + #[tokio::test] + async fn a_knock_carries_the_inviters_handle_from_the_users_table() { + let pool = test_pool_or_skip!(); + let (host, stranger, session_id) = direct_session_with_stranger(&pool).await; + sqlx::query("INSERT INTO terminal_session_invitees (session_id, user_id, invited_by) VALUES ($1, $2, $3)") + .bind(session_id).bind(stranger).bind(host).execute(&pool).await.unwrap(); + let host_handle: String = sqlx::query_scalar("SELECT handle FROM users WHERE id = $1") + .bind(host).fetch_one(&pool).await.unwrap(); + + let rows = visible_sessions(&pool, stranger).await.unwrap(); + let row = rows.iter().find(|r| r.id == session_id).expect("the knock must be visible"); + assert_eq!( + row.invited_by_handle.as_deref(), + Some(host_handle.as_str()), + "the knock's identity is the server-owned handle, not anything the sender supplies", + ); + + // The host is nobody's invitee, so their own row carries no inviter. + let for_host = visible_sessions(&pool, host).await.unwrap(); + assert!(for_host.iter().find(|r| r.id == session_id).unwrap().invited_by_handle.is_none()); + } } From 2151993154fb0993a0e1726d7f8e1524db9929df Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 06:57:07 +0000 Subject: [PATCH 19/22] fix(terminal): bound and validate the WebSocket display_name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/routes/terminal.rs | 56 +++++++++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/src/routes/terminal.rs b/src/routes/terminal.rs index 602b775..aa831c9 100644 --- a/src/routes/terminal.rs +++ b/src/routes/terminal.rs @@ -1151,6 +1151,28 @@ pub struct WsQuery { pub invite_token: Option, } +/// Longest display name the relay will carry. Long enough for any real name or +/// email, short enough that it cannot be used as a payload. +const MAX_DISPLAY_NAME_CHARS: usize = 64; + +/// Sanitizes the caller-supplied `display_name` before it reaches participant +/// lists. `None` (empty or absent) means "fall back to the user id"; `Err` means +/// the value is malformed and the upgrade is refused. +/// +/// Control characters are rejected rather than stripped: no legitimate client +/// sends them, and a name is rendered in enough places that silently reshaping +/// one is worse than telling the caller it was wrong. Length is truncated +/// instead, since a merely long name is plausible input. +fn sanitize_display_name(raw: Option) -> Result, ()> { + let Some(name) = raw.filter(|s| !s.is_empty()) else { + return Ok(None); + }; + if name.chars().any(|c| c.is_control()) { + return Err(()); + } + Ok(Some(name.chars().take(MAX_DISPLAY_NAME_CHARS).collect())) +} + pub async fn ws_handler( ws: WebSocketUpgrade, Path(session_id): Path, @@ -1167,10 +1189,13 @@ pub async fn ws_handler( } }; - let display_name = query - .display_name - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| user_id.to_string()); + let display_name = match sanitize_display_name(query.display_name) { + Ok(name) => name.unwrap_or_else(|| user_id.to_string()), + Err(()) => { + warn!(session_id = %session_id, "WS upgrade rejected: malformed display_name"); + return StatusCode::BAD_REQUEST.into_response(); + } + }; ws.on_upgrade(move |socket| { handle_socket( @@ -2799,4 +2824,27 @@ mod tests { let for_host = visible_sessions(&pool, host).await.unwrap(); assert!(for_host.iter().find(|r| r.id == session_id).unwrap().invited_by_handle.is_none()); } + + #[test] + fn an_over_long_display_name_is_truncated() { + let name = "k".repeat(500); + let out = sanitize_display_name(Some(name)).unwrap().unwrap(); + assert_eq!(out.chars().count(), MAX_DISPLAY_NAME_CHARS); + } + + #[test] + fn a_display_name_with_control_characters_is_refused() { + assert!(sanitize_display_name(Some("Voltius\u{0}Support".to_string())).is_err()); + assert!(sanitize_display_name(Some("line\nbreak".to_string())).is_err()); + } + + #[test] + fn an_ordinary_display_name_passes_through_unchanged() { + assert_eq!( + sanitize_display_name(Some("Kévin P.".to_string())).unwrap().as_deref(), + Some("Kévin P."), + ); + assert_eq!(sanitize_display_name(Some(String::new())).unwrap(), None); + assert_eq!(sanitize_display_name(None).unwrap(), None); + } } From e336c153508b0a246aa348a580c1b67f613a1763 Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 06:57:07 +0000 Subject: [PATCH 20/22] fix(terminal): clear suppressed_invites when a session ends MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/routes/terminal.rs | 41 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/src/routes/terminal.rs b/src/routes/terminal.rs index aa831c9..6ce68a1 100644 --- a/src/routes/terminal.rs +++ b/src/routes/terminal.rs @@ -1025,9 +1025,13 @@ async fn fan_out_session_ended( // rows `session_end_recipients` reads invitees from, so deleting first // would fan the "session ended" push out to nobody. // - // Both tables hold per-invitee grant state whose `ON DELETE CASCADE` a soft - // end (`ended_at = now()`) never fires, so both must be cleared explicitly. - for table in ["terminal_session_invitees", "terminal_session_keys"] { + // Every one of these holds per-invitee grant state whose `ON DELETE CASCADE` + // a soft end (`ended_at = now()`) never fires, so each must be cleared + // explicitly. The side tables come from `GRANT_SIDE_TABLES` rather than a + // second hardcoded list — that is the whole point of the constant, and a + // stale `suppressed_invites` row is exactly the social-graph record D9 + // refused to create. + for table in std::iter::once(&"terminal_session_invitees").chain(GRANT_SIDE_TABLES) { if let Err(e) = sqlx::query(&format!("DELETE FROM {table} WHERE session_id = $1")) .bind(session_id) .execute(pool) @@ -2847,4 +2851,35 @@ mod tests { assert_eq!(sanitize_display_name(Some(String::new())).unwrap(), None); assert_eq!(sanitize_display_name(None).unwrap(), None); } + + #[tokio::test] + async fn ending_a_session_clears_the_suppressed_rows_too() { + let pool = test_pool_or_skip!(); + let (notifier, manager) = harness(); + let (host, stranger, session_id) = direct_session_with_stranger(&pool).await; + sqlx::query("UPDATE users SET allow_stranger_invites = FALSE WHERE id = $1") + .bind(stranger) + .execute(&pool) + .await + .unwrap(); + assert_eq!( + grant_invitee(&pool, ¬ifier, &manager, &knocks(), session_id, host, stranger, "w").await.unwrap(), + GrantOutcome::Suppressed, + ); + + // A soft end never fires ON DELETE CASCADE, so a row saying "this + // recipient blocked or opted out of this sender" would otherwise outlive + // the session forever — the social-graph record D9 refused to create. + sqlx::query("UPDATE terminal_sessions SET ended_at = now() WHERE id = $1") + .bind(session_id) + .execute(&pool) + .await + .unwrap(); + fan_out_session_ended(&pool, ¬ifier, session_id, host).await; + + let suppressed: i64 = sqlx::query_scalar( + "SELECT count(*) FROM suppressed_invites WHERE session_id = $1") + .bind(session_id).fetch_one(&pool).await.unwrap(); + assert_eq!(suppressed, 0); + } } From c0955bc162af53b72d13e292a16de0c0d394558d Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 06:57:07 +0000 Subject: [PATCH 21/22] fix(terminal): withhold participants from an unaccepted stranger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/routes/terminal.rs | 48 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/routes/terminal.rs b/src/routes/terminal.rs index 6ce68a1..e6308d0 100644 --- a/src/routes/terminal.rs +++ b/src/routes/terminal.rs @@ -835,13 +835,22 @@ pub async fn list_active_sessions( .into_iter() .filter(|row| sessions_lock.contains_key(&row.id)) .map(|row| { - let (participant_count, participants, host_public_key) = sessions_lock + let (mut participant_count, mut participants, host_public_key) = sessions_lock .get(&row.id) .map(|s| { let ps: Vec = s.participants.values().cloned().collect(); (ps.len() as i64, ps, s.host_public_key.clone()) }) .unwrap_or_default(); + // A redacted `connection_name` marks an unaccepted stranger, and the + // rest of this row is just as identifying: participant display names + // and a headcount say who is already in the room. D7 promises such a + // recipient learns a handle and nothing else. `host_public_key` stays + // — it is inert and plausibly needed before joining. + if row.connection_name.is_none() { + participants = Vec::new(); + participant_count = 0; + } ActiveSession { id: row.id, connection_name: row.connection_name, @@ -2882,4 +2891,41 @@ mod tests { .bind(session_id).fetch_one(&pool).await.unwrap(); assert_eq!(suppressed, 0); } + + #[tokio::test] + async fn an_unaccepted_stranger_sees_no_participant_names() { + let pool = test_pool_or_skip!(); + let (host, stranger, session_id) = direct_session_with_stranger(&pool).await; + sqlx::query("INSERT INTO terminal_session_invitees (session_id, user_id, invited_by) VALUES ($1, $2, $3)") + .bind(session_id).bind(stranger).bind(host).execute(&pool).await.unwrap(); + + let manager = TerminalManager::new(); + manager.insert_test_session(session_id, host).await; + manager.sessions.lock().await.get_mut(&session_id).unwrap().participants.insert( + host, + Participant { user_id: host, display_name: "Real Hostname Owner".to_string() }, + ); + + let Json(sessions) = list_active_sessions( + State(pool.clone()), + Extension(AuthUser(stranger)), + Extension(manager.clone()), + ) + .await + .unwrap(); + let row = sessions.iter().find(|s| s.id == session_id).expect("the knock must be listed"); + assert!(row.connection_name.is_none()); + assert!(row.participants.is_empty(), "D7 leaks a handle, never who is already in the room"); + assert_eq!(row.participant_count, 0); + + // Accepting un-redacts the whole row, participants included. + sqlx::query("UPDATE terminal_session_invitees SET accepted_at = now() WHERE session_id = $1 AND user_id = $2") + .bind(session_id).bind(stranger).execute(&pool).await.unwrap(); + let Json(sessions) = + list_active_sessions(State(pool), Extension(AuthUser(stranger)), Extension(manager)) + .await + .unwrap(); + let row = sessions.iter().find(|s| s.id == session_id).unwrap(); + assert_eq!(row.participant_count, 1); + } } From b43fb486dc2a89e4f31e507a2caf72cc80f69ccd Mon Sep 17 00:00:00 2001 From: kipavy Date: Sat, 15 Aug 2026 08:16:05 +0000 Subject: [PATCH 22/22] fix(knock): run both consent reads for every stranger outcome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/routes/terminal.rs | 65 +++++++++++++++++++++++++++++++++--------- 1 file changed, 52 insertions(+), 13 deletions(-) diff --git a/src/routes/terminal.rs b/src/routes/terminal.rs index e6308d0..511a9c7 100644 --- a/src/routes/terminal.rs +++ b/src/routes/terminal.rs @@ -137,6 +137,34 @@ async fn is_blocked(pool: &PgPool, blocked_by: Uuid, sender: Uuid) -> Result Result { + let opted_in = sqlx::query_scalar::<_, bool>( + "SELECT allow_stranger_invites FROM users WHERE id = $1 AND deleted_at IS NULL", + ) + .bind(recipient) + .fetch_optional(pool) + .await + .map_err(|e| { + error!(error = %e, "Failed to read invite preference"); + StatusCode::INTERNAL_SERVER_ERROR + })? + .unwrap_or(false); + let blocked = is_blocked(pool, recipient, sender).await?; + Ok(opted_in && !blocked) +} + /// Grants one named user access to a session: the durable row, the wrapped key, /// the in-memory authorization set, and the push. The single grant path — both /// `create_session` with visibility "direct" and the invitees endpoint call it. @@ -166,19 +194,7 @@ pub(crate) async fn grant_invitee( warn!(host = %host_user_id, "Knock rate limit exceeded"); return Err(StatusCode::TOO_MANY_REQUESTS); } - let opted_in = sqlx::query_scalar::<_, bool>( - "SELECT allow_stranger_invites FROM users WHERE id = $1 AND deleted_at IS NULL", - ) - .bind(user_id) - .fetch_optional(pool) - .await - .map_err(|e| { - error!(error = %e, "Failed to read invite preference"); - StatusCode::INTERNAL_SERVER_ERROR - })? - .unwrap_or(false); - - if !opted_in || is_blocked(pool, user_id, host_user_id).await? { + if !stranger_knock_allowed(pool, user_id, host_user_id).await? { info!(target: "knock", sender = %host_user_id, recipient = %user_id, outcome = "suppressed", "Stranger knock suppressed"); // No grant row, ever — that silence is the whole point. This is the // one place that writes here: it exists only so the host's own @@ -2484,6 +2500,29 @@ mod tests { assert!(accepted.is_some(), "the shipped teammate path must not change behaviour"); } + /// The three stranger outcomes must take the same path through the two + /// consent reads, so that neither an opt-out nor a block is distinguishable + /// from a grant — or from each other — by how much work the server did. + #[tokio::test] + async fn stranger_consent_reads_both_facts_for_every_outcome() { + let pool = test_pool_or_skip!(); + let (host, stranger, _) = direct_session_with_stranger(&pool).await; + assert!(stranger_knock_allowed(&pool, stranger, host).await.unwrap()); + + sqlx::query("INSERT INTO user_blocks (blocker_id, blocked_id, expires_at) VALUES ($1, $2, now() + interval '7 days')") + .bind(stranger).bind(host).execute(&pool).await.unwrap(); + assert!(!stranger_knock_allowed(&pool, stranger, host).await.unwrap()); + + // Opted out *and* blocked: the block read still runs, since the opt-out + // no longer short-circuits it. + sqlx::query("UPDATE users SET allow_stranger_invites = FALSE WHERE id = $1") + .bind(stranger).execute(&pool).await.unwrap(); + assert!(!stranger_knock_allowed(&pool, stranger, host).await.unwrap()); + + sqlx::query("DELETE FROM user_blocks WHERE blocker_id = $1").bind(stranger).execute(&pool).await.unwrap(); + assert!(!stranger_knock_allowed(&pool, stranger, host).await.unwrap(), "opted out alone still refuses"); + } + #[tokio::test] async fn a_block_suppresses_the_grant_without_reporting_failure() { let pool = test_pool_or_skip!();