Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
4390870
feat(users): add the handle namespace, blocks table and invitee accep…
kipavy Aug 15, 2026
493e80a
fix(handles): narrow per-component reserved check, propagate handle-g…
kipavy Aug 15, 2026
d10348a
feat(users): claim a custom handle, opt out of stranger invites, expo…
kipavy Aug 15, 2026
674d1a9
feat(search): resolve strangers by exact handle or full email and dro…
kipavy Aug 15, 2026
ab0a0e0
feat(users): look up one user's current public key by id
kipavy Aug 15, 2026
8652064
feat(terminal): allow stranger knocks under opt-out, blocks and a per…
kipavy Aug 15, 2026
d8702e0
refactor(test): dedupe the default knock-limiter fixture into test_su…
kipavy Aug 15, 2026
068adbc
fix(terminal): make suppressed knocks indistinguishable in the host's…
kipavy Aug 15, 2026
aa9ec08
feat(terminal): hide the session name from an unaccepted stranger unt…
kipavy Aug 15, 2026
26007f5
docs(terminal): coordinate the fourth teammate-pair predicate copy
kipavy Aug 15, 2026
45b9ceb
feat(terminal): let an invitee decline and block, and a host withdraw…
kipavy Aug 15, 2026
491b492
feat(teams): carry each member's handle in the members response
kipavy Aug 15, 2026
70fd2c5
fix(terminal): clear suppressed_invites when a departed member's gran…
kipavy Aug 15, 2026
977ec63
refactor(test): move unique_handle into test_support
kipavy Aug 15, 2026
0d0be3d
perf(migrations): create the handle index before the backfill, not after
kipavy Aug 15, 2026
4bbeeef
fix(users): gate the handle claim on the effective tier, not the stor…
kipavy Aug 15, 2026
f948d0c
test(users): seed with generate_unique_handle like every other path
kipavy Aug 15, 2026
3d6fedd
feat(terminal): carry the inviter's handle on a knock
kipavy Aug 15, 2026
2151993
fix(terminal): bound and validate the WebSocket display_name
kipavy Aug 15, 2026
e336c15
fix(terminal): clear suppressed_invites when a session ends
kipavy Aug 15, 2026
c0955bc
fix(terminal): withhold participants from an unaccepted stranger
kipavy Aug 15, 2026
b43fb48
fix(knock): run both consent reads for every stranger outcome
kipavy Aug 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions migrations/035_user_handles_and_blocks.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
-- 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;

-- 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.
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 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;

-- 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)
);
28 changes: 28 additions & 0 deletions src/entitlement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<DateTime<Utc>>, bool, Option<String>)>(
"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::*;
Expand Down
245 changes: 245 additions & 0 deletions src/handles.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
//! The handle namespace: every user has one, it is never recycled, and a custom
//! one is what makes an account fuzzy-searchable.

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 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",
"support",
"help",
"helpdesk",
"voltius",
"security",
"billing",
"root",
"system",
"staff",
"moderator",
"mod",
"official",
"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,
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.
/// 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<String, sqlx::Error> {
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?;
if !taken {
return Ok(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' => 'i',
'3' => 'e',
'4' => 'a',
'5' => 's',
'7' => 't',
other => other,
})
.collect()
}

pub fn validate_custom_handle(input: &str) -> Result<String, HandleError> {
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 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 VENDOR_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",
"adm1n",
"voltius",
"v0ltius",
"voltius-support",
"admin-2",
"administrator",
"team",
] {
assert_eq!(
validate_custom_handle(h),
Err(HandleError::Reserved),
"{h} must be reserved"
);
}
}

#[test]
fn allows_ordinary_names_that_merely_contain_a_reserved_substring() {
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]
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);
}
}
Loading