Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038) | 1 | //! Shared invite links: one link staff hand to a group (a conference's |
| 2 | //! judges, a post, a community), made in sudo. | |
| 3 | //! | |
| 4 | //! A shared link makes up to `max_uses` new accounts until it expires or | |
| 5 | //! staff revoke it, optionally only for addresses at some email domains. | |
| 6 | //! Each use makes a new account, which then makes its own workspace: a | |
| 7 | //! shared link never joins anyone to an existing workspace, and uses | |
| 8 | //! nobody's allowance. Its code is an ordinary invite code (`g1t-` and | |
| 9 | //! eight groups), stored the same way: only its SHA-256, and a copy sealed | |
| 10 | //! under IDENTITY_KEY so staff can copy the link again while it is live. | |
| 11 | //! | |
| 12 | //! Using one goes through [`Identity::create_account`] like any invite | |
| 13 | //! (invites.rs): the same per-client failure throttle, the same one answer | |
| 14 | //! for a code that is unknown, expired, revoked or used up, and a use | |
| 15 | //! taken in the same transaction that makes the account. The statement | |
| 16 | //! that takes a use counts the uses taken and adds one only while fewer | |
| 17 | //! than `max_uses` are, and the account is made only if that row was | |
| 18 | //! added, so people racing for the last use cannot both get one. | |
| 19 | //! | |
| 20 | //! Each use is a row in `shared_invite_uses`, which also says where the | |
| 21 | //! account came from: sudo shows "Joined through <label>". | |
| 22 | ||
| 23 | use g1t_contracts::identity::*; | |
| 24 | use g1t_contracts::time::{SQL_NOW, parse_rfc3339, rfc3339}; | |
| 25 | use g1t_contracts::{FailureCode, Outcome, new_id}; | |
| 26 | use g1t_kit::now_ms; | |
| 27 | use serde::Deserialize; | |
| 28 | use worker::Result; | |
| 29 | use worker::wasm_bindgen::JsValue; | |
| 30 | ||
| 31 | use crate::Identity; | |
| 32 | use crate::invites::{Refusal, code_hash, code_hint, format_code, new_code_body, normalize_code}; | |
| 33 | ||
| 34 | const DAY_MS: u64 = 24 * 60 * 60 * 1000; | |
| 35 | ||
| 36 | /// What sudo's audit log files shared links under: `invites` is a reserved | |
| 37 | /// name, so no workspace has it. | |
| 38 | pub const AUDIT_ACCOUNT: &str = "invites"; | |
| 39 | ||
| 40 | /// What someone whose address is at another domain is told. The domains | |
| 41 | /// are no secret to whoever holds the link: the sign-up page lists them. | |
| 42 | pub fn wrong_domain(domains: &[String]) -> String { | |
| 43 | let list = match domains { | |
| 44 | [] => String::new(), | |
| 45 | [one] => one.clone(), | |
| 46 | [rest @ .., last] => format!("{} or {last}", rest.join(", ")), | |
| 47 | }; | |
| 48 | format!("This invite is only for email addresses at {list}. Sign up with your address there.") | |
| 49 | } | |
| 50 | ||
| 51 | // --- Rules -------------------------------------------------------------------- | |
| 52 | ||
| 53 | /// Where a shared link stands at `now`. Revoked first, then used up, then | |
| 54 | /// expired: what staff did, before what time did. | |
| 55 | pub fn shared_status( | |
| 56 | revoked: bool, | |
| 57 | expires_at: &str, | |
| 58 | uses: u32, | |
| 59 | max_uses: u32, | |
| 60 | now: &str, | |
| 61 | ) -> SharedInviteStatus { | |
| 62 | if revoked { | |
| 63 | SharedInviteStatus::Revoked | |
| 64 | } else if uses >= max_uses { | |
| 65 | SharedInviteStatus::UsedUp | |
| 66 | } else if expires_at <= now { | |
| 67 | SharedInviteStatus::Expired | |
| 68 | } else { | |
| 69 | SharedInviteStatus::Live | |
| 70 | } | |
| 71 | } | |
| 72 | ||
| 73 | /// Whether `email` is at one of `domains`: the part after the last `@`, | |
| 74 | /// exactly (a subdomain is another domain). Any address when `domains` is | |
| 75 | /// empty. | |
| 76 | pub fn domain_allowed(domains: &[String], email: &str) -> bool { | |
| 77 | if domains.is_empty() { | |
| 78 | return true; | |
| 79 | } | |
| 80 | let Some((_, domain)) = email.trim().rsplit_once('@') else { | |
| 81 | return false; | |
| 82 | }; | |
| 83 | domains | |
| 84 | .iter() | |
| 85 | .any(|allowed| allowed.eq_ignore_ascii_case(domain)) | |
| 86 | } | |
| 87 | ||
| 88 | /// The parts of a shared link that decide whether it makes an account. | |
| 89 | #[derive(Debug)] | |
| 90 | pub struct SharedAdmits<'a> { | |
| 91 | pub status: SharedInviteStatus, | |
| 92 | pub domains: &'a [String], | |
| 93 | } | |
| 94 | ||
| 95 | /// Whether a shared link makes an account for `email`. Anything but a | |
| 96 | /// live link is [`Refusal::Invalid`], the one answer every unusable code | |
| 97 | /// gets, so nobody learns whether a link was used up, revoked or expired. | |
| 98 | pub fn shared_admits(link: Option<&SharedAdmits>, email: &str) -> std::result::Result<(), Refusal> { | |
| 99 | match link { | |
| 100 | Some(link) if link.status == SharedInviteStatus::Live => { | |
| 101 | if domain_allowed(link.domains, email) { | |
| 102 | Ok(()) | |
| 103 | } else { | |
| 104 | Err(Refusal::WrongDomain) | |
| 105 | } | |
| 106 | } | |
| 107 | _ => Err(Refusal::Invalid), | |
| 108 | } | |
| 109 | } | |
| 110 | ||
| 111 | /// One email domain as staff typed it (`@Cloudflare.com ` reads as | |
| 112 | /// `cloudflare.com`), if it is one. | |
| 113 | pub fn normalize_domain(input: &str) -> Option<String> { | |
| 114 | let domain = input | |
| 115 | .trim() | |
| 116 | .trim_start_matches('@') | |
| 117 | .trim_end_matches('.') | |
| 118 | .to_ascii_lowercase(); | |
| 119 | let well_formed = (3..=253).contains(&domain.len()) | |
| 120 | && domain.contains('.') | |
| 121 | && domain.split('.').all(|label| { | |
| 122 | !label.is_empty() | |
| 123 | && label.len() <= 63 | |
| 124 | && !label.starts_with('-') | |
| 125 | && !label.ends_with('-') | |
| 126 | && label | |
| 127 | .bytes() | |
| 128 | .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') | |
| 129 | }); | |
| 130 | well_formed.then_some(domain) | |
| 131 | } | |
| 132 | ||
| 133 | /// What staff asked for, checked: what a shared link is made from. | |
| 134 | #[derive(Debug, PartialEq, Eq)] | |
| 135 | pub struct SharedDraft { | |
| 136 | pub label: String, | |
| 137 | pub max_uses: u32, | |
| 138 | /// RFC 3339. | |
| 139 | pub expires_at: String, | |
| 140 | pub domains: Vec<String>, | |
| 141 | } | |
| 142 | ||
| 143 | /// The end of `YYYY-MM-DD` (UTC), in g1t's format, if it is a real day. | |
| 144 | fn end_of_day(date: &str) -> Option<String> { | |
| 145 | let date = date.trim(); | |
| 146 | if date.len() != 10 { | |
| 147 | return None; | |
| 148 | } | |
| 149 | let end = format!("{date}T23:59:59.999Z"); | |
| 150 | // Round-trips only for a day that exists: not 2026-02-30. | |
| 151 | let ms = parse_rfc3339(&end)?; | |
| 152 | (rfc3339(ms) == end).then_some(end) | |
| 153 | } | |
| 154 | ||
| 155 | /// Checks what staff asked for at `now_ms`: a label, 1 to 1000 uses, an | |
| 156 | /// expiry from today to a year ahead (14 days when none is given), and up | |
| 157 | /// to 10 domains. Every problem is said the way sudo shows it. | |
| 158 | pub fn check_draft( | |
| 159 | label: &str, | |
| 160 | max_uses: u32, | |
| 161 | expires_on: Option<&str>, | |
| 162 | domains: &[String], | |
| 163 | now_ms: u64, | |
| 164 | ) -> std::result::Result<SharedDraft, String> { | |
| 165 | let label = label.split_whitespace().collect::<Vec<_>>().join(" "); | |
| 166 | if label.is_empty() { | |
| 167 | return Err("Give the link a label, such as Cloudflare judges.".to_owned()); | |
| 168 | } | |
| 169 | if label.chars().count() > MAX_SHARED_INVITE_LABEL { | |
| 170 | return Err(format!( | |
| 171 | "Keep the label to {MAX_SHARED_INVITE_LABEL} characters." | |
| 172 | )); | |
| 173 | } | |
| 174 | if !(1..=MAX_SHARED_INVITE_USES).contains(&max_uses) { | |
| 175 | return Err(format!( | |
| 176 | "A shared link makes between 1 and {MAX_SHARED_INVITE_USES} accounts." | |
| 177 | )); | |
| 178 | } | |
| 179 | let now = rfc3339(now_ms); | |
| 180 | let expires_at = match expires_on.map(str::trim).filter(|date| !date.is_empty()) { | |
| 181 | None => rfc3339(now_ms + SHARED_INVITE_TTL_DAYS * DAY_MS), | |
| 182 | Some(date) => { | |
| 183 | let Some(end) = end_of_day(date) else { | |
| 184 | return Err("Give the expiry as a date, such as 2026-10-28.".to_owned()); | |
| 185 | }; | |
| 186 | if end <= now { | |
| 187 | return Err("The expiry has passed. Choose today or a later day.".to_owned()); | |
| 188 | } | |
| 189 | // The last day allowed is a year from today. | |
| 190 | if end[..10] > rfc3339(now_ms + SHARED_INVITE_MAX_DAYS * DAY_MS)[..10] { | |
| 191 | return Err(format!( | |
| 192 | "A shared link works for at most {SHARED_INVITE_MAX_DAYS} days." | |
| 193 | )); | |
| 194 | } | |
| 195 | end | |
| 196 | } | |
| 197 | }; | |
| 198 | let mut checked: Vec<String> = Vec::new(); | |
| 199 | for domain in domains | |
| 200 | .iter() | |
| 201 | .flat_map(|entry| entry.split([',', ' ', '\n', '\r', '\t'])) | |
| 202 | { | |
| 203 | if domain.trim().is_empty() { | |
| 204 | continue; | |
| 205 | } | |
| 206 | let Some(domain) = normalize_domain(domain) else { | |
| 207 | return Err(format!( | |
| 208 | "{} is not an email domain. Write domains such as cloudflare.com.", | |
| 209 | domain.trim() | |
| 210 | )); | |
| 211 | }; | |
| 212 | if !checked.contains(&domain) { | |
| 213 | checked.push(domain); | |
| 214 | } | |
| 215 | } | |
| 216 | if checked.len() > MAX_SHARED_INVITE_DOMAINS { | |
| 217 | return Err(format!( | |
| 218 | "Limit a link to at most {MAX_SHARED_INVITE_DOMAINS} domains." | |
| 219 | )); | |
| 220 | } | |
| 221 | Ok(SharedDraft { | |
| 222 | label, | |
| 223 | max_uses, | |
| 224 | expires_at, | |
| 225 | domains: checked, | |
| 226 | }) | |
| 227 | } | |
| 228 | ||
| 229 | /// The domains column as a list. | |
| 230 | pub fn domains_of(column: Option<&str>) -> Vec<String> { | |
| 231 | column | |
| 232 | .unwrap_or_default() | |
| 233 | .split(',') | |
| 234 | .map(str::trim) | |
| 235 | .filter(|domain| !domain.is_empty()) | |
| 236 | .map(str::to_owned) | |
| 237 | .collect() | |
| 238 | } | |
| 239 | ||
| 240 | // --- Taking a use ------------------------------------------------------------- | |
| 241 | ||
| 242 | /// Takes one use of shared link `?2` for new account `?1`: adds the row | |
| 243 | /// only while the link is not revoked, not expired, and has fewer uses | |
| 244 | /// than `max_uses`, counted in this same statement. Runs in one batch | |
| 245 | /// (a transaction) with [`make_account_sql`], so either both happen or | |
| 246 | /// neither does. | |
| 247 | pub fn take_use_sql() -> String { | |
| 248 | format!( | |
| 249 | "INSERT INTO shared_invite_uses (user_id, shared_invite_id, created_at) | |
| 250 | SELECT ?1, s.id, {SQL_NOW} FROM shared_invites s | |
| 251 | WHERE s.id = ?2 AND s.revoked_at IS NULL AND s.expires_at > {SQL_NOW} | |
| 252 | AND (SELECT count(*) FROM shared_invite_uses u WHERE u.shared_invite_id = s.id) < s.max_uses" | |
| 253 | ) | |
| 254 | } | |
| 255 | ||
| 256 | /// Makes the account (`?1` to `?4`: id, username, email, password hash) | |
| 257 | /// only if [`take_use_sql`] took a use of link `?5` for it. | |
| 258 | pub fn make_account_sql(verified_at: &str) -> String { | |
| 259 | format!( | |
| 260 | "INSERT INTO users (id, username, email, password_hash, email_verified_at) | |
| 261 | SELECT ?1, ?2, ?3, ?4, {verified_at} | |
| 262 | WHERE EXISTS (SELECT 1 FROM shared_invite_uses WHERE user_id = ?1 AND shared_invite_id = ?5)" | |
| 263 | ) | |
| 264 | } | |
| 265 | ||
| 266 | /// Forgets the sealed code of link `?1` once its last use is taken: there | |
| 267 | /// is nothing left to copy. | |
| 268 | pub fn seal_used_up_sql() -> &'static str { | |
| 269 | "UPDATE shared_invites SET sealed_code = NULL | |
| 270 | WHERE id = ?1 AND (SELECT count(*) FROM shared_invite_uses u WHERE u.shared_invite_id = ?1) >= max_uses" | |
| 271 | } | |
| 272 | ||
| 273 | /// Revokes link `?2` for staff member `?1`, once: its sealed code goes | |
| 274 | /// with it, and the accounts it made stay. | |
| 275 | pub fn revoke_sql() -> String { | |
| 276 | format!( | |
| 277 | "UPDATE shared_invites SET revoked_at = {SQL_NOW}, revoked_by = ?1, sealed_code = NULL | |
| 278 | WHERE id = ?2 AND revoked_at IS NULL RETURNING id" | |
| 279 | ) | |
| 280 | } | |
| 281 | ||
| 282 | // --- Rows --------------------------------------------------------------------- | |
| 283 | ||
| 284 | const COLUMNS: &str = "s.id, s.label, s.hint, s.sealed_code, s.max_uses, s.domains, s.staff, s.created_at, s.expires_at, | |
| 285 | s.revoked_at, s.revoked_by, | |
| 286 | (SELECT count(*) FROM shared_invite_uses u WHERE u.shared_invite_id = s.id) AS uses | |
| 287 | FROM shared_invites s"; | |
| 288 | ||
| 289 | /// The most shared links sudo lists. | |
| 290 | const LIST_LIMIT: usize = 200; | |
| 291 | ||
| 292 | #[derive(Debug, Deserialize)] | |
| 293 | pub struct SharedRow { | |
| 294 | pub id: String, | |
| 295 | pub label: String, | |
| 296 | pub hint: String, | |
| 297 | pub sealed_code: Option<String>, | |
| 298 | pub max_uses: f64, | |
| 299 | pub domains: Option<String>, | |
| 300 | pub staff: String, | |
| 301 | pub created_at: String, | |
| 302 | pub expires_at: String, | |
| 303 | pub revoked_at: Option<String>, | |
| 304 | pub revoked_by: Option<String>, | |
| 305 | pub uses: f64, | |
| 306 | } | |
| 307 | ||
| 308 | impl SharedRow { | |
| 309 | pub fn status(&self, now: &str) -> SharedInviteStatus { | |
| 310 | shared_status( | |
| 311 | self.revoked_at.is_some(), | |
| 312 | &self.expires_at, | |
| 313 | self.uses as u32, | |
| 314 | self.max_uses as u32, | |
| 315 | now, | |
| 316 | ) | |
| 317 | } | |
| 318 | ||
| 319 | pub fn domains(&self) -> Vec<String> { | |
| 320 | domains_of(self.domains.as_deref()) | |
| 321 | } | |
| 322 | } | |
| 323 | ||
| 324 | impl Identity { | |
| 325 | pub(crate) async fn shared_by_code(&self, code: &str) -> Result<Option<SharedRow>> { | |
| 326 | let Some(body) = normalize_code(code) else { | |
| 327 | return Ok(None); | |
| 328 | }; | |
| 329 | self.db | |
| 330 | .prepare(format!("SELECT {COLUMNS} WHERE s.code_hash = ?")) | |
| 331 | .bind(&[code_hash(&body).into()])? | |
| 332 | .first::<SharedRow>(None) | |
| 333 | .await | |
| 334 | } | |
| 335 | ||
| 336 | async fn shared_by_id(&self, id: &str) -> Result<Option<SharedRow>> { | |
| 337 | self.db | |
| 338 | .prepare(format!("SELECT {COLUMNS} WHERE s.id = ?")) | |
| 339 | .bind(&[id.into()])? | |
| 340 | .first::<SharedRow>(None) | |
| 341 | .await | |
| 342 | } | |
| 343 | ||
| 344 | /// The statements that take a use of `link` and make the account, for | |
| 345 | /// create_account's batch: the account row comes to exist only if the | |
| 346 | /// use was taken for it. | |
| 347 | pub(crate) fn shared_account_statements( | |
| 348 | &self, | |
| 349 | link: &SharedRow, | |
| 350 | values: &[JsValue; 4], | |
| 351 | verified_at: &str, | |
| 352 | ) -> Result<Vec<worker::D1PreparedStatement>> { | |
| 353 | let user_id = values[0].clone(); | |
| 354 | let mut make = values.to_vec(); | |
| 355 | make.push(link.id.as_str().into()); | |
| 356 | Ok(vec![ | |
| 357 | self.db | |
| 358 | .prepare(take_use_sql()) | |
| 359 | .bind(&[user_id, link.id.as_str().into()])?, | |
| 360 | self.db.prepare(make_account_sql(verified_at)).bind(&make)?, | |
| 361 | self.db | |
| 362 | .prepare(seal_used_up_sql()) | |
| 363 | .bind(&[link.id.as_str().into()])?, | |
| 364 | ]) | |
| 365 | } | |
| 366 | ||
| 367 | /// What the sign-up page shows for a live shared link's code. | |
| 368 | pub(crate) fn shared_preview(&self, link: &SharedRow) -> InvitePreview { | |
| 369 | InvitePreview { | |
| 370 | kind: InviteKind::Account, | |
| 371 | status: InviteStatus::Pending, | |
| 372 | invited_by: None, | |
| 373 | workspace: None, | |
| 374 | repository: None, | |
| 375 | email: None, | |
| 376 | address: None, | |
| 377 | has_account: false, | |
| 378 | for_viewer: None, | |
| 379 | expires_at: link.expires_at.clone(), | |
| 380 | shared_label: Some(link.label.clone()), | |
| 381 | shared_domains: link.domains(), | |
| 382 | } | |
| 383 | } | |
| 384 | ||
| 385 | /// The shared link an account was made with, if it was. | |
| 386 | pub(crate) async fn shared_source(&self, user_id: &str) -> Result<Option<SharedInviteSource>> { | |
| 387 | #[derive(Deserialize)] | |
| 388 | struct Row { | |
| 389 | id: String, | |
| 390 | label: String, | |
| 391 | } | |
| 392 | Ok(self | |
| 393 | .db | |
| 394 | .prepare( | |
| 395 | "SELECT s.id, s.label FROM shared_invite_uses u JOIN shared_invites s ON s.id = u.shared_invite_id | |
| 396 | WHERE u.user_id = ?", | |
| 397 | ) | |
| 398 | .bind(&[user_id.into()])? | |
| 399 | .first::<Row>(None) | |
| 400 | .await? | |
| 401 | .map(|row| SharedInviteSource { id: row.id, label: row.label })) | |
| 402 | } | |
| 403 | ||
| 404 | /// A shared link as staff see it, with its code while it is live. | |
| 405 | fn shown_shared(&self, row: SharedRow, accounts: Vec<SharedInviteAccount>) -> SharedInvite { | |
| 406 | let status = row.status(&rfc3339(now_ms())); | |
| 407 | let code = if status == SharedInviteStatus::Live { | |
| 408 | row.sealed_code | |
| 409 | .as_deref() | |
| 410 | .and_then(|sealed| self.invite_sealer()?.open(sealed, &row.id)) | |
| 411 | } else { | |
| 412 | None | |
| 413 | }; | |
| 414 | let domains = row.domains(); | |
| 415 | SharedInvite { | |
| 416 | id: row.id, | |
| 417 | label: row.label, | |
| 418 | code, | |
| 419 | hint: row.hint, | |
| 420 | max_uses: row.max_uses as u32, | |
| 421 | uses: row.uses as u32, | |
| 422 | domains, | |
| 423 | status, | |
| 424 | staff: row.staff, | |
| 425 | created_at: row.created_at, | |
| 426 | expires_at: row.expires_at, | |
| 427 | revoked_at: row.revoked_at, | |
| 428 | revoked_by: row.revoked_by, | |
| 429 | accounts, | |
| 430 | } | |
| 431 | } | |
| 432 | ||
| 433 | /// The accounts each of `ids` made, oldest first. | |
| 434 | async fn shared_accounts(&self, ids: &[String]) -> Result<Vec<(String, SharedInviteAccount)>> { | |
| 435 | if ids.is_empty() { | |
| 436 | return Ok(Vec::new()); | |
| 437 | } | |
| 438 | #[derive(Deserialize)] | |
| 439 | struct Row { | |
| 440 | shared_invite_id: String, | |
| 441 | username: Option<String>, | |
| 442 | created_at: String, | |
| 443 | } | |
| 444 | let marks = vec!["?"; ids.len()].join(", "); | |
| 445 | let binds: Vec<JsValue> = ids.iter().map(|id| JsValue::from(id.as_str())).collect(); | |
| 446 | Ok(self | |
| 447 | .db | |
| 448 | .prepare(format!( | |
| 449 | "SELECT u.shared_invite_id, us.username, u.created_at FROM shared_invite_uses u | |
| 450 | LEFT JOIN users us ON us.id = u.user_id | |
| 451 | WHERE u.shared_invite_id IN ({marks}) ORDER BY u.created_at, u.user_id" | |
| 452 | )) | |
| 453 | .bind(&binds)? | |
| 454 | .all() | |
| 455 | .await? | |
| 456 | .results::<Row>()? | |
| 457 | .into_iter() | |
| 458 | .map(|row| { | |
| 459 | ( | |
| 460 | row.shared_invite_id, | |
| 461 | SharedInviteAccount { | |
| 462 | username: row.username, | |
| 463 | joined_at: row.created_at, | |
| 464 | }, | |
| 465 | ) | |
| 466 | }) | |
| 467 | .collect()) | |
| 468 | } | |
| 469 | ||
| 470 | /// `admin_shared_invites`. | |
| 471 | pub async fn admin_shared_invites(&self) -> Result<Vec<SharedInvite>> { | |
| 472 | let rows = self | |
| 473 | .db | |
| 474 | .prepare(format!( | |
| 475 | "SELECT {COLUMNS} ORDER BY s.created_at DESC, s.id DESC LIMIT {LIST_LIMIT}" | |
| 476 | )) | |
| 477 | .all() | |
| 478 | .await? | |
| 479 | .results::<SharedRow>()?; | |
| 480 | let ids: Vec<String> = rows.iter().map(|row| row.id.clone()).collect(); | |
| 481 | let mut accounts = self.shared_accounts(&ids).await?; | |
| 482 | Ok(rows | |
| 483 | .into_iter() | |
| 484 | .map(|row| { | |
| 485 | let (mine, rest): (Vec<_>, Vec<_>) = | |
| 486 | accounts.drain(..).partition(|(id, _)| *id == row.id); | |
| 487 | accounts = rest; | |
| 488 | let mine = mine.into_iter().map(|(_, account)| account).collect(); | |
| 489 | self.shown_shared(row, mine) | |
| 490 | }) | |
| 491 | .collect()) | |
| 492 | } | |
| 493 | ||
| 494 | /// `admin_create_shared_invite`. | |
| 495 | pub async fn admin_create_shared_invite( | |
| 496 | &self, | |
| 497 | a: AdminCreateSharedInviteArgs, | |
| 498 | ) -> Result<Outcome<SharedInvite>> { | |
| 499 | let staff = a.staff.trim(); | |
| 500 | if staff.is_empty() { | |
| 501 | return Ok(Outcome::fail( | |
| 502 | FailureCode::Forbidden, | |
| 503 | "Say which staff member is making it.", | |
| 504 | )); | |
| 505 | } | |
| 506 | let now = now_ms(); | |
| 507 | let draft = match check_draft( | |
| 508 | &a.label, | |
| 509 | a.max_uses, | |
| 510 | a.expires_on.as_deref(), | |
| 511 | &a.domains, | |
| 512 | now, | |
| 513 | ) { | |
| 514 | Ok(draft) => draft, | |
| 515 | Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)), | |
| 516 | }; | |
| 517 | let body = new_code_body(); | |
| 518 | let code = format_code(&body); | |
| 519 | let id = new_id("sinv", now); | |
| 520 | let sealed = self.invite_sealer().map(|sealer| sealer.seal(&code, &id)); | |
| 521 | let domains = draft.domains.join(","); | |
| 522 | self.db | |
| 523 | .prepare( | |
| 524 | "INSERT INTO shared_invites (id, label, code_hash, hint, sealed_code, max_uses, domains, staff, created_at, expires_at) | |
| 525 | VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| 526 | ) | |
| 527 | .bind(&[ | |
| 528 | id.as_str().into(), | |
| 529 | draft.label.as_str().into(), | |
| 530 | code_hash(&body).into(), | |
| 531 | code_hint(&body).into(), | |
| 532 | sealed.as_deref().map_or(JsValue::NULL, JsValue::from), | |
| 533 | f64::from(draft.max_uses).into(), | |
| 534 | if domains.is_empty() { JsValue::NULL } else { domains.as_str().into() }, | |
| 535 | staff.into(), | |
| 536 | rfc3339(now).into(), | |
| 537 | draft.expires_at.as_str().into(), | |
| 538 | ])? | |
| 539 | .run() | |
| 540 | .await?; | |
| 541 | let only = if draft.domains.is_empty() { | |
| 542 | String::new() | |
| 543 | } else { | |
| 544 | format!(", only {}", draft.domains.join(", ")) | |
| 545 | }; | |
| 546 | self.record_for_staff( | |
| 547 | AUDIT_ACCOUNT, | |
| 548 | "shared_invite_created", | |
| 549 | &format!( | |
| 550 | "Shared invite link {} ({}) for {}: up to {} accounts until {}{only}", | |
| 551 | code_hint(&body), | |
| 552 | id, | |
| 553 | draft.label, | |
| 554 | draft.max_uses, | |
| 555 | &draft.expires_at[..10] | |
| 556 | ), | |
| 557 | staff, | |
| 558 | ) | |
| 559 | .await; | |
| 560 | let Some(row) = self.shared_by_id(&id).await? else { | |
| 561 | return Ok(Outcome::fail( | |
| 562 | FailureCode::Conflict, | |
| 563 | "The link could not be made. Try again.", | |
| 564 | )); | |
| 565 | }; | |
| 566 | let mut shown = self.shown_shared(row, Vec::new()); | |
| 567 | shown.code = Some(code); | |
| 568 | Ok(Outcome::Ok(shown)) | |
| 569 | } | |
| 570 | ||
| 571 | /// `admin_revoke_shared_invite`. | |
| 572 | pub async fn admin_revoke_shared_invite( | |
| 573 | &self, | |
| 574 | a: AdminRevokeSharedInviteArgs, | |
| 575 | ) -> Result<Outcome<SharedInvite>> { | |
| 576 | let staff = a.staff.trim(); | |
| 577 | if staff.is_empty() { | |
| 578 | return Ok(Outcome::fail( | |
| 579 | FailureCode::Forbidden, | |
| 580 | "Say which staff member is revoking it.", | |
| 581 | )); | |
| 582 | } | |
| 583 | let revoked = self | |
| 584 | .db | |
| 585 | .prepare(revoke_sql()) | |
| 586 | .bind(&[staff.into(), a.id.as_str().into()])? | |
| 587 | .first::<serde_json::Value>(None) | |
| 588 | .await?; | |
| 589 | if revoked.is_none() { | |
| 590 | return Ok(Outcome::fail( | |
| 591 | FailureCode::Conflict, | |
| 592 | "That link is revoked already, or there is no such link.", | |
| 593 | )); | |
| 594 | } | |
| 595 | let Some(row) = self.shared_by_id(&a.id).await? else { | |
| 596 | return Ok(Outcome::fail( | |
| 597 | FailureCode::NotFound, | |
| 598 | "Shared invite link not found.", | |
| 599 | )); | |
| 600 | }; | |
| 601 | self.record_for_staff( | |
| 602 | AUDIT_ACCOUNT, | |
| 603 | "shared_invite_revoked", | |
| 604 | &format!( | |
| 605 | "Revoked shared invite link {} ({}) for {} after {} of {} uses", | |
| 606 | row.hint, row.id, row.label, row.uses as u32, row.max_uses as u32 | |
| 607 | ), | |
| 608 | staff, | |
| 609 | ) | |
| 610 | .await; | |
| 611 | let accounts = self | |
| 612 | .shared_accounts(std::slice::from_ref(&row.id)) | |
| 613 | .await? | |
| 614 | .into_iter() | |
| 615 | .map(|(_, account)| account) | |
| 616 | .collect(); | |
| 617 | Ok(Outcome::Ok(self.shown_shared(row, accounts))) | |
| 618 | } | |
| 619 | } | |
| 620 | ||
| 621 | #[cfg(test)] | |
| 622 | mod tests { | |
| 623 | use super::*; | |
| 624 | ||
| 625 | const NOW: &str = "2026-10-08T12:00:00.000Z"; | |
| 626 | const LATER: &str = "2026-10-22T12:00:00.000Z"; | |
| 627 | const EARLIER: &str = "2026-10-01T12:00:00.000Z"; | |
| 628 | /// 2026-10-08T12:00:00.000Z. | |
| 629 | const NOW_MS: u64 = 1_791_460_800_000; | |
| 630 | ||
| 631 | #[test] | |
| 632 | fn the_test_clock_is_what_it_says() { | |
| 633 | assert_eq!(rfc3339(NOW_MS), NOW); | |
| 634 | } | |
| 635 | ||
| 636 | #[test] | |
| 637 | fn a_link_is_live_until_revoked_used_up_or_expired() { | |
| 638 | assert_eq!( | |
| 639 | shared_status(false, LATER, 0, 10, NOW), | |
| 640 | SharedInviteStatus::Live | |
| 641 | ); | |
| 642 | assert_eq!( | |
| 643 | shared_status(false, LATER, 9, 10, NOW), | |
| 644 | SharedInviteStatus::Live | |
| 645 | ); | |
| 646 | assert_eq!( | |
| 647 | shared_status(false, LATER, 10, 10, NOW), | |
| 648 | SharedInviteStatus::UsedUp | |
| 649 | ); | |
| 650 | assert_eq!( | |
| 651 | shared_status(false, EARLIER, 3, 10, NOW), | |
| 652 | SharedInviteStatus::Expired | |
| 653 | ); | |
| 654 | // It stops at its expiry, not a moment after. | |
| 655 | assert_eq!( | |
| 656 | shared_status(false, NOW, 3, 10, NOW), | |
| 657 | SharedInviteStatus::Expired | |
| 658 | ); | |
| 659 | assert_eq!( | |
| 660 | shared_status(true, LATER, 3, 10, NOW), | |
| 661 | SharedInviteStatus::Revoked | |
| 662 | ); | |
| 663 | // What staff did comes before what time did. | |
| 664 | assert_eq!( | |
| 665 | shared_status(true, EARLIER, 10, 10, NOW), | |
| 666 | SharedInviteStatus::Revoked | |
| 667 | ); | |
| 668 | assert_eq!( | |
| 669 | shared_status(false, EARLIER, 10, 10, NOW), | |
| 670 | SharedInviteStatus::UsedUp | |
| 671 | ); | |
| 672 | } | |
| 673 | ||
| 674 | #[test] | |
| 675 | fn expired_revoked_and_used_up_links_all_get_the_one_answer() { | |
| 676 | let none: [String; 0] = []; | |
| 677 | for status in [ | |
| 678 | SharedInviteStatus::UsedUp, | |
| 679 | SharedInviteStatus::Expired, | |
| 680 | SharedInviteStatus::Revoked, | |
| 681 | ] { | |
| 682 | let link = SharedAdmits { | |
| 683 | status, | |
| 684 | domains: &none, | |
| 685 | }; | |
| 686 | assert_eq!( | |
| 687 | shared_admits(Some(&link), "ada@example.com"), | |
| 688 | Err(Refusal::Invalid), | |
| 689 | "{status:?}" | |
| 690 | ); | |
| 691 | // Even at another domain: a dead link says nothing about whom it was for. | |
| 692 | let domains = ["cloudflare.com".to_owned()]; | |
| 693 | let bound = SharedAdmits { | |
| 694 | status, | |
| 695 | domains: &domains, | |
| 696 | }; | |
| 697 | assert_eq!( | |
| 698 | shared_admits(Some(&bound), "eve@example.com"), | |
| 699 | Err(Refusal::Invalid) | |
| 700 | ); | |
| 701 | } | |
| 702 | assert_eq!( | |
| 703 | shared_admits(None, "ada@example.com"), | |
| 704 | Err(Refusal::Invalid) | |
| 705 | ); | |
| 706 | let live = SharedAdmits { | |
| 707 | status: SharedInviteStatus::Live, | |
| 708 | domains: &none, | |
| 709 | }; | |
| 710 | assert_eq!(shared_admits(Some(&live), "anyone@anywhere.dev"), Ok(())); | |
| 711 | } | |
| 712 | ||
| 713 | #[test] | |
| 714 | fn a_link_limited_to_domains_admits_only_addresses_there() { | |
| 715 | let domains = ["cloudflare.com".to_owned(), "flagon.io".to_owned()]; | |
| 716 | let live = SharedAdmits { | |
| 717 | status: SharedInviteStatus::Live, | |
| 718 | domains: &domains, | |
| 719 | }; | |
| 720 | assert_eq!(shared_admits(Some(&live), "judge@cloudflare.com"), Ok(())); | |
| 721 | assert_eq!(shared_admits(Some(&live), " Judge@CloudFlare.COM "), Ok(())); | |
| 722 | assert_eq!(shared_admits(Some(&live), "chase@flagon.io"), Ok(())); | |
| 723 | assert_eq!( | |
| 724 | shared_admits(Some(&live), "eve@example.com"), | |
| 725 | Err(Refusal::WrongDomain) | |
| 726 | ); | |
| 727 | // A subdomain, or a domain that only ends the same, is another domain. | |
| 728 | assert_eq!( | |
| 729 | shared_admits(Some(&live), "a@eu.cloudflare.com"), | |
| 730 | Err(Refusal::WrongDomain) | |
| 731 | ); | |
| 732 | assert_eq!( | |
| 733 | shared_admits(Some(&live), "a@notcloudflare.com"), | |
| 734 | Err(Refusal::WrongDomain) | |
| 735 | ); | |
| 736 | // The last @ decides. | |
| 737 | assert_eq!( | |
| 738 | shared_admits(Some(&live), "\"a@cloudflare.com\"@evil.com"), | |
| 739 | Err(Refusal::WrongDomain) | |
| 740 | ); | |
| 741 | assert_eq!( | |
| 742 | shared_admits(Some(&live), "no-at-sign"), | |
| 743 | Err(Refusal::WrongDomain) | |
| 744 | ); | |
| 745 | assert_eq!( | |
| 746 | wrong_domain(&domains), | |
| 747 | "This invite is only for email addresses at cloudflare.com or flagon.io. Sign up with your address there." | |
| 748 | ); | |
| 749 | assert!( | |
| 750 | wrong_domain(&["a.com".into(), "b.com".into(), "c.com".into()]) | |
| 751 | .contains("a.com, b.com or c.com") | |
| 752 | ); | |
| 753 | } | |
| 754 | ||
| 755 | #[test] | |
| 756 | fn a_use_is_taken_only_under_max_uses_in_the_statement_that_takes_it() { | |
| 757 | let take = take_use_sql(); | |
| 758 | // The count, the cap, revocation and expiry are all checked in the | |
| 759 | // insert itself: no read-then-write gap for a race to slip into. | |
| 760 | assert!(take.starts_with("INSERT INTO shared_invite_uses")); | |
| 761 | assert!(take.contains("(SELECT count(*) FROM shared_invite_uses u WHERE u.shared_invite_id = s.id) < s.max_uses")); | |
| 762 | assert!(take.contains("s.revoked_at IS NULL")); | |
| 763 | assert!(take.contains(&format!("s.expires_at > {SQL_NOW}"))); | |
| 764 | assert!(!take.contains("VALUES")); | |
| 765 | // The account is made only if this sign-up took the use. | |
| 766 | let make = make_account_sql("NULL"); | |
| 767 | assert!(make.starts_with("INSERT INTO users")); | |
| 768 | assert!(make.contains("WHERE EXISTS (SELECT 1 FROM shared_invite_uses WHERE user_id = ?1 AND shared_invite_id = ?5)")); | |
| 769 | assert!(make.contains("SELECT ?1, ?2, ?3, ?4, NULL")); | |
| 770 | // The sealed code goes once the last use does. | |
| 771 | assert!(seal_used_up_sql().contains(">= max_uses")); | |
| 772 | } | |
| 773 | ||
| 774 | /// The take-a-use statement's rule, applied to sign-ups one after | |
| 775 | /// another as D1 runs them (one writer; each batch a transaction). | |
| 776 | fn race(max_uses: u32, signups: u32, revoked: bool, expires_at: &str) -> u32 { | |
| 777 | let mut uses = 0; | |
| 778 | for _ in 0..signups { | |
| 779 | if shared_status(revoked, expires_at, uses, max_uses, NOW) == SharedInviteStatus::Live { | |
| 780 | uses += 1; | |
| 781 | } | |
| 782 | } | |
| 783 | uses | |
| 784 | } | |
| 785 | ||
| 786 | #[test] | |
| 787 | fn however_many_race_for_it_a_link_never_passes_max_uses() { | |
| 788 | assert_eq!(race(1, 50, false, LATER), 1); | |
| 789 | assert_eq!(race(25, 1000, false, LATER), 25); | |
| 790 | assert_eq!(race(1000, 999, false, LATER), 999); | |
| 791 | assert_eq!(race(10, 10, true, LATER), 0); | |
| 792 | assert_eq!(race(10, 10, false, EARLIER), 0); | |
| 793 | } | |
| 794 | ||
| 795 | fn draft( | |
| 796 | label: &str, | |
| 797 | max_uses: u32, | |
| 798 | expires_on: Option<&str>, | |
| 799 | domains: &[&str], | |
| 800 | ) -> std::result::Result<SharedDraft, String> { | |
| 801 | let domains: Vec<String> = domains.iter().map(|d| (*d).to_owned()).collect(); | |
| 802 | check_draft(label, max_uses, expires_on, &domains, NOW_MS) | |
| 803 | } | |
| 804 | ||
| 805 | #[test] | |
| 806 | fn a_link_needs_a_label_and_one_to_a_thousand_uses() { | |
| 807 | let made = draft(" Cloudflare judges ", 40, None, &[]).unwrap(); | |
| 808 | assert_eq!(made.label, "Cloudflare judges"); | |
| 809 | assert_eq!(made.max_uses, 40); | |
| 810 | assert!(made.domains.is_empty()); | |
| 811 | assert!(draft("", 10, None, &[]).unwrap_err().contains("label")); | |
| 812 | assert!(draft(" ", 10, None, &[]).unwrap_err().contains("label")); | |
| 813 | assert!(draft(&"x".repeat(MAX_SHARED_INVITE_LABEL + 1), 10, None, &[]).is_err()); | |
| 814 | assert!(draft(&"x".repeat(MAX_SHARED_INVITE_LABEL), 10, None, &[]).is_ok()); | |
| 815 | assert!( | |
| 816 | draft("Judges", 0, None, &[]) | |
| 817 | .unwrap_err() | |
| 818 | .contains("between 1 and 1000") | |
| 819 | ); | |
| 820 | assert!(draft("Judges", 1001, None, &[]).is_err()); | |
| 821 | assert!(draft("Judges", 1, None, &[]).is_ok()); | |
| 822 | assert!(draft("Judges", 1000, None, &[]).is_ok()); | |
| 823 | } | |
| 824 | ||
| 825 | #[test] | |
| 826 | fn a_link_expires_in_14_days_unless_given_a_day_within_a_year() { | |
| 827 | assert_eq!( | |
| 828 | draft("Judges", 10, None, &[]).unwrap().expires_at, | |
| 829 | "2026-10-22T12:00:00.000Z" | |
| 830 | ); | |
| 831 | assert_eq!( | |
| 832 | draft("Judges", 10, Some(""), &[]).unwrap().expires_at, | |
| 833 | "2026-10-22T12:00:00.000Z" | |
| 834 | ); | |
| 835 | // A day works until its end, UTC. | |
| 836 | assert_eq!( | |
| 837 | draft("Judges", 10, Some("2026-10-14"), &[]) | |
| 838 | .unwrap() | |
| 839 | .expires_at, | |
| 840 | "2026-10-14T23:59:59.999Z" | |
| 841 | ); | |
| 842 | assert_eq!( | |
| 843 | draft("Judges", 10, Some("2026-10-08"), &[]) | |
| 844 | .unwrap() | |
| 845 | .expires_at, | |
| 846 | "2026-10-08T23:59:59.999Z" | |
| 847 | ); | |
| 848 | assert!( | |
| 849 | draft("Judges", 10, Some("2026-10-07"), &[]) | |
| 850 | .unwrap_err() | |
| 851 | .contains("passed") | |
| 852 | ); | |
| 853 | assert!(draft("Judges", 10, Some("2027-10-08"), &[]).is_ok()); | |
| 854 | assert!( | |
| 855 | draft("Judges", 10, Some("2027-10-09"), &[]) | |
| 856 | .unwrap_err() | |
| 857 | .contains("365 days") | |
| 858 | ); | |
| 859 | for bad in [ | |
| 860 | "2026-02-30", | |
| 861 | "2026-13-01", | |
| 862 | "next week", | |
| 863 | "2026-10-8", | |
| 864 | "2026/10/14", | |
| 865 | ] { | |
| 866 | assert!( | |
| 867 | draft("Judges", 10, Some(bad), &[]) | |
| 868 | .unwrap_err() | |
| 869 | .contains("as a date"), | |
| 870 | "{bad}" | |
| 871 | ); | |
| 872 | } | |
| 873 | } | |
| 874 | ||
| 875 | #[test] | |
| 876 | fn domains_are_tidied_and_checked() { | |
| 877 | let made = draft( | |
| 878 | "Judges", | |
| 879 | 10, | |
| 880 | None, | |
| 881 | &["@Cloudflare.com, flagon.io", "cloudflare.com\nexample.dev."], | |
| 882 | ) | |
| 883 | .unwrap(); | |
| 884 | assert_eq!(made.domains, ["cloudflare.com", "flagon.io", "example.dev"]); | |
| 885 | assert!(draft("Judges", 10, None, &["not a domain!"]).is_err()); | |
| 886 | assert!( | |
| 887 | draft("Judges", 10, None, &["localhost"]) | |
| 888 | .unwrap_err() | |
| 889 | .contains("localhost is not an email domain") | |
| 890 | ); | |
| 891 | assert!(draft("Judges", 10, None, &["-bad.com"]).is_err()); | |
| 892 | let eleven: Vec<String> = (0..11).map(|n| format!("d{n}.com")).collect(); | |
| 893 | let eleven: Vec<&str> = eleven.iter().map(String::as_str).collect(); | |
| 894 | assert!( | |
| 895 | draft("Judges", 10, None, &eleven) | |
| 896 | .unwrap_err() | |
| 897 | .contains("at most 10") | |
| 898 | ); | |
| 899 | assert_eq!( | |
| 900 | normalize_domain(" @EXAMPLE.com. ").as_deref(), | |
| 901 | Some("example.com") | |
| 902 | ); | |
| 903 | assert_eq!( | |
| 904 | domains_of(Some("cloudflare.com,flagon.io")), | |
| 905 | ["cloudflare.com", "flagon.io"] | |
| 906 | ); | |
| 907 | assert!(domains_of(None).is_empty()); | |
| 908 | assert!(domains_of(Some("")).is_empty()); | |
| 909 | } | |
| 910 | ||
| 911 | #[test] | |
| 912 | fn a_shared_code_is_an_ordinary_invite_code() { | |
| 913 | let body = new_code_body(); | |
| 914 | let link = format!("https://g1t.sh/register?invite={}", format_code(&body)); | |
| 915 | assert_eq!(normalize_code(&link).as_deref(), Some(body.as_str())); | |
| 916 | assert_eq!(code_hash(&body).len(), 64); | |
| 917 | assert_eq!(AUDIT_ACCOUNT, "invites"); | |
| 918 | assert!(g1t_contracts::is_reserved_name(AUDIT_ACCOUNT)); | |
| 919 | } | |
| 920 | ||
| 921 | #[test] | |
| 922 | fn revoking_stops_new_accounts_and_forgets_the_code_once() { | |
| 923 | let revoke = revoke_sql(); | |
| 924 | assert!(revoke.contains("revoked_by = ?1")); | |
| 925 | assert!(revoke.contains("sealed_code = NULL")); | |
| 926 | // Revoking twice changes nothing the second time. | |
| 927 | assert!(revoke.contains("AND revoked_at IS NULL")); | |
| 928 | // It deletes nothing: the accounts it made, and their uses, stay. | |
| 929 | assert!(!revoke.contains("DELETE")); | |
| 930 | let none: [String; 0] = []; | |
| 931 | let revoked = SharedAdmits { status: shared_status(true, LATER, 0, 10, NOW), domains: &none }; | |
| 932 | assert_eq!(shared_admits(Some(&revoked), "ada@example.com"), Err(Refusal::Invalid)); | |
| 933 | } | |
| 934 | ||
| 935 | #[test] | |
| 936 | fn each_use_records_where_the_account_came_from_and_outlives_a_purge() { | |
| 937 | // The row that takes a use names the account and the link: sudo's | |
| 938 | // "Joined through <label>". | |
| 939 | assert!(take_use_sql().contains("INSERT INTO shared_invite_uses (user_id, shared_invite_id, created_at)")); | |
| 940 | assert!(take_use_sql().contains("SELECT ?1, s.id,")); | |
| 941 | // Purging the account keeps the use, so it is never given back. | |
| 942 | let purge = crate::account_deletion::purge_statements(); | |
| 943 | assert!(!purge.is_empty()); | |
| 944 | assert!(purge.iter().all(|(sql, _)| !sql.contains("shared_invite_uses"))); | |
| 945 | } | |
| 946 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.