g1t/services/identity/src/invites.rs
Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1 | //! Invite-only registration: invite codes, allowances, the waitlist, and |
| 2 | //! the one place every new account is made. | |
| 3 | //! | |
| 4 | //! [`Identity::create_account`] is the only way an account comes to exist. | |
| 5 | //! While `REGISTRATION_MODE` is `invite` (or unset), it needs an invite | |
| 6 | //! code: unknown, used, revoked and expired codes all get the same answer, | |
| 7 | //! an email-bound code works only with that address, and the code is spent | |
| 8 | //! in the same transaction that makes the account, so two people racing | |
| 9 | //! with one code cannot both get in. | |
| 10 | //! | |
| 11 | //! A code is 160 random bits in Crockford base32, shown as `g1t-` and eight | |
| 12 | //! groups of four. Only its SHA-256 is kept to find it, with a copy sealed | |
| 13 | //! under IDENTITY_KEY so whoever made it can copy the link again while it | |
| 14 | //! is pending. | |
| 15 | //! | |
| 16 | //! Each person may have `INVITES_PER_USER` (5) invites out: pending and | |
| 17 | //! used ones count, and a revoked or expired one that was never used comes | |
| 18 | //! back. Staff grant more in sudo, to a person or to a workspace, whose | |
| 19 | //! owners share them. Owners of the workspaces in | |
| 20 | //! `INVITE_STAFF_WORKSPACES` (g1t's own) have no limit. Inviting an address | |
| 21 | //! into a workspace always makes an invite bound to it, and costs one only | |
| 22 | //! when the address has no account, so the answer never says which. | |
| 23 | //! | |
| 24 | //! Vars: REGISTRATION_MODE (`invite` | `open`), INVITES_PER_USER, | |
| 25 | //! INVITE_TTL_DAYS, INVITE_STAFF_WORKSPACES (comma separated slugs). | |
| 26 | ||
| 27 | use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface}; | |
| 28 | use g1t_contracts::events::{InviteCreated, InviteRedeemed, WaitlistRequested}; | |
| 29 | use g1t_contracts::identity::*; | |
| 30 | use g1t_contracts::time::{SQL_NOW, rfc3339}; | |
| 31 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id}; | |
| 32 | use g1t_kit::now_ms; | |
| 33 | use g1t_secrets::Sealer; | |
| 34 | use serde::Deserialize; | |
| 35 | use worker::Result; | |
| 36 | use worker::wasm_bindgen::JsValue; | |
| 37 | ||
| 38 | use crate::{Identity, crypto}; | |
| 39 | ||
| 40 | /// Crockford base32, as ids use: no i, l, o or u. | |
| 41 | const ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz"; | |
| 42 | /// 32 characters of 5 bits: 160 random bits. | |
| 43 | const CODE_LENGTH: usize = 32; | |
| 44 | const GROUP: usize = 4; | |
| 45 | ||
| 46 | pub const INVALID: &str = | |
| 47 | "That invite code is not valid. It may have been used, revoked or expired; ask whoever invited you for a new one."; | |
| 48 | pub const WRONG_EMAIL: &str = "This invite is for a different email address. Use the address it was sent to."; | |
| 49 | pub const MISSING: &str = "g1t is invite-only for now. Enter your invite code, or request access."; | |
| 50 | const TOO_MANY: &str = "Too many attempts. Try again in an hour."; | |
| 51 | const PEOPLE_ONLY: &str = "Only a person can make invites, not an agent or a workspace's token."; | |
| 52 | const CONFIRM_FIRST: &str = "Confirm your email address before inviting anyone."; | |
| 53 | const BAD_EMAIL: &str = "Enter a valid email address."; | |
| 54 | ||
| 55 | const HOUR_MS: u64 = 60 * 60 * 1000; | |
| 56 | /// Invites one person may make in an hour, whatever their allowance. | |
| 57 | const CREATES_PER_HOUR: u32 = 20; | |
| 58 | /// Wrong codes one client may try in an hour before being turned away. | |
| 59 | const FAILURES_PER_HOUR: u32 = 20; | |
| 60 | /// Access requests from one client in an hour. | |
| 61 | const REQUESTS_PER_HOUR: u32 = 5; | |
| 62 | /// Access requests from clients that sent no address, together, in an hour. | |
| 63 | const ANONYMOUS_REQUESTS_PER_HOUR: u32 = 200; | |
| 64 | /// The most invites a person's or workspace's list shows. | |
| 65 | const LIST_LIMIT: u32 = 200; | |
| 66 | /// How far down the invite tree staff see. | |
| 67 | const TREE_DEPTH: usize = 3; | |
| 68 | ||
| 69 | // --- Codes ------------------------------------------------------------------ | |
| 70 | ||
| 71 | /// The 32 characters of a code from 20 random bytes. | |
| 72 | fn encode(bytes: &[u8; 20]) -> String { | |
| 73 | let mut out = String::with_capacity(CODE_LENGTH); | |
| 74 | let (mut buffer, mut bits) = (0u32, 0u32); | |
| 75 | for &byte in bytes { | |
| 76 | buffer = (buffer << 8) | u32::from(byte); | |
| 77 | bits += 8; | |
| 78 | while bits >= 5 { | |
| 79 | bits -= 5; | |
| 80 | out.push(ALPHABET[((buffer >> bits) & 31) as usize] as char); | |
| 81 | } | |
| 82 | buffer &= (1 << bits) - 1; | |
| 83 | } | |
| 84 | out | |
| 85 | } | |
| 86 | ||
| 87 | /// A new code's 32 characters. | |
| 88 | pub fn new_code_body() -> String { | |
| 89 | let mut bytes = [0u8; 20]; | |
| 90 | getrandom::getrandom(&mut bytes).expect("no source of randomness"); | |
| 91 | encode(&bytes) | |
| 92 | } | |
| 93 | ||
| 94 | /// How a code is shown: `g1t-` and groups of four. | |
| 95 | pub fn format_code(body: &str) -> String { | |
| 96 | let groups: Vec<&str> = body | |
| 97 | .as_bytes() | |
| 98 | .chunks(GROUP) | |
| 99 | .map(|chunk| std::str::from_utf8(chunk).unwrap_or_default()) | |
| 100 | .collect(); | |
| 101 | format!("g1t-{}", groups.join("-")) | |
| 102 | } | |
| 103 | ||
| 104 | /// A code's 32 characters from however it was typed or pasted: any case, | |
| 105 | /// with or without `g1t-`, hyphens or spaces, or a whole invite link. | |
| 106 | /// Letters easily misread are read as Crockford reads them. | |
| 107 | pub fn normalize_code(input: &str) -> Option<String> { | |
| 108 | let mut text = input.trim().to_ascii_lowercase(); | |
| 109 | // A pasted link: the last path segment, or the `invite` parameter. | |
| 110 | if let Some(at) = text.find("invite=") { | |
| 111 | text = text[at + "invite=".len()..].split('&').next().unwrap_or_default().to_owned(); | |
| 112 | } else if let Some(at) = text.rfind('/') { | |
| 113 | text = text[at + 1..].to_owned(); | |
| 114 | } | |
| 115 | let text = text.strip_prefix("g1t").unwrap_or(&text); | |
| 116 | let mut body = String::with_capacity(CODE_LENGTH); | |
| 117 | for c in text.chars() { | |
| 118 | let c = match c { | |
| 119 | '-' | ' ' | '_' => continue, | |
| 120 | 'i' | 'l' => '1', | |
| 121 | 'o' => '0', | |
| 122 | c if ALPHABET.contains(&(c as u8)) && c.is_ascii() => c, | |
| 123 | _ => return None, | |
| 124 | }; | |
| 125 | body.push(c); | |
| 126 | } | |
| 127 | (body.len() == CODE_LENGTH).then_some(body) | |
| 128 | } | |
| 129 | ||
| 130 | /// What is stored to find a code. | |
| 131 | pub fn code_hash(body: &str) -> String { | |
| 132 | crypto::sha256_hex(body) | |
| 133 | } | |
| 134 | ||
| 135 | /// The code's first group, kept to recognise it: 20 of its 160 bits. | |
| 136 | pub fn code_hint(body: &str) -> String { | |
| 137 | format!("g1t-{}", &body[..GROUP]) | |
| 138 | } | |
| 139 | ||
| 140 | // --- Rules -------------------------------------------------------------------- | |
| 141 | ||
| 142 | /// Where an invite stands at `now`, from its row. | |
| 143 | pub fn status_of(revoked_at: Option<&str>, redeemed_at: Option<&str>, expires_at: &str, now: &str) -> InviteStatus { | |
| 144 | if redeemed_at.is_some() { | |
| 145 | InviteStatus::Redeemed | |
| 146 | } else if revoked_at.is_some() { | |
| 147 | InviteStatus::Revoked | |
| 148 | } else if expires_at <= now { | |
| 149 | InviteStatus::Expired | |
| 150 | } else { | |
| 151 | InviteStatus::Pending | |
| 152 | } | |
| 153 | } | |
| 154 | ||
| 155 | /// Whether an invite in this state uses up one of an allowance: pending | |
| 156 | /// and used ones do; a revoked or expired one never used gives it back. | |
| 157 | #[cfg(test)] | |
| 158 | pub fn counts_against_allowance(status: InviteStatus) -> bool { | |
| 159 | matches!(status, InviteStatus::Pending | InviteStatus::Redeemed) | |
| 160 | } | |
| 161 | ||
| 162 | /// The SQL condition that matches [`counts_against_allowance`] for rows of | |
| 163 | /// `invites` aliased `i`. | |
| 164 | fn counted_sql() -> String { | |
| 165 | format!("(i.redeemed_at IS NOT NULL OR (i.revoked_at IS NULL AND i.expires_at > {SQL_NOW}))") | |
| 166 | } | |
| 167 | ||
| 168 | /// How many invites someone may have out: the default plus staff grants, | |
| 169 | /// never below zero; None for no limit. | |
| 170 | pub fn limit_for(default: u32, granted: i64, unlimited: bool) -> Option<u32> { | |
| 171 | if unlimited { | |
| 172 | return None; | |
| 173 | } | |
| 174 | Some((i64::from(default) + granted).clamp(0, i64::from(u32::MAX)) as u32) | |
| 175 | } | |
| 176 | ||
| 177 | /// Why an invite cannot make an account. | |
| 178 | #[derive(Debug, PartialEq, Eq)] | |
| 179 | pub enum Refusal { | |
| 180 | /// Unknown, used, revoked, expired, or not for making accounts. One | |
| 181 | /// answer for all, so codes cannot be probed. | |
| 182 | Invalid, | |
| 183 | /// It is bound to another address. | |
| 184 | WrongEmail, | |
| 185 | } | |
| 186 | ||
| 187 | /// The parts of an invite that decide whether it admits someone. | |
| 188 | #[derive(Debug)] | |
| 189 | pub struct Admits<'a> { | |
| 190 | pub kind: &'a str, | |
| 191 | pub email: Option<&'a str>, | |
| 192 | pub status: InviteStatus, | |
| 193 | } | |
| 194 | ||
| 195 | /// Whether an invite lets `email` make an account (`for_account`) or join | |
| 196 | /// its workspace with an existing one. | |
| 197 | pub fn admits(invite: Option<&Admits>, email: &str, for_account: bool) -> std::result::Result<(), Refusal> { | |
| 198 | let Some(invite) = invite else { | |
| 199 | return Err(Refusal::Invalid); | |
| 200 | }; | |
| 201 | if invite.status != InviteStatus::Pending || (for_account && invite.kind != "account") { | |
| 202 | return Err(Refusal::Invalid); | |
| 203 | } | |
| 204 | match invite.email { | |
| 205 | Some(bound) if !bound.eq_ignore_ascii_case(email.trim()) => Err(Refusal::WrongEmail), | |
| 206 | _ => Ok(()), | |
| 207 | } | |
| 208 | } | |
| 209 | ||
| 210 | /// A trimmed, lowercased address, if it looks like one. | |
| 211 | pub fn normalize_email(email: &str) -> Option<String> { | |
| 212 | let email = email.trim().to_lowercase(); | |
| 213 | let well_formed = email.len() <= 254 | |
| 214 | ||
| 215 | .split_once('@') | |
| 216 | .is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.') && !domain.contains('@')) | |
| 217 | && !email.contains(char::is_whitespace); | |
| 218 | well_formed.then_some(email) | |
| 219 | } | |
| 220 | ||
| 221 | /// An address with most of its local part hidden: `a•••@example.com`. | |
| 222 | pub fn mask_email(email: &str) -> String { | |
| 223 | match email.split_once('@') { | |
| 224 | Some((local, domain)) => { | |
| 225 | let first: String = local.chars().take(1).collect(); | |
| 226 | format!("{first}•••@{domain}") | |
| 227 | } | |
| 228 | None => "•••".to_owned(), | |
| 229 | } | |
| 230 | } | |
| 231 | ||
| 232 | /// The fixed window a moment falls in. | |
| 233 | pub fn bucket(now_ms: u64, window_ms: u64) -> u64 { | |
| 234 | now_ms / window_ms | |
| 235 | } | |
| 236 | ||
| 237 | // --- Rows --------------------------------------------------------------------- | |
| 238 | ||
| 239 | const COLUMNS: &str = "i.id, i.hint, i.sealed_code, i.email, i.kind, i.workspace_id, w.slug AS workspace, | |
| 240 | i.inviter_id, iu.username AS inviter, i.staff, i.charged_to, i.charged_workspace_id, i.created_at, i.expires_at, | |
| 241 | i.revoked_at, i.redeemed_by, ru.username AS redeemer, i.redeemed_at | |
| 242 | FROM invites i | |
| 243 | LEFT JOIN workspaces w ON w.id = i.workspace_id | |
| 244 | LEFT JOIN users iu ON iu.id = i.inviter_id | |
| 245 | LEFT JOIN users ru ON ru.id = i.redeemed_by"; | |
| 246 | ||
| 247 | #[derive(Debug, Deserialize)] | |
| 248 | pub struct InviteRow { | |
| 249 | pub id: String, | |
| 250 | pub hint: String, | |
| 251 | pub sealed_code: Option<String>, | |
| 252 | pub email: Option<String>, | |
| 253 | pub kind: String, | |
| 254 | pub workspace_id: Option<String>, | |
| 255 | pub workspace: Option<String>, | |
| 256 | pub inviter_id: Option<String>, | |
| 257 | pub inviter: Option<String>, | |
| 258 | pub staff: Option<String>, | |
| 259 | pub charged_to: String, | |
| 260 | pub created_at: String, | |
| 261 | pub expires_at: String, | |
| 262 | pub revoked_at: Option<String>, | |
| 263 | pub redeemer: Option<String>, | |
| 264 | pub redeemed_at: Option<String>, | |
| 265 | } | |
| 266 | ||
| 267 | impl InviteRow { | |
| 268 | pub fn status(&self, now: &str) -> InviteStatus { | |
| 269 | status_of(self.revoked_at.as_deref(), self.redeemed_at.as_deref(), &self.expires_at, now) | |
| 270 | } | |
| 271 | ||
| 272 | fn admits(&self, now: &str) -> Admits<'_> { | |
| 273 | Admits { | |
| 274 | kind: &self.kind, | |
| 275 | email: self.email.as_deref(), | |
| 276 | status: self.status(now), | |
| 277 | } | |
| 278 | } | |
| 279 | } | |
| 280 | ||
| 281 | fn kind_of(kind: &str) -> InviteKind { | |
| 282 | if kind == "workspace" { InviteKind::Workspace } else { InviteKind::Account } | |
| 283 | } | |
| 284 | ||
| 285 | fn charge_of(charged_to: &str) -> InviteCharge { | |
| 286 | match charged_to { | |
| 287 | "user" => InviteCharge::User, | |
| 288 | "workspace" => InviteCharge::Workspace, | |
| 289 | _ => InviteCharge::None, | |
| 290 | } | |
| 291 | } | |
| 292 | ||
| 293 | #[derive(Deserialize)] | |
| 294 | struct Count { | |
| 295 | n: f64, | |
| 296 | } | |
| 297 | ||
| 298 | #[derive(Deserialize)] | |
| 299 | struct Id { | |
| 300 | id: String, | |
| 301 | } | |
| 302 | ||
| 303 | #[derive(Deserialize)] | |
| 304 | struct WaitlistRow { | |
| 305 | id: String, | |
| 306 | email: String, | |
| 307 | about: Option<String>, | |
| 308 | status: String, | |
| 309 | invite_id: Option<String>, | |
| 310 | decided_by: Option<String>, | |
| 311 | decided_at: Option<String>, | |
| 312 | created_at: String, | |
| 313 | updated_at: String, | |
| 314 | } | |
| 315 | ||
| 316 | impl From<WaitlistRow> for WaitlistEntry { | |
| 317 | fn from(row: WaitlistRow) -> Self { | |
| 318 | WaitlistEntry { | |
| 319 | id: row.id, | |
| 320 | email: row.email, | |
| 321 | about: row.about, | |
| 322 | status: match row.status.as_str() { | |
| 323 | "invited" => WaitlistStatus::Invited, | |
| 324 | "dismissed" => WaitlistStatus::Dismissed, | |
| 325 | _ => WaitlistStatus::Waiting, | |
| 326 | }, | |
| 327 | invite_id: row.invite_id, | |
| 328 | decided_by: row.decided_by, | |
| 329 | decided_at: row.decided_at, | |
| 330 | created_at: row.created_at, | |
| 331 | updated_at: row.updated_at, | |
| 332 | } | |
| 333 | } | |
| 334 | } | |
| 335 | ||
| 336 | /// What a new account is made from. | |
| 337 | pub struct NewAccount<'a> { | |
| 338 | /// Checked by the caller: valid, and free. | |
| 339 | pub username: &'a str, | |
| 340 | /// Lowercased and checked by the caller. | |
| 341 | pub email: &'a str, | |
| 342 | /// Empty for an account with no password (made through GitHub). | |
| 343 | pub password_hash: &'a str, | |
| 344 | /// Whether the address is confirmed already (GitHub's verified email). | |
| 345 | pub verified: bool, | |
| 346 | pub invite_code: Option<&'a str>, | |
| 347 | /// Who is asking, for rate limits. | |
| 348 | pub client: Option<&'a str>, | |
| 349 | } | |
| 350 | ||
| 351 | /// What an invite was made for. | |
| 352 | struct Draft<'a> { | |
| 353 | email: Option<&'a str>, | |
| 354 | kind: &'a str, | |
| 355 | /// The workspace using it joins. | |
| 356 | workspace_id: Option<&'a str>, | |
| 357 | inviter: Option<&'a User>, | |
| 358 | staff: Option<&'a str>, | |
| 359 | /// `user`, `workspace` or `none`; the workspace for `workspace`; and | |
| 360 | /// the limit when there is one. | |
| 361 | charged_to: &'a str, | |
| 362 | charged_workspace_id: Option<&'a str>, | |
| 363 | limit: Option<u32>, | |
| 364 | } | |
| 365 | ||
| 366 | impl Identity { | |
| 367 | // --- Settings --- | |
| 368 | ||
| 369 | pub fn registration_mode(&self) -> RegistrationMode { | |
| 370 | RegistrationMode::parse(self.env.var("REGISTRATION_MODE").ok().map(|v| v.to_string()).as_deref()) | |
| 371 | } | |
| 372 | ||
| 373 | /// Whether new accounts need an invite code. | |
| 374 | pub fn invites_required(&self) -> bool { | |
| 375 | self.registration_mode() == RegistrationMode::Invite | |
| 376 | } | |
| 377 | ||
| 378 | fn var_number(&self, name: &str) -> Option<u64> { | |
| 379 | self.env.var(name).ok()?.to_string().trim().parse().ok() | |
| 380 | } | |
| 381 | ||
| 382 | fn invites_per_user(&self) -> u32 { | |
| 383 | self.var_number("INVITES_PER_USER").map_or(INVITES_PER_USER, |n| n.min(u64::from(u32::MAX)) as u32) | |
| 384 | } | |
| 385 | ||
| 386 | fn invite_ttl_days(&self) -> u64 { | |
| 387 | self.var_number("INVITE_TTL_DAYS").filter(|days| (1..=365).contains(days)).unwrap_or(INVITE_TTL_DAYS) | |
| 388 | } | |
| 389 | ||
| 390 | /// The workspaces whose owners invite without limit: g1t's own. | |
| 391 | fn staff_workspaces(&self) -> Vec<String> { | |
| 392 | self.env | |
| 393 | .var("INVITE_STAFF_WORKSPACES") | |
| 394 | .map(|v| v.to_string()) | |
| 395 | .unwrap_or_default() | |
| 396 | .split(',') | |
| 397 | .map(|slug| slug.trim().to_lowercase()) | |
| 398 | .filter(|slug| !slug.is_empty()) | |
| 399 | .collect() | |
| 400 | } | |
| 401 | ||
| 402 | fn invite_sealer(&self) -> Option<Sealer> { | |
| 403 | Sealer::new(&self.env.secret("IDENTITY_KEY").ok()?.to_string()) | |
| 404 | } | |
| 405 | ||
| 406 | // --- Rate limits --- | |
| 407 | ||
| 408 | /// Counts one more hit on `key` this hour; false once past `limit`. | |
| 409 | async fn hit(&self, key: &str, limit: u32) -> Result<bool> { | |
| 410 | let now = bucket(now_ms(), HOUR_MS); | |
| 411 | let hits = self | |
| 412 | .db | |
| 413 | .prepare( | |
| 414 | "INSERT INTO rate_limits (key, bucket, hits) VALUES (?1, ?2, 1) | |
| 415 | ON CONFLICT (key) DO UPDATE SET | |
| 416 | hits = CASE WHEN rate_limits.bucket = excluded.bucket THEN rate_limits.hits + 1 ELSE 1 END, | |
| 417 | bucket = excluded.bucket | |
| 418 | RETURNING hits AS n", | |
| 419 | ) | |
| 420 | .bind(&[key.into(), (now as f64).into()])? | |
| 421 | .first::<Count>(None) | |
| 422 | .await? | |
| 423 | .map_or(1.0, |count| count.n); | |
| 424 | if hits <= 1.0 { | |
| 425 | // A new window: forget windows gone by. | |
| 426 | self.db | |
| 427 | .prepare("DELETE FROM rate_limits WHERE bucket < ?") | |
| 428 | .bind(&[((now.saturating_sub(1)) as f64).into()])? | |
| 429 | .run() | |
| 430 | .await?; | |
| 431 | } | |
| 432 | Ok(hits <= f64::from(limit)) | |
| 433 | } | |
| 434 | ||
| 435 | /// Hits on `key` this hour, without adding one. | |
| 436 | async fn hits(&self, key: &str) -> Result<u32> { | |
| 437 | Ok(self | |
| 438 | .db | |
| 439 | .prepare("SELECT hits AS n FROM rate_limits WHERE key = ? AND bucket = ?") | |
| 440 | .bind(&[key.into(), (bucket(now_ms(), HOUR_MS) as f64).into()])? | |
| 441 | .first::<Count>(None) | |
| 442 | .await? | |
| 443 | .map_or(0, |count| count.n as u32)) | |
| 444 | } | |
| 445 | ||
| 446 | /// Whether `client` has tried too many wrong codes this hour. | |
| 447 | async fn turned_away(&self, client: Option<&str>) -> Result<bool> { | |
| 448 | Ok(match client { | |
| 449 | Some(client) => self.hits(&format!("invite.fail:{}", crypto::sha256_hex(client))).await? >= FAILURES_PER_HOUR, | |
| 450 | None => false, | |
| 451 | }) | |
| 452 | } | |
| 453 | ||
| 454 | async fn count_failure(&self, client: Option<&str>) -> Result<()> { | |
| 455 | if let Some(client) = client { | |
| 456 | self.hit(&format!("invite.fail:{}", crypto::sha256_hex(client)), FAILURES_PER_HOUR).await?; | |
| 457 | } | |
| 458 | Ok(()) | |
| 459 | } | |
| 460 | ||
| 461 | // --- Reading --- | |
| 462 | ||
| 463 | async fn invite_by_code(&self, code: &str) -> Result<Option<InviteRow>> { | |
| 464 | let Some(body) = normalize_code(code) else { | |
| 465 | return Ok(None); | |
| 466 | }; | |
| 467 | self.db | |
| 468 | .prepare(format!("SELECT {COLUMNS} WHERE i.code_hash = ?")) | |
| 469 | .bind(&[code_hash(&body).into()])? | |
| 470 | .first::<InviteRow>(None) | |
| 471 | .await | |
| 472 | } | |
| 473 | ||
| 474 | async fn invite_by_id(&self, id: &str) -> Result<Option<InviteRow>> { | |
| 475 | self.db | |
| 476 | .prepare(format!("SELECT {COLUMNS} WHERE i.id = ?")) | |
| 477 | .bind(&[id.into()])? | |
| 478 | .first::<InviteRow>(None) | |
| 479 | .await | |
| 480 | } | |
| 481 | ||
| 482 | /// An invite as shown, with its code when `reveal` and it is pending. | |
| 483 | fn shown(&self, row: InviteRow, reveal: bool, staff_view: bool) -> Invite { | |
| 484 | let now = rfc3339(now_ms()); | |
| 485 | let status = row.status(&now); | |
| 486 | let code = if reveal && status == InviteStatus::Pending { | |
| 487 | row.sealed_code | |
| 488 | .as_deref() | |
| 489 | .and_then(|sealed| self.invite_sealer()?.open(sealed, &row.id)) | |
| 490 | } else { | |
| 491 | None | |
| 492 | }; | |
| 493 | Invite { | |
| 494 | id: row.id, | |
| 495 | code, | |
| 496 | hint: row.hint, | |
| 497 | email: row.email, | |
| 498 | kind: kind_of(&row.kind), | |
| 499 | workspace: row.workspace, | |
| 500 | status, | |
| 501 | charged_to: charge_of(&row.charged_to), | |
| 502 | invited_by: row.inviter, | |
| 503 | redeemed_by: row.redeemer, | |
| 504 | created_at: row.created_at, | |
| 505 | expires_at: row.expires_at, | |
| 506 | redeemed_at: row.redeemed_at, | |
| 507 | revoked_at: row.revoked_at, | |
| 508 | staff: if staff_view { row.staff } else { None }, | |
| 509 | } | |
| 510 | } | |
| 511 | ||
| 512 | async fn rows(&self, filter: &str, binds: &[JsValue], limit: u32) -> Result<Vec<InviteRow>> { | |
| 513 | self.db | |
| 514 | .prepare(format!("SELECT {COLUMNS} {filter} ORDER BY i.created_at DESC, i.id DESC LIMIT {limit}")) | |
| 515 | .bind(binds)? | |
| 516 | .all() | |
| 517 | .await? | |
| 518 | .results::<InviteRow>() | |
| 519 | } | |
| 520 | ||
| 521 | async fn granted(&self, target: GrantTarget, id: &str) -> Result<i64> { | |
| 522 | Ok(self | |
| 523 | .db | |
| 524 | .prepare("SELECT COALESCE(SUM(amount), 0) AS n FROM invite_grants WHERE target_kind = ? AND target_id = ?") | |
| 525 | .bind(&[target.as_str().into(), id.into()])? | |
| 526 | .first::<Count>(None) | |
| 527 | .await? | |
| 528 | .map_or(0, |count| count.n as i64)) | |
| 529 | } | |
| 530 | ||
| 531 | async fn is_invite_staff(&self, user_id: &str) -> Result<bool> { | |
| 532 | let staff = self.staff_workspaces(); | |
| 533 | if staff.is_empty() { | |
| 534 | return Ok(false); | |
| 535 | } | |
| 536 | let marks = vec!["?"; staff.len()].join(", "); | |
| 537 | let mut binds: Vec<JsValue> = vec![user_id.into()]; | |
| 538 | binds.extend(staff.iter().map(|slug| JsValue::from(slug.as_str()))); | |
| 539 | Ok(self | |
| 540 | .db | |
| 541 | .prepare(format!( | |
| 542 | "SELECT count(*) AS n FROM workspace_members m JOIN workspaces w ON w.id = m.workspace_id | |
| 543 | WHERE m.user_id = ? AND m.role = 'owner' AND w.slug IN ({marks})" | |
| 544 | )) | |
| 545 | .bind(&binds)? | |
| 546 | .first::<Count>(None) | |
| 547 | .await? | |
| 548 | .is_some_and(|count| count.n > 0.0)) | |
| 549 | } | |
| 550 | ||
| 551 | async fn used(&self, column: &'static str, id: &str, charged_to: &str) -> Result<u32> { | |
| 552 | Ok(self | |
| 553 | .db | |
| 554 | .prepare(format!( | |
| 555 | "SELECT count(*) AS n FROM invites i WHERE i.{column} = ? AND i.charged_to = ? AND {}", | |
| 556 | counted_sql() | |
| 557 | )) | |
| 558 | .bind(&[id.into(), charged_to.into()])? | |
| 559 | .first::<Count>(None) | |
| 560 | .await? | |
| 561 | .map_or(0, |count| count.n as u32)) | |
| 562 | } | |
| 563 | ||
| 564 | /// A person's own allowance. | |
| 565 | pub async fn user_allowance(&self, user_id: &str) -> Result<Allowance> { | |
| 566 | let unlimited = self.is_invite_staff(user_id).await?; | |
| 567 | let granted = self.granted(GrantTarget::User, user_id).await?; | |
| 568 | let used = self.used("inviter_id", user_id, "user").await?; | |
| 569 | Ok(Allowance::new(limit_for(self.invites_per_user(), granted, unlimited), used)) | |
| 570 | } | |
| 571 | ||
| 572 | /// A workspace's shared allowance: only what staff granted it. | |
| 573 | async fn workspace_allowance(&self, workspace_id: &str) -> Result<Allowance> { | |
| 574 | let granted = self.granted(GrantTarget::Workspace, workspace_id).await?; | |
| 575 | let used = self.used("charged_workspace_id", workspace_id, "workspace").await?; | |
| 576 | Ok(Allowance::new(limit_for(0, granted, false), used)) | |
| 577 | } | |
| 578 | ||
| 579 | async fn workspace_id(&self, slug: &str) -> Result<Option<String>> { | |
| 580 | Ok(self | |
| 581 | .db | |
| 582 | .prepare("SELECT id FROM workspaces WHERE slug = ?") | |
| 583 | .bind(&[slug.trim().to_lowercase().into()])? | |
| 584 | .first::<Id>(None) | |
| 585 | .await? | |
| 586 | .map(|row| row.id)) | |
| 587 | } | |
| 588 | ||
| 589 | /// Whether an address is any account's: confirmed on one, or the | |
| 590 | /// address a new account signed up with (emails.rs). | |
| 591 | async fn email_has_account(&self, email: &str) -> Result<bool> { | |
| 592 | self.email_in_use(email).await | |
| 593 | } | |
| 594 | ||
| 595 | // --- The gate --- | |
| 596 | ||
| 597 | /// Makes an account: the only place one is made. While registration is | |
| 598 | /// invite-only, `invite_code` must admit `email`; the code is spent in | |
| 599 | /// the same transaction as the account is made. An invite for a | |
| 600 | /// workspace also joins it. In open mode a code is used if it is good | |
| 601 | /// and otherwise ignored. | |
| 602 | pub async fn create_account(&self, new: NewAccount<'_>) -> Result<Outcome<User>> { | |
| 603 | let required = self.invites_required(); | |
| 604 | let code = new.invite_code.map(str::trim).filter(|code| !code.is_empty()); | |
| 605 | let mut invite = None; | |
| 606 | match code { | |
| 607 | None if required => return Ok(Outcome::fail(FailureCode::Forbidden, MISSING)), | |
| 608 | None => {} | |
| 609 | Some(code) => { | |
| 610 | if required && self.turned_away(new.client).await? { | |
| 611 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); | |
| 612 | } | |
| 613 | let row = self.invite_by_code(code).await?; | |
| 614 | let now = rfc3339(now_ms()); | |
| 615 | match admits(row.as_ref().map(|row| row.admits(&now)).as_ref(), new.email, true) { | |
| 616 | Ok(()) => invite = row, | |
| 617 | Err(_) if !required => {} | |
| 618 | Err(refusal) => { | |
| 619 | self.count_failure(new.client).await?; | |
| 620 | let message = if refusal == Refusal::WrongEmail { WRONG_EMAIL } else { INVALID }; | |
| 621 | return Ok(Outcome::fail(FailureCode::Forbidden, message)); | |
| 622 | } | |
| 623 | } | |
| 624 | } | |
| 625 | } | |
| 626 | ||
| 627 | let user = User { | |
| 628 | id: new_id("usr", now_ms()), | |
| 629 | username: new.username.to_owned(), | |
| 630 | verified: new.verified, | |
| 631 | ..User::default() | |
| 632 | }; | |
| 633 | let verified_at = if new.verified { SQL_NOW } else { "NULL" }; | |
| 634 | let values = [ | |
| 635 | JsValue::from(user.id.as_str()), | |
| 636 | new.username.into(), | |
| 637 | new.email.into(), | |
| 638 | new.password_hash.into(), | |
| 639 | ]; | |
| 640 | let made = match &invite { | |
| 641 | None => { | |
| 642 | self.db | |
| 643 | .prepare(format!( | |
| 644 | "INSERT INTO users (id, username, email, password_hash, email_verified_at) | |
| 645 | VALUES (?, ?, ?, ?, {verified_at})" | |
| 646 | )) | |
| 647 | .bind(&values)? | |
| 648 | .run() | |
| 649 | .await | |
| 650 | .map(|_| ()) | |
| 651 | } | |
| 652 | // Spend the code, then make the account only if this request | |
| 653 | // spent it: one transaction, so a second use finds it gone. | |
| 654 | Some(row) => { | |
| 655 | let mut insert = values.to_vec(); | |
| 656 | insert.extend([JsValue::from(row.id.as_str()), user.id.as_str().into()]); | |
| 657 | self.db | |
| 658 | .batch(vec![ | |
| 659 | self.db | |
| 660 | .prepare(format!( | |
| 661 | "UPDATE invites SET redeemed_by = ?, redeemed_at = {SQL_NOW}, sealed_code = NULL | |
| 662 | WHERE id = ? AND kind = 'account' AND redeemed_at IS NULL AND revoked_at IS NULL | |
| 663 | AND expires_at > {SQL_NOW}" | |
| 664 | )) | |
| 665 | .bind(&[user.id.as_str().into(), row.id.as_str().into()])?, | |
| 666 | self.db | |
| 667 | .prepare(format!( | |
| 668 | "INSERT INTO users (id, username, email, password_hash, email_verified_at) | |
| 669 | SELECT ?, ?, ?, ?, {verified_at} | |
| 670 | WHERE EXISTS (SELECT 1 FROM invites WHERE id = ? AND redeemed_by = ?)" | |
| 671 | )) | |
| 672 | .bind(&insert)?, | |
| 673 | ]) | |
| 674 | .await | |
| 675 | .map(|_| ()) | |
| 676 | } | |
| 677 | }; | |
| 678 | if let Err(error) = made { | |
| 679 | // Someone took the username or email a moment ago; nothing | |
| 680 | // was written, the code included. | |
| 681 | if error.to_string().contains("UNIQUE") { | |
| 682 | return Ok(Outcome::fail(FailureCode::Conflict, "That username or email is already registered.")); | |
| 683 | } | |
| 684 | return Err(error); | |
| 685 | } | |
| 686 | let exists = self | |
| 687 | .db | |
| 688 | .prepare("SELECT id FROM users WHERE id = ?") | |
| 689 | .bind(&[user.id.as_str().into()])? | |
| 690 | .first::<Id>(None) | |
| 691 | .await? | |
| 692 | .is_some(); | |
| 693 | if !exists { | |
| 694 | // Another sign-up spent the code first. | |
| 695 | self.count_failure(new.client).await?; | |
| 696 | return Ok(Outcome::fail(FailureCode::Forbidden, INVALID)); | |
| 697 | } | |
| 698 | if let Some(row) = invite { | |
| 699 | self.after_redeemed(&row, &user, true).await?; | |
| 700 | } | |
| 701 | Ok(Outcome::Ok(user)) | |
| 702 | } | |
| 703 | ||
| 704 | /// Joins the invite's workspace, and tells the event log and audit log. | |
| 705 | async fn after_redeemed(&self, row: &InviteRow, user: &User, created_account: bool) -> Result<()> { | |
| 706 | let mut joined = None; | |
| 707 | if let (Some(workspace_id), Some(slug)) = (&row.workspace_id, &row.workspace) { | |
| 708 | self.db | |
| 709 | .prepare( | |
| 710 | "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at) | |
| 711 | VALUES (?, ?, 'member', ?)", | |
| 712 | ) | |
| 713 | .bind(&[workspace_id.as_str().into(), user.id.as_str().into(), rfc3339(now_ms()).into()])? | |
| 714 | .run() | |
| 715 | .await?; | |
| 716 | joined = Some(slug.clone()); | |
| 717 | } | |
| 718 | // A code sent with an invitation to collaborate on a repository: | |
| 719 | // using it accepts (access.rs). | |
| 720 | if let Err(error) = self.accept_invitations_of_code(&row.id, user).await { | |
| 721 | worker::console_error!("repository invitations for {} not accepted: {error}", row.id); | |
| 722 | } | |
| 723 | self.announce( | |
| 724 | "invite.redeemed", | |
| 725 | Some(&user.id), | |
| 726 | InviteRedeemed { | |
| 727 | invite_id: row.id.clone(), | |
| 728 | user_id: user.id.clone(), | |
| 729 | inviter_id: row.inviter_id.clone(), | |
| 730 | workspace_id: row.workspace_id.clone(), | |
| 731 | created_account, | |
| 732 | }, | |
| 733 | ) | |
| 734 | .await; | |
| 735 | if let Some(slug) = joined { | |
| 736 | let message = match &row.inviter { | |
| 737 | Some(inviter) => format!("Joined with an invite from {inviter}"), | |
| 738 | None => "Joined with an invite from g1t".to_owned(), | |
| 739 | }; | |
| 740 | self.audit_invites(user, "invite.redeemed", vec![slug], Surface::Web, message).await; | |
| 741 | } | |
| 742 | Ok(()) | |
| 743 | } | |
| 744 | ||
| 745 | // --- People's invites --- | |
| 746 | ||
| 747 | fn draft_allowed(user: &User) -> Option<&'static str> { | |
| 748 | if user.kind != PrincipalKind::User || user.acting.is_some() { | |
| 749 | return Some(PEOPLE_ONLY); | |
| 750 | } | |
| 751 | if !user.verified { | |
| 752 | return Some(CONFIRM_FIRST); | |
| 753 | } | |
| 754 | None | |
| 755 | } | |
| 756 | ||
| 757 | /// Stores a new invite and returns it with its code, or None when the | |
| 758 | /// allowance ran out between reading it and writing. | |
| 759 | async fn insert_invite(&self, draft: Draft<'_>) -> Result<Option<Invite>> { | |
| 760 | let body = new_code_body(); | |
| 761 | let code = format_code(&body); | |
| 762 | let now = now_ms(); | |
| 763 | let id = new_id("inv", now); | |
| 764 | let sealed = self.invite_sealer().map(|sealer| sealer.seal(&code, &id)); | |
| 765 | let expires_at = rfc3339(now + self.invite_ttl_days() * 24 * HOUR_MS); | |
| 766 | let created_at = rfc3339(now); | |
| 767 | let opt = |value: Option<&str>| value.map_or(JsValue::NULL, JsValue::from); | |
| 768 | let mut binds = vec![ | |
| 769 | JsValue::from(id.as_str()), | |
| 770 | code_hash(&body).into(), | |
| 771 | code_hint(&body).into(), | |
| 772 | opt(sealed.as_deref()), | |
| 773 | opt(draft.email), | |
| 774 | draft.kind.into(), | |
| 775 | opt(draft.workspace_id), | |
| 776 | opt(draft.inviter.map(|user| user.id.as_str())), | |
| 777 | opt(draft.staff), | |
| 778 | draft.charged_to.into(), | |
| 779 | opt(draft.charged_workspace_id), | |
| 780 | created_at.as_str().into(), | |
| 781 | expires_at.as_str().into(), | |
| 782 | ]; | |
| 783 | // The allowance is checked in the insert itself, so two invites made | |
| 784 | // at once cannot both take the last one. | |
| 785 | let guard = match (draft.charged_to, draft.limit) { | |
| 786 | ("user", Some(limit)) => { | |
| 787 | binds.extend([opt(draft.inviter.map(|user| user.id.as_str())), f64::from(limit).into()]); | |
| 788 | format!( | |
| 789 | "WHERE (SELECT count(*) FROM invites i WHERE i.inviter_id = ? AND i.charged_to = 'user' AND {}) < ?", | |
| 790 | counted_sql() | |
| 791 | ) | |
| 792 | } | |
| 793 | ("workspace", Some(limit)) => { | |
| 794 | binds.extend([opt(draft.charged_workspace_id), f64::from(limit).into()]); | |
| 795 | format!( | |
| 796 | "WHERE (SELECT count(*) FROM invites i WHERE i.charged_workspace_id = ? AND i.charged_to = 'workspace' AND {}) < ?", | |
| 797 | counted_sql() | |
| 798 | ) | |
| 799 | } | |
| 800 | _ => String::new(), | |
| 801 | }; | |
| 802 | let inserted = self | |
| 803 | .db | |
| 804 | .prepare(format!( | |
| 805 | "INSERT INTO invites (id, code_hash, hint, sealed_code, email, kind, workspace_id, inviter_id, | |
| 806 | staff, charged_to, charged_workspace_id, created_at, expires_at) | |
| 807 | SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? {guard} | |
| 808 | RETURNING id" | |
| 809 | )) | |
| 810 | .bind(&binds)? | |
| 811 | .first::<Id>(None) | |
| 812 | .await?; | |
| 813 | if inserted.is_none() { | |
| 814 | return Ok(None); | |
| 815 | } | |
| 816 | self.announce( | |
| 817 | "invite.created", | |
| 818 | draft.inviter.map(|user| user.id.as_str()), | |
| 819 | InviteCreated { | |
| 820 | invite_id: id.clone(), | |
| 821 | inviter_id: draft.inviter.map(|user| user.id.clone()), | |
| 822 | workspace_id: draft.workspace_id.map(str::to_owned), | |
| 823 | bound: draft.email.is_some(), | |
| 824 | }, | |
| 825 | ) | |
| 826 | .await; | |
| 827 | let Some(row) = self.invite_by_id(&id).await? else { | |
| 828 | return Ok(None); | |
| 829 | }; | |
| 830 | let mut invite = self.shown(row, false, false); | |
| 831 | invite.code = Some(code); | |
| 832 | Ok(Some(invite)) | |
| 833 | } | |
| 834 | ||
| 835 | fn out_of_invites() -> Outcome<Invite> { | |
| 836 | Outcome::fail( | |
| 837 | FailureCode::Limit, | |
| 838 | "You have no invites left. Need more? Contact us at hey@flagon.io with the subject [g1t Invites].", | |
| 839 | ) | |
| 840 | } | |
| 841 | ||
| 842 | pub async fn create_invite(&self, a: CreateInviteArgs) -> Result<Outcome<Invite>> { | |
| 843 | if let Some(reason) = Self::draft_allowed(&a.user) { | |
| 844 | return Ok(Outcome::fail(FailureCode::Forbidden, reason)); | |
| 845 | } | |
| 846 | let email = match a.email.as_deref().map(str::trim).filter(|email| !email.is_empty()) { | |
| 847 | Some(email) => match normalize_email(email) { | |
| 848 | Some(email) => Some(email), | |
| 849 | None => return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL)), | |
| 850 | }, | |
| 851 | None => None, | |
| 852 | }; | |
| 853 | if !self.hit(&format!("invite.create:{}", a.user.id), CREATES_PER_HOUR).await? { | |
| 854 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); | |
| 855 | } | |
| 856 | if let Some(email) = &email { | |
| 857 | if self.email_has_account(email).await? { | |
| 858 | return Ok(Outcome::fail( | |
| 859 | FailureCode::Conflict, | |
| 860 | "That address already has a g1t account. Add them to a workspace from its People page instead.", | |
| 861 | )); | |
| 862 | } | |
| 863 | let pending = self | |
| 864 | .rows( | |
| 865 | &format!( | |
| 866 | "WHERE i.inviter_id = ? AND i.email = ? AND i.redeemed_at IS NULL AND i.revoked_at IS NULL AND i.expires_at > {SQL_NOW}" | |
| 867 | ), | |
| 868 | &[a.user.id.as_str().into(), email.as_str().into()], | |
| 869 | 1, | |
| 870 | ) | |
| 871 | .await?; | |
| 872 | if !pending.is_empty() { | |
| 873 | return Ok(Outcome::fail( | |
| 874 | FailureCode::Conflict, | |
| 875 | "You already have a pending invite for that address. Revoke it to send a new one.", | |
| 876 | )); | |
| 877 | } | |
| 878 | } | |
| 879 | // A workspace's granted invites, for its owners. | |
| 880 | let (workspace_id, charged_to, limit) = match a.workspace.as_deref().map(str::trim).filter(|slug| !slug.is_empty()) { | |
| 881 | Some(slug) => { | |
| 882 | let slug = slug.to_lowercase(); | |
| 883 | if a.user.role_in(&slug) != Some(Role::Owner) { | |
| 884 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only a workspace's owners can use its invites.")); | |
| 885 | } | |
| 886 | let Some(id) = self.workspace_id(&slug).await? else { | |
| 887 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); | |
| 888 | }; | |
| 889 | let allowance = self.workspace_allowance(&id).await?; | |
| 890 | if allowance.exhausted() { | |
| 891 | return Ok(Outcome::fail( | |
| 892 | FailureCode::Limit, | |
| 893 | format!("{slug} has no invites left. Need more? Contact us at hey@flagon.io with the subject [g1t Invites]."), | |
| 894 | )); | |
| 895 | } | |
| 896 | (Some(id), "workspace", allowance.limit) | |
| 897 | } | |
| 898 | None => { | |
| 899 | let allowance = self.user_allowance(&a.user.id).await?; | |
| 900 | if allowance.exhausted() { | |
| 901 | return Ok(Self::out_of_invites()); | |
| 902 | } | |
| 903 | (None, "user", allowance.limit) | |
| 904 | } | |
| 905 | }; | |
| 906 | let draft = Draft { | |
| 907 | email: email.as_deref(), | |
| 908 | kind: "account", | |
| 909 | workspace_id: None, | |
| 910 | inviter: Some(&a.user), | |
| 911 | staff: None, | |
| 912 | charged_to, | |
| 913 | charged_workspace_id: workspace_id.as_deref(), | |
| 914 | limit, | |
| 915 | }; | |
| 916 | let Some(invite) = self.insert_invite(draft).await? else { | |
| 917 | return Ok(Self::out_of_invites()); | |
| 918 | }; | |
| 919 | if let (Some(email), Some(code)) = (&email, &invite.code) { | |
| 920 | self.send_invite_email(email, Some(&a.user.username), None, false, code).await; | |
| 921 | } | |
| 922 | let logs: Vec<String> = a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect(); | |
| 923 | self.audit_invites(&a.user, "invite.created", logs, a.surface.unwrap_or(Surface::Web), format!("Created invite {}", invite.hint)) | |
| 924 | .await; | |
| 925 | Ok(Outcome::Ok(invite)) | |
| 926 | } | |
| 927 | ||
| 928 | async fn send_invite_email(&self, to: &str, from: Option<&str>, workspace: Option<&str>, existing: bool, code: &str) { | |
| 929 | if let Err(error) = | |
| 930 | crate::email::send_invite(&self.env, to, from, workspace, existing, code, self.invite_ttl_days()).await | |
| 931 | { | |
| 932 | worker::console_error!("invite email failed: {error}"); | |
| 933 | } | |
| 934 | } | |
| 935 | ||
| 936 | pub async fn list_invites(&self, a: UserArgs) -> Result<InvitesOverview> { | |
| 937 | let invites: Vec<Invite> = self | |
| 938 | .rows("WHERE i.inviter_id = ?", &[a.user.id.as_str().into()], LIST_LIMIT) | |
| 939 | .await? | |
| 940 | .into_iter() | |
| 941 | .map(|row| self.shown(row, true, false)) | |
| 942 | .collect(); | |
| 943 | let mut workspaces = Vec::new(); | |
| 944 | for membership in a.user.workspaces.iter().filter(|membership| membership.role == Role::Owner) { | |
| 945 | if let Some(id) = self.workspace_id(&membership.slug).await? | |
| 946 | && self.granted(GrantTarget::Workspace, &id).await? != 0 | |
| 947 | { | |
| 948 | workspaces.push(WorkspaceAllowance { | |
| 949 | slug: membership.slug.clone(), | |
| 950 | allowance: self.workspace_allowance(&id).await?, | |
| 951 | }); | |
| 952 | } | |
| 953 | } | |
| 954 | Ok(InvitesOverview { | |
| 955 | mode: self.registration_mode(), | |
| 956 | allowance: self.user_allowance(&a.user.id).await?, | |
| 957 | workspaces, | |
| 958 | invites, | |
| 959 | }) | |
| 960 | } | |
| 961 | ||
| 962 | /// Revokes a pending invite the person made, or one made for (or | |
| 963 | /// charged to) a workspace they own. | |
| 964 | pub async fn revoke_invite(&self, a: RemoveArgs) -> Result<Outcome<Invite>> { | |
| 965 | if a.user.kind != PrincipalKind::User || a.user.acting.is_some() { | |
| 966 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); | |
| 967 | } | |
| 968 | let revoked = self | |
| 969 | .db | |
| 970 | .prepare(format!( | |
| 971 | "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL | |
| 972 | WHERE id = ?2 AND redeemed_at IS NULL AND revoked_at IS NULL | |
| 973 | AND (inviter_id = ?1 | |
| 974 | OR workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = ?1 AND role = 'owner') | |
| 975 | OR charged_workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = ?1 AND role = 'owner')) | |
| 976 | RETURNING id" | |
| 977 | )) | |
| 978 | .bind(&[a.user.id.as_str().into(), a.id.as_str().into()])? | |
| 979 | .first::<Id>(None) | |
| 980 | .await?; | |
| 981 | let Some(Id { id }) = revoked else { | |
| 982 | return Ok(Outcome::fail(FailureCode::NotFound, "There is no pending invite of yours with that id.")); | |
| 983 | }; | |
| 984 | let Some(row) = self.invite_by_id(&id).await? else { | |
| 985 | return Ok(Outcome::fail(FailureCode::NotFound, "Invite not found.")); | |
| 986 | }; | |
| 987 | let logs = match &row.workspace { | |
| 988 | Some(slug) => vec![slug.clone()], | |
| 989 | None => a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect(), | |
| 990 | }; | |
| 991 | self.audit_invites(&a.user, "invite.revoked", logs, Surface::Web, format!("Revoked invite {}", row.hint)).await; | |
| 992 | Ok(Outcome::Ok(self.shown(row, false, false))) | |
| 993 | } | |
| 994 | ||
| 995 | /// What an invite code is for: who sent it, and which workspace it | |
| 996 | /// joins. Any code that cannot be used gets the same answer. | |
| 997 | pub async fn check_invite(&self, a: InviteCodeArgs) -> Result<Outcome<InvitePreview>> { | |
| 998 | if self.turned_away(a.client.as_deref()).await? { | |
| 999 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); | |
| 1000 | } | |
| 1001 | let now = rfc3339(now_ms()); | |
| 1002 | let row = self.invite_by_code(&a.code).await?.filter(|row| row.status(&now) == InviteStatus::Pending); | |
| 1003 | let Some(row) = row else { | |
| 1004 | self.count_failure(a.client.as_deref()).await?; | |
| 1005 | return Ok(Outcome::fail(FailureCode::NotFound, INVALID)); | |
| 1006 | }; | |
| 1007 | #[derive(Deserialize)] | |
| 1008 | struct From { | |
| 1009 | username: String, | |
| 1010 | name: Option<String>, | |
| 1011 | avatar: Option<String>, | |
| 1012 | } | |
| 1013 | let invited_by = match &row.inviter_id { | |
| 1014 | Some(id) => self | |
| 1015 | .db | |
| 1016 | .prepare("SELECT username, display_name AS name, avatar FROM users WHERE id = ?") | |
| 1017 | .bind(&[id.as_str().into()])? | |
| 1018 | .first::<From>(None) | |
| 1019 | .await? | |
| 1020 | .map(|from| InviteFrom { | |
| 1021 | username: from.username, | |
| 1022 | name: from.name, | |
| 1023 | avatar: from.avatar, | |
| 1024 | }), | |
| 1025 | None => None, | |
| 1026 | }; | |
| 1027 | let workspace = match &row.workspace_id { | |
| 1028 | Some(id) => self | |
| 1029 | .db | |
| 1030 | .prepare("SELECT slug, name, avatar FROM workspaces WHERE id = ?") | |
| 1031 | .bind(&[id.as_str().into()])? | |
| 1032 | .first::<ProfileWorkspace>(None) | |
| 1033 | .await?, | |
| 1034 | None => None, | |
| 1035 | }; | |
| 1036 | Ok(Outcome::Ok(InvitePreview { | |
| 1037 | kind: kind_of(&row.kind), | |
| 1038 | invited_by, | |
| 1039 | workspace, | |
| 1040 | email: row.email.as_deref().map(mask_email), | |
| 1041 | expires_at: row.expires_at, | |
| 1042 | })) | |
| 1043 | } | |
| 1044 | ||
| 1045 | /// A signed-in person uses a workspace invite sent to their address. | |
| 1046 | pub async fn accept_invite(&self, a: AcceptInviteArgs) -> Result<Outcome<String>> { | |
| 1047 | if a.user.kind != PrincipalKind::User || a.user.acting.is_some() { | |
| 1048 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can accept an invite.")); | |
| 1049 | } | |
| 1050 | // Any of the person's confirmed addresses can match an invite bound | |
| 1051 | // to one (emails.rs); the primary otherwise. | |
| 1052 | let verified = self.verified_emails(&a.user.id).await?; | |
| 1053 | let Some(primary) = verified.first().cloned() else { | |
| 1054 | return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first, then open the invite again.")); | |
| 1055 | }; | |
| 1056 | let now = rfc3339(now_ms()); | |
| 1057 | let row = self.invite_by_code(&a.code).await?; | |
| 1058 | let email = row | |
| 1059 | .as_ref() | |
| 1060 | .and_then(|row| row.email.as_deref()) | |
| 1061 | .and_then(|bound| verified.iter().find(|address| address.eq_ignore_ascii_case(bound.trim())).cloned()) | |
| 1062 | .unwrap_or(primary); | |
| 1063 | let joins = row.as_ref().is_some_and(joins_workspace); | |
| 1064 | if let Err(refusal) = admits(row.as_ref().map(|row| row.admits(&now)).as_ref(), &email, false) { | |
| 1065 | return Ok(Outcome::fail( | |
| 1066 | FailureCode::Forbidden, | |
| 1067 | if refusal == Refusal::WrongEmail { WRONG_EMAIL } else { INVALID }, | |
| 1068 | )); | |
| 1069 | } | |
| 1070 | let Some(row) = row.filter(|_| joins) else { | |
| 1071 | return Ok(Outcome::fail( | |
| 1072 | FailureCode::Conflict, | |
| 1073 | "You already have a g1t account, so this invite has nothing more to give you. Pass it on to someone who needs it.", | |
| 1074 | )); | |
| 1075 | }; | |
| 1076 | // What the workspace asks of its members (security.rs); nothing yet. | |
| 1077 | if let Some(slug) = row.workspace.as_deref() | |
| 1078 | && let Some(why) = self.policy_refusal(&a.user.id, slug).await? | |
| 1079 | { | |
| 1080 | return Ok(Outcome::fail(FailureCode::Forbidden, why)); | |
| 1081 | } | |
| 1082 | let claimed = self | |
| 1083 | .db | |
| 1084 | .prepare(format!( | |
| 1085 | "UPDATE invites SET redeemed_by = ?, redeemed_at = {SQL_NOW}, sealed_code = NULL | |
| 1086 | WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL AND expires_at > {SQL_NOW} | |
| 1087 | RETURNING id" | |
| 1088 | )) | |
| 1089 | .bind(&[a.user.id.as_str().into(), row.id.as_str().into()])? | |
| 1090 | .first::<Id>(None) | |
| 1091 | .await?; | |
| 1092 | if claimed.is_none() { | |
| 1093 | return Ok(Outcome::fail(FailureCode::Forbidden, INVALID)); | |
| 1094 | } | |
| 1095 | let slug = row.workspace.clone().unwrap_or_default(); | |
| 1096 | self.after_redeemed(&row, &a.user, false).await?; | |
| 1097 | Ok(Outcome::Ok(slug)) | |
| 1098 | } | |
| 1099 | ||
| 1100 | // --- Workspace invitations --- | |
| 1101 | ||
| 1102 | pub async fn invite_member(&self, a: InviteMemberArgs) -> Result<Outcome<Invite>> { | |
| 1103 | let slug = a.slug.trim().to_lowercase(); | |
| 1104 | if let Some(reason) = Self::draft_allowed(&a.actor) { | |
| 1105 | return Ok(Outcome::fail(FailureCode::Forbidden, reason)); | |
| 1106 | } | |
| 1107 | if a.actor.role_in(&slug) != Some(Role::Owner) { | |
| 1108 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can invite people to a workspace.")); | |
| 1109 | } | |
| 1110 | let Some(email) = normalize_email(&a.email) else { | |
| 1111 | return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL)); | |
| 1112 | }; | |
| 1113 | let Some(workspace_id) = self.workspace_id(&slug).await? else { | |
| 1114 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); | |
| 1115 | }; | |
| 1116 | if !self.hit(&format!("invite.create:{}", a.actor.id), CREATES_PER_HOUR).await? { | |
| 1117 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); | |
| 1118 | } | |
| 1119 | let pending = self | |
| 1120 | .rows( | |
| 1121 | &format!( | |
| 1122 | "WHERE i.workspace_id = ? AND i.email = ? | |
| 1123 | AND i.redeemed_at IS NULL AND i.revoked_at IS NULL AND i.expires_at > {SQL_NOW}" | |
| 1124 | ), | |
| 1125 | &[workspace_id.as_str().into(), email.as_str().into()], | |
| 1126 | 1, | |
| 1127 | ) | |
| 1128 | .await?; | |
| 1129 | if !pending.is_empty() { | |
| 1130 | return Ok(Outcome::fail( | |
| 1131 | FailureCode::Conflict, | |
| 1132 | "There is already a pending invite for that address. Revoke it to send a new one.", | |
| 1133 | )); | |
| 1134 | } | |
| 1135 | let has_account = self.email_has_account(&email).await?; | |
| 1136 | let draft = if has_account { | |
| 1137 | // Costs nothing: the person is on g1t already. | |
| 1138 | Draft { | |
| 1139 | email: Some(&email), | |
| 1140 | kind: "workspace", | |
| 1141 | workspace_id: Some(&workspace_id), | |
| 1142 | inviter: Some(&a.actor), | |
| 1143 | staff: None, | |
| 1144 | charged_to: "none", | |
| 1145 | charged_workspace_id: None, | |
| 1146 | limit: None, | |
| 1147 | } | |
| 1148 | } else { | |
| 1149 | let shared = self.workspace_allowance(&workspace_id).await?; | |
| 1150 | let (charged_to, charged_workspace_id, limit) = if shared.remaining.is_some_and(|left| left > 0) { | |
| 1151 | ("workspace", Some(workspace_id.as_str()), shared.limit) | |
| 1152 | } else { | |
| 1153 | let own = self.user_allowance(&a.actor.id).await?; | |
| 1154 | if own.exhausted() { | |
| 1155 | return Ok(Self::out_of_invites()); | |
| 1156 | } | |
| 1157 | ("user", None, own.limit) | |
| 1158 | }; | |
| 1159 | Draft { | |
| 1160 | email: Some(&email), | |
| 1161 | kind: "account", | |
| 1162 | workspace_id: Some(&workspace_id), | |
| 1163 | inviter: Some(&a.actor), | |
| 1164 | staff: None, | |
| 1165 | charged_to, | |
| 1166 | charged_workspace_id, | |
| 1167 | limit, | |
| 1168 | } | |
| 1169 | }; | |
| 1170 | let Some(invite) = self.insert_invite(draft).await? else { | |
| 1171 | return Ok(Self::out_of_invites()); | |
| 1172 | }; | |
| 1173 | if let Some(code) = &invite.code { | |
| 1174 | self.send_invite_email(&email, Some(&a.actor.username), Some(&slug), has_account, code).await; | |
| 1175 | } | |
| 1176 | self.audit_invites( | |
| 1177 | &a.actor, | |
| 1178 | "invite.created", | |
| 1179 | vec![slug.clone()], | |
| 1180 | a.surface.unwrap_or(Surface::Web), | |
| 1181 | format!("Invited {email} to {slug}"), | |
| 1182 | ) | |
| 1183 | .await; | |
| 1184 | Ok(Outcome::Ok(invite)) | |
| 1185 | } | |
| 1186 | ||
| 1187 | /// An invite code for an address without an account, invited to | |
| 1188 | /// collaborate on one repository of `workspace_id` (access.rs). Charged | |
| 1189 | /// as a workspace invite is: the workspace's shared invites first, then | |
| 1190 | /// the inviter's own. The code joins no workspace; redeeming it accepts | |
| 1191 | /// the repository invitation that names it. | |
| 1192 | pub(crate) async fn repo_invite_code(&self, actor: &User, email: &str, workspace_id: &str) -> Result<Outcome<Invite>> { | |
| 1193 | if let Some(reason) = Self::draft_allowed(actor) { | |
| 1194 | return Ok(Outcome::fail(FailureCode::Forbidden, reason)); | |
| 1195 | } | |
| 1196 | if !self.hit(&format!("invite.create:{}", actor.id), CREATES_PER_HOUR).await? { | |
| 1197 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); | |
| 1198 | } | |
| 1199 | let shared = self.workspace_allowance(workspace_id).await?; | |
| 1200 | let (charged_to, charged_workspace_id, limit) = if shared.remaining.is_some_and(|left| left > 0) { | |
| 1201 | ("workspace", Some(workspace_id), shared.limit) | |
| 1202 | } else { | |
| 1203 | let own = self.user_allowance(&actor.id).await?; | |
| 1204 | if own.exhausted() { | |
| 1205 | return Ok(Self::out_of_invites()); | |
| 1206 | } | |
| 1207 | ("user", None, own.limit) | |
| 1208 | }; | |
| 1209 | let draft = Draft { | |
| 1210 | email: Some(email), | |
| 1211 | kind: "account", | |
| 1212 | workspace_id: None, | |
| 1213 | inviter: Some(actor), | |
| 1214 | staff: None, | |
| 1215 | charged_to, | |
| 1216 | charged_workspace_id, | |
| 1217 | limit, | |
| 1218 | }; | |
| 1219 | Ok(match self.insert_invite(draft).await? { | |
| 1220 | Some(invite) => Outcome::Ok(invite), | |
| 1221 | None => Self::out_of_invites(), | |
| 1222 | }) | |
| 1223 | } | |
| 1224 | ||
| 1225 | /// Revokes an invite code made for a repository invitation, when that | |
| 1226 | /// invitation is revoked. Only a pending code changes. | |
| 1227 | pub(crate) async fn revoke_code(&self, invite_id: &str) -> Result<()> { | |
| 1228 | self.db | |
| 1229 | .prepare(format!( | |
| 1230 | "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL | |
| 1231 | WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL" | |
| 1232 | )) | |
| 1233 | .bind(&[invite_id.into()])? | |
| 1234 | .run() | |
| 1235 | .await?; | |
| 1236 | Ok(()) | |
| 1237 | } | |
| 1238 | ||
| 1239 | pub async fn workspace_invites(&self, a: ListMembersArgs) -> Result<Outcome<Vec<Invite>>> { | |
| 1240 | let slug = a.slug.trim().to_lowercase(); | |
| 1241 | if !a.viewer.as_ref().is_some_and(|viewer| viewer.role_in(&slug) == Some(Role::Owner)) { | |
| 1242 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only owners can see a workspace's invites.")); | |
| 1243 | } | |
| 1244 | let Some(workspace_id) = self.workspace_id(&slug).await? else { | |
| 1245 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); | |
| 1246 | }; | |
| 1247 | let rows = self.rows("WHERE i.workspace_id = ?", &[workspace_id.as_str().into()], LIST_LIMIT).await?; | |
| 1248 | Ok(Outcome::Ok(rows.into_iter().map(|row| self.shown(row, true, false)).collect())) | |
| 1249 | } | |
| 1250 | ||
| 1251 | pub async fn revoke_workspace_invite(&self, a: WorkspaceInviteArgs) -> Result<Outcome<Invite>> { | |
| 1252 | let slug = a.slug.trim().to_lowercase(); | |
| 1253 | if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) { | |
| 1254 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can revoke a workspace's invites.")); | |
| 1255 | } | |
| 1256 | self.revoke_invite(RemoveArgs { user: a.actor, id: a.id }).await | |
| 1257 | } | |
| 1258 | ||
| 1259 | // --- The waitlist --- | |
| 1260 | ||
| 1261 | pub async fn request_access(&self, a: RequestAccessArgs) -> Result<Outcome<bool>> { | |
| 1262 | let Some(email) = normalize_email(&a.email) else { | |
| 1263 | return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL)); | |
| 1264 | }; | |
| 1265 | let allowed = match a.client.as_deref().filter(|client| !client.is_empty()) { | |
| 1266 | Some(client) => self.hit(&format!("waitlist:{}", crypto::sha256_hex(client)), REQUESTS_PER_HOUR).await?, | |
| 1267 | None => self.hit("waitlist:anonymous", ANONYMOUS_REQUESTS_PER_HOUR).await?, | |
| 1268 | }; | |
| 1269 | if !allowed { | |
| 1270 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); | |
| 1271 | } | |
| 1272 | let about: String = a.about.trim().chars().take(MAX_WAITLIST_ABOUT).collect(); | |
| 1273 | let now = rfc3339(now_ms()); | |
| 1274 | #[derive(Deserialize)] | |
| 1275 | struct Upserted { | |
| 1276 | id: String, | |
| 1277 | created_at: String, | |
| 1278 | } | |
| 1279 | let row = self | |
| 1280 | .db | |
| 1281 | .prepare( | |
| 1282 | "INSERT INTO waitlist (id, email, about, status, created_at, updated_at) | |
| 1283 | VALUES (?1, ?2, ?3, 'waiting', ?4, ?4) | |
| 1284 | ON CONFLICT (email) DO UPDATE SET | |
| 1285 | about = COALESCE(excluded.about, waitlist.about), updated_at = excluded.updated_at | |
| 1286 | RETURNING id, created_at", | |
| 1287 | ) | |
| 1288 | .bind(&[ | |
| 1289 | new_id("wl", now_ms()).into(), | |
| 1290 | email.as_str().into(), | |
| 1291 | if about.is_empty() { JsValue::NULL } else { about.as_str().into() }, | |
| 1292 | now.as_str().into(), | |
| 1293 | ])? | |
| 1294 | .first::<Upserted>(None) | |
| 1295 | .await?; | |
| 1296 | if let Some(row) = row.filter(|row| row.created_at == now) { | |
| 1297 | self.announce("waitlist.requested", None, WaitlistRequested { entry_id: row.id }).await; | |
| 1298 | } | |
| 1299 | Ok(Outcome::Ok(true)) | |
| 1300 | } | |
| 1301 | ||
| 1302 | // --- Staff --- | |
| 1303 | ||
| 1304 | pub async fn admin_waitlist(&self, a: AdminWaitlistArgs) -> Result<Vec<WaitlistEntry>> { | |
| 1305 | let mut filters = Vec::new(); | |
| 1306 | let mut binds: Vec<JsValue> = Vec::new(); | |
| 1307 | if let Some(status) = a.status { | |
| 1308 | filters.push("status = ?".to_owned()); | |
| 1309 | binds.push(status.as_str().into()); | |
| 1310 | } | |
| 1311 | if let Some(pattern) = crate::admin::like_pattern(a.query.as_deref()) { | |
| 1312 | filters.push("(email LIKE ? ESCAPE '\\' OR lower(about) LIKE ? ESCAPE '\\')".to_owned()); | |
| 1313 | binds.push(pattern.as_str().into()); | |
| 1314 | binds.push(pattern.as_str().into()); | |
| 1315 | } | |
| 1316 | let filter = if filters.is_empty() { String::new() } else { format!("WHERE {}", filters.join(" AND ")) }; | |
| 1317 | Ok(self | |
| 1318 | .db | |
| 1319 | .prepare(format!( | |
| 1320 | "SELECT id, email, about, status, invite_id, decided_by, decided_at, created_at, updated_at | |
| 1321 | FROM waitlist {filter} ORDER BY created_at, id LIMIT {ADMIN_INVITES_LIMIT}" | |
| 1322 | )) | |
| 1323 | .bind(&binds)? | |
| 1324 | .all() | |
| 1325 | .await? | |
| 1326 | .results::<WaitlistRow>()? | |
| 1327 | .into_iter() | |
| 1328 | .map(WaitlistEntry::from) | |
| 1329 | .collect()) | |
| 1330 | } | |
| 1331 | ||
| 1332 | async fn waitlist_entry(&self, id: &str) -> Result<Option<WaitlistRow>> { | |
| 1333 | self.db | |
| 1334 | .prepare( | |
| 1335 | "SELECT id, email, about, status, invite_id, decided_by, decided_at, created_at, updated_at | |
| 1336 | FROM waitlist WHERE id = ?", | |
| 1337 | ) | |
| 1338 | .bind(&[id.into()])? | |
| 1339 | .first::<WaitlistRow>(None) | |
| 1340 | .await | |
| 1341 | } | |
| 1342 | ||
| 1343 | pub async fn admin_decide_waitlist(&self, a: AdminDecideWaitlistArgs) -> Result<Outcome<WaitlistEntry>> { | |
| 1344 | let Some(entry) = self.waitlist_entry(&a.id).await? else { | |
| 1345 | return Ok(Outcome::fail(FailureCode::NotFound, "That request is not on the waitlist.")); | |
| 1346 | }; | |
| 1347 | let staff = a.staff.trim(); | |
| 1348 | if staff.is_empty() { | |
| 1349 | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member decided.")); | |
| 1350 | } | |
| 1351 | let mut invite_id = JsValue::NULL; | |
| 1352 | if a.approve { | |
| 1353 | if self.email_has_account(&entry.email).await? { | |
| 1354 | return Ok(Outcome::fail(FailureCode::Conflict, "That address already has a g1t account.")); | |
| 1355 | } | |
| 1356 | let minted = match self.admin_mint_invite(AdminMintInviteArgs { email: Some(entry.email.clone()), staff: staff.to_owned() }).await? { | |
| 1357 | Outcome::Ok(invite) => invite, | |
| 1358 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 1359 | }; | |
| 1360 | invite_id = minted.id.as_str().into(); | |
| 1361 | } | |
| 1362 | self.db | |
| 1363 | .prepare(format!( | |
| 1364 | "UPDATE waitlist SET status = ?, invite_id = COALESCE(?, invite_id), decided_by = ?, decided_at = {SQL_NOW} | |
| 1365 | WHERE id = ?" | |
| 1366 | )) | |
| 1367 | .bind(&[ | |
| 1368 | if a.approve { "invited" } else { "dismissed" }.into(), | |
| 1369 | invite_id, | |
| 1370 | staff.into(), | |
| 1371 | entry.id.as_str().into(), | |
| 1372 | ])? | |
| 1373 | .run() | |
| 1374 | .await?; | |
| 1375 | Ok(match self.waitlist_entry(&entry.id).await? { | |
| 1376 | Some(row) => Outcome::Ok(row.into()), | |
| 1377 | None => Outcome::fail(FailureCode::NotFound, "That request is not on the waitlist."), | |
| 1378 | }) | |
| 1379 | } | |
| 1380 | ||
| 1381 | pub async fn admin_invites(&self, a: AdminInvitesArgs) -> Result<Vec<Invite>> { | |
| 1382 | let query = a.query.as_deref().map(str::trim).filter(|query| !query.is_empty()); | |
| 1383 | let rows = match query { | |
| 1384 | None => self.rows("", &[], ADMIN_INVITES_LIMIT as u32).await?, | |
| 1385 | Some(query) => { | |
| 1386 | // A code, or its start: matched by its hint. | |
| 1387 | let prefix = query.to_lowercase(); | |
| 1388 | let prefix = prefix.strip_prefix("g1t-").unwrap_or(&prefix).replace('-', ""); | |
| 1389 | let hint = (prefix.len() >= GROUP && prefix.chars().all(|c| ALPHABET.contains(&(c as u8)))) | |
| 1390 | .then(|| code_hint(&prefix)); | |
| 1391 | let pattern = crate::admin::like_pattern(Some(query)).unwrap_or_default(); | |
| 1392 | let mut binds: Vec<JsValue> = vec![pattern.as_str().into(), pattern.as_str().into(), pattern.as_str().into()]; | |
| 1393 | let mut filter = "WHERE (lower(i.email) LIKE ? ESCAPE '\\' OR iu.username LIKE ? ESCAPE '\\' OR ru.username LIKE ? ESCAPE '\\'".to_owned(); | |
| 1394 | if let Some(hint) = hint { | |
| 1395 | filter.push_str(" OR i.hint = ?"); | |
| 1396 | binds.push(hint.into()); | |
| 1397 | } | |
| 1398 | filter.push(')'); | |
| 1399 | self.rows(&filter, &binds, ADMIN_INVITES_LIMIT as u32).await? | |
| 1400 | } | |
| 1401 | }; | |
| 1402 | Ok(rows.into_iter().map(|row| self.shown(row, false, true)).collect()) | |
| 1403 | } | |
| 1404 | ||
| 1405 | pub async fn admin_revoke_invite(&self, a: AdminRevokeInviteArgs) -> Result<Outcome<Invite>> { | |
| 1406 | let revoked = self | |
| 1407 | .db | |
| 1408 | .prepare(format!( | |
| 1409 | "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL | |
| 1410 | WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL RETURNING id" | |
| 1411 | )) | |
| 1412 | .bind(&[a.id.as_str().into()])? | |
| 1413 | .first::<Id>(None) | |
| 1414 | .await?; | |
| 1415 | if revoked.is_none() { | |
| 1416 | return Ok(Outcome::fail(FailureCode::Conflict, "Only a pending invite can be revoked.")); | |
| 1417 | } | |
| 1418 | worker::console_log!("invite {} revoked by staff {}", a.id, a.staff); | |
| 1419 | Ok(match self.invite_by_id(&a.id).await? { | |
| 1420 | Some(row) => Outcome::Ok(self.shown(row, false, true)), | |
| 1421 | None => Outcome::fail(FailureCode::NotFound, "Invite not found."), | |
| 1422 | }) | |
| 1423 | } | |
| 1424 | ||
| 1425 | pub async fn admin_mint_invite(&self, a: AdminMintInviteArgs) -> Result<Outcome<Invite>> { | |
| 1426 | let staff = a.staff.trim(); | |
| 1427 | if staff.is_empty() { | |
| 1428 | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is minting it.")); | |
| 1429 | } | |
| 1430 | let email = match a.email.as_deref().map(str::trim).filter(|email| !email.is_empty()) { | |
| 1431 | Some(email) => match normalize_email(email) { | |
| 1432 | Some(email) => Some(email), | |
| 1433 | None => return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL)), | |
| 1434 | }, | |
| 1435 | None => None, | |
| 1436 | }; | |
| 1437 | let draft = Draft { | |
| 1438 | email: email.as_deref(), | |
| 1439 | kind: "account", | |
| 1440 | workspace_id: None, | |
| 1441 | inviter: None, | |
| 1442 | staff: Some(staff), | |
| 1443 | charged_to: "none", | |
| 1444 | charged_workspace_id: None, | |
| 1445 | limit: None, | |
| 1446 | }; | |
| 1447 | let Some(mut invite) = self.insert_invite(draft).await? else { | |
| 1448 | return Ok(Outcome::fail(FailureCode::Conflict, "The invite could not be made. Try again.")); | |
| 1449 | }; | |
| 1450 | if let (Some(email), Some(code)) = (&email, &invite.code) { | |
| 1451 | self.send_invite_email(email, None, None, false, code).await; | |
| 1452 | } | |
| 1453 | invite.staff = Some(staff.to_owned()); | |
| 1454 | Ok(Outcome::Ok(invite)) | |
| 1455 | } | |
| 1456 | ||
| 1457 | pub async fn admin_grant_invites(&self, a: AdminGrantInvitesArgs) -> Result<Outcome<Allowance>> { | |
| 1458 | if a.amount == 0 || a.amount.abs() > MAX_INVITE_GRANT { | |
| 1459 | return Ok(Outcome::fail(FailureCode::Invalid, format!("Grant between 1 and {MAX_INVITE_GRANT} invites, or take some back with a negative number."))); | |
| 1460 | } | |
| 1461 | let staff = a.staff.trim(); | |
| 1462 | if staff.is_empty() { | |
| 1463 | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member granted them.")); | |
| 1464 | } | |
| 1465 | let name = a.name.trim().to_lowercase(); | |
| 1466 | let target_id = match a.target { | |
| 1467 | GrantTarget::User => self | |
| 1468 | .db | |
| 1469 | .prepare("SELECT id FROM users WHERE username = ?") | |
| 1470 | .bind(&[name.as_str().into()])? | |
| 1471 | .first::<Id>(None) | |
| 1472 | .await? | |
| 1473 | .map(|row| row.id), | |
| 1474 | GrantTarget::Workspace => self.workspace_id(&name).await?, | |
| 1475 | }; | |
| 1476 | let Some(target_id) = target_id else { | |
| 1477 | return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no {} named {name}.", a.target.as_str()))); | |
| 1478 | }; | |
| 1479 | let note = a.note.trim(); | |
| 1480 | self.db | |
| 1481 | .prepare( | |
| 1482 | "INSERT INTO invite_grants (id, target_kind, target_id, amount, note, granted_by, created_at) | |
| 1483 | VALUES (?, ?, ?, ?, ?, ?, ?)", | |
| 1484 | ) | |
| 1485 | .bind(&[ | |
| 1486 | new_id("igr", now_ms()).into(), | |
| 1487 | a.target.as_str().into(), | |
| 1488 | target_id.as_str().into(), | |
| 1489 | f64::from(a.amount).into(), | |
| 1490 | if note.is_empty() { JsValue::NULL } else { note.into() }, | |
| 1491 | staff.into(), | |
| 1492 | rfc3339(now_ms()).into(), | |
| 1493 | ])? | |
| 1494 | .run() | |
| 1495 | .await?; | |
| 1496 | Ok(Outcome::Ok(match a.target { | |
| 1497 | GrantTarget::User => self.user_allowance(&target_id).await?, | |
| 1498 | GrantTarget::Workspace => self.workspace_allowance(&target_id).await?, | |
| 1499 | })) | |
| 1500 | } | |
| 1501 | ||
| 1502 | async fn grants(&self, target: GrantTarget, id: &str) -> Result<Vec<InviteGrant>> { | |
| 1503 | #[derive(Deserialize)] | |
| 1504 | struct Row { | |
| 1505 | amount: f64, | |
| 1506 | note: Option<String>, | |
| 1507 | granted_by: String, | |
| 1508 | created_at: String, | |
| 1509 | } | |
| 1510 | Ok(self | |
| 1511 | .db | |
| 1512 | .prepare( | |
| 1513 | "SELECT amount, note, granted_by, created_at FROM invite_grants | |
| 1514 | WHERE target_kind = ? AND target_id = ? ORDER BY created_at DESC LIMIT 100", | |
| 1515 | ) | |
| 1516 | .bind(&[target.as_str().into(), id.into()])? | |
| 1517 | .all() | |
| 1518 | .await? | |
| 1519 | .results::<Row>()? | |
| 1520 | .into_iter() | |
| 1521 | .map(|row| InviteGrant { | |
| 1522 | amount: row.amount as i32, | |
| 1523 | note: row.note, | |
| 1524 | granted_by: row.granted_by, | |
| 1525 | created_at: row.created_at, | |
| 1526 | }) | |
| 1527 | .collect()) | |
| 1528 | } | |
| 1529 | ||
| 1530 | /// Whom `user_id` invited, `depth` levels down. | |
| 1531 | async fn invited_by_user(&self, user_id: &str, depth: usize) -> Result<Vec<InviteTreeNode>> { | |
| 1532 | #[derive(Deserialize)] | |
| 1533 | struct Row { | |
| 1534 | id: String, | |
| 1535 | username: String, | |
| 1536 | redeemed_at: String, | |
| 1537 | } | |
| 1538 | let rows = self | |
| 1539 | .db | |
| 1540 | .prepare( | |
| 1541 | "SELECT u.id, u.username, i.redeemed_at FROM invites i JOIN users u ON u.id = i.redeemed_by | |
| 1542 | WHERE i.inviter_id = ? AND i.kind = 'account' ORDER BY i.redeemed_at LIMIT 200", | |
| 1543 | ) | |
| 1544 | .bind(&[user_id.into()])? | |
| 1545 | .all() | |
| 1546 | .await? | |
| 1547 | .results::<Row>()?; | |
| 1548 | let mut nodes = Vec::with_capacity(rows.len()); | |
| 1549 | for row in rows { | |
| 1550 | let invited = if depth > 1 { Box::pin(self.invited_by_user(&row.id, depth - 1)).await? } else { Vec::new() }; | |
| 1551 | nodes.push(InviteTreeNode { | |
| 1552 | username: row.username, | |
| 1553 | joined_at: row.redeemed_at, | |
| 1554 | invited, | |
| 1555 | }); | |
| 1556 | } | |
| 1557 | Ok(nodes) | |
| 1558 | } | |
| 1559 | ||
| 1560 | pub async fn admin_invite_tree(&self, a: UsernameArgs) -> Result<Option<InviteTree>> { | |
| 1561 | let name = a.username.trim().to_lowercase(); | |
| 1562 | let Some(user) = self | |
| 1563 | .db | |
| 1564 | .prepare("SELECT id FROM users WHERE username = ?") | |
| 1565 | .bind(&[name.as_str().into()])? | |
| 1566 | .first::<Id>(None) | |
| 1567 | .await? | |
| 1568 | else { | |
| 1569 | return Ok(None); | |
| 1570 | }; | |
| 1571 | // Up the tree: who invited them, and who invited that person. | |
| 1572 | #[derive(Deserialize)] | |
| 1573 | struct Parent { | |
| 1574 | inviter_id: Option<String>, | |
| 1575 | inviter: Option<String>, | |
| 1576 | staff: Option<String>, | |
| 1577 | } | |
| 1578 | let mut invited_by = Vec::new(); | |
| 1579 | let mut staff = None; | |
| 1580 | let mut current = user.id.clone(); | |
| 1581 | for _ in 0..20 { | |
| 1582 | let parent = self | |
| 1583 | .db | |
| 1584 | .prepare( | |
| 1585 | "SELECT i.inviter_id, u.username AS inviter, i.staff FROM invites i | |
| 1586 | LEFT JOIN users u ON u.id = i.inviter_id | |
| 1587 | WHERE i.redeemed_by = ? AND i.kind = 'account' LIMIT 1", | |
| 1588 | ) | |
| 1589 | .bind(&[current.as_str().into()])? | |
| 1590 | .first::<Parent>(None) | |
| 1591 | .await?; | |
| 1592 | let Some(parent) = parent else { break }; | |
| 1593 | if invited_by.is_empty() { | |
| 1594 | staff = parent.staff.clone(); | |
| 1595 | } | |
| 1596 | match (parent.inviter_id, parent.inviter) { | |
| 1597 | (Some(id), Some(username)) if !invited_by.contains(&username) => { | |
| 1598 | invited_by.push(username); | |
| 1599 | current = id; | |
| 1600 | } | |
| 1601 | _ => break, | |
| 1602 | } | |
| 1603 | } | |
| 1604 | let invites = self | |
| 1605 | .rows("WHERE i.inviter_id = ?", &[user.id.as_str().into()], LIST_LIMIT) | |
| 1606 | .await? | |
| 1607 | .into_iter() | |
| 1608 | .map(|row| self.shown(row, false, true)) | |
| 1609 | .collect(); | |
| 1610 | Ok(Some(InviteTree { | |
| 1611 | username: name, | |
| 1612 | invited_by, | |
| 1613 | staff, | |
| 1614 | allowance: self.user_allowance(&user.id).await?, | |
| 1615 | grants: self.grants(GrantTarget::User, &user.id).await?, | |
| 1616 | invites, | |
| 1617 | invited: self.invited_by_user(&user.id, TREE_DEPTH).await?, | |
| 1618 | })) | |
| 1619 | } | |
| 1620 | ||
| 1621 | pub async fn admin_workspace_invites(&self, a: SlugArgs) -> Result<Option<InviteTree>> { | |
| 1622 | let slug = a.slug.trim().to_lowercase(); | |
| 1623 | let Some(id) = self.workspace_id(&slug).await? else { | |
| 1624 | return Ok(None); | |
| 1625 | }; | |
| 1626 | let invites = self | |
| 1627 | .rows("WHERE i.workspace_id = ?1 OR i.charged_workspace_id = ?1", &[id.as_str().into()], LIST_LIMIT) | |
| 1628 | .await? | |
| 1629 | .into_iter() | |
| 1630 | .map(|row| self.shown(row, false, true)) | |
| 1631 | .collect(); | |
| 1632 | Ok(Some(InviteTree { | |
| 1633 | username: slug, | |
| 1634 | invited_by: Vec::new(), | |
| 1635 | staff: None, | |
| 1636 | allowance: self.workspace_allowance(&id).await?, | |
| 1637 | grants: self.grants(GrantTarget::Workspace, &id).await?, | |
| 1638 | invites, | |
| 1639 | invited: Vec::new(), | |
| 1640 | })) | |
| 1641 | } | |
| 1642 | ||
| 1643 | // --- Audit --- | |
| 1644 | ||
| 1645 | async fn audit_invites(&self, actor: &User, action: &str, workspaces: Vec<String>, surface: Surface, message: String) { | |
| 1646 | let Ok(events) = self.env.service("EVENTS") else { | |
| 1647 | return; | |
| 1648 | }; | |
| 1649 | let entries: Vec<NewAuditEntry> = workspaces | |
| 1650 | .into_iter() | |
| 1651 | .map(|workspace| NewAuditEntry { | |
| 1652 | actor: AuditActor::of(actor), | |
| 1653 | action: action.to_owned(), | |
| 1654 | surface, | |
| 1655 | target: AuditTarget { | |
| 1656 | workspace, | |
| 1657 | ..AuditTarget::default() | |
| 1658 | }, | |
| 1659 | outcome: AuditOutcome::Allowed, | |
| 1660 | rule: "invite".to_owned(), | |
| 1661 | result: Some("ok".to_owned()), | |
| 1662 | message: Some(message.clone()), | |
| 1663 | request_id: new_id("req", now_ms()), | |
| 1664 | }) | |
| 1665 | .collect(); | |
| 1666 | if entries.is_empty() { | |
| 1667 | return; | |
| 1668 | } | |
| 1669 | let recorded: Result<u32> = g1t_kit::call(&events, "audit_record", &RecordAuditArgs { entries }).await; | |
| 1670 | if let Err(error) = recorded { | |
| 1671 | worker::console_error!("{action} not recorded: {error}"); | |
| 1672 | } | |
| 1673 | } | |
| 1674 | } | |
| 1675 | ||
| 1676 | /// Whether using the invite joins a workspace. | |
| 1677 | fn joins_workspace(row: &InviteRow) -> bool { | |
| 1678 | row.workspace_id.is_some() | |
| 1679 | } | |
| 1680 | ||
| 1681 | #[cfg(test)] | |
| 1682 | mod tests { | |
| 1683 | use super::*; | |
| 1684 | ||
| 1685 | #[test] | |
| 1686 | fn codes_carry_160_bits_in_eight_groups() { | |
| 1687 | assert_eq!(encode(&[0u8; 20]), "0".repeat(32)); | |
| 1688 | assert_eq!(encode(&[0xff; 20]), "z".repeat(32)); | |
| 1689 | let body = new_code_body(); | |
| 1690 | assert_eq!(body.len(), CODE_LENGTH); | |
| 1691 | assert!(body.bytes().all(|b| ALPHABET.contains(&b))); | |
| 1692 | let code = format_code(&body); | |
| 1693 | assert!(code.starts_with("g1t-")); | |
| 1694 | assert_eq!(code.split('-').count(), 9); | |
| 1695 | assert_eq!(code.len(), 4 + 32 + 7); | |
| 1696 | // Every bit is used: one bit set shows in exactly one character. | |
| 1697 | let mut bytes = [0u8; 20]; | |
| 1698 | bytes[19] = 1; | |
| 1699 | assert_eq!(encode(&bytes), format!("{}1", "0".repeat(31))); | |
| 1700 | } | |
| 1701 | ||
| 1702 | #[test] | |
| 1703 | fn codes_are_not_repeated() { | |
| 1704 | let codes: std::collections::HashSet<String> = (0..2000).map(|_| new_code_body()).collect(); | |
| 1705 | assert_eq!(codes.len(), 2000); | |
| 1706 | } | |
| 1707 | ||
| 1708 | #[test] | |
| 1709 | fn a_code_reads_however_it_is_typed_or_pasted() { | |
| 1710 | let body = "k7m2q9xd4hpwabcd0123456789efghjk"; | |
| 1711 | let shown = format_code(body); | |
| 1712 | for typed in [ | |
| 1713 | shown.clone(), | |
| 1714 | shown.to_uppercase(), | |
| 1715 | body.to_owned(), | |
| 1716 | format!(" {} ", shown.replace('-', " ")), | |
| 1717 | format!("https://g1t.sh/invite/{shown}"), | |
| 1718 | format!("https://g1t.sh/register?invite={shown}&next=/"), | |
| 1719 | ] { | |
| 1720 | assert_eq!(normalize_code(&typed).as_deref(), Some(body), "{typed}"); | |
| 1721 | } | |
| 1722 | // Letters people misread are read as Crockford reads them. | |
| 1723 | assert_eq!(normalize_code(&"o".repeat(32)), Some("0".repeat(32))); | |
| 1724 | assert_eq!(normalize_code(&"il".repeat(16)), Some("1".repeat(32))); | |
| 1725 | assert_eq!(normalize_code("g1t-k7m2"), None); | |
| 1726 | assert_eq!(normalize_code(&format!("{body}0")), None); | |
| 1727 | assert_eq!(normalize_code(&"u".repeat(32)), None); | |
| 1728 | assert_eq!(normalize_code(""), None); | |
| 1729 | } | |
| 1730 | ||
| 1731 | #[test] | |
| 1732 | fn only_the_hash_and_a_short_hint_are_kept() { | |
| 1733 | let body = "k7m2q9xd4hpwabcd0123456789efghjk"; | |
| 1734 | assert_eq!(code_hash(body), crypto::sha256_hex(body)); | |
| 1735 | assert_eq!(code_hash(body).len(), 64); | |
| 1736 | assert_ne!(code_hash(body), code_hash(&body.replace('k', "m"))); | |
| 1737 | assert_eq!(code_hint(body), "g1t-k7m2"); | |
| 1738 | // The same code typed differently finds the same row. | |
| 1739 | let typed = normalize_code(&format_code(body).to_uppercase()).unwrap(); | |
| 1740 | assert_eq!(code_hash(&typed), code_hash(body)); | |
| 1741 | } | |
| 1742 | ||
| 1743 | const NOW: &str = "2026-10-05T12:00:00.000Z"; | |
| 1744 | const LATER: &str = "2026-11-04T12:00:00.000Z"; | |
| 1745 | const EARLIER: &str = "2026-10-01T12:00:00.000Z"; | |
| 1746 | ||
| 1747 | #[test] | |
| 1748 | fn an_invite_is_pending_until_used_revoked_or_expired() { | |
| 1749 | assert_eq!(status_of(None, None, LATER, NOW), InviteStatus::Pending); | |
| 1750 | assert_eq!(status_of(None, None, EARLIER, NOW), InviteStatus::Expired); | |
| 1751 | assert_eq!(status_of(None, None, NOW, NOW), InviteStatus::Expired); | |
| 1752 | assert_eq!(status_of(Some(EARLIER), None, LATER, NOW), InviteStatus::Revoked); | |
| 1753 | assert_eq!(status_of(None, Some(EARLIER), EARLIER, NOW), InviteStatus::Redeemed); | |
| 1754 | } | |
| 1755 | ||
| 1756 | #[test] | |
| 1757 | fn revoked_and_expired_invites_give_the_allowance_back() { | |
| 1758 | assert!(counts_against_allowance(InviteStatus::Pending)); | |
| 1759 | assert!(counts_against_allowance(InviteStatus::Redeemed)); | |
| 1760 | assert!(!counts_against_allowance(InviteStatus::Revoked)); | |
| 1761 | assert!(!counts_against_allowance(InviteStatus::Expired)); | |
| 1762 | // The SQL says the same: used, or neither revoked nor expired. | |
| 1763 | let sql = counted_sql(); | |
| 1764 | assert!(sql.contains("i.redeemed_at IS NOT NULL OR (i.revoked_at IS NULL AND i.expires_at >")); | |
| 1765 | } | |
| 1766 | ||
| 1767 | #[test] | |
| 1768 | fn allowances_are_five_plus_grants_or_unlimited_for_staff() { | |
| 1769 | assert_eq!(limit_for(INVITES_PER_USER, 0, false), Some(5)); | |
| 1770 | assert_eq!(limit_for(5, 10, false), Some(15)); | |
| 1771 | assert_eq!(limit_for(5, -3, false), Some(2)); | |
| 1772 | assert_eq!(limit_for(5, -30, false), Some(0)); | |
| 1773 | assert_eq!(limit_for(5, 0, true), None); | |
| 1774 | // A workspace has only what staff granted it. | |
| 1775 | assert_eq!(limit_for(0, 0, false), Some(0)); | |
| 1776 | assert_eq!(limit_for(0, 25, false), Some(25)); | |
| 1777 | let full = Allowance::new(Some(5), 5); | |
| 1778 | assert!(full.exhausted()); | |
| 1779 | assert_eq!(full.remaining, Some(0)); | |
| 1780 | let over = Allowance::new(Some(2), 4); | |
| 1781 | assert_eq!(over.remaining, Some(0)); | |
| 1782 | let open = Allowance::new(None, 400); | |
| 1783 | assert!(!open.exhausted()); | |
| 1784 | assert_eq!(open.remaining, None); | |
| 1785 | assert_eq!(Allowance::new(Some(5), 3).remaining, Some(2)); | |
| 1786 | } | |
| 1787 | ||
| 1788 | fn invite(kind: &'static str, email: Option<&'static str>, status: InviteStatus) -> Admits<'static> { | |
| 1789 | Admits { kind, email, status } | |
| 1790 | } | |
| 1791 | ||
| 1792 | #[test] | |
| 1793 | fn an_invite_admits_only_its_address_while_pending() { | |
| 1794 | let open = invite("account", None, InviteStatus::Pending); | |
| 1795 | assert_eq!(admits(Some(&open), "anyone@example.com", true), Ok(())); | |
| 1796 | let bound = invite("account", Some("ada@example.com"), InviteStatus::Pending); | |
| 1797 | assert_eq!(admits(Some(&bound), "ada@example.com", true), Ok(())); | |
| 1798 | assert_eq!(admits(Some(&bound), " ADA@Example.com ", true), Ok(())); | |
| 1799 | assert_eq!(admits(Some(&bound), "eve@example.com", true), Err(Refusal::WrongEmail)); | |
| 1800 | for status in [InviteStatus::Redeemed, InviteStatus::Revoked, InviteStatus::Expired] { | |
| 1801 | assert_eq!(admits(Some(&invite("account", None, status)), "a@example.com", true), Err(Refusal::Invalid)); | |
| 1802 | // A dead code says nothing about whom it was for. | |
| 1803 | assert_eq!( | |
| 1804 | admits(Some(&invite("account", Some("ada@example.com"), status)), "eve@example.com", true), | |
| 1805 | Err(Refusal::Invalid) | |
| 1806 | ); | |
| 1807 | } | |
| 1808 | assert_eq!(admits(None, "a@example.com", true), Err(Refusal::Invalid)); | |
| 1809 | } | |
| 1810 | ||
| 1811 | #[test] | |
| 1812 | fn a_workspace_invite_never_makes_an_account() { | |
| 1813 | let join = invite("workspace", Some("ada@example.com"), InviteStatus::Pending); | |
| 1814 | assert_eq!(admits(Some(&join), "ada@example.com", true), Err(Refusal::Invalid)); | |
| 1815 | assert_eq!(admits(Some(&join), "ada@example.com", false), Ok(())); | |
| 1816 | assert_eq!(admits(Some(&join), "eve@example.com", false), Err(Refusal::WrongEmail)); | |
| 1817 | // An account invite for a workspace can be accepted by the address | |
| 1818 | // once it has an account. | |
| 1819 | let account = invite("account", Some("ada@example.com"), InviteStatus::Pending); | |
| 1820 | assert_eq!(admits(Some(&account), "ada@example.com", false), Ok(())); | |
| 1821 | } | |
| 1822 | ||
| 1823 | #[test] | |
| 1824 | fn addresses_are_checked_and_masked() { | |
| 1825 | assert_eq!(normalize_email(" Ada@Example.COM ").as_deref(), Some("ada@example.com")); | |
| 1826 | for bad in ["", "ada", "ada@", "@example.com", "ada@example", "a b@example.com", "ada@.com", "ada@example.", "a@b@c.com"] { | |
| 1827 | assert_eq!(normalize_email(bad), None, "{bad}"); | |
| 1828 | } | |
| 1829 | assert_eq!(mask_email("ada@example.com"), "a•••@example.com"); | |
| 1830 | assert_eq!(mask_email("x@example.com"), "x•••@example.com"); | |
| 1831 | } | |
| 1832 | ||
| 1833 | #[test] | |
| 1834 | fn rate_limits_count_in_hour_long_windows() { | |
| 1835 | assert_eq!(bucket(0, HOUR_MS), 0); | |
| 1836 | assert_eq!(bucket(HOUR_MS - 1, HOUR_MS), 0); | |
| 1837 | assert_eq!(bucket(HOUR_MS, HOUR_MS), 1); | |
| 1838 | // The limits stop guessing long before a code could be found, and | |
| 1839 | // leave room for people who mistype. | |
| 1840 | assert!((5..=100).contains(&FAILURES_PER_HOUR)); | |
| 1841 | const { assert!(CREATES_PER_HOUR >= INVITES_PER_USER) }; | |
| 1842 | const { assert!(REQUESTS_PER_HOUR >= 1) }; | |
| 1843 | } | |
| 1844 | ||
| 1845 | #[test] | |
| 1846 | fn registration_is_invite_only_unless_opened() { | |
| 1847 | assert_eq!(RegistrationMode::parse(None), RegistrationMode::Invite); | |
| 1848 | assert_eq!(RegistrationMode::parse(Some("invite")), RegistrationMode::Invite); | |
| 1849 | assert_eq!(RegistrationMode::parse(Some("")), RegistrationMode::Invite); | |
| 1850 | assert_eq!(RegistrationMode::parse(Some("opne")), RegistrationMode::Invite); | |
| 1851 | assert_eq!(RegistrationMode::parse(Some(" Open ")), RegistrationMode::Open); | |
| 1852 | } | |
| 1853 | } |