| 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, while g1t is |
| 22 | //! invite-only, costs one only when the address has no account (the |
| 23 | //! invitation then lets it make one), so the answer never says which. |
| 24 | //! Once registration is open it costs nothing. |
| 25 | //! |
| 26 | //! A code may instead be a shared invite link's, which staff hand to a |
| 27 | //! group: it makes up to a set number of accounts, each its own, and is |
| 28 | //! checked and spent here the same way (shared_invites.rs). |
| 29 | //! |
| 30 | //! Vars: REGISTRATION_MODE (`invite` | `open`), INVITES_PER_USER, |
| 31 | //! INVITE_TTL_DAYS, INVITE_STAFF_WORKSPACES (comma separated slugs). |
| 32 | |
| 33 | use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface}; |
| 34 | use g1t_contracts::events::{InviteCreated, InviteRedeemed, JoinedHow, WaitlistRequested}; |
| 35 | use g1t_contracts::identity::*; |
| 36 | use g1t_contracts::time::{SQL_NOW, rfc3339}; |
| 37 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id}; |
| 38 | use g1t_kit::now_ms; |
| 39 | use g1t_secrets::Sealer; |
| 40 | use serde::Deserialize; |
| 41 | use worker::Result; |
| 42 | use worker::wasm_bindgen::JsValue; |
| 43 | |
| 44 | use crate::shared_invites::{SharedAdmits, shared_admits, wrong_domain}; |
| 45 | use crate::{Identity, crypto}; |
| 46 | |
| 47 | mod invitations; |
| 48 | |
| 49 | /// Crockford base32, as ids use: no i, l, o or u. |
| 50 | const ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz"; |
| 51 | /// 32 characters of 5 bits: 160 random bits. |
| 52 | const CODE_LENGTH: usize = 32; |
| 53 | const GROUP: usize = 4; |
| 54 | |
| 55 | pub const INVALID: &str = |
| 56 | "That invite code is not valid. It may have been used, revoked or expired; ask whoever invited you for a new one."; |
| 57 | pub const WRONG_EMAIL: &str = "This invite is for a different email address. Use the address it was sent to."; |
| 58 | pub const MISSING: &str = "g1t is invite-only for now. Enter your invite code, or request access."; |
| 59 | const TOO_MANY: &str = "Too many attempts. Try again in an hour."; |
| 60 | const PEOPLE_ONLY: &str = "Only a person can make invites, not an agent or a workspace's token."; |
| 61 | const CONFIRM_FIRST: &str = "Confirm your email address before inviting anyone."; |
| 62 | const BAD_EMAIL: &str = "Enter a valid email address."; |
| 63 | |
| 64 | const HOUR_MS: u64 = 60 * 60 * 1000; |
| 65 | /// Invites one person may make in an hour, whatever their allowance. |
| 66 | const CREATES_PER_HOUR: u32 = 20; |
| 67 | /// Wrong codes one client may try in an hour before being turned away. |
| 68 | const FAILURES_PER_HOUR: u32 = 20; |
| 69 | /// Access requests from one client in an hour. |
| 70 | const REQUESTS_PER_HOUR: u32 = 5; |
| 71 | /// Access requests from clients that sent no address, together, in an hour. |
| 72 | const ANONYMOUS_REQUESTS_PER_HOUR: u32 = 200; |
| 73 | /// Confirmations of access requests, to everyone together, in an hour. |
| 74 | const CONFIRMATIONS_PER_HOUR: u32 = 300; |
| 75 | /// The least time between two summaries of new requests to staff. |
| 76 | const SUMMARY_EVERY_MS: u64 = 15 * 60 * 1000; |
| 77 | /// The most invites a person's or workspace's list shows. |
| 78 | const LIST_LIMIT: u32 = 200; |
| 79 | /// How far down the invite tree staff see. |
| 80 | const TREE_DEPTH: usize = 3; |
| 81 | |
| 82 | // --- Codes ------------------------------------------------------------------ |
| 83 | |
| 84 | /// The 32 characters of a code from 20 random bytes. |
| 85 | fn encode(bytes: &[u8; 20]) -> String { |
| 86 | let mut out = String::with_capacity(CODE_LENGTH); |
| 87 | let (mut buffer, mut bits) = (0u32, 0u32); |
| 88 | for &byte in bytes { |
| 89 | buffer = (buffer << 8) | u32::from(byte); |
| 90 | bits += 8; |
| 91 | while bits >= 5 { |
| 92 | bits -= 5; |
| 93 | out.push(ALPHABET[((buffer >> bits) & 31) as usize] as char); |
| 94 | } |
| 95 | buffer &= (1 << bits) - 1; |
| 96 | } |
| 97 | out |
| 98 | } |
| 99 | |
| 100 | /// A new code's 32 characters. |
| 101 | pub fn new_code_body() -> String { |
| 102 | let mut bytes = [0u8; 20]; |
| 103 | getrandom::getrandom(&mut bytes).expect("no source of randomness"); |
| 104 | encode(&bytes) |
| 105 | } |
| 106 | |
| 107 | /// How a code is shown: `g1t-` and groups of four. |
| 108 | pub fn format_code(body: &str) -> String { |
| 109 | let groups: Vec<&str> = body |
| 110 | .as_bytes() |
| 111 | .chunks(GROUP) |
| 112 | .map(|chunk| std::str::from_utf8(chunk).unwrap_or_default()) |
| 113 | .collect(); |
| 114 | format!("g1t-{}", groups.join("-")) |
| 115 | } |
| 116 | |
| 117 | /// A code's 32 characters from however it was typed or pasted: any case, |
| 118 | /// with or without `g1t-`, hyphens or spaces, or a whole invite link. |
| 119 | /// Letters easily misread are read as Crockford reads them. |
| 120 | pub fn normalize_code(input: &str) -> Option<String> { |
| 121 | let mut text = input.trim().to_ascii_lowercase(); |
| 122 | // A pasted link: the last path segment, or the `invite` parameter. |
| 123 | if let Some(at) = text.find("invite=") { |
| 124 | text = text[at + "invite=".len()..].split('&').next().unwrap_or_default().to_owned(); |
| 125 | } else if let Some(at) = text.rfind('/') { |
| 126 | text = text[at + 1..].to_owned(); |
| 127 | } |
| 128 | let text = text.strip_prefix("g1t").unwrap_or(&text); |
| 129 | let mut body = String::with_capacity(CODE_LENGTH); |
| 130 | for c in text.chars() { |
| 131 | let c = match c { |
| 132 | '-' | ' ' | '_' => continue, |
| 133 | 'i' | 'l' => '1', |
| 134 | 'o' => '0', |
| 135 | c if ALPHABET.contains(&(c as u8)) && c.is_ascii() => c, |
| 136 | _ => return None, |
| 137 | }; |
| 138 | body.push(c); |
| 139 | } |
| 140 | (body.len() == CODE_LENGTH).then_some(body) |
| 141 | } |
| 142 | |
| 143 | /// What is stored to find a code. |
| 144 | pub fn code_hash(body: &str) -> String { |
| 145 | crypto::sha256_hex(body) |
| 146 | } |
| 147 | |
| 148 | /// The code's first group, kept to recognise it: 20 of its 160 bits. |
| 149 | pub fn code_hint(body: &str) -> String { |
| 150 | format!("g1t-{}", &body[..GROUP]) |
| 151 | } |
| 152 | |
| 153 | // --- Rules -------------------------------------------------------------------- |
| 154 | |
| 155 | /// Where an invite stands at `now`, from its row. A used invite whose |
| 156 | /// account has not confirmed its address yet is awaiting confirmation |
| 157 | /// (`applied_at` is null); revoking it then stops it joining anything. |
| 158 | pub fn status_of( |
| 159 | revoked_at: Option<&str>, |
| 160 | redeemed_at: Option<&str>, |
| 161 | applied_at: Option<&str>, |
| 162 | expires_at: &str, |
| 163 | now: &str, |
| 164 | ) -> InviteStatus { |
| 165 | if redeemed_at.is_some() { |
| 166 | if revoked_at.is_some() { |
| 167 | InviteStatus::Revoked |
| 168 | } else if applied_at.is_some() { |
| 169 | InviteStatus::Redeemed |
| 170 | } else { |
| 171 | InviteStatus::AwaitingConfirmation |
| 172 | } |
| 173 | } else if revoked_at.is_some() { |
| 174 | InviteStatus::Revoked |
| 175 | } else if expires_at <= now { |
| 176 | InviteStatus::Expired |
| 177 | } else { |
| 178 | InviteStatus::Pending |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | /// Whether an invitation can be sent again: only while it waits to be |
| 183 | /// used. One whose account is confirming its address or answering already |
| 184 | /// has what it needs; the rest are over. |
| 185 | pub fn resendable(status: InviteStatus) -> bool { |
| 186 | status == InviteStatus::Pending |
| 187 | } |
| 188 | |
| 189 | /// The note an inviter wrote, as the email quotes it: trimmed and cut to |
| 190 | /// [`MAX_INVITE_MESSAGE`] characters; none when blank. |
| 191 | pub fn invite_note(message: Option<&str>) -> Option<String> { |
| 192 | let note: String = message?.trim().chars().take(MAX_INVITE_MESSAGE).collect(); |
| 193 | (!note.is_empty()).then_some(note) |
| 194 | } |
| 195 | |
| 196 | /// Where an invite stands once the workspace invitation in it is counted: |
| 197 | /// `base` from [`status_of`]. A declined one is declined; an account |
| 198 | /// invite whose account is confirmed but has not answered the workspace it |
| 199 | /// names (`names_workspace`: one that still exists) awaits that answer |
| 200 | /// until it expires. |
| 201 | pub fn answered_status( |
| 202 | base: InviteStatus, |
| 203 | kind: &str, |
| 204 | names_workspace: bool, |
| 205 | accepted_at: Option<&str>, |
| 206 | declined_at: Option<&str>, |
| 207 | expires_at: &str, |
| 208 | now: &str, |
| 209 | ) -> InviteStatus { |
| 210 | if declined_at.is_some() && base != InviteStatus::Revoked { |
| 211 | return InviteStatus::Declined; |
| 212 | } |
| 213 | if base == InviteStatus::Redeemed && kind == "account" && names_workspace && accepted_at.is_none() { |
| 214 | return if expires_at <= now { InviteStatus::Expired } else { InviteStatus::AwaitingAnswer }; |
| 215 | } |
| 216 | base |
| 217 | } |
| 218 | |
| 219 | /// Whether an invite in this state uses up one of an allowance: pending |
| 220 | /// and used ones do; a revoked or expired one never used gives it back. |
| 221 | #[cfg(test)] |
| 222 | pub fn counts_against_allowance(status: InviteStatus) -> bool { |
| 223 | matches!( |
| 224 | status, |
| 225 | InviteStatus::Pending | InviteStatus::AwaitingConfirmation | InviteStatus::AwaitingAnswer | InviteStatus::Redeemed |
| 226 | ) |
| 227 | } |
| 228 | |
| 229 | /// The SQL condition that matches [`counts_against_allowance`] for rows of |
| 230 | /// `invites` aliased `i`. |
| 231 | fn counted_sql() -> String { |
| 232 | format!("(i.redeemed_at IS NOT NULL OR (i.revoked_at IS NULL AND i.expires_at > {SQL_NOW}))") |
| 233 | } |
| 234 | |
| 235 | /// How many invites someone may have out: the default plus staff grants, |
| 236 | /// never below zero; None for no limit. |
| 237 | pub fn limit_for(default: u32, granted: i64, unlimited: bool) -> Option<u32> { |
| 238 | if unlimited { |
| 239 | return None; |
| 240 | } |
| 241 | Some((i64::from(default) + granted).clamp(0, i64::from(u32::MAX)) as u32) |
| 242 | } |
| 243 | |
| 244 | /// Why an invite cannot make an account. |
| 245 | #[derive(Debug, PartialEq, Eq)] |
| 246 | pub enum Refusal { |
| 247 | /// Unknown, used, revoked, expired, or not for making accounts. One |
| 248 | /// answer for all, so codes cannot be probed. |
| 249 | Invalid, |
| 250 | /// It is bound to another address. |
| 251 | WrongEmail, |
| 252 | /// A shared invite link limited to email domains the address is not |
| 253 | /// at (shared_invites.rs). |
| 254 | WrongDomain, |
| 255 | } |
| 256 | |
| 257 | /// The parts of an invite that decide whether it admits someone. |
| 258 | #[derive(Debug)] |
| 259 | pub struct Admits<'a> { |
| 260 | pub kind: &'a str, |
| 261 | pub email: Option<&'a str>, |
| 262 | pub status: InviteStatus, |
| 263 | } |
| 264 | |
| 265 | /// Whether an invite lets `email` make an account (`for_account`) or join |
| 266 | /// its workspace with an existing one. |
| 267 | pub fn admits(invite: Option<&Admits>, email: &str, for_account: bool) -> std::result::Result<(), Refusal> { |
| 268 | let Some(invite) = invite else { |
| 269 | return Err(Refusal::Invalid); |
| 270 | }; |
| 271 | if invite.status != InviteStatus::Pending || (for_account && invite.kind != "account") { |
| 272 | return Err(Refusal::Invalid); |
| 273 | } |
| 274 | match invite.email { |
| 275 | Some(bound) if !bound.eq_ignore_ascii_case(email.trim()) => Err(Refusal::WrongEmail), |
| 276 | _ => Ok(()), |
| 277 | } |
| 278 | } |
| 279 | |
| 280 | /// The proof for an invite's email link, or None when there is none to |
| 281 | /// make: no key (a development setup), or no address the invite is bound |
| 282 | /// to. See [`crypto::invite_proof`]. |
| 283 | pub fn email_proof(key: &[u8], invite_id: &str, bound: Option<&str>) -> Option<String> { |
| 284 | let bound = bound.map(str::trim).filter(|bound| !bound.is_empty())?; |
| 285 | (!key.is_empty()).then(|| crypto::invite_proof(key, invite_id, bound)) |
| 286 | } |
| 287 | |
| 288 | /// Whether `proof` shows that whoever brings it followed the invite's own |
| 289 | /// email: it is the proof for this invite and the address it is bound to, |
| 290 | /// and `email`, the address the account is made with, is that address. |
| 291 | /// Anything else (no proof, a wrong or altered one, another invite's, an |
| 292 | /// invite bound to no address, a different address) proves nothing, and |
| 293 | /// the address is confirmed as any other is. |
| 294 | pub fn proves_email(key: &[u8], invite_id: &str, bound: Option<&str>, email: &str, proof: Option<&str>) -> bool { |
| 295 | let (Some(expected), Some(proof)) = (email_proof(key, invite_id, bound), proof.map(str::trim)) else { |
| 296 | return false; |
| 297 | }; |
| 298 | let same_address = bound.is_some_and(|bound| bound.trim().to_lowercase() == email.trim().to_lowercase()); |
| 299 | same_address && crypto::same(&expected, &proof.to_ascii_lowercase()) |
| 300 | } |
| 301 | |
| 302 | /// Whether a new account starts with its address confirmed: GitHub |
| 303 | /// confirmed it (`verified`), or `invite`, the one-person invite that |
| 304 | /// admitted it, was followed from its own email with `proof` and `email` is |
| 305 | /// the address it was sent to. A shared link, a code typed in or passed on, |
| 306 | /// or an invite bound to no address: confirmed as any other is. |
| 307 | pub fn starts_confirmed(key: &[u8], verified: bool, invite: Option<&InviteRow>, email: &str, proof: Option<&str>) -> bool { |
| 308 | verified |
| 309 | || invite.is_some_and(|row| row.kind == "account" && proves_email(key, &row.id, row.email.as_deref(), email, proof)) |
| 310 | } |
| 311 | |
| 312 | /// What an invite used to sign up does once its account confirms its |
| 313 | /// address. |
| 314 | #[derive(Clone, Debug, PartialEq, Eq)] |
| 315 | pub enum AwaitingJoin { |
| 316 | /// It invites the account to this workspace: a workspace invitation |
| 317 | /// now waits for its answer. Nothing is joined without one. |
| 318 | Invited { workspace_id: String, slug: String }, |
| 319 | /// It names no workspace; repository invitations sent with it are |
| 320 | /// accepted. |
| 321 | Nothing, |
| 322 | /// It no longer applies, and why, as the person is told. |
| 323 | Lapsed(String), |
| 324 | } |
| 325 | |
| 326 | /// [`AwaitingJoin`] for an invite's row at `now`. `row.workspace` is the |
| 327 | /// workspace's slug, None once it was deleted. A workspace on the free |
| 328 | /// plan still invites: accepting waits until it starts the plan (paid.rs). |
| 329 | pub fn awaiting_join(row: &InviteRow, now: &str) -> AwaitingJoin { |
| 330 | let what = match &row.workspace { |
| 331 | Some(slug) => format!("no longer invites you to {slug}"), |
| 332 | None => "no longer applies".to_owned(), |
| 333 | }; |
| 334 | if row.revoked_at.is_some() { |
| 335 | return AwaitingJoin::Lapsed(format!( |
| 336 | "Your email address is confirmed. The invite you signed up with was revoked while you were confirming it, so it {what}." |
| 337 | )); |
| 338 | } |
| 339 | if row.expires_at.as_str() <= now { |
| 340 | return AwaitingJoin::Lapsed(format!( |
| 341 | "Your email address is confirmed. The invite you signed up with expired before you confirmed it, so it {what}. Ask whoever invited you to invite you again." |
| 342 | )); |
| 343 | } |
| 344 | match (&row.workspace_id, &row.workspace) { |
| 345 | (None, _) => AwaitingJoin::Nothing, |
| 346 | (Some(_), None) => AwaitingJoin::Lapsed( |
| 347 | "Your email address is confirmed. The workspace your invite was for has been deleted, so the invite no longer applies.".to_owned(), |
| 348 | ), |
| 349 | (Some(workspace_id), Some(slug)) => AwaitingJoin::Invited { workspace_id: workspace_id.clone(), slug: slug.clone() }, |
| 350 | } |
| 351 | } |
| 352 | |
| 353 | /// A trimmed, lowercased address, if it looks like one. |
| 354 | pub fn normalize_email(email: &str) -> Option<String> { |
| 355 | let email = email.trim().to_lowercase(); |
| 356 | let well_formed = email.len() <= 254 |
| 357 | && email |
| 358 | .split_once('@') |
| 359 | .is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.') && !domain.contains('@')) |
| 360 | && !email.contains(char::is_whitespace); |
| 361 | well_formed.then_some(email) |
| 362 | } |
| 363 | |
| 364 | /// An address with most of its local part hidden: `a•••@example.com`. |
| 365 | pub fn mask_email(email: &str) -> String { |
| 366 | match email.split_once('@') { |
| 367 | Some((local, domain)) => { |
| 368 | let first: String = local.chars().take(1).collect(); |
| 369 | format!("{first}•••@{domain}") |
| 370 | } |
| 371 | None => "•••".to_owned(), |
| 372 | } |
| 373 | } |
| 374 | |
| 375 | /// The fixed window a moment falls in. |
| 376 | pub fn bucket(now_ms: u64, window_ms: u64) -> u64 { |
| 377 | now_ms / window_ms |
| 378 | } |
| 379 | |
| 380 | /// Whether staff may be sent a summary of new requests: none was sent yet, |
| 381 | /// or the last went before `since` (15 minutes ago). RFC 3339 times. |
| 382 | pub fn summary_due(last: Option<&str>, since: &str) -> bool { |
| 383 | last.is_none_or(|last| last <= since) |
| 384 | } |
| 385 | |
| 386 | // --- Rows --------------------------------------------------------------------- |
| 387 | |
| 388 | const COLUMNS: &str = "i.id, i.hint, i.sealed_code, i.email, i.kind, i.workspace_id, w.slug AS workspace, |
| 389 | i.inviter_id, iu.username AS inviter, i.staff, i.charged_to, i.charged_workspace_id, i.created_at, i.expires_at, |
| 390 | i.revoked_at, i.redeemed_by, ru.username AS redeemer, i.redeemed_at, i.applied_at, |
| 391 | i.invitee_id, vu.username AS invitee, i.role, i.accepted_at, i.declined_at |
| 392 | FROM invites i |
| 393 | LEFT JOIN workspaces w ON w.id = i.workspace_id AND w.deleted_at IS NULL |
| 394 | LEFT JOIN users iu ON iu.id = i.inviter_id |
| 395 | LEFT JOIN users ru ON ru.id = i.redeemed_by |
| 396 | LEFT JOIN users vu ON vu.id = i.invitee_id"; |
| 397 | |
| 398 | #[derive(Debug, Deserialize)] |
| 399 | pub struct InviteRow { |
| 400 | pub id: String, |
| 401 | pub hint: String, |
| 402 | pub sealed_code: Option<String>, |
| 403 | pub email: Option<String>, |
| 404 | pub kind: String, |
| 405 | pub workspace_id: Option<String>, |
| 406 | pub workspace: Option<String>, |
| 407 | pub inviter_id: Option<String>, |
| 408 | pub inviter: Option<String>, |
| 409 | pub staff: Option<String>, |
| 410 | pub charged_to: String, |
| 411 | pub created_at: String, |
| 412 | pub expires_at: String, |
| 413 | pub revoked_at: Option<String>, |
| 414 | pub redeemer: Option<String>, |
| 415 | pub redeemed_at: Option<String>, |
| 416 | #[serde(default)] |
| 417 | pub applied_at: Option<String>, |
| 418 | /// The account a workspace invitation is for (invitations.rs). |
| 419 | #[serde(default)] |
| 420 | pub invitee_id: Option<String>, |
| 421 | #[serde(default)] |
| 422 | pub invitee: Option<String>, |
| 423 | /// `owner` or `member`; null is member. |
| 424 | #[serde(default)] |
| 425 | pub role: Option<String>, |
| 426 | #[serde(default)] |
| 427 | pub accepted_at: Option<String>, |
| 428 | #[serde(default)] |
| 429 | pub declined_at: Option<String>, |
| 430 | } |
| 431 | |
| 432 | impl InviteRow { |
| 433 | pub fn status(&self, now: &str) -> InviteStatus { |
| 434 | answered_status( |
| 435 | status_of( |
| 436 | self.revoked_at.as_deref(), |
| 437 | self.redeemed_at.as_deref(), |
| 438 | self.applied_at.as_deref(), |
| 439 | &self.expires_at, |
| 440 | now, |
| 441 | ), |
| 442 | &self.kind, |
| 443 | self.workspace.is_some(), |
| 444 | self.accepted_at.as_deref(), |
| 445 | self.declined_at.as_deref(), |
| 446 | &self.expires_at, |
| 447 | now, |
| 448 | ) |
| 449 | } |
| 450 | |
| 451 | /// The role accepting it joins with. |
| 452 | pub fn joins_as(&self) -> Role { |
| 453 | if self.role.as_deref() == Some("owner") { Role::Owner } else { Role::Member } |
| 454 | } |
| 455 | |
| 456 | fn admits(&self, now: &str) -> Admits<'_> { |
| 457 | Admits { |
| 458 | kind: &self.kind, |
| 459 | email: self.email.as_deref(), |
| 460 | status: self.status(now), |
| 461 | } |
| 462 | } |
| 463 | } |
| 464 | |
| 465 | fn kind_of(kind: &str) -> InviteKind { |
| 466 | if kind == "workspace" { InviteKind::Workspace } else { InviteKind::Account } |
| 467 | } |
| 468 | |
| 469 | fn charge_of(charged_to: &str) -> InviteCharge { |
| 470 | match charged_to { |
| 471 | "user" => InviteCharge::User, |
| 472 | "workspace" => InviteCharge::Workspace, |
| 473 | _ => InviteCharge::None, |
| 474 | } |
| 475 | } |
| 476 | |
| 477 | #[derive(Deserialize)] |
| 478 | struct Count { |
| 479 | n: f64, |
| 480 | } |
| 481 | |
| 482 | #[derive(Deserialize)] |
| 483 | struct Id { |
| 484 | id: String, |
| 485 | } |
| 486 | |
| 487 | #[derive(Deserialize)] |
| 488 | struct WaitlistRow { |
| 489 | id: String, |
| 490 | email: String, |
| 491 | about: Option<String>, |
| 492 | status: String, |
| 493 | invite_id: Option<String>, |
| 494 | decided_by: Option<String>, |
| 495 | decided_at: Option<String>, |
| 496 | #[serde(default)] |
| 497 | note: Option<String>, |
| 498 | #[serde(default)] |
| 499 | joined_as: Option<String>, |
| 500 | created_at: String, |
| 501 | updated_at: String, |
| 502 | } |
| 503 | |
| 504 | const WAITLIST_COLUMNS: &str = "wl.id, wl.email, wl.about, wl.status, wl.invite_id, wl.decided_by, wl.decided_at, wl.note, |
| 505 | ju.username AS joined_as, wl.created_at, wl.updated_at |
| 506 | FROM waitlist wl |
| 507 | LEFT JOIN invites wi ON wi.id = wl.invite_id |
| 508 | LEFT JOIN users ju ON ju.id = wi.redeemed_by"; |
| 509 | |
| 510 | impl From<WaitlistRow> for WaitlistEntry { |
| 511 | fn from(row: WaitlistRow) -> Self { |
| 512 | WaitlistEntry { |
| 513 | id: row.id, |
| 514 | email: row.email, |
| 515 | about: row.about, |
| 516 | status: match row.status.as_str() { |
| 517 | "invited" => WaitlistStatus::Invited, |
| 518 | "dismissed" => WaitlistStatus::Dismissed, |
| 519 | _ => WaitlistStatus::Waiting, |
| 520 | }, |
| 521 | invite_id: row.invite_id, |
| 522 | decided_by: row.decided_by, |
| 523 | decided_at: row.decided_at, |
| 524 | note: row.note, |
| 525 | joined_as: row.joined_as, |
| 526 | created_at: row.created_at, |
| 527 | updated_at: row.updated_at, |
| 528 | } |
| 529 | } |
| 530 | } |
| 531 | |
| 532 | /// What a new account is made from. |
| 533 | pub struct NewAccount<'a> { |
| 534 | /// Checked by the caller: valid, free, and lowercased. |
| 535 | pub username: &'a str, |
| 536 | /// The username as the person wrote it, when its case differs (`Ana` |
| 537 | /// for `ana`): kept beside it for showing. None shows `username`. |
| 538 | pub display_username: Option<&'a str>, |
| 539 | /// Lowercased and checked by the caller. |
| 540 | pub email: &'a str, |
| 541 | /// Empty for an account with no password (made through GitHub). |
| 542 | pub password_hash: &'a str, |
| 543 | /// Whether the address is confirmed already (GitHub's verified email). |
| 544 | pub verified: bool, |
| 545 | pub invite_code: Option<&'a str>, |
| 546 | /// The proof from the invite email's link ([`proves_email`]): when it |
| 547 | /// is the invite's and `email` is the address the invite was sent to, |
| 548 | /// the account starts with that address confirmed. |
| 549 | pub email_proof: Option<&'a str>, |
| 550 | /// Who is asking, for rate limits. |
| 551 | pub client: Option<&'a str>, |
| 552 | } |
| 553 | |
| 554 | /// What an invite was made for. |
| 555 | struct Draft<'a> { |
| 556 | email: Option<&'a str>, |
| 557 | kind: &'a str, |
| 558 | /// The workspace using it joins. |
| 559 | workspace_id: Option<&'a str>, |
| 560 | inviter: Option<&'a User>, |
| 561 | staff: Option<&'a str>, |
| 562 | /// `user`, `workspace` or `none`; the workspace for `workspace`; and |
| 563 | /// the limit when there is one. |
| 564 | charged_to: &'a str, |
| 565 | charged_workspace_id: Option<&'a str>, |
| 566 | limit: Option<u32>, |
| 567 | /// The account a workspace invitation is for, when it has one already. |
| 568 | invitee_id: Option<&'a str>, |
| 569 | /// The role joining `workspace_id` gives: `member` or `owner`. |
| 570 | role: Option<&'a str>, |
| 571 | } |
| 572 | |
| 573 | impl Identity { |
| 574 | // --- Settings --- |
| 575 | |
| 576 | pub fn registration_mode(&self) -> RegistrationMode { |
| 577 | RegistrationMode::parse(self.env.var("REGISTRATION_MODE").ok().map(|v| v.to_string()).as_deref()) |
| 578 | } |
| 579 | |
| 580 | /// Whether new accounts need an invite code. |
| 581 | pub fn invites_required(&self) -> bool { |
| 582 | self.registration_mode() == RegistrationMode::Invite |
| 583 | } |
| 584 | |
| 585 | fn var_number(&self, name: &str) -> Option<u64> { |
| 586 | self.env.var(name).ok()?.to_string().trim().parse().ok() |
| 587 | } |
| 588 | |
| 589 | fn invites_per_user(&self) -> u32 { |
| 590 | self.var_number("INVITES_PER_USER").map_or(INVITES_PER_USER, |n| n.min(u64::from(u32::MAX)) as u32) |
| 591 | } |
| 592 | |
| 593 | fn invite_ttl_days(&self) -> u64 { |
| 594 | self.var_number("INVITE_TTL_DAYS").filter(|days| (1..=365).contains(days)).unwrap_or(INVITE_TTL_DAYS) |
| 595 | } |
| 596 | |
| 597 | /// The workspaces whose owners invite without limit: g1t's own. |
| 598 | fn staff_workspaces(&self) -> Vec<String> { |
| 599 | self.env |
| 600 | .var("INVITE_STAFF_WORKSPACES") |
| 601 | .map(|v| v.to_string()) |
| 602 | .unwrap_or_default() |
| 603 | .split(',') |
| 604 | .map(|slug| slug.trim().to_lowercase()) |
| 605 | .filter(|slug| !slug.is_empty()) |
| 606 | .collect() |
| 607 | } |
| 608 | |
| 609 | pub(crate) fn invite_sealer(&self) -> Option<Sealer> { |
| 610 | Sealer::new(&self.env.secret("IDENTITY_KEY").ok()?.to_string()) |
| 611 | } |
| 612 | |
| 613 | /// The key invite email proofs are made under: IDENTITY_KEY, or none |
| 614 | /// in a development setup without one (then no proof is made, and none |
| 615 | /// is accepted). |
| 616 | fn proof_key(&self) -> Vec<u8> { |
| 617 | self.env.secret("IDENTITY_KEY").map(|key| key.to_string().into_bytes()).unwrap_or_default() |
| 618 | } |
| 619 | |
| 620 | /// The proof for the link of an invite emailed to `to`, the address it |
| 621 | /// is bound to; never shown anywhere but in that email. |
| 622 | pub(crate) fn email_proof_for(&self, invite_id: &str, to: &str) -> Option<String> { |
| 623 | email_proof(&self.proof_key(), invite_id, Some(to)) |
| 624 | } |
| 625 | |
| 626 | /// Whether `proof` shows the invite in `row` was followed from its own |
| 627 | /// email, by someone making an account with `email`. |
| 628 | fn proven(&self, row: &InviteRow, email: &str, proof: Option<&str>) -> bool { |
| 629 | starts_confirmed(&self.proof_key(), false, Some(row), email, proof) |
| 630 | } |
| 631 | |
| 632 | // --- Rate limits --- |
| 633 | |
| 634 | /// Counts one more hit on `key` this hour; false once past `limit`. |
| 635 | async fn hit(&self, key: &str, limit: u32) -> Result<bool> { |
| 636 | let now = bucket(now_ms(), HOUR_MS); |
| 637 | let hits = self |
| 638 | .db |
| 639 | .prepare( |
| 640 | "INSERT INTO rate_limits (key, bucket, hits) VALUES (?1, ?2, 1) |
| 641 | ON CONFLICT (key) DO UPDATE SET |
| 642 | hits = CASE WHEN rate_limits.bucket = excluded.bucket THEN rate_limits.hits + 1 ELSE 1 END, |
| 643 | bucket = excluded.bucket |
| 644 | RETURNING hits AS n", |
| 645 | ) |
| 646 | .bind(&[key.into(), (now as f64).into()])? |
| 647 | .first::<Count>(None) |
| 648 | .await? |
| 649 | .map_or(1.0, |count| count.n); |
| 650 | if hits <= 1.0 { |
| 651 | // A new window: forget windows gone by. |
| 652 | self.db |
| 653 | .prepare("DELETE FROM rate_limits WHERE bucket < ?") |
| 654 | .bind(&[((now.saturating_sub(1)) as f64).into()])? |
| 655 | .run() |
| 656 | .await?; |
| 657 | } |
| 658 | Ok(hits <= f64::from(limit)) |
| 659 | } |
| 660 | |
| 661 | /// Hits on `key` this hour, without adding one. |
| 662 | async fn hits(&self, key: &str) -> Result<u32> { |
| 663 | Ok(self |
| 664 | .db |
| 665 | .prepare("SELECT hits AS n FROM rate_limits WHERE key = ? AND bucket = ?") |
| 666 | .bind(&[key.into(), (bucket(now_ms(), HOUR_MS) as f64).into()])? |
| 667 | .first::<Count>(None) |
| 668 | .await? |
| 669 | .map_or(0, |count| count.n as u32)) |
| 670 | } |
| 671 | |
| 672 | /// Whether `client` has tried too many wrong codes this hour. |
| 673 | async fn turned_away(&self, client: Option<&str>) -> Result<bool> { |
| 674 | Ok(match client { |
| 675 | Some(client) => self.hits(&format!("invite.fail:{}", crypto::sha256_hex(client))).await? >= FAILURES_PER_HOUR, |
| 676 | None => false, |
| 677 | }) |
| 678 | } |
| 679 | |
| 680 | async fn count_failure(&self, client: Option<&str>) -> Result<()> { |
| 681 | if let Some(client) = client { |
| 682 | self.hit(&format!("invite.fail:{}", crypto::sha256_hex(client)), FAILURES_PER_HOUR).await?; |
| 683 | } |
| 684 | Ok(()) |
| 685 | } |
| 686 | |
| 687 | // --- Reading --- |
| 688 | |
| 689 | async fn invite_by_code(&self, code: &str) -> Result<Option<InviteRow>> { |
| 690 | let Some(body) = normalize_code(code) else { |
| 691 | return Ok(None); |
| 692 | }; |
| 693 | self.db |
| 694 | .prepare(format!("SELECT {COLUMNS} WHERE i.code_hash = ?")) |
| 695 | .bind(&[code_hash(&body).into()])? |
| 696 | .first::<InviteRow>(None) |
| 697 | .await |
| 698 | } |
| 699 | |
| 700 | async fn invite_by_id(&self, id: &str) -> Result<Option<InviteRow>> { |
| 701 | self.db |
| 702 | .prepare(format!("SELECT {COLUMNS} WHERE i.id = ?")) |
| 703 | .bind(&[id.into()])? |
| 704 | .first::<InviteRow>(None) |
| 705 | .await |
| 706 | } |
| 707 | |
| 708 | /// An invite as shown, with its code when `reveal` and it is pending. |
| 709 | fn shown(&self, row: InviteRow, reveal: bool, staff_view: bool) -> Invite { |
| 710 | let now = rfc3339(now_ms()); |
| 711 | let status = row.status(&now); |
| 712 | let role = row.workspace_id.is_some().then(|| row.joins_as()); |
| 713 | // An invite sent to an address never says which account has it, |
| 714 | // until that account uses it. |
| 715 | let invitee = row.invitee.clone().filter(|_| row.email.is_none() || row.redeemed_at.is_some()); |
| 716 | let code = if reveal && status == InviteStatus::Pending { |
| 717 | row.sealed_code |
| 718 | .as_deref() |
| 719 | .and_then(|sealed| self.invite_sealer()?.open(sealed, &row.id)) |
| 720 | } else { |
| 721 | None |
| 722 | }; |
| 723 | Invite { |
| 724 | id: row.id, |
| 725 | code, |
| 726 | hint: row.hint, |
| 727 | email: row.email, |
| 728 | kind: kind_of(&row.kind), |
| 729 | workspace: row.workspace, |
| 730 | status, |
| 731 | charged_to: charge_of(&row.charged_to), |
| 732 | invited_by: row.inviter, |
| 733 | redeemed_by: row.redeemer, |
| 734 | created_at: row.created_at, |
| 735 | expires_at: row.expires_at, |
| 736 | redeemed_at: row.redeemed_at, |
| 737 | revoked_at: row.revoked_at, |
| 738 | invitee, |
| 739 | role, |
| 740 | staff: if staff_view { row.staff } else { None }, |
| 741 | } |
| 742 | } |
| 743 | |
| 744 | async fn rows(&self, filter: &str, binds: &[JsValue], limit: u32) -> Result<Vec<InviteRow>> { |
| 745 | self.db |
| 746 | .prepare(format!("SELECT {COLUMNS} {filter} ORDER BY i.created_at DESC, i.id DESC LIMIT {limit}")) |
| 747 | .bind(binds)? |
| 748 | .all() |
| 749 | .await? |
| 750 | .results::<InviteRow>() |
| 751 | } |
| 752 | |
| 753 | async fn granted(&self, target: GrantTarget, id: &str) -> Result<i64> { |
| 754 | Ok(self |
| 755 | .db |
| 756 | .prepare("SELECT COALESCE(SUM(amount), 0) AS n FROM invite_grants WHERE target_kind = ? AND target_id = ?") |
| 757 | .bind(&[target.as_str().into(), id.into()])? |
| 758 | .first::<Count>(None) |
| 759 | .await? |
| 760 | .map_or(0, |count| count.n as i64)) |
| 761 | } |
| 762 | |
| 763 | async fn is_invite_staff(&self, user_id: &str) -> Result<bool> { |
| 764 | let staff = self.staff_workspaces(); |
| 765 | if staff.is_empty() { |
| 766 | return Ok(false); |
| 767 | } |
| 768 | let marks = vec!["?"; staff.len()].join(", "); |
| 769 | let mut binds: Vec<JsValue> = vec![user_id.into()]; |
| 770 | binds.extend(staff.iter().map(|slug| JsValue::from(slug.as_str()))); |
| 771 | Ok(self |
| 772 | .db |
| 773 | .prepare(format!( |
| 774 | "SELECT count(*) AS n FROM workspace_members m JOIN workspaces w ON w.id = m.workspace_id |
| 775 | WHERE m.user_id = ? AND m.role = 'owner' AND w.deleted_at IS NULL AND w.slug IN ({marks})" |
| 776 | )) |
| 777 | .bind(&binds)? |
| 778 | .first::<Count>(None) |
| 779 | .await? |
| 780 | .is_some_and(|count| count.n > 0.0)) |
| 781 | } |
| 782 | |
| 783 | async fn used(&self, column: &'static str, id: &str, charged_to: &str) -> Result<u32> { |
| 784 | Ok(self |
| 785 | .db |
| 786 | .prepare(format!( |
| 787 | "SELECT count(*) AS n FROM invites i WHERE i.{column} = ? AND i.charged_to = ? AND {}", |
| 788 | counted_sql() |
| 789 | )) |
| 790 | .bind(&[id.into(), charged_to.into()])? |
| 791 | .first::<Count>(None) |
| 792 | .await? |
| 793 | .map_or(0, |count| count.n as u32)) |
| 794 | } |
| 795 | |
| 796 | /// A person's own allowance. |
| 797 | pub async fn user_allowance(&self, user_id: &str) -> Result<Allowance> { |
| 798 | let unlimited = self.is_invite_staff(user_id).await?; |
| 799 | let granted = self.granted(GrantTarget::User, user_id).await?; |
| 800 | let used = self.used("inviter_id", user_id, "user").await?; |
| 801 | Ok(Allowance::new(limit_for(self.invites_per_user(), granted, unlimited), used)) |
| 802 | } |
| 803 | |
| 804 | /// A workspace's shared allowance: only what staff granted it. |
| 805 | async fn workspace_allowance(&self, workspace_id: &str) -> Result<Allowance> { |
| 806 | let granted = self.granted(GrantTarget::Workspace, workspace_id).await?; |
| 807 | let used = self.used("charged_workspace_id", workspace_id, "workspace").await?; |
| 808 | Ok(Allowance::new(limit_for(0, granted, false), used)) |
| 809 | } |
| 810 | |
| 811 | async fn workspace_id(&self, slug: &str) -> Result<Option<String>> { |
| 812 | Ok(self |
| 813 | .db |
| 814 | .prepare("SELECT id FROM workspaces WHERE slug = ? AND deleted_at IS NULL") |
| 815 | .bind(&[slug.trim().to_lowercase().into()])? |
| 816 | .first::<Id>(None) |
| 817 | .await? |
| 818 | .map(|row| row.id)) |
| 819 | } |
| 820 | |
| 821 | /// Whether an address is any account's: confirmed on one, or the |
| 822 | /// address a new account signed up with (emails.rs). |
| 823 | async fn email_has_account(&self, email: &str) -> Result<bool> { |
| 824 | self.email_in_use(email).await |
| 825 | } |
| 826 | |
| 827 | // --- The gate --- |
| 828 | |
| 829 | /// Makes an account: the only place one is made. While registration is |
| 830 | /// invite-only, `invite_code` must admit `email`; the code is spent in |
| 831 | /// the same transaction as the account is made. What the invite gives |
| 832 | /// (a workspace, repository invitations) is applied once the address |
| 833 | /// is confirmed: at once for an address GitHub has confirmed or one |
| 834 | /// proven by the invite email's link ([`proves_email`]), otherwise |
| 835 | /// in the transaction that confirms it (emails.rs, `confirm_address`). |
| 836 | /// In open mode a code is used if it is good and otherwise ignored. |
| 837 | pub async fn create_account(&self, new: NewAccount<'_>) -> Result<Outcome<User>> { |
| 838 | let required = self.invites_required(); |
| 839 | let code = new.invite_code.map(str::trim).filter(|code| !code.is_empty()); |
| 840 | let mut invite = None; |
| 841 | // A shared invite link's code instead (shared_invites.rs). |
| 842 | let mut shared = None; |
| 843 | match code { |
| 844 | None if required => return Ok(Outcome::fail(FailureCode::Forbidden, MISSING)), |
| 845 | None => {} |
| 846 | Some(code) => { |
| 847 | if required && self.turned_away(new.client).await? { |
| 848 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); |
| 849 | } |
| 850 | let row = self.invite_by_code(code).await?; |
| 851 | let link = match row { |
| 852 | None => self.shared_by_code(code).await?, |
| 853 | Some(_) => None, |
| 854 | }; |
| 855 | let now = rfc3339(now_ms()); |
| 856 | let verdict = match &link { |
| 857 | Some(link) => { |
| 858 | let domains = link.domains(); |
| 859 | let admits = SharedAdmits { status: link.status(&now), domains: &domains }; |
| 860 | shared_admits(Some(&admits), new.email) |
| 861 | } |
| 862 | None => admits(row.as_ref().map(|row| row.admits(&now)).as_ref(), new.email, true), |
| 863 | }; |
| 864 | match verdict { |
| 865 | Ok(()) => (invite, shared) = (row, link), |
| 866 | Err(_) if !required => {} |
| 867 | Err(refusal) => { |
| 868 | self.count_failure(new.client).await?; |
| 869 | let message = match refusal { |
| 870 | Refusal::WrongEmail => WRONG_EMAIL.to_owned(), |
| 871 | Refusal::WrongDomain => wrong_domain(&link.map(|link| link.domains()).unwrap_or_default()), |
| 872 | Refusal::Invalid => INVALID.to_owned(), |
| 873 | }; |
| 874 | return Ok(Outcome::fail(FailureCode::Forbidden, message)); |
| 875 | } |
| 876 | } |
| 877 | } |
| 878 | } |
| 879 | |
| 880 | // An address GitHub has confirmed starts confirmed, and so does the |
| 881 | // address an invite was emailed to, when the link followed was the |
| 882 | // email's own: its proof is in no code the inviter sees or shares. |
| 883 | // The code alone proves nothing (it can be passed on), so without |
| 884 | // the proof the new account confirms the address like any other. |
| 885 | let verified = starts_confirmed(&self.proof_key(), new.verified, invite.as_ref(), new.email, new.email_proof); |
| 886 | let user = User { |
| 887 | id: new_id("usr", now_ms()), |
| 888 | username: new.username.to_owned(), |
| 889 | verified, |
| 890 | ..User::default() |
| 891 | }; |
| 892 | let verified_at = if verified { SQL_NOW } else { "NULL" }; |
| 893 | let values = [ |
| 894 | JsValue::from(user.id.as_str()), |
| 895 | new.username.into(), |
| 896 | new.email.into(), |
| 897 | new.password_hash.into(), |
| 898 | ]; |
| 899 | let made = match (&invite, &shared) { |
| 900 | // Take a use of the shared link, then make the account only if |
| 901 | // this request took it: one transaction, counted in the |
| 902 | // statement that takes it, so racing past its uses is |
| 903 | // impossible. |
| 904 | (None, Some(link)) => self |
| 905 | .db |
| 906 | .batch(self.shared_account_statements(link, &values, verified_at)?) |
| 907 | .await |
| 908 | .map(|_| ()), |
| 909 | (None, None) => { |
| 910 | self.db |
| 911 | .prepare(format!( |
| 912 | "INSERT INTO users (id, username, email, password_hash, email_verified_at) |
| 913 | VALUES (?, ?, ?, ?, {verified_at})" |
| 914 | )) |
| 915 | .bind(&values)? |
| 916 | .run() |
| 917 | .await |
| 918 | .map(|_| ()) |
| 919 | } |
| 920 | // Spend the code, then make the account only if this request |
| 921 | // spent it: one transaction, so a second use finds it gone. |
| 922 | (Some(row), _) => { |
| 923 | let mut insert = values.to_vec(); |
| 924 | insert.extend([JsValue::from(row.id.as_str()), user.id.as_str().into()]); |
| 925 | self.db |
| 926 | .batch(vec![ |
| 927 | self.db |
| 928 | .prepare(format!( |
| 929 | "UPDATE invites SET redeemed_by = ?1, invitee_id = ?1, redeemed_at = {SQL_NOW}, sealed_code = NULL |
| 930 | WHERE id = ?2 AND kind = 'account' AND redeemed_at IS NULL AND revoked_at IS NULL |
| 931 | AND expires_at > {SQL_NOW}" |
| 932 | )) |
| 933 | .bind(&[user.id.as_str().into(), row.id.as_str().into()])?, |
| 934 | self.db |
| 935 | .prepare(format!( |
| 936 | "INSERT INTO users (id, username, email, password_hash, email_verified_at) |
| 937 | SELECT ?, ?, ?, ?, {verified_at} |
| 938 | WHERE EXISTS (SELECT 1 FROM invites WHERE id = ? AND redeemed_by = ?)" |
| 939 | )) |
| 940 | .bind(&insert)?, |
| 941 | ]) |
| 942 | .await |
| 943 | .map(|_| ()) |
| 944 | } |
| 945 | }; |
| 946 | if let Err(error) = made { |
| 947 | // Someone took the username or email a moment ago; nothing |
| 948 | // was written, the code included. |
| 949 | if error.to_string().contains("UNIQUE") { |
| 950 | return Ok(Outcome::fail(FailureCode::Conflict, "That username or email is already registered.")); |
| 951 | } |
| 952 | return Err(error); |
| 953 | } |
| 954 | let exists = self |
| 955 | .db |
| 956 | .prepare("SELECT id FROM users WHERE id = ?") |
| 957 | .bind(&[user.id.as_str().into()])? |
| 958 | .first::<Id>(None) |
| 959 | .await? |
| 960 | .is_some(); |
| 961 | if !exists { |
| 962 | // Another sign-up spent the code first. |
| 963 | self.count_failure(new.client).await?; |
| 964 | return Ok(Outcome::fail(FailureCode::Forbidden, INVALID)); |
| 965 | } |
| 966 | // The case it was chosen in, beside the lowercased name everything finds it by. |
| 967 | let user = match new.display_username.filter(|display| display.eq_ignore_ascii_case(new.username) && *display != new.username) { |
| 968 | Some(display) => { |
| 969 | self.db |
| 970 | .prepare("UPDATE users SET display_username = ? WHERE id = ?") |
| 971 | .bind(&[display.into(), user.id.as_str().into()])? |
| 972 | .run() |
| 973 | .await?; |
| 974 | User { display_username: Some(display.to_owned()), ..user } |
| 975 | } |
| 976 | None => user, |
| 977 | }; |
| 978 | // Nobody is left without a workspace: one of its own, unless its |
| 979 | // invite brings it into one (invitations.rs). |
| 980 | self.give_own_workspace(&user, invite.as_ref()).await; |
| 981 | // Confirmed already (GitHub, or the invite email): what the invite |
| 982 | // gives, now: a workspace it names is an invitation to accept, |
| 983 | // never joined without saying yes. Otherwise |
| 984 | // it waits, spent, for the address to be confirmed. |
| 985 | if let Some(row) = invite |
| 986 | && user.verified |
| 987 | { |
| 988 | self.after_redeemed(&row, &user, true).await?; |
| 989 | } |
| 990 | // A shared link gives nothing to wait for: the account makes its |
| 991 | // own workspace. |
| 992 | if let Some(link) = shared { |
| 993 | self.announce( |
| 994 | "invite.redeemed", |
| 995 | Some(&user.id), |
| 996 | InviteRedeemed { |
| 997 | invite_id: link.id, |
| 998 | user_id: user.id.clone(), |
| 999 | inviter_id: None, |
| 1000 | workspace_id: None, |
| 1001 | created_account: true, |
| 1002 | }, |
| 1003 | ) |
| 1004 | .await; |
| 1005 | } |
| 1006 | Ok(Outcome::Ok(user)) |
| 1007 | } |
| 1008 | |
| 1009 | /// What using an invite gives, once its account is confirmed, and tells |
| 1010 | /// the event log and audit log. A new account (`created_account`) is |
| 1011 | /// invited to the workspace the invite names, to accept or decline |
| 1012 | /// (invitations.rs): nobody joins a workspace without saying yes. An |
| 1013 | /// existing account that opened the invite and accepted it |
| 1014 | /// (`accept_invite`) joins now, with the role it names. |
| 1015 | async fn after_redeemed(&self, row: &InviteRow, user: &User, created_account: bool) -> Result<()> { |
| 1016 | let mut joined = None; |
| 1017 | if let (Some(workspace_id), Some(slug)) = (&row.workspace_id, &row.workspace) { |
| 1018 | if created_account { |
| 1019 | self.db |
| 1020 | .prepare("UPDATE invites SET expires_at = max(expires_at, ?) WHERE id = ? AND accepted_at IS NULL") |
| 1021 | .bind(&[self.answer_by().into(), row.id.as_str().into()])? |
| 1022 | .run() |
| 1023 | .await?; |
| 1024 | self.invitation_sent(row, &user.username).await; |
| 1025 | } else { |
| 1026 | let role = if row.joins_as() == Role::Owner { "owner" } else { "member" }; |
| 1027 | self.db |
| 1028 | .batch(vec![ |
| 1029 | self.db |
| 1030 | .prepare( |
| 1031 | "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at) |
| 1032 | VALUES (?, ?, ?, ?)", |
| 1033 | ) |
| 1034 | .bind(&[workspace_id.as_str().into(), user.id.as_str().into(), role.into(), rfc3339(now_ms()).into()])?, |
| 1035 | self.db |
| 1036 | .prepare(format!("UPDATE invites SET accepted_at = {SQL_NOW} WHERE id = ? AND accepted_at IS NULL")) |
| 1037 | .bind(&[row.id.as_str().into()])?, |
| 1038 | ]) |
| 1039 | .await?; |
| 1040 | joined = Some(slug.clone()); |
| 1041 | // They are a member now: the workspace's @g1t welcomes them (agents service). |
| 1042 | self.announce_member_joined(workspace_id, slug, user, row.joins_as(), JoinedHow::Joined).await; |
| 1043 | } |
| 1044 | } |
| 1045 | self.db |
| 1046 | .prepare(format!("UPDATE invites SET applied_at = {SQL_NOW} WHERE id = ? AND applied_at IS NULL")) |
| 1047 | .bind(&[row.id.as_str().into()])? |
| 1048 | .run() |
| 1049 | .await?; |
| 1050 | self.settled(row, user, created_account, joined).await; |
| 1051 | Ok(()) |
| 1052 | } |
| 1053 | |
| 1054 | /// When a workspace invitation made now, or handed to a new account |
| 1055 | /// now, stops working: the invite TTL from now, RFC 3339. |
| 1056 | pub(crate) fn answer_by(&self) -> String { |
| 1057 | rfc3339(now_ms() + self.invite_ttl_days() * 24 * HOUR_MS) |
| 1058 | } |
| 1059 | |
| 1060 | /// What follows an invite's workspace being joined (`joined`, by slug) |
| 1061 | /// or not: repository invitations sent with its code are accepted, and |
| 1062 | /// the event log and the workspace's audit log are told. |
| 1063 | pub(crate) async fn settled(&self, row: &InviteRow, user: &User, created_account: bool, joined: Option<String>) { |
| 1064 | // A code sent with an invitation to collaborate on a repository: |
| 1065 | // using it accepts (access.rs). |
| 1066 | if let Err(error) = self.accept_invitations_of_code(&row.id, user).await { |
| 1067 | worker::console_error!("repository invitations for {} not accepted: {error}", row.id); |
| 1068 | } |
| 1069 | self.announce( |
| 1070 | "invite.redeemed", |
| 1071 | Some(&user.id), |
| 1072 | InviteRedeemed { |
| 1073 | invite_id: row.id.clone(), |
| 1074 | user_id: user.id.clone(), |
| 1075 | inviter_id: row.inviter_id.clone(), |
| 1076 | workspace_id: row.workspace_id.clone(), |
| 1077 | created_account, |
| 1078 | }, |
| 1079 | ) |
| 1080 | .await; |
| 1081 | if let Some(slug) = joined { |
| 1082 | let message = match &row.inviter { |
| 1083 | Some(inviter) => format!("Joined with an invite from {inviter}"), |
| 1084 | None => "Joined with an invite from g1t".to_owned(), |
| 1085 | }; |
| 1086 | let role = if row.joins_as() == Role::Owner { "an owner" } else { "a member" }; |
| 1087 | self.audit_invites(user, "invite.redeemed", vec![slug.clone()], Surface::Web, message).await; |
| 1088 | self.audit_invites(user, "member.added", vec![slug], Surface::Web, format!("{} joined as {role}", user.username)).await; |
| 1089 | } |
| 1090 | } |
| 1091 | |
| 1092 | /// The invite an account signed up with, while it waits for the account |
| 1093 | /// to confirm its address: spent, not yet applied. |
| 1094 | pub(crate) async fn awaiting_invite(&self, user_id: &str) -> Result<Option<InviteRow>> { |
| 1095 | Ok(self |
| 1096 | .rows( |
| 1097 | "WHERE i.redeemed_by = ? AND i.kind = 'account' AND i.redeemed_at IS NOT NULL AND i.applied_at IS NULL", |
| 1098 | &[user_id.into()], |
| 1099 | 1, |
| 1100 | ) |
| 1101 | .await? |
| 1102 | .into_iter() |
| 1103 | .next()) |
| 1104 | } |
| 1105 | |
| 1106 | /// What an awaiting invite does now that its account is being |
| 1107 | /// confirmed, worked out before the batch that confirms it (which |
| 1108 | /// checks the same again). |
| 1109 | pub(crate) async fn awaiting_join(&self, row: &InviteRow) -> AwaitingJoin { |
| 1110 | awaiting_join(row, &rfc3339(now_ms())) |
| 1111 | } |
| 1112 | |
| 1113 | /// The statements that apply an awaiting invite, for the batch that |
| 1114 | /// confirms `user_id`'s address, after the statement that marks the |
| 1115 | /// account confirmed: give a workspace invitation the invite TTL from |
| 1116 | /// now to be answered in, only if the account is confirmed now; then |
| 1117 | /// mark the invite settled, whatever it gave. Nothing is joined here: |
| 1118 | /// the person accepts the invitation (invitations.rs). |
| 1119 | pub(crate) fn apply_invite_statements( |
| 1120 | &self, |
| 1121 | user_id: &str, |
| 1122 | row: &InviteRow, |
| 1123 | join: &AwaitingJoin, |
| 1124 | ) -> Result<Vec<worker::D1PreparedStatement>> { |
| 1125 | let confirmed = "EXISTS (SELECT 1 FROM users WHERE id = ?1 AND email_verified_at IS NOT NULL)"; |
| 1126 | let mut statements = Vec::new(); |
| 1127 | if let AwaitingJoin::Invited { .. } = join { |
| 1128 | statements.push( |
| 1129 | self.db |
| 1130 | .prepare(format!( |
| 1131 | "UPDATE invites SET expires_at = max(expires_at, ?3) |
| 1132 | WHERE id = ?2 AND redeemed_by = ?1 AND applied_at IS NULL AND revoked_at IS NULL AND {confirmed}" |
| 1133 | )) |
| 1134 | .bind(&[user_id.into(), row.id.as_str().into(), self.answer_by().into()])?, |
| 1135 | ); |
| 1136 | } |
| 1137 | statements.push( |
| 1138 | self.db |
| 1139 | .prepare(format!( |
| 1140 | "UPDATE invites SET applied_at = {SQL_NOW} |
| 1141 | WHERE id = ?2 AND redeemed_by = ?1 AND applied_at IS NULL AND {confirmed}" |
| 1142 | )) |
| 1143 | .bind(&[user_id.into(), row.id.as_str().into()])?, |
| 1144 | ); |
| 1145 | Ok(statements) |
| 1146 | } |
| 1147 | |
| 1148 | /// After the batch: the workspace the account is invited to, by slug, |
| 1149 | /// if the invitation still waits for its answer (and it is told in |
| 1150 | /// its inbox); and, unless the invite lapsed, the repository |
| 1151 | /// invitations, event and audit entries that follow using it. |
| 1152 | pub(crate) async fn after_applied(&self, row: &InviteRow, user: &User, join: &AwaitingJoin) -> Result<Option<String>> { |
| 1153 | let invited = match join { |
| 1154 | AwaitingJoin::Invited { slug, .. } => self |
| 1155 | .db |
| 1156 | .prepare(format!( |
| 1157 | "SELECT 1 AS n FROM invites WHERE id = ? AND applied_at IS NOT NULL AND revoked_at IS NULL |
| 1158 | AND accepted_at IS NULL AND declined_at IS NULL AND expires_at > {SQL_NOW}" |
| 1159 | )) |
| 1160 | .bind(&[row.id.as_str().into()])? |
| 1161 | .first::<Count>(None) |
| 1162 | .await? |
| 1163 | .map(|_| slug.clone()), |
| 1164 | _ => None, |
| 1165 | }; |
| 1166 | if invited.is_some() { |
| 1167 | self.invitation_sent(row, &user.username).await; |
| 1168 | } |
| 1169 | if !matches!(join, AwaitingJoin::Lapsed(_)) { |
| 1170 | self.settled(row, user, true, None).await; |
| 1171 | } |
| 1172 | Ok(invited) |
| 1173 | } |
| 1174 | |
| 1175 | // --- People's invites --- |
| 1176 | |
| 1177 | fn draft_allowed(user: &User) -> Option<&'static str> { |
| 1178 | if user.kind != PrincipalKind::User || user.acting.is_some() { |
| 1179 | return Some(PEOPLE_ONLY); |
| 1180 | } |
| 1181 | if !user.verified { |
| 1182 | return Some(CONFIRM_FIRST); |
| 1183 | } |
| 1184 | None |
| 1185 | } |
| 1186 | |
| 1187 | /// Stores a new invite and returns it with its code, or None when the |
| 1188 | /// allowance ran out between reading it and writing. |
| 1189 | async fn insert_invite(&self, draft: Draft<'_>) -> Result<Option<Invite>> { |
| 1190 | let body = new_code_body(); |
| 1191 | let code = format_code(&body); |
| 1192 | let now = now_ms(); |
| 1193 | let id = new_id("inv", now); |
| 1194 | let sealed = self.invite_sealer().map(|sealer| sealer.seal(&code, &id)); |
| 1195 | let expires_at = rfc3339(now + self.invite_ttl_days() * 24 * HOUR_MS); |
| 1196 | let created_at = rfc3339(now); |
| 1197 | let opt = |value: Option<&str>| value.map_or(JsValue::NULL, JsValue::from); |
| 1198 | let mut binds = vec![ |
| 1199 | JsValue::from(id.as_str()), |
| 1200 | code_hash(&body).into(), |
| 1201 | code_hint(&body).into(), |
| 1202 | opt(sealed.as_deref()), |
| 1203 | opt(draft.email), |
| 1204 | draft.kind.into(), |
| 1205 | opt(draft.workspace_id), |
| 1206 | opt(draft.inviter.map(|user| user.id.as_str())), |
| 1207 | opt(draft.staff), |
| 1208 | draft.charged_to.into(), |
| 1209 | opt(draft.charged_workspace_id), |
| 1210 | created_at.as_str().into(), |
| 1211 | expires_at.as_str().into(), |
| 1212 | opt(draft.invitee_id), |
| 1213 | opt(draft.role), |
| 1214 | ]; |
| 1215 | // The allowance is checked in the insert itself, so two invites made |
| 1216 | // at once cannot both take the last one. |
| 1217 | let guard = match (draft.charged_to, draft.limit) { |
| 1218 | ("user", Some(limit)) => { |
| 1219 | binds.extend([opt(draft.inviter.map(|user| user.id.as_str())), f64::from(limit).into()]); |
| 1220 | format!( |
| 1221 | "WHERE (SELECT count(*) FROM invites i WHERE i.inviter_id = ? AND i.charged_to = 'user' AND {}) < ?", |
| 1222 | counted_sql() |
| 1223 | ) |
| 1224 | } |
| 1225 | ("workspace", Some(limit)) => { |
| 1226 | binds.extend([opt(draft.charged_workspace_id), f64::from(limit).into()]); |
| 1227 | format!( |
| 1228 | "WHERE (SELECT count(*) FROM invites i WHERE i.charged_workspace_id = ? AND i.charged_to = 'workspace' AND {}) < ?", |
| 1229 | counted_sql() |
| 1230 | ) |
| 1231 | } |
| 1232 | _ => String::new(), |
| 1233 | }; |
| 1234 | let inserted = self |
| 1235 | .db |
| 1236 | .prepare(format!( |
| 1237 | "INSERT INTO invites (id, code_hash, hint, sealed_code, email, kind, workspace_id, inviter_id, |
| 1238 | staff, charged_to, charged_workspace_id, created_at, expires_at, invitee_id, role) |
| 1239 | SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? {guard} |
| 1240 | RETURNING id" |
| 1241 | )) |
| 1242 | .bind(&binds)? |
| 1243 | .first::<Id>(None) |
| 1244 | .await?; |
| 1245 | if inserted.is_none() { |
| 1246 | return Ok(None); |
| 1247 | } |
| 1248 | self.announce( |
| 1249 | "invite.created", |
| 1250 | draft.inviter.map(|user| user.id.as_str()), |
| 1251 | InviteCreated { |
| 1252 | invite_id: id.clone(), |
| 1253 | inviter_id: draft.inviter.map(|user| user.id.clone()), |
| 1254 | workspace_id: draft.workspace_id.map(str::to_owned), |
| 1255 | bound: draft.email.is_some(), |
| 1256 | }, |
| 1257 | ) |
| 1258 | .await; |
| 1259 | let Some(row) = self.invite_by_id(&id).await? else { |
| 1260 | return Ok(None); |
| 1261 | }; |
| 1262 | let mut invite = self.shown(row, false, false); |
| 1263 | invite.code = Some(code); |
| 1264 | Ok(Some(invite)) |
| 1265 | } |
| 1266 | |
| 1267 | fn out_of_invites() -> Outcome<Invite> { |
| 1268 | Outcome::fail( |
| 1269 | FailureCode::Limit, |
| 1270 | "You have no invites left. Need more? Contact us at hey@flagon.io with the subject [g1t Invites].", |
| 1271 | ) |
| 1272 | } |
| 1273 | |
| 1274 | pub async fn create_invite(&self, a: CreateInviteArgs) -> Result<Outcome<Invite>> { |
| 1275 | if let Some(reason) = Self::draft_allowed(&a.user) { |
| 1276 | return Ok(Outcome::fail(FailureCode::Forbidden, reason)); |
| 1277 | } |
| 1278 | let email = match a.email.as_deref().map(str::trim).filter(|email| !email.is_empty()) { |
| 1279 | Some(email) => match normalize_email(email) { |
| 1280 | Some(email) => Some(email), |
| 1281 | None => return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL)), |
| 1282 | }, |
| 1283 | None => None, |
| 1284 | }; |
| 1285 | if !self.hit(&format!("invite.create:{}", a.user.id), CREATES_PER_HOUR).await? { |
| 1286 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); |
| 1287 | } |
| 1288 | if let Some(email) = &email { |
| 1289 | if self.email_has_account(email).await? { |
| 1290 | return Ok(Outcome::fail( |
| 1291 | FailureCode::Conflict, |
| 1292 | "That address already has a g1t account. Add them to a workspace from its People page instead.", |
| 1293 | )); |
| 1294 | } |
| 1295 | let pending = self |
| 1296 | .rows( |
| 1297 | &format!( |
| 1298 | "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}" |
| 1299 | ), |
| 1300 | &[a.user.id.as_str().into(), email.as_str().into()], |
| 1301 | 1, |
| 1302 | ) |
| 1303 | .await?; |
| 1304 | if !pending.is_empty() { |
| 1305 | return Ok(Outcome::fail( |
| 1306 | FailureCode::Conflict, |
| 1307 | "You already have a pending invite for that address. Revoke it to send a new one.", |
| 1308 | )); |
| 1309 | } |
| 1310 | } |
| 1311 | // A workspace's granted invites, for its owners. |
| 1312 | let (workspace_id, charged_to, limit) = match a.workspace.as_deref().map(str::trim).filter(|slug| !slug.is_empty()) { |
| 1313 | Some(slug) => { |
| 1314 | let slug = slug.to_lowercase(); |
| 1315 | if a.user.role_in(&slug) != Some(Role::Owner) { |
| 1316 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only a workspace's owners can use its invites.")); |
| 1317 | } |
| 1318 | let Some(id) = self.workspace_id(&slug).await? else { |
| 1319 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); |
| 1320 | }; |
| 1321 | let allowance = self.workspace_allowance(&id).await?; |
| 1322 | if allowance.exhausted() { |
| 1323 | return Ok(Outcome::fail( |
| 1324 | FailureCode::Limit, |
| 1325 | format!("{slug} has no invites left. Need more? Contact us at hey@flagon.io with the subject [g1t Invites]."), |
| 1326 | )); |
| 1327 | } |
| 1328 | (Some(id), "workspace", allowance.limit) |
| 1329 | } |
| 1330 | None => { |
| 1331 | let allowance = self.user_allowance(&a.user.id).await?; |
| 1332 | if allowance.exhausted() { |
| 1333 | return Ok(Self::out_of_invites()); |
| 1334 | } |
| 1335 | (None, "user", allowance.limit) |
| 1336 | } |
| 1337 | }; |
| 1338 | // The workspace it brings them into, if any (invitations.rs). |
| 1339 | let joins = match self.joinable_workspace(&a.user, a.join.as_deref()).await? { |
| 1340 | Outcome::Ok(joins) => joins, |
| 1341 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 1342 | }; |
| 1343 | let draft = Draft { |
| 1344 | email: email.as_deref(), |
| 1345 | kind: "account", |
| 1346 | workspace_id: joins.as_ref().map(|(id, _)| id.as_str()), |
| 1347 | inviter: Some(&a.user), |
| 1348 | staff: None, |
| 1349 | charged_to, |
| 1350 | charged_workspace_id: workspace_id.as_deref(), |
| 1351 | limit, |
| 1352 | invitee_id: None, |
| 1353 | role: joins.as_ref().map(|_| if a.join_role == Some(Role::Owner) { "owner" } else { "member" }), |
| 1354 | }; |
| 1355 | let Some(invite) = self.insert_invite(draft).await? else { |
| 1356 | return Ok(Self::out_of_invites()); |
| 1357 | }; |
| 1358 | if let (Some(email), Some(code)) = (&email, &invite.code) { |
| 1359 | let from = self.display_name(&a.user).await; |
| 1360 | let workspace = match &joins { |
| 1361 | Some((id, slug)) => Some(self.workspace_name(id, slug).await), |
| 1362 | None => None, |
| 1363 | }; |
| 1364 | self.send_invite_email(email, Some(&from), workspace.as_deref(), false, code, &invite.id, None).await; |
| 1365 | } |
| 1366 | let mut logs: Vec<String> = a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect(); |
| 1367 | if let Some((_, slug)) = &joins { |
| 1368 | logs = vec![slug.clone()]; |
| 1369 | } |
| 1370 | self.audit_invites(&a.user, "invite.created", logs, a.surface.unwrap_or(Surface::Web), format!("Created invite {}", invite.hint)) |
| 1371 | .await; |
| 1372 | Ok(Outcome::Ok(invite)) |
| 1373 | } |
| 1374 | |
| 1375 | async fn send_invite_email( |
| 1376 | &self, |
| 1377 | to: &str, |
| 1378 | from: Option<&str>, |
| 1379 | workspace: Option<&str>, |
| 1380 | existing: bool, |
| 1381 | code: &str, |
| 1382 | invite_id: &str, |
| 1383 | note: Option<&str>, |
| 1384 | ) { |
| 1385 | // An invite that makes an account carries the proof that the link |
| 1386 | // came from this email; one for an existing account has nothing |
| 1387 | // to prove. |
| 1388 | let proof = if existing { None } else { self.email_proof_for(invite_id, to) }; |
| 1389 | let invite = crate::email::InviteEmail { |
| 1390 | to, |
| 1391 | from, |
| 1392 | workspace, |
| 1393 | joins_existing_account: existing, |
| 1394 | code, |
| 1395 | proof: proof.as_deref(), |
| 1396 | days: self.invite_ttl_days(), |
| 1397 | note, |
| 1398 | }; |
| 1399 | if let Err(error) = crate::email::send_invite(&self.env, &invite).await { |
| 1400 | worker::console_error!("invite email failed: {error}"); |
| 1401 | } |
| 1402 | } |
| 1403 | |
| 1404 | /// How an invite names the person who sent it: their name, else their |
| 1405 | /// username. |
| 1406 | async fn display_name(&self, user: &User) -> String { |
| 1407 | self.name_of("SELECT display_name AS name FROM users WHERE id = ?", &user.id) |
| 1408 | .await |
| 1409 | .unwrap_or_else(|| user.username.clone()) |
| 1410 | } |
| 1411 | |
| 1412 | /// A workspace's name, as an invite shows it; its slug if it has none. |
| 1413 | async fn workspace_name(&self, workspace_id: &str, slug: &str) -> String { |
| 1414 | self.name_of("SELECT name FROM workspaces WHERE id = ?", workspace_id) |
| 1415 | .await |
| 1416 | .unwrap_or_else(|| slug.to_owned()) |
| 1417 | } |
| 1418 | |
| 1419 | /// A name `sql` selects for `id`, if it has one. Only for wording an |
| 1420 | /// email, so a failed read is no name. |
| 1421 | async fn name_of(&self, sql: &str, id: &str) -> Option<String> { |
| 1422 | #[derive(Deserialize)] |
| 1423 | struct Name { |
| 1424 | name: Option<String>, |
| 1425 | } |
| 1426 | let read = async { self.db.prepare(sql).bind(&[id.into()])?.first::<Name>(None).await }; |
| 1427 | read.await |
| 1428 | .ok() |
| 1429 | .flatten() |
| 1430 | .and_then(|row| row.name) |
| 1431 | .map(|name| name.trim().to_owned()) |
| 1432 | .filter(|name| !name.is_empty()) |
| 1433 | } |
| 1434 | |
| 1435 | /// The address a pending invite is bound to, if it is: signing up with |
| 1436 | /// GitHub uses it when GitHub has confirmed it too (github.rs). |
| 1437 | pub(crate) async fn bound_email_of(&self, code: &str) -> Result<Option<String>> { |
| 1438 | let now = rfc3339(now_ms()); |
| 1439 | Ok(self |
| 1440 | .invite_by_code(code) |
| 1441 | .await? |
| 1442 | .filter(|row| row.status(&now) == InviteStatus::Pending) |
| 1443 | .and_then(|row| row.email)) |
| 1444 | } |
| 1445 | |
| 1446 | pub async fn list_invites(&self, a: UserArgs) -> Result<InvitesOverview> { |
| 1447 | let invites: Vec<Invite> = self |
| 1448 | .rows("WHERE i.inviter_id = ?", &[a.user.id.as_str().into()], LIST_LIMIT) |
| 1449 | .await? |
| 1450 | .into_iter() |
| 1451 | .map(|row| self.shown(row, true, false)) |
| 1452 | .collect(); |
| 1453 | let mut workspaces = Vec::new(); |
| 1454 | for membership in a.user.workspaces.iter().filter(|membership| membership.role == Role::Owner) { |
| 1455 | if let Some(id) = self.workspace_id(&membership.slug).await? |
| 1456 | && self.granted(GrantTarget::Workspace, &id).await? != 0 |
| 1457 | { |
| 1458 | workspaces.push(WorkspaceAllowance { |
| 1459 | slug: membership.slug.clone(), |
| 1460 | allowance: self.workspace_allowance(&id).await?, |
| 1461 | }); |
| 1462 | } |
| 1463 | } |
| 1464 | Ok(InvitesOverview { |
| 1465 | mode: self.registration_mode(), |
| 1466 | allowance: self.user_allowance(&a.user.id).await?, |
| 1467 | workspaces, |
| 1468 | invites, |
| 1469 | }) |
| 1470 | } |
| 1471 | |
| 1472 | /// Revokes a pending invite the person made, or one made for (or |
| 1473 | /// charged to) a workspace they own. An invite used to sign up whose |
| 1474 | /// account has not confirmed its address yet can be revoked too: the |
| 1475 | /// account stays, and joins nothing when it confirms. |
| 1476 | pub async fn revoke_invite(&self, a: RemoveArgs) -> Result<Outcome<Invite>> { |
| 1477 | if a.user.kind != PrincipalKind::User || a.user.acting.is_some() { |
| 1478 | return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY)); |
| 1479 | } |
| 1480 | let revoked = self |
| 1481 | .db |
| 1482 | .prepare(format!( |
| 1483 | "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL |
| 1484 | WHERE id = ?2 AND revoked_at IS NULL AND declined_at IS NULL |
| 1485 | AND (redeemed_at IS NULL OR applied_at IS NULL |
| 1486 | -- A workspace invitation not yet answered. |
| 1487 | OR (workspace_id IS NOT NULL AND accepted_at IS NULL)) |
| 1488 | AND (inviter_id = ?1 |
| 1489 | OR workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = ?1 AND role = 'owner') |
| 1490 | OR charged_workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = ?1 AND role = 'owner')) |
| 1491 | RETURNING id" |
| 1492 | )) |
| 1493 | .bind(&[a.user.id.as_str().into(), a.id.as_str().into()])? |
| 1494 | .first::<Id>(None) |
| 1495 | .await?; |
| 1496 | let Some(Id { id }) = revoked else { |
| 1497 | return Ok(Outcome::fail(FailureCode::NotFound, "There is no pending invite of yours with that id.")); |
| 1498 | }; |
| 1499 | let Some(row) = self.invite_by_id(&id).await? else { |
| 1500 | return Ok(Outcome::fail(FailureCode::NotFound, "Invite not found.")); |
| 1501 | }; |
| 1502 | let logs = match &row.workspace { |
| 1503 | Some(slug) => vec![slug.clone()], |
| 1504 | None => a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect(), |
| 1505 | }; |
| 1506 | self.audit_invites(&a.user, "invite.revoked", logs, Surface::Web, format!("Revoked invite {}", row.hint)).await; |
| 1507 | self.invitation_revoked(&a.user, &row).await; |
| 1508 | Ok(Outcome::Ok(self.shown(row, false, false))) |
| 1509 | } |
| 1510 | |
| 1511 | /// What an invite code is for: who sent it, and which workspace it |
| 1512 | /// joins. Any code that cannot be used gets the same answer. |
| 1513 | pub async fn check_invite(&self, a: InviteCodeArgs) -> Result<Outcome<InvitePreview>> { |
| 1514 | if self.turned_away(a.client.as_deref()).await? { |
| 1515 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); |
| 1516 | } |
| 1517 | let now = rfc3339(now_ms()); |
| 1518 | let found = self.invite_by_code(&a.code).await?; |
| 1519 | // A shared invite link's code, while it is live: its label and |
| 1520 | // domains are for the sign-up page. Expired, revoked and used up |
| 1521 | // get the one answer below, whatever `any_status` asks. |
| 1522 | if found.is_none() |
| 1523 | && let Some(link) = self.shared_by_code(&a.code).await? |
| 1524 | && link.status(&now) == SharedInviteStatus::Live |
| 1525 | { |
| 1526 | return Ok(Outcome::Ok(self.shared_preview(&link))); |
| 1527 | } |
| 1528 | // A spent code is still a real one (160 random bits): saying what |
| 1529 | // became of it tells a guesser nothing. |
| 1530 | let row = found.filter(|row| a.any_status || row.status(&now) == InviteStatus::Pending); |
| 1531 | let Some(row) = row else { |
| 1532 | self.count_failure(a.client.as_deref()).await?; |
| 1533 | return Ok(Outcome::fail(FailureCode::NotFound, INVALID)); |
| 1534 | }; |
| 1535 | let status = row.status(&now); |
| 1536 | let pending = status == InviteStatus::Pending; |
| 1537 | // Whether it is the viewer's: for one of their confirmed addresses, |
| 1538 | // or, once used, used by them. |
| 1539 | let for_viewer = match &a.viewer { |
| 1540 | Some(viewer) if viewer.kind == PrincipalKind::User => match (&row.email, status) { |
| 1541 | (_, InviteStatus::Redeemed | InviteStatus::AwaitingConfirmation) => { |
| 1542 | Some(row.redeemer.as_deref() == Some(viewer.username.as_str())) |
| 1543 | } |
| 1544 | (Some(bound), _) => { |
| 1545 | let mine = self.verified_emails(&viewer.id).await?; |
| 1546 | Some(mine.iter().any(|address| address.eq_ignore_ascii_case(bound.trim()))) |
| 1547 | } |
| 1548 | // An invitation to someone by username is theirs alone. |
| 1549 | (None, _) => row.invitee_id.as_ref().map(|invitee| *invitee == viewer.id), |
| 1550 | }, |
| 1551 | _ => None, |
| 1552 | }; |
| 1553 | let has_account = match (&row.email, pending) { |
| 1554 | (Some(bound), true) => self.email_has_account(bound).await?, |
| 1555 | _ => false, |
| 1556 | }; |
| 1557 | let repository = self.repository_of_code(&row.id).await?; |
| 1558 | // Opened from the invite's own email: the account it makes starts |
| 1559 | // with the address confirmed. Said only while it can make one. |
| 1560 | let email_proven = pending |
| 1561 | && !has_account |
| 1562 | && row.email.as_deref().is_some_and(|bound| self.proven(&row, bound, a.email_proof.as_deref())); |
| 1563 | #[derive(Deserialize)] |
| 1564 | struct From { |
| 1565 | username: String, |
| 1566 | name: Option<String>, |
| 1567 | avatar: Option<String>, |
| 1568 | } |
| 1569 | let invited_by = match &row.inviter_id { |
| 1570 | Some(id) => self |
| 1571 | .db |
| 1572 | .prepare("SELECT username, display_name AS name, avatar FROM users WHERE id = ?") |
| 1573 | .bind(&[id.as_str().into()])? |
| 1574 | .first::<From>(None) |
| 1575 | .await? |
| 1576 | .map(|from| InviteFrom { |
| 1577 | username: from.username, |
| 1578 | name: from.name, |
| 1579 | avatar: from.avatar, |
| 1580 | }), |
| 1581 | None => None, |
| 1582 | }; |
| 1583 | let workspace = match &row.workspace_id { |
| 1584 | Some(id) => self |
| 1585 | .db |
| 1586 | .prepare("SELECT slug, name, avatar FROM workspaces WHERE id = ?") |
| 1587 | .bind(&[id.as_str().into()])? |
| 1588 | .first::<ProfileWorkspace>(None) |
| 1589 | .await?, |
| 1590 | None => None, |
| 1591 | }; |
| 1592 | Ok(Outcome::Ok(InvitePreview { |
| 1593 | kind: kind_of(&row.kind), |
| 1594 | status, |
| 1595 | invited_by, |
| 1596 | workspace, |
| 1597 | repository, |
| 1598 | email: row.email.as_deref().map(mask_email), |
| 1599 | address: row.email.clone().filter(|_| pending), |
| 1600 | has_account, |
| 1601 | for_viewer, |
| 1602 | expires_at: row.expires_at, |
| 1603 | shared_label: None, |
| 1604 | shared_domains: Vec::new(), |
| 1605 | email_proven, |
| 1606 | })) |
| 1607 | } |
| 1608 | |
| 1609 | /// A signed-in person uses a workspace invite sent to their address, |
| 1610 | /// or one sent with a repository invitation. |
| 1611 | pub async fn accept_invite(&self, a: AcceptInviteArgs) -> Result<Outcome<String>> { |
| 1612 | if a.user.kind != PrincipalKind::User || a.user.acting.is_some() { |
| 1613 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can accept an invite.")); |
| 1614 | } |
| 1615 | // Any of the person's confirmed addresses can match an invite bound |
| 1616 | // to one (emails.rs); the primary otherwise. |
| 1617 | let verified = self.verified_emails(&a.user.id).await?; |
| 1618 | let Some(primary) = verified.first().cloned() else { |
| 1619 | return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first, then open the invite again.")); |
| 1620 | }; |
| 1621 | let now = rfc3339(now_ms()); |
| 1622 | let row = self.invite_by_code(&a.code).await?; |
| 1623 | let email = row |
| 1624 | .as_ref() |
| 1625 | .and_then(|row| row.email.as_deref()) |
| 1626 | .and_then(|bound| verified.iter().find(|address| address.eq_ignore_ascii_case(bound.trim())).cloned()) |
| 1627 | .unwrap_or(primary); |
| 1628 | // What using it gives an account that exists: a workspace, or a |
| 1629 | // repository it was sent with. |
| 1630 | let repository = match &row { |
| 1631 | Some(row) => self.repository_of_code(&row.id).await?, |
| 1632 | None => None, |
| 1633 | }; |
| 1634 | let joins = row.as_ref().is_some_and(joins_workspace) || repository.is_some(); |
| 1635 | if let Err(refusal) = admits(row.as_ref().map(|row| row.admits(&now)).as_ref(), &email, false) { |
| 1636 | return Ok(Outcome::fail( |
| 1637 | FailureCode::Forbidden, |
| 1638 | if refusal == Refusal::WrongEmail { WRONG_EMAIL } else { INVALID }, |
| 1639 | )); |
| 1640 | } |
| 1641 | let Some(row) = row.filter(|_| joins) else { |
| 1642 | return Ok(Outcome::fail( |
| 1643 | FailureCode::Conflict, |
| 1644 | "You already have a g1t account, so this invite has nothing more to give you. Pass it on to someone who needs it.", |
| 1645 | )); |
| 1646 | }; |
| 1647 | // An invitation to one account works for that account only. |
| 1648 | if row.invitee_id.as_deref().is_some_and(|invitee| invitee != a.user.id) { |
| 1649 | return Ok(Outcome::fail( |
| 1650 | FailureCode::Forbidden, |
| 1651 | "This invitation is for a different g1t account. Sign in as the account it was sent to.", |
| 1652 | )); |
| 1653 | } |
| 1654 | // What the workspace asks of its members (security.rs); nothing yet. |
| 1655 | if let Some(slug) = row.workspace.as_deref() |
| 1656 | && let Some(why) = self.policy_refusal(&a.user.id, slug).await? |
| 1657 | { |
| 1658 | return Ok(Outcome::fail(FailureCode::Forbidden, why)); |
| 1659 | } |
| 1660 | // An invite sent before the workspace was free waits until it |
| 1661 | // starts the plan (paid.rs); the code is not used up. |
| 1662 | let joins_slug = row.workspace.clone().or_else(|| { |
| 1663 | repository.as_ref().and_then(|r| r.name.split_once('/').map(|(workspace, _)| workspace.to_owned())) |
| 1664 | }); |
| 1665 | if let Some(slug) = joins_slug.as_deref() |
| 1666 | && let Some(refused) = self.free_workspace_refusal(slug).await? |
| 1667 | { |
| 1668 | return Ok(refused); |
| 1669 | } |
| 1670 | let claimed = self |
| 1671 | .db |
| 1672 | .prepare(format!( |
| 1673 | "UPDATE invites SET redeemed_by = ?1, invitee_id = COALESCE(invitee_id, ?1), redeemed_at = {SQL_NOW}, sealed_code = NULL |
| 1674 | WHERE id = ?2 AND redeemed_at IS NULL AND revoked_at IS NULL AND declined_at IS NULL AND expires_at > {SQL_NOW} |
| 1675 | RETURNING id" |
| 1676 | )) |
| 1677 | .bind(&[a.user.id.as_str().into(), row.id.as_str().into()])? |
| 1678 | .first::<Id>(None) |
| 1679 | .await?; |
| 1680 | if claimed.is_none() { |
| 1681 | return Ok(Outcome::fail(FailureCode::Forbidden, INVALID)); |
| 1682 | } |
| 1683 | let lands = row |
| 1684 | .workspace |
| 1685 | .clone() |
| 1686 | .or_else(|| repository.map(|repository| repository.name)) |
| 1687 | .unwrap_or_default(); |
| 1688 | self.after_redeemed(&row, &a.user, false).await?; |
| 1689 | Ok(Outcome::Ok(lands)) |
| 1690 | } |
| 1691 | |
| 1692 | // --- Workspace invitations --- |
| 1693 | |
| 1694 | pub async fn invite_member(&self, a: InviteMemberArgs) -> Result<Outcome<Invite>> { |
| 1695 | let slug = a.slug.trim().to_lowercase(); |
| 1696 | if let Some(reason) = Self::draft_allowed(&a.actor) { |
| 1697 | return Ok(Outcome::fail(FailureCode::Forbidden, reason)); |
| 1698 | } |
| 1699 | if a.actor.role_in(&slug) != Some(Role::Owner) { |
| 1700 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can invite people to a workspace.")); |
| 1701 | } |
| 1702 | // A username, or an address; an address typed in the username's |
| 1703 | // place is an address. |
| 1704 | let username = a |
| 1705 | .username |
| 1706 | .as_deref() |
| 1707 | .map(|name| name.trim().trim_start_matches('@').to_lowercase()) |
| 1708 | .filter(|name| !name.is_empty() && !name.contains('@')); |
| 1709 | let email = match (&username, normalize_email(a.username.as_deref().unwrap_or(&a.email))) { |
| 1710 | (Some(_), _) => None, |
| 1711 | (None, Some(email)) => Some(email), |
| 1712 | (None, None) => return Ok(Outcome::fail(FailureCode::Invalid, "Enter a g1t username or a valid email address.")), |
| 1713 | }; |
| 1714 | let role = a.role.unwrap_or(Role::Member); |
| 1715 | let role_name = if role == Role::Owner { "owner" } else { "member" }; |
| 1716 | let Some(workspace_id) = self.workspace_id(&slug).await? else { |
| 1717 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); |
| 1718 | }; |
| 1719 | // A free workspace invites no one until it starts the plan (paid.rs). |
| 1720 | if let Some(refused) = self.free_workspace_refusal(&slug).await? { |
| 1721 | return Ok(refused); |
| 1722 | } |
| 1723 | let surface = a.surface.unwrap_or(Surface::Web); |
| 1724 | // A note from the inviter goes in the email, cut to its limit. |
| 1725 | let note = invite_note(a.message.as_deref()); |
| 1726 | // Someone on g1t, by username: an invitation to accept or decline |
| 1727 | // (invites/invitations.rs). |
| 1728 | let Some(email) = email else { |
| 1729 | let username = username.unwrap_or_default(); |
| 1730 | return self.invite_account(&a.actor, &slug, &workspace_id, &username, role, note.as_deref(), surface).await; |
| 1731 | }; |
| 1732 | if !self.hit(&format!("invite.create:{}", a.actor.id), CREATES_PER_HOUR).await? { |
| 1733 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); |
| 1734 | } |
| 1735 | let pending = self |
| 1736 | .rows( |
| 1737 | &format!( |
| 1738 | "WHERE i.workspace_id = ? AND i.email = ? |
| 1739 | AND i.redeemed_at IS NULL AND i.revoked_at IS NULL AND i.declined_at IS NULL AND i.expires_at > {SQL_NOW}" |
| 1740 | ), |
| 1741 | &[workspace_id.as_str().into(), email.as_str().into()], |
| 1742 | 1, |
| 1743 | ) |
| 1744 | .await?; |
| 1745 | if !pending.is_empty() { |
| 1746 | return Ok(Outcome::fail( |
| 1747 | FailureCode::Conflict, |
| 1748 | "There is already a pending invite for that address. Revoke it to send a new one.", |
| 1749 | )); |
| 1750 | } |
| 1751 | let has_account = self.email_has_account(&email).await?; |
| 1752 | // The account that has confirmed the address, which the invitation |
| 1753 | // is for. Never shown to the inviter: the answer does not say |
| 1754 | // whether the address has an account. |
| 1755 | let invitee = self.user_with_verified_email(&email).await?; |
| 1756 | let draft = if has_account { |
| 1757 | // Costs nothing: the person is on g1t already. |
| 1758 | Draft { |
| 1759 | email: Some(&email), |
| 1760 | kind: "workspace", |
| 1761 | workspace_id: Some(&workspace_id), |
| 1762 | inviter: Some(&a.actor), |
| 1763 | staff: None, |
| 1764 | charged_to: "none", |
| 1765 | charged_workspace_id: None, |
| 1766 | limit: None, |
| 1767 | invitee_id: invitee.as_deref(), |
| 1768 | role: Some(role_name), |
| 1769 | } |
| 1770 | } else { |
| 1771 | // While g1t is invite-only, the invitation also lets the address |
| 1772 | // make its account, so it costs an invite: the workspace's shared |
| 1773 | // ones first, then the owner's own. Once anyone can sign up, an |
| 1774 | // account needs no invite and it costs nothing. |
| 1775 | let (charged_to, charged_workspace_id, limit) = if self.invites_required() { |
| 1776 | let shared = self.workspace_allowance(&workspace_id).await?; |
| 1777 | if shared.remaining.is_some_and(|left| left > 0) { |
| 1778 | ("workspace", Some(workspace_id.as_str()), shared.limit) |
| 1779 | } else { |
| 1780 | let own = self.user_allowance(&a.actor.id).await?; |
| 1781 | if own.exhausted() { |
| 1782 | return Ok(Self::out_of_invites()); |
| 1783 | } |
| 1784 | ("user", None, own.limit) |
| 1785 | } |
| 1786 | } else { |
| 1787 | ("none", None, None) |
| 1788 | }; |
| 1789 | Draft { |
| 1790 | email: Some(&email), |
| 1791 | kind: "account", |
| 1792 | workspace_id: Some(&workspace_id), |
| 1793 | inviter: Some(&a.actor), |
| 1794 | staff: None, |
| 1795 | charged_to, |
| 1796 | charged_workspace_id, |
| 1797 | limit, |
| 1798 | invitee_id: None, |
| 1799 | role: Some(role_name), |
| 1800 | } |
| 1801 | }; |
| 1802 | let Some(invite) = self.insert_invite(draft).await? else { |
| 1803 | return Ok(Self::out_of_invites()); |
| 1804 | }; |
| 1805 | if let Some(code) = &invite.code { |
| 1806 | let from = self.display_name(&a.actor).await; |
| 1807 | let workspace = self.workspace_name(&workspace_id, &slug).await; |
| 1808 | self.send_invite_email(&email, Some(&from), Some(&workspace), has_account, code, &invite.id, note.as_deref()).await; |
| 1809 | } |
| 1810 | // Someone on g1t hears of it in their inbox too. |
| 1811 | if invitee.is_some() |
| 1812 | && let Some(row) = self.invite_by_id(&invite.id).await? |
| 1813 | && let Some(username) = row.invitee.clone() |
| 1814 | { |
| 1815 | self.invitation_sent(&row, &username).await; |
| 1816 | } |
| 1817 | self.audit_invites(&a.actor, "invite.created", vec![slug.clone()], surface, format!("Invited {email} to {slug} as {role_name}")) |
| 1818 | .await; |
| 1819 | Ok(Outcome::Ok(invite)) |
| 1820 | } |
| 1821 | |
| 1822 | /// An invite code for an address without an account, invited to |
| 1823 | /// collaborate on one repository of `workspace_id` (access.rs). Charged |
| 1824 | /// as a workspace invite is: the workspace's shared invites first, then |
| 1825 | /// the inviter's own. The code joins no workspace; redeeming it accepts |
| 1826 | /// the repository invitation that names it. |
| 1827 | pub(crate) async fn repo_invite_code(&self, actor: &User, email: &str, workspace_id: &str) -> Result<Outcome<Invite>> { |
| 1828 | if let Some(reason) = Self::draft_allowed(actor) { |
| 1829 | return Ok(Outcome::fail(FailureCode::Forbidden, reason)); |
| 1830 | } |
| 1831 | if !self.hit(&format!("invite.create:{}", actor.id), CREATES_PER_HOUR).await? { |
| 1832 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); |
| 1833 | } |
| 1834 | let shared = self.workspace_allowance(workspace_id).await?; |
| 1835 | let (charged_to, charged_workspace_id, limit) = if shared.remaining.is_some_and(|left| left > 0) { |
| 1836 | ("workspace", Some(workspace_id), shared.limit) |
| 1837 | } else { |
| 1838 | let own = self.user_allowance(&actor.id).await?; |
| 1839 | if own.exhausted() { |
| 1840 | return Ok(Self::out_of_invites()); |
| 1841 | } |
| 1842 | ("user", None, own.limit) |
| 1843 | }; |
| 1844 | let draft = Draft { |
| 1845 | email: Some(email), |
| 1846 | kind: "account", |
| 1847 | workspace_id: None, |
| 1848 | inviter: Some(actor), |
| 1849 | staff: None, |
| 1850 | charged_to, |
| 1851 | charged_workspace_id, |
| 1852 | limit, |
| 1853 | invitee_id: None, |
| 1854 | role: None, |
| 1855 | }; |
| 1856 | Ok(match self.insert_invite(draft).await? { |
| 1857 | Some(invite) => Outcome::Ok(invite), |
| 1858 | None => Self::out_of_invites(), |
| 1859 | }) |
| 1860 | } |
| 1861 | |
| 1862 | /// Revokes an invite code made for a repository invitation, when that |
| 1863 | /// invitation is revoked. Only a pending code changes. |
| 1864 | pub(crate) async fn revoke_code(&self, invite_id: &str) -> Result<()> { |
| 1865 | self.db |
| 1866 | .prepare(format!( |
| 1867 | "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL |
| 1868 | WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL" |
| 1869 | )) |
| 1870 | .bind(&[invite_id.into()])? |
| 1871 | .run() |
| 1872 | .await?; |
| 1873 | Ok(()) |
| 1874 | } |
| 1875 | |
| 1876 | pub async fn workspace_invites(&self, a: ListMembersArgs) -> Result<Outcome<Vec<Invite>>> { |
| 1877 | let slug = a.slug.trim().to_lowercase(); |
| 1878 | if !a.viewer.as_ref().is_some_and(|viewer| viewer.role_in(&slug) == Some(Role::Owner)) { |
| 1879 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only owners can see a workspace's invites.")); |
| 1880 | } |
| 1881 | let Some(workspace_id) = self.workspace_id(&slug).await? else { |
| 1882 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); |
| 1883 | }; |
| 1884 | let rows = self.rows("WHERE i.workspace_id = ?", &[workspace_id.as_str().into()], LIST_LIMIT).await?; |
| 1885 | Ok(Outcome::Ok(rows.into_iter().map(|row| self.shown(row, true, false)).collect())) |
| 1886 | } |
| 1887 | |
| 1888 | pub async fn revoke_workspace_invite(&self, a: WorkspaceInviteArgs) -> Result<Outcome<Invite>> { |
| 1889 | let slug = a.slug.trim().to_lowercase(); |
| 1890 | if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) { |
| 1891 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can revoke a workspace's invites.")); |
| 1892 | } |
| 1893 | self.revoke_invite(RemoveArgs { user: a.actor, id: a.id }).await |
| 1894 | } |
| 1895 | |
| 1896 | /// Sends one of the workspace's pending invitations again: the same |
| 1897 | /// email to the address it is bound to, or to the invited account's |
| 1898 | /// confirmed address, and the inbox notice again for an account. The |
| 1899 | /// invitation itself does not change. Counted against the owner's |
| 1900 | /// hourly allowance of invites made, so a list cannot be used to flood |
| 1901 | /// an inbox. |
| 1902 | pub async fn resend_workspace_invite(&self, a: WorkspaceInviteArgs) -> Result<Outcome<Invite>> { |
| 1903 | let slug = a.slug.trim().to_lowercase(); |
| 1904 | if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) { |
| 1905 | return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can send a workspace's invitations again.")); |
| 1906 | } |
| 1907 | let row = self.invite_by_id(a.id.trim()).await?.filter(|row| row.workspace.as_deref() == Some(slug.as_str())); |
| 1908 | let Some(row) = row else { |
| 1909 | return Ok(Outcome::fail(FailureCode::NotFound, "There is no invitation to this workspace with that id.")); |
| 1910 | }; |
| 1911 | let now = rfc3339(now_ms()); |
| 1912 | if !resendable(row.status(&now)) { |
| 1913 | return Ok(Outcome::fail(FailureCode::Conflict, "Only a pending invitation can be sent again.")); |
| 1914 | } |
| 1915 | if !self.hit(&format!("invite.create:{}", a.actor.id), CREATES_PER_HOUR).await? { |
| 1916 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); |
| 1917 | } |
| 1918 | let Some(workspace_id) = row.workspace_id.as_deref() else { |
| 1919 | return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found.")); |
| 1920 | }; |
| 1921 | let code = row.sealed_code.as_deref().and_then(|sealed| self.invite_sealer()?.open(sealed, &row.id)); |
| 1922 | // Where it goes: the address it is bound to, else the invited |
| 1923 | // account's confirmed address. An account invite (one that also |
| 1924 | // makes the account) carries the proof the link came from its email. |
| 1925 | let to = match &row.email { |
| 1926 | Some(email) => Some(email.clone()), |
| 1927 | None => match &row.invitee_id { |
| 1928 | Some(invitee) => self.verified_email_of(invitee).await?, |
| 1929 | None => None, |
| 1930 | }, |
| 1931 | }; |
| 1932 | if let (Some(to), Some(code)) = (&to, &code) { |
| 1933 | let from = self.display_name(&a.actor).await; |
| 1934 | let workspace = self.workspace_name(workspace_id, &slug).await; |
| 1935 | self.send_invite_email(to, Some(&from), Some(&workspace), row.kind == "workspace", code, &row.id, None).await; |
| 1936 | } |
| 1937 | if let Some(username) = row.invitee.as_deref().filter(|_| row.email.is_none()) { |
| 1938 | self.invitation_sent(&row, username).await; |
| 1939 | } |
| 1940 | self.audit_invites(&a.actor, "invite.resent", vec![slug.clone()], Surface::Web, format!("Sent invite {} again", row.hint)).await; |
| 1941 | Ok(Outcome::Ok(self.shown(row, true, false))) |
| 1942 | } |
| 1943 | |
| 1944 | /// The confirmed primary address of the account `user_id`, if it has one. |
| 1945 | async fn verified_email_of(&self, user_id: &str) -> Result<Option<String>> { |
| 1946 | #[derive(Deserialize)] |
| 1947 | struct Address { |
| 1948 | email: Option<String>, |
| 1949 | } |
| 1950 | Ok(self |
| 1951 | .db |
| 1952 | .prepare("SELECT email FROM users WHERE id = ? AND email_verified_at IS NOT NULL AND deleted_at IS NULL") |
| 1953 | .bind(&[user_id.into()])? |
| 1954 | .first::<Address>(None) |
| 1955 | .await? |
| 1956 | .and_then(|row| row.email)) |
| 1957 | } |
| 1958 | |
| 1959 | // --- The waitlist --- |
| 1960 | |
| 1961 | pub async fn request_access(&self, a: RequestAccessArgs) -> Result<Outcome<bool>> { |
| 1962 | let Some(email) = normalize_email(&a.email) else { |
| 1963 | return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL)); |
| 1964 | }; |
| 1965 | let allowed = match a.client.as_deref().filter(|client| !client.is_empty()) { |
| 1966 | Some(client) => self.hit(&format!("waitlist:{}", crypto::sha256_hex(client)), REQUESTS_PER_HOUR).await?, |
| 1967 | None => self.hit("waitlist:anonymous", ANONYMOUS_REQUESTS_PER_HOUR).await?, |
| 1968 | }; |
| 1969 | if !allowed { |
| 1970 | return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY)); |
| 1971 | } |
| 1972 | let about: String = a.about.trim().chars().take(MAX_WAITLIST_ABOUT).collect(); |
| 1973 | let now = rfc3339(now_ms()); |
| 1974 | #[derive(Deserialize)] |
| 1975 | struct Upserted { |
| 1976 | id: String, |
| 1977 | created_at: String, |
| 1978 | } |
| 1979 | let row = self |
| 1980 | .db |
| 1981 | .prepare( |
| 1982 | "INSERT INTO waitlist (id, email, about, status, created_at, updated_at) |
| 1983 | VALUES (?1, ?2, ?3, 'waiting', ?4, ?4) |
| 1984 | ON CONFLICT (email) DO UPDATE SET |
| 1985 | about = COALESCE(excluded.about, waitlist.about), updated_at = excluded.updated_at |
| 1986 | RETURNING id, created_at", |
| 1987 | ) |
| 1988 | .bind(&[ |
| 1989 | new_id("wl", now_ms()).into(), |
| 1990 | email.as_str().into(), |
| 1991 | if about.is_empty() { JsValue::NULL } else { about.as_str().into() }, |
| 1992 | now.as_str().into(), |
| 1993 | ])? |
| 1994 | .first::<Upserted>(None) |
| 1995 | .await?; |
| 1996 | if let Some(row) = row.filter(|row| row.created_at == now) { |
| 1997 | self.announce("waitlist.requested", None, WaitlistRequested { entry_id: row.id.clone() }).await; |
| 1998 | self.acknowledge_request(&row.id, &email).await?; |
| 1999 | self.notify_staff_of_requests().await?; |
| 2000 | } |
| 2001 | Ok(Outcome::Ok(true)) |
| 2002 | } |
| 2003 | |
| 2004 | /// The one confirmation an address gets for asking: claimed in the |
| 2005 | /// database first, so a repeat request (or two at once) never sends a |
| 2006 | /// second, and capped across everyone, since anyone can type any |
| 2007 | /// address. |
| 2008 | async fn acknowledge_request(&self, id: &str, email: &str) -> Result<()> { |
| 2009 | if !self.hit("waitlist.ack", CONFIRMATIONS_PER_HOUR).await? { |
| 2010 | return Ok(()); |
| 2011 | } |
| 2012 | let claimed = self |
| 2013 | .db |
| 2014 | .prepare(format!( |
| 2015 | "UPDATE waitlist SET acknowledged_at = {SQL_NOW} WHERE id = ? AND acknowledged_at IS NULL RETURNING id" |
| 2016 | )) |
| 2017 | .bind(&[id.into()])? |
| 2018 | .first::<Id>(None) |
| 2019 | .await?; |
| 2020 | if claimed.is_none() { |
| 2021 | return Ok(()); |
| 2022 | } |
| 2023 | if let Err(error) = crate::email::send_waitlist_confirmation(&self.env, email).await { |
| 2024 | worker::console_error!("waitlist confirmation failed: {error}"); |
| 2025 | // Not sent: leave it unclaimed, so staff can see it was not. |
| 2026 | self.db |
| 2027 | .prepare("UPDATE waitlist SET acknowledged_at = NULL WHERE id = ?") |
| 2028 | .bind(&[id.into()])? |
| 2029 | .run() |
| 2030 | .await?; |
| 2031 | } |
| 2032 | Ok(()) |
| 2033 | } |
| 2034 | |
| 2035 | /// Where staff hear about new requests: WAITLIST_NOTIFY_EMAIL, unset or |
| 2036 | /// empty for nobody. |
| 2037 | fn waitlist_notify_email(&self) -> Option<String> { |
| 2038 | let to = self.env.var("WAITLIST_NOTIFY_EMAIL").ok()?.to_string(); |
| 2039 | normalize_email(&to) |
| 2040 | } |
| 2041 | |
| 2042 | /// Tells staff about every request they have not heard about, unless a |
| 2043 | /// summary went in the last 15 minutes: then the next request after |
| 2044 | /// that brings them all in one. The rows are claimed before sending, so |
| 2045 | /// two requests at once send one summary. |
| 2046 | pub(crate) async fn notify_staff_of_requests(&self) -> Result<()> { |
| 2047 | let Some(to) = self.waitlist_notify_email() else { |
| 2048 | return Ok(()); |
| 2049 | }; |
| 2050 | #[derive(Deserialize)] |
| 2051 | struct Last { |
| 2052 | at: Option<String>, |
| 2053 | } |
| 2054 | let last = self |
| 2055 | .db |
| 2056 | .prepare("SELECT max(notified_at) AS at FROM waitlist") |
| 2057 | .first::<Last>(None) |
| 2058 | .await? |
| 2059 | .and_then(|last| last.at); |
| 2060 | let now = now_ms(); |
| 2061 | if !summary_due(last.as_deref(), &rfc3339(now.saturating_sub(SUMMARY_EVERY_MS))) { |
| 2062 | return Ok(()); |
| 2063 | } |
| 2064 | let stamp = rfc3339(now); |
| 2065 | #[derive(Deserialize)] |
| 2066 | struct New { |
| 2067 | email: String, |
| 2068 | about: Option<String>, |
| 2069 | created_at: String, |
| 2070 | } |
| 2071 | let mut new = self |
| 2072 | .db |
| 2073 | .prepare( |
| 2074 | "UPDATE waitlist SET notified_at = ? WHERE notified_at IS NULL AND status = 'waiting' |
| 2075 | RETURNING email, about, created_at", |
| 2076 | ) |
| 2077 | .bind(&[stamp.as_str().into()])? |
| 2078 | .all() |
| 2079 | .await? |
| 2080 | .results::<New>()?; |
| 2081 | if new.is_empty() { |
| 2082 | return Ok(()); |
| 2083 | } |
| 2084 | new.sort_by(|a, b| a.created_at.cmp(&b.created_at)); |
| 2085 | let waiting = self |
| 2086 | .db |
| 2087 | .prepare("SELECT count(*) AS n FROM waitlist WHERE status = 'waiting'") |
| 2088 | .first::<Count>(None) |
| 2089 | .await? |
| 2090 | .map_or(0, |count| count.n as u32); |
| 2091 | let new: Vec<crate::email::Requested> = new |
| 2092 | .into_iter() |
| 2093 | .map(|row| crate::email::Requested { email: row.email, about: row.about }) |
| 2094 | .collect(); |
| 2095 | if let Err(error) = crate::email::send_waitlist_summary(&self.env, &to, &new, waiting).await { |
| 2096 | worker::console_error!("waitlist summary failed: {error}"); |
| 2097 | // Not sent: the next request tries again with these too. |
| 2098 | self.db |
| 2099 | .prepare("UPDATE waitlist SET notified_at = NULL WHERE notified_at = ?") |
| 2100 | .bind(&[stamp.as_str().into()])? |
| 2101 | .run() |
| 2102 | .await?; |
| 2103 | } |
| 2104 | Ok(()) |
| 2105 | } |
| 2106 | |
| 2107 | // --- Staff --- |
| 2108 | |
| 2109 | pub async fn admin_waitlist(&self, a: AdminWaitlistArgs) -> Result<Vec<WaitlistEntry>> { |
| 2110 | let mut filters = Vec::new(); |
| 2111 | let mut binds: Vec<JsValue> = Vec::new(); |
| 2112 | if let Some(status) = a.status { |
| 2113 | filters.push("wl.status = ?".to_owned()); |
| 2114 | binds.push(status.as_str().into()); |
| 2115 | } |
| 2116 | if let Some(pattern) = crate::admin::like_pattern(a.query.as_deref()) { |
| 2117 | filters.push("(wl.email LIKE ? ESCAPE '\\' OR lower(wl.about) LIKE ? ESCAPE '\\')".to_owned()); |
| 2118 | binds.push(pattern.as_str().into()); |
| 2119 | binds.push(pattern.as_str().into()); |
| 2120 | } |
| 2121 | let filter = if filters.is_empty() { String::new() } else { format!("WHERE {}", filters.join(" AND ")) }; |
| 2122 | Ok(self |
| 2123 | .db |
| 2124 | .prepare(format!( |
| 2125 | "SELECT {WAITLIST_COLUMNS} {filter} ORDER BY wl.created_at DESC, wl.id DESC LIMIT {ADMIN_INVITES_LIMIT}" |
| 2126 | )) |
| 2127 | .bind(&binds)? |
| 2128 | .all() |
| 2129 | .await? |
| 2130 | .results::<WaitlistRow>()? |
| 2131 | .into_iter() |
| 2132 | .map(WaitlistEntry::from) |
| 2133 | .collect()) |
| 2134 | } |
| 2135 | |
| 2136 | async fn waitlist_entry(&self, id: &str) -> Result<Option<WaitlistRow>> { |
| 2137 | self.db |
| 2138 | .prepare(format!("SELECT {WAITLIST_COLUMNS} WHERE wl.id = ?")) |
| 2139 | .bind(&[id.into()])? |
| 2140 | .first::<WaitlistRow>(None) |
| 2141 | .await |
| 2142 | } |
| 2143 | |
| 2144 | /// How many requests are waiting, for sudo's navigation. |
| 2145 | pub async fn admin_waitlist_pending(&self) -> Result<u32> { |
| 2146 | Ok(self |
| 2147 | .db |
| 2148 | .prepare("SELECT count(*) AS n FROM waitlist WHERE status = 'waiting'") |
| 2149 | .first::<Count>(None) |
| 2150 | .await? |
| 2151 | .map_or(0, |count| count.n as u32)) |
| 2152 | } |
| 2153 | |
| 2154 | pub async fn admin_decide_waitlist(&self, a: AdminDecideWaitlistArgs) -> Result<Outcome<WaitlistEntry>> { |
| 2155 | let Some(entry) = self.waitlist_entry(&a.id).await? else { |
| 2156 | return Ok(Outcome::fail(FailureCode::NotFound, "That request is not on the waitlist.")); |
| 2157 | }; |
| 2158 | let staff = a.staff.trim(); |
| 2159 | if staff.is_empty() { |
| 2160 | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member decided.")); |
| 2161 | } |
| 2162 | if entry.status != "waiting" { |
| 2163 | return Ok(Outcome::fail( |
| 2164 | FailureCode::Conflict, |
| 2165 | format!("{} was already {} by {}.", entry.email, entry.status, entry.decided_by.as_deref().unwrap_or("staff")), |
| 2166 | )); |
| 2167 | } |
| 2168 | let note: String = a.note.as_deref().unwrap_or_default().trim().chars().take(MAX_WAITLIST_NOTE).collect(); |
| 2169 | let note = (!note.is_empty()).then_some(note); |
| 2170 | let mut invite_id = JsValue::NULL; |
| 2171 | if a.approve { |
| 2172 | if self.email_has_account(&entry.email).await? { |
| 2173 | return Ok(Outcome::fail(FailureCode::Conflict, "That address already has a g1t account.")); |
| 2174 | } |
| 2175 | let minted = match self.mint_staff_invite(Some(entry.email.clone()), staff, note.as_deref()).await? { |
| 2176 | Outcome::Ok(invite) => invite, |
| 2177 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), |
| 2178 | }; |
| 2179 | invite_id = minted.id.as_str().into(); |
| 2180 | } |
| 2181 | self.db |
| 2182 | .prepare(format!( |
| 2183 | "UPDATE waitlist SET status = ?, invite_id = COALESCE(?, invite_id), decided_by = ?, decided_at = {SQL_NOW}, |
| 2184 | note = ?, notified_at = COALESCE(notified_at, {SQL_NOW}) |
| 2185 | WHERE id = ?" |
| 2186 | )) |
| 2187 | .bind(&[ |
| 2188 | if a.approve { "invited" } else { "dismissed" }.into(), |
| 2189 | invite_id, |
| 2190 | staff.into(), |
| 2191 | note.as_deref().map_or(JsValue::NULL, JsValue::from), |
| 2192 | entry.id.as_str().into(), |
| 2193 | ])? |
| 2194 | .run() |
| 2195 | .await?; |
| 2196 | Ok(match self.waitlist_entry(&entry.id).await? { |
| 2197 | Some(row) => Outcome::Ok(row.into()), |
| 2198 | None => Outcome::fail(FailureCode::NotFound, "That request is not on the waitlist."), |
| 2199 | }) |
| 2200 | } |
| 2201 | |
| 2202 | pub async fn admin_invites(&self, a: AdminInvitesArgs) -> Result<Vec<Invite>> { |
| 2203 | let query = a.query.as_deref().map(str::trim).filter(|query| !query.is_empty()); |
| 2204 | let rows = match query { |
| 2205 | None => self.rows("", &[], ADMIN_INVITES_LIMIT as u32).await?, |
| 2206 | Some(query) => { |
| 2207 | // A code, or its start: matched by its hint. |
| 2208 | let prefix = query.to_lowercase(); |
| 2209 | let prefix = prefix.strip_prefix("g1t-").unwrap_or(&prefix).replace('-', ""); |
| 2210 | let hint = (prefix.len() >= GROUP && prefix.chars().all(|c| ALPHABET.contains(&(c as u8)))) |
| 2211 | .then(|| code_hint(&prefix)); |
| 2212 | let pattern = crate::admin::like_pattern(Some(query)).unwrap_or_default(); |
| 2213 | let mut binds: Vec<JsValue> = vec![pattern.as_str().into(), pattern.as_str().into(), pattern.as_str().into()]; |
| 2214 | let mut filter = "WHERE (lower(i.email) LIKE ? ESCAPE '\\' OR iu.username LIKE ? ESCAPE '\\' OR ru.username LIKE ? ESCAPE '\\'".to_owned(); |
| 2215 | if let Some(hint) = hint { |
| 2216 | filter.push_str(" OR i.hint = ?"); |
| 2217 | binds.push(hint.into()); |
| 2218 | } |
| 2219 | filter.push(')'); |
| 2220 | self.rows(&filter, &binds, ADMIN_INVITES_LIMIT as u32).await? |
| 2221 | } |
| 2222 | }; |
| 2223 | Ok(rows.into_iter().map(|row| self.shown(row, false, true)).collect()) |
| 2224 | } |
| 2225 | |
| 2226 | pub async fn admin_revoke_invite(&self, a: AdminRevokeInviteArgs) -> Result<Outcome<Invite>> { |
| 2227 | let revoked = self |
| 2228 | .db |
| 2229 | .prepare(format!( |
| 2230 | "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL |
| 2231 | WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL RETURNING id" |
| 2232 | )) |
| 2233 | .bind(&[a.id.as_str().into()])? |
| 2234 | .first::<Id>(None) |
| 2235 | .await?; |
| 2236 | if revoked.is_none() { |
| 2237 | return Ok(Outcome::fail(FailureCode::Conflict, "Only a pending invite can be revoked.")); |
| 2238 | } |
| 2239 | worker::console_log!("invite {} revoked by staff {}", a.id, a.staff); |
| 2240 | Ok(match self.invite_by_id(&a.id).await? { |
| 2241 | Some(row) => Outcome::Ok(self.shown(row, false, true)), |
| 2242 | None => Outcome::fail(FailureCode::NotFound, "Invite not found."), |
| 2243 | }) |
| 2244 | } |
| 2245 | |
| 2246 | pub async fn admin_mint_invite(&self, a: AdminMintInviteArgs) -> Result<Outcome<Invite>> { |
| 2247 | self.mint_staff_invite(a.email, &a.staff, None).await |
| 2248 | } |
| 2249 | |
| 2250 | /// An invite staff make, emailed with `note` when it is for an address. |
| 2251 | async fn mint_staff_invite(&self, email: Option<String>, staff: &str, note: Option<&str>) -> Result<Outcome<Invite>> { |
| 2252 | let staff = staff.trim(); |
| 2253 | if staff.is_empty() { |
| 2254 | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is minting it.")); |
| 2255 | } |
| 2256 | let email = match email.as_deref().map(str::trim).filter(|email| !email.is_empty()) { |
| 2257 | Some(email) => match normalize_email(email) { |
| 2258 | Some(email) => Some(email), |
| 2259 | None => return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL)), |
| 2260 | }, |
| 2261 | None => None, |
| 2262 | }; |
| 2263 | let draft = Draft { |
| 2264 | email: email.as_deref(), |
| 2265 | kind: "account", |
| 2266 | workspace_id: None, |
| 2267 | inviter: None, |
| 2268 | staff: Some(staff), |
| 2269 | charged_to: "none", |
| 2270 | charged_workspace_id: None, |
| 2271 | limit: None, |
| 2272 | invitee_id: None, |
| 2273 | role: None, |
| 2274 | }; |
| 2275 | let Some(mut invite) = self.insert_invite(draft).await? else { |
| 2276 | return Ok(Outcome::fail(FailureCode::Conflict, "The invite could not be made. Try again.")); |
| 2277 | }; |
| 2278 | if let (Some(email), Some(code)) = (&email, &invite.code) { |
| 2279 | self.send_invite_email(email, None, None, false, code, &invite.id, note).await; |
| 2280 | } |
| 2281 | invite.staff = Some(staff.to_owned()); |
| 2282 | Ok(Outcome::Ok(invite)) |
| 2283 | } |
| 2284 | |
| 2285 | pub async fn admin_grant_invites(&self, a: AdminGrantInvitesArgs) -> Result<Outcome<Allowance>> { |
| 2286 | if a.amount == 0 || a.amount.abs() > MAX_INVITE_GRANT { |
| 2287 | return Ok(Outcome::fail(FailureCode::Invalid, format!("Grant between 1 and {MAX_INVITE_GRANT} invites, or take some back with a negative number."))); |
| 2288 | } |
| 2289 | let staff = a.staff.trim(); |
| 2290 | if staff.is_empty() { |
| 2291 | return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member granted them.")); |
| 2292 | } |
| 2293 | let name = a.name.trim().to_lowercase(); |
| 2294 | let target_id = match a.target { |
| 2295 | GrantTarget::User => self |
| 2296 | .db |
| 2297 | .prepare("SELECT id FROM users WHERE username = ?") |
| 2298 | .bind(&[name.as_str().into()])? |
| 2299 | .first::<Id>(None) |
| 2300 | .await? |
| 2301 | .map(|row| row.id), |
| 2302 | GrantTarget::Workspace => self.workspace_id(&name).await?, |
| 2303 | }; |
| 2304 | let Some(target_id) = target_id else { |
| 2305 | return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no {} named {name}.", a.target.as_str()))); |
| 2306 | }; |
| 2307 | let note = a.note.trim(); |
| 2308 | self.db |
| 2309 | .prepare( |
| 2310 | "INSERT INTO invite_grants (id, target_kind, target_id, amount, note, granted_by, created_at) |
| 2311 | VALUES (?, ?, ?, ?, ?, ?, ?)", |
| 2312 | ) |
| 2313 | .bind(&[ |
| 2314 | new_id("igr", now_ms()).into(), |
| 2315 | a.target.as_str().into(), |
| 2316 | target_id.as_str().into(), |
| 2317 | f64::from(a.amount).into(), |
| 2318 | if note.is_empty() { JsValue::NULL } else { note.into() }, |
| 2319 | staff.into(), |
| 2320 | rfc3339(now_ms()).into(), |
| 2321 | ])? |
| 2322 | .run() |
| 2323 | .await?; |
| 2324 | Ok(Outcome::Ok(match a.target { |
| 2325 | GrantTarget::User => self.user_allowance(&target_id).await?, |
| 2326 | GrantTarget::Workspace => self.workspace_allowance(&target_id).await?, |
| 2327 | })) |
| 2328 | } |
| 2329 | |
| 2330 | async fn grants(&self, target: GrantTarget, id: &str) -> Result<Vec<InviteGrant>> { |
| 2331 | #[derive(Deserialize)] |
| 2332 | struct Row { |
| 2333 | amount: f64, |
| 2334 | note: Option<String>, |
| 2335 | granted_by: String, |
| 2336 | created_at: String, |
| 2337 | } |
| 2338 | Ok(self |
| 2339 | .db |
| 2340 | .prepare( |
| 2341 | "SELECT amount, note, granted_by, created_at FROM invite_grants |
| 2342 | WHERE target_kind = ? AND target_id = ? ORDER BY created_at DESC LIMIT 100", |
| 2343 | ) |
| 2344 | .bind(&[target.as_str().into(), id.into()])? |
| 2345 | .all() |
| 2346 | .await? |
| 2347 | .results::<Row>()? |
| 2348 | .into_iter() |
| 2349 | .map(|row| InviteGrant { |
| 2350 | amount: row.amount as i32, |
| 2351 | note: row.note, |
| 2352 | granted_by: row.granted_by, |
| 2353 | created_at: row.created_at, |
| 2354 | }) |
| 2355 | .collect()) |
| 2356 | } |
| 2357 | |
| 2358 | /// Whom `user_id` invited, `depth` levels down. |
| 2359 | async fn invited_by_user(&self, user_id: &str, depth: usize) -> Result<Vec<InviteTreeNode>> { |
| 2360 | #[derive(Deserialize)] |
| 2361 | struct Row { |
| 2362 | id: String, |
| 2363 | username: String, |
| 2364 | redeemed_at: String, |
| 2365 | } |
| 2366 | let rows = self |
| 2367 | .db |
| 2368 | .prepare( |
| 2369 | "SELECT u.id, u.username, i.redeemed_at FROM invites i JOIN users u ON u.id = i.redeemed_by |
| 2370 | WHERE i.inviter_id = ? AND i.kind = 'account' ORDER BY i.redeemed_at LIMIT 200", |
| 2371 | ) |
| 2372 | .bind(&[user_id.into()])? |
| 2373 | .all() |
| 2374 | .await? |
| 2375 | .results::<Row>()?; |
| 2376 | let mut nodes = Vec::with_capacity(rows.len()); |
| 2377 | for row in rows { |
| 2378 | let invited = if depth > 1 { Box::pin(self.invited_by_user(&row.id, depth - 1)).await? } else { Vec::new() }; |
| 2379 | nodes.push(InviteTreeNode { |
| 2380 | username: row.username, |
| 2381 | joined_at: row.redeemed_at, |
| 2382 | invited, |
| 2383 | }); |
| 2384 | } |
| 2385 | Ok(nodes) |
| 2386 | } |
| 2387 | |
| 2388 | pub async fn admin_invite_tree(&self, a: UsernameArgs) -> Result<Option<InviteTree>> { |
| 2389 | let name = a.username.trim().to_lowercase(); |
| 2390 | let Some(user) = self |
| 2391 | .db |
| 2392 | .prepare("SELECT id FROM users WHERE username = ?") |
| 2393 | .bind(&[name.as_str().into()])? |
| 2394 | .first::<Id>(None) |
| 2395 | .await? |
| 2396 | else { |
| 2397 | return Ok(None); |
| 2398 | }; |
| 2399 | // Up the tree: who invited them, and who invited that person. |
| 2400 | #[derive(Deserialize)] |
| 2401 | struct Parent { |
| 2402 | inviter_id: Option<String>, |
| 2403 | inviter: Option<String>, |
| 2404 | staff: Option<String>, |
| 2405 | } |
| 2406 | let mut invited_by = Vec::new(); |
| 2407 | let mut staff = None; |
| 2408 | let mut current = user.id.clone(); |
| 2409 | for _ in 0..20 { |
| 2410 | let parent = self |
| 2411 | .db |
| 2412 | .prepare( |
| 2413 | "SELECT i.inviter_id, u.username AS inviter, i.staff FROM invites i |
| 2414 | LEFT JOIN users u ON u.id = i.inviter_id |
| 2415 | WHERE i.redeemed_by = ? AND i.kind = 'account' LIMIT 1", |
| 2416 | ) |
| 2417 | .bind(&[current.as_str().into()])? |
| 2418 | .first::<Parent>(None) |
| 2419 | .await?; |
| 2420 | let Some(parent) = parent else { break }; |
| 2421 | if invited_by.is_empty() { |
| 2422 | staff = parent.staff.clone(); |
| 2423 | } |
| 2424 | match (parent.inviter_id, parent.inviter) { |
| 2425 | (Some(id), Some(username)) if !invited_by.contains(&username) => { |
| 2426 | invited_by.push(username); |
| 2427 | current = id; |
| 2428 | } |
| 2429 | _ => break, |
| 2430 | } |
| 2431 | } |
| 2432 | let invites = self |
| 2433 | .rows("WHERE i.inviter_id = ?", &[user.id.as_str().into()], LIST_LIMIT) |
| 2434 | .await? |
| 2435 | .into_iter() |
| 2436 | .map(|row| self.shown(row, false, true)) |
| 2437 | .collect(); |
| 2438 | Ok(Some(InviteTree { |
| 2439 | username: name, |
| 2440 | invited_by, |
| 2441 | staff, |
| 2442 | allowance: self.user_allowance(&user.id).await?, |
| 2443 | grants: self.grants(GrantTarget::User, &user.id).await?, |
| 2444 | invites, |
| 2445 | invited: self.invited_by_user(&user.id, TREE_DEPTH).await?, |
| 2446 | shared: self.shared_source(&user.id).await?, |
| 2447 | })) |
| 2448 | } |
| 2449 | |
| 2450 | pub async fn admin_workspace_invites(&self, a: SlugArgs) -> Result<Option<InviteTree>> { |
| 2451 | let slug = a.slug.trim().to_lowercase(); |
| 2452 | let Some(id) = self.workspace_id(&slug).await? else { |
| 2453 | return Ok(None); |
| 2454 | }; |
| 2455 | let invites = self |
| 2456 | .rows("WHERE i.workspace_id = ?1 OR i.charged_workspace_id = ?1", &[id.as_str().into()], LIST_LIMIT) |
| 2457 | .await? |
| 2458 | .into_iter() |
| 2459 | .map(|row| self.shown(row, false, true)) |
| 2460 | .collect(); |
| 2461 | Ok(Some(InviteTree { |
| 2462 | username: slug, |
| 2463 | invited_by: Vec::new(), |
| 2464 | staff: None, |
| 2465 | allowance: self.workspace_allowance(&id).await?, |
| 2466 | grants: self.grants(GrantTarget::Workspace, &id).await?, |
| 2467 | invites, |
| 2468 | invited: Vec::new(), |
| 2469 | shared: None, |
| 2470 | })) |
| 2471 | } |
| 2472 | |
| 2473 | // --- Audit --- |
| 2474 | |
| 2475 | async fn audit_invites(&self, actor: &User, action: &str, workspaces: Vec<String>, surface: Surface, message: String) { |
| 2476 | let Ok(events) = self.env.service("EVENTS") else { |
| 2477 | return; |
| 2478 | }; |
| 2479 | let entries: Vec<NewAuditEntry> = workspaces |
| 2480 | .into_iter() |
| 2481 | .map(|workspace| NewAuditEntry { |
| 2482 | actor: AuditActor::of(actor), |
| 2483 | action: action.to_owned(), |
| 2484 | surface, |
| 2485 | target: AuditTarget { |
| 2486 | workspace, |
| 2487 | ..AuditTarget::default() |
| 2488 | }, |
| 2489 | outcome: AuditOutcome::Allowed, |
| 2490 | rule: "invite".to_owned(), |
| 2491 | result: Some("ok".to_owned()), |
| 2492 | message: Some(message.clone()), |
| 2493 | request_id: new_id("req", now_ms()), |
| 2494 | }) |
| 2495 | .collect(); |
| 2496 | if entries.is_empty() { |
| 2497 | return; |
| 2498 | } |
| 2499 | let recorded: Result<u32> = g1t_kit::call(&events, "audit_record", &RecordAuditArgs { entries }).await; |
| 2500 | if let Err(error) = recorded { |
| 2501 | worker::console_error!("{action} not recorded: {error}"); |
| 2502 | } |
| 2503 | } |
| 2504 | } |
| 2505 | |
| 2506 | /// Whether using the invite joins a workspace. |
| 2507 | fn joins_workspace(row: &InviteRow) -> bool { |
| 2508 | row.workspace_id.is_some() |
| 2509 | } |
| 2510 | |
| 2511 | #[cfg(test)] |
| 2512 | mod tests { |
| 2513 | use super::*; |
| 2514 | |
| 2515 | #[test] |
| 2516 | fn codes_carry_160_bits_in_eight_groups() { |
| 2517 | assert_eq!(encode(&[0u8; 20]), "0".repeat(32)); |
| 2518 | assert_eq!(encode(&[0xff; 20]), "z".repeat(32)); |
| 2519 | let body = new_code_body(); |
| 2520 | assert_eq!(body.len(), CODE_LENGTH); |
| 2521 | assert!(body.bytes().all(|b| ALPHABET.contains(&b))); |
| 2522 | let code = format_code(&body); |
| 2523 | assert!(code.starts_with("g1t-")); |
| 2524 | assert_eq!(code.split('-').count(), 9); |
| 2525 | assert_eq!(code.len(), 4 + 32 + 7); |
| 2526 | // Every bit is used: one bit set shows in exactly one character. |
| 2527 | let mut bytes = [0u8; 20]; |
| 2528 | bytes[19] = 1; |
| 2529 | assert_eq!(encode(&bytes), format!("{}1", "0".repeat(31))); |
| 2530 | } |
| 2531 | |
| 2532 | #[test] |
| 2533 | fn only_a_pending_invitation_is_sent_again() { |
| 2534 | assert!(resendable(InviteStatus::Pending)); |
| 2535 | for over in [ |
| 2536 | InviteStatus::AwaitingConfirmation, |
| 2537 | InviteStatus::AwaitingAnswer, |
| 2538 | InviteStatus::Redeemed, |
| 2539 | InviteStatus::Declined, |
| 2540 | InviteStatus::Expired, |
| 2541 | InviteStatus::Revoked, |
| 2542 | ] { |
| 2543 | assert!(!resendable(over), "{over:?}"); |
| 2544 | } |
| 2545 | } |
| 2546 | |
| 2547 | #[test] |
| 2548 | fn an_inviters_note_is_trimmed_cut_and_dropped_when_blank() { |
| 2549 | assert_eq!(invite_note(None), None); |
| 2550 | assert_eq!(invite_note(Some(" ")), None); |
| 2551 | assert_eq!(invite_note(Some(" Welcome aboard ")).as_deref(), Some("Welcome aboard")); |
| 2552 | let long = "x".repeat(MAX_INVITE_MESSAGE + 40); |
| 2553 | assert_eq!(invite_note(Some(&long)).map(|note| note.chars().count()), Some(MAX_INVITE_MESSAGE)); |
| 2554 | } |
| 2555 | |
| 2556 | #[test] |
| 2557 | fn codes_are_not_repeated() { |
| 2558 | let codes: std::collections::HashSet<String> = (0..2000).map(|_| new_code_body()).collect(); |
| 2559 | assert_eq!(codes.len(), 2000); |
| 2560 | } |
| 2561 | |
| 2562 | #[test] |
| 2563 | fn a_code_reads_however_it_is_typed_or_pasted() { |
| 2564 | let body = "k7m2q9xd4hpwabcd0123456789efghjk"; |
| 2565 | let shown = format_code(body); |
| 2566 | for typed in [ |
| 2567 | shown.clone(), |
| 2568 | shown.to_uppercase(), |
| 2569 | body.to_owned(), |
| 2570 | format!(" {} ", shown.replace('-', " ")), |
| 2571 | format!("https://g1t.sh/invite/{shown}"), |
| 2572 | format!("https://g1t.sh/register?invite={shown}&next=/"), |
| 2573 | ] { |
| 2574 | assert_eq!(normalize_code(&typed).as_deref(), Some(body), "{typed}"); |
| 2575 | } |
| 2576 | // Letters people misread are read as Crockford reads them. |
| 2577 | assert_eq!(normalize_code(&"o".repeat(32)), Some("0".repeat(32))); |
| 2578 | assert_eq!(normalize_code(&"il".repeat(16)), Some("1".repeat(32))); |
| 2579 | assert_eq!(normalize_code("g1t-k7m2"), None); |
| 2580 | assert_eq!(normalize_code(&format!("{body}0")), None); |
| 2581 | assert_eq!(normalize_code(&"u".repeat(32)), None); |
| 2582 | assert_eq!(normalize_code(""), None); |
| 2583 | } |
| 2584 | |
| 2585 | #[test] |
| 2586 | fn only_the_hash_and_a_short_hint_are_kept() { |
| 2587 | let body = "k7m2q9xd4hpwabcd0123456789efghjk"; |
| 2588 | assert_eq!(code_hash(body), crypto::sha256_hex(body)); |
| 2589 | assert_eq!(code_hash(body).len(), 64); |
| 2590 | assert_ne!(code_hash(body), code_hash(&body.replace('k', "m"))); |
| 2591 | assert_eq!(code_hint(body), "g1t-k7m2"); |
| 2592 | // The same code typed differently finds the same row. |
| 2593 | let typed = normalize_code(&format_code(body).to_uppercase()).unwrap(); |
| 2594 | assert_eq!(code_hash(&typed), code_hash(body)); |
| 2595 | } |
| 2596 | |
| 2597 | const NOW: &str = "2026-10-05T12:00:00.000Z"; |
| 2598 | const LATER: &str = "2026-11-04T12:00:00.000Z"; |
| 2599 | const EARLIER: &str = "2026-10-01T12:00:00.000Z"; |
| 2600 | |
| 2601 | #[test] |
| 2602 | fn an_invite_is_pending_until_used_revoked_or_expired() { |
| 2603 | assert_eq!(status_of(None, None, None, LATER, NOW), InviteStatus::Pending); |
| 2604 | assert_eq!(status_of(None, None, None, EARLIER, NOW), InviteStatus::Expired); |
| 2605 | assert_eq!(status_of(None, None, None, NOW, NOW), InviteStatus::Expired); |
| 2606 | assert_eq!(status_of(Some(EARLIER), None, None, LATER, NOW), InviteStatus::Revoked); |
| 2607 | assert_eq!(status_of(None, Some(EARLIER), Some(EARLIER), EARLIER, NOW), InviteStatus::Redeemed); |
| 2608 | } |
| 2609 | |
| 2610 | #[test] |
| 2611 | fn an_invite_used_to_sign_up_awaits_the_account_confirming_its_address() { |
| 2612 | // Spent, not applied: waiting, even past its expiry. |
| 2613 | assert_eq!(status_of(None, Some(EARLIER), None, LATER, NOW), InviteStatus::AwaitingConfirmation); |
| 2614 | assert_eq!(status_of(None, Some(EARLIER), None, EARLIER, NOW), InviteStatus::AwaitingConfirmation); |
| 2615 | // Revoked while waiting: revoked, whatever happens when it settles. |
| 2616 | assert_eq!(status_of(Some(NOW), Some(EARLIER), None, LATER, NOW), InviteStatus::Revoked); |
| 2617 | assert_eq!(status_of(Some(NOW), Some(EARLIER), Some(NOW), LATER, NOW), InviteStatus::Revoked); |
| 2618 | // A spent invite still counts against the allowance while it waits, |
| 2619 | // and cannot be used again. |
| 2620 | assert!(counts_against_allowance(InviteStatus::AwaitingConfirmation)); |
| 2621 | let waiting = invite("account", None, InviteStatus::AwaitingConfirmation); |
| 2622 | assert_eq!(admits(Some(&waiting), "anyone@example.com", true), Err(Refusal::Invalid)); |
| 2623 | } |
| 2624 | |
| 2625 | fn row(workspace: Option<(&str, Option<&str>)>, revoked: bool, expires_at: &str) -> InviteRow { |
| 2626 | InviteRow { |
| 2627 | id: "inv_1".into(), |
| 2628 | hint: "g1t-k7m2".into(), |
| 2629 | sealed_code: None, |
| 2630 | email: Some("ada@example.com".into()), |
| 2631 | kind: "account".into(), |
| 2632 | workspace_id: workspace.map(|(id, _)| id.to_owned()), |
| 2633 | workspace: workspace.and_then(|(_, slug)| slug.map(str::to_owned)), |
| 2634 | inviter_id: Some("usr_owner".into()), |
| 2635 | inviter: Some("bo".into()), |
| 2636 | staff: None, |
| 2637 | charged_to: "user".into(), |
| 2638 | created_at: EARLIER.into(), |
| 2639 | expires_at: expires_at.into(), |
| 2640 | revoked_at: revoked.then(|| NOW.to_owned()), |
| 2641 | redeemer: Some("ada".into()), |
| 2642 | redeemed_at: Some(EARLIER.into()), |
| 2643 | applied_at: None, |
| 2644 | invitee_id: Some("usr_ada".into()), |
| 2645 | invitee: Some("ada".into()), |
| 2646 | role: None, |
| 2647 | accepted_at: None, |
| 2648 | declined_at: None, |
| 2649 | } |
| 2650 | } |
| 2651 | |
| 2652 | #[test] |
| 2653 | fn confirming_joins_the_workspace_the_invite_named_while_it_still_applies() { |
| 2654 | // Confirming no longer joins anything by itself: the workspace the |
| 2655 | // invite named becomes an invitation the person accepts or declines |
| 2656 | // (invites/invitations.rs), and accepting joins it. |
| 2657 | let good = row(Some(("wsp_1", Some("acme"))), false, LATER); |
| 2658 | assert_eq!(awaiting_join(&good, NOW), AwaitingJoin::Invited { workspace_id: "wsp_1".into(), slug: "acme".into() }); |
| 2659 | // No workspace: nothing to join, and what came with it is accepted. |
| 2660 | assert_eq!(awaiting_join(&row(None, false, LATER), NOW), AwaitingJoin::Nothing); |
| 2661 | // Confirmed and not yet answered: awaiting the answer. |
| 2662 | let confirmed = InviteRow { applied_at: Some(NOW.into()), ..good }; |
| 2663 | assert_eq!(confirmed.status(NOW), InviteStatus::AwaitingAnswer); |
| 2664 | // Accepted: used. |
| 2665 | let accepted = InviteRow { accepted_at: Some(NOW.into()), ..confirmed }; |
| 2666 | assert_eq!(accepted.status(NOW), InviteStatus::Redeemed); |
| 2667 | } |
| 2668 | |
| 2669 | #[test] |
| 2670 | fn a_revoked_or_expired_invite_or_a_deleted_workspace_lapses_and_the_address_is_confirmed_anyway() { |
| 2671 | let lapsed = |join: AwaitingJoin| match join { |
| 2672 | AwaitingJoin::Lapsed(why) => why, |
| 2673 | other => panic!("expected a lapse, got {other:?}"), |
| 2674 | }; |
| 2675 | let revoked = lapsed(awaiting_join(&row(Some(("wsp_1", Some("acme"))), true, LATER), NOW)); |
| 2676 | assert!(revoked.starts_with("Your email address is confirmed.")); |
| 2677 | assert!(revoked.contains("was revoked") && revoked.contains("no longer invites you to acme")); |
| 2678 | let expired = lapsed(awaiting_join(&row(Some(("wsp_1", Some("acme"))), false, EARLIER), NOW)); |
| 2679 | assert!(expired.contains("expired before you confirmed it")); |
| 2680 | assert!(lapsed(awaiting_join(&row(Some(("wsp_1", Some("acme"))), false, NOW), NOW)).contains("expired")); |
| 2681 | // The workspace was deleted: its row no longer joins a slug. |
| 2682 | let deleted = lapsed(awaiting_join(&row(Some(("wsp_1", None)), false, LATER), NOW)); |
| 2683 | assert!(deleted.contains("has been deleted")); |
| 2684 | // A free workspace still invites; accepting waits for its plan. |
| 2685 | assert!(matches!(awaiting_join(&row(Some(("wsp_1", Some("acme"))), false, LATER), NOW), AwaitingJoin::Invited { .. })); |
| 2686 | // Revoked beats expired; an invite without a workspace lapses too. |
| 2687 | assert!(lapsed(awaiting_join(&row(None, true, EARLIER), NOW)).contains("no longer applies")); |
| 2688 | } |
| 2689 | |
| 2690 | #[test] |
| 2691 | fn revoked_and_expired_invites_give_the_allowance_back() { |
| 2692 | assert!(counts_against_allowance(InviteStatus::Pending)); |
| 2693 | assert!(counts_against_allowance(InviteStatus::Redeemed)); |
| 2694 | assert!(!counts_against_allowance(InviteStatus::Revoked)); |
| 2695 | assert!(!counts_against_allowance(InviteStatus::Expired)); |
| 2696 | // The SQL says the same: used, or neither revoked nor expired. |
| 2697 | let sql = counted_sql(); |
| 2698 | assert!(sql.contains("i.redeemed_at IS NOT NULL OR (i.revoked_at IS NULL AND i.expires_at >")); |
| 2699 | } |
| 2700 | |
| 2701 | #[test] |
| 2702 | fn allowances_are_five_plus_grants_or_unlimited_for_staff() { |
| 2703 | assert_eq!(limit_for(INVITES_PER_USER, 0, false), Some(5)); |
| 2704 | assert_eq!(limit_for(5, 10, false), Some(15)); |
| 2705 | assert_eq!(limit_for(5, -3, false), Some(2)); |
| 2706 | assert_eq!(limit_for(5, -30, false), Some(0)); |
| 2707 | assert_eq!(limit_for(5, 0, true), None); |
| 2708 | // A workspace has only what staff granted it. |
| 2709 | assert_eq!(limit_for(0, 0, false), Some(0)); |
| 2710 | assert_eq!(limit_for(0, 25, false), Some(25)); |
| 2711 | let full = Allowance::new(Some(5), 5); |
| 2712 | assert!(full.exhausted()); |
| 2713 | assert_eq!(full.remaining, Some(0)); |
| 2714 | let over = Allowance::new(Some(2), 4); |
| 2715 | assert_eq!(over.remaining, Some(0)); |
| 2716 | let open = Allowance::new(None, 400); |
| 2717 | assert!(!open.exhausted()); |
| 2718 | assert_eq!(open.remaining, None); |
| 2719 | assert_eq!(Allowance::new(Some(5), 3).remaining, Some(2)); |
| 2720 | } |
| 2721 | |
| 2722 | fn invite(kind: &'static str, email: Option<&'static str>, status: InviteStatus) -> Admits<'static> { |
| 2723 | Admits { kind, email, status } |
| 2724 | } |
| 2725 | |
| 2726 | #[test] |
| 2727 | fn an_invite_admits_only_its_address_while_pending() { |
| 2728 | let open = invite("account", None, InviteStatus::Pending); |
| 2729 | assert_eq!(admits(Some(&open), "anyone@example.com", true), Ok(())); |
| 2730 | let bound = invite("account", Some("ada@example.com"), InviteStatus::Pending); |
| 2731 | assert_eq!(admits(Some(&bound), "ada@example.com", true), Ok(())); |
| 2732 | assert_eq!(admits(Some(&bound), " ADA@Example.com ", true), Ok(())); |
| 2733 | assert_eq!(admits(Some(&bound), "eve@example.com", true), Err(Refusal::WrongEmail)); |
| 2734 | for status in [InviteStatus::Redeemed, InviteStatus::Revoked, InviteStatus::Expired] { |
| 2735 | assert_eq!(admits(Some(&invite("account", None, status)), "a@example.com", true), Err(Refusal::Invalid)); |
| 2736 | // A dead code says nothing about whom it was for. |
| 2737 | assert_eq!( |
| 2738 | admits(Some(&invite("account", Some("ada@example.com"), status)), "eve@example.com", true), |
| 2739 | Err(Refusal::Invalid) |
| 2740 | ); |
| 2741 | } |
| 2742 | assert_eq!(admits(None, "a@example.com", true), Err(Refusal::Invalid)); |
| 2743 | } |
| 2744 | |
| 2745 | #[test] |
| 2746 | fn a_workspace_invite_never_makes_an_account() { |
| 2747 | let join = invite("workspace", Some("ada@example.com"), InviteStatus::Pending); |
| 2748 | assert_eq!(admits(Some(&join), "ada@example.com", true), Err(Refusal::Invalid)); |
| 2749 | assert_eq!(admits(Some(&join), "ada@example.com", false), Ok(())); |
| 2750 | assert_eq!(admits(Some(&join), "eve@example.com", false), Err(Refusal::WrongEmail)); |
| 2751 | // An account invite for a workspace can be accepted by the address |
| 2752 | // once it has an account. |
| 2753 | let account = invite("account", Some("ada@example.com"), InviteStatus::Pending); |
| 2754 | assert_eq!(admits(Some(&account), "ada@example.com", false), Ok(())); |
| 2755 | } |
| 2756 | |
| 2757 | const KEY: &[u8] = b"identity key"; |
| 2758 | |
| 2759 | #[test] |
| 2760 | fn an_invite_emails_proof_is_for_its_invite_and_address_only() { |
| 2761 | let proof = email_proof(KEY, "inv_1", Some("ada@example.com")).unwrap(); |
| 2762 | assert_eq!(proof.len(), 64); |
| 2763 | let proves = |id: &str, bound: Option<&str>, email: &str, proof: Option<&str>| proves_email(KEY, id, bound, email, proof); |
| 2764 | // The right invite and address, however the address is written. |
| 2765 | assert!(proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof))); |
| 2766 | assert!(proves("inv_1", Some("Ada@Example.com"), " ADA@example.com ", Some(&proof))); |
| 2767 | assert!(proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof.to_uppercase()))); |
| 2768 | // Another address: the account confirms that one itself. |
| 2769 | assert!(!proves("inv_1", Some("ada@example.com"), "eve@example.com", Some(&proof))); |
| 2770 | // Another invite's proof, even for the same address. |
| 2771 | assert!(!proves("inv_2", Some("ada@example.com"), "ada@example.com", Some(&proof))); |
| 2772 | // Tampered, cut short, empty or missing. |
| 2773 | let mut tampered = proof.clone().into_bytes(); |
| 2774 | tampered[10] = if tampered[10] == b'0' { b'1' } else { b'0' }; |
| 2775 | let tampered = String::from_utf8(tampered).unwrap(); |
| 2776 | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&tampered))); |
| 2777 | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof[..32]))); |
| 2778 | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(""))); |
| 2779 | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", None)); |
| 2780 | // An invite bound to no address has no proof to give. |
| 2781 | assert_eq!(email_proof(KEY, "inv_1", None), None); |
| 2782 | assert!(!proves("inv_1", None, "ada@example.com", Some(&proof))); |
| 2783 | // Made under another key: not ours. |
| 2784 | let foreign = email_proof(b"another key", "inv_1", Some("ada@example.com")).unwrap(); |
| 2785 | assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&foreign))); |
| 2786 | // Without a key (development) none is made, and none is taken. |
| 2787 | assert_eq!(email_proof(b"", "inv_1", Some("ada@example.com")), None); |
| 2788 | let unkeyed = crypto::invite_proof(b"", "inv_1", "ada@example.com"); |
| 2789 | assert!(!proves_email(b"", "inv_1", Some("ada@example.com"), "ada@example.com", Some(&unkeyed))); |
| 2790 | } |
| 2791 | |
| 2792 | #[test] |
| 2793 | fn an_account_starts_confirmed_only_from_the_invite_email_to_its_address() { |
| 2794 | let invite = row(None, false, LATER); |
| 2795 | let proof = email_proof(KEY, &invite.id, invite.email.as_deref()).unwrap(); |
| 2796 | // From the invite email, with the address it was sent to. |
| 2797 | assert!(starts_confirmed(KEY, false, Some(&invite), "ada@example.com", Some(&proof))); |
| 2798 | // The code alone (typed in, or a link passed on), or a bad proof. |
| 2799 | assert!(!starts_confirmed(KEY, false, Some(&invite), "ada@example.com", None)); |
| 2800 | assert!(!starts_confirmed(KEY, false, Some(&invite), "ada@example.com", Some("0123"))); |
| 2801 | // A different address than the invite's. |
| 2802 | assert!(!starts_confirmed(KEY, false, Some(&invite), "eve@example.com", Some(&proof))); |
| 2803 | // No invite (open registration, or a shared link), or one bound to no address. |
| 2804 | assert!(!starts_confirmed(KEY, false, None, "ada@example.com", Some(&proof))); |
| 2805 | let unbound = InviteRow { email: None, ..row(None, false, LATER) }; |
| 2806 | assert!(!starts_confirmed(KEY, false, Some(&unbound), "ada@example.com", Some(&proof))); |
| 2807 | // A workspace invite makes no account. |
| 2808 | let join = InviteRow { kind: "workspace".into(), ..row(None, false, LATER) }; |
| 2809 | assert!(!starts_confirmed(KEY, false, Some(&join), "ada@example.com", Some(&proof))); |
| 2810 | // GitHub's confirmed address, whatever else. |
| 2811 | assert!(starts_confirmed(KEY, true, None, "ada@example.com", None)); |
| 2812 | } |
| 2813 | |
| 2814 | #[test] |
| 2815 | fn addresses_are_checked_and_masked() { |
| 2816 | assert_eq!(normalize_email(" Ada@Example.COM ").as_deref(), Some("ada@example.com")); |
| 2817 | for bad in ["", "ada", "ada@", "@example.com", "ada@example", "a b@example.com", "ada@.com", "ada@example.", "a@b@c.com"] { |
| 2818 | assert_eq!(normalize_email(bad), None, "{bad}"); |
| 2819 | } |
| 2820 | assert_eq!(mask_email("ada@example.com"), "a•••@example.com"); |
| 2821 | assert_eq!(mask_email("x@example.com"), "x•••@example.com"); |
| 2822 | } |
| 2823 | |
| 2824 | #[test] |
| 2825 | fn rate_limits_count_in_hour_long_windows() { |
| 2826 | assert_eq!(bucket(0, HOUR_MS), 0); |
| 2827 | assert_eq!(bucket(HOUR_MS - 1, HOUR_MS), 0); |
| 2828 | assert_eq!(bucket(HOUR_MS, HOUR_MS), 1); |
| 2829 | // The limits stop guessing long before a code could be found, and |
| 2830 | // leave room for people who mistype. |
| 2831 | assert!((5..=100).contains(&FAILURES_PER_HOUR)); |
| 2832 | const { assert!(CREATES_PER_HOUR >= INVITES_PER_USER) }; |
| 2833 | const { assert!(REQUESTS_PER_HOUR >= 1) }; |
| 2834 | } |
| 2835 | |
| 2836 | #[test] |
| 2837 | fn staff_hear_about_requests_at_most_every_15_minutes() { |
| 2838 | assert_eq!(SUMMARY_EVERY_MS, 15 * 60 * 1000); |
| 2839 | let since = "2026-10-05T11:45:00.000Z"; |
| 2840 | assert!(summary_due(None, since)); |
| 2841 | assert!(summary_due(Some("2026-10-05T11:30:00.000Z"), since)); |
| 2842 | assert!(summary_due(Some(since), since)); |
| 2843 | assert!(!summary_due(Some("2026-10-05T11:50:00.000Z"), since)); |
| 2844 | const { assert!(CONFIRMATIONS_PER_HOUR >= ANONYMOUS_REQUESTS_PER_HOUR) }; |
| 2845 | } |
| 2846 | |
| 2847 | #[test] |
| 2848 | fn registration_is_invite_only_unless_opened() { |
| 2849 | assert_eq!(RegistrationMode::parse(None), RegistrationMode::Invite); |
| 2850 | assert_eq!(RegistrationMode::parse(Some("invite")), RegistrationMode::Invite); |
| 2851 | assert_eq!(RegistrationMode::parse(Some("")), RegistrationMode::Invite); |
| 2852 | assert_eq!(RegistrationMode::parse(Some("opne")), RegistrationMode::Invite); |
| 2853 | assert_eq!(RegistrationMode::parse(Some(" Open ")), RegistrationMode::Open); |
| 2854 | } |
| 2855 | } |