Skip to content
946 linesCodeBlameRaw
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
23use g1t_contracts::identity::*;
24use g1t_contracts::time::{SQL_NOW, parse_rfc3339, rfc3339};
25use g1t_contracts::{FailureCode, Outcome, new_id};
26use g1t_kit::now_ms;
27use serde::Deserialize;
28use worker::Result;
29use worker::wasm_bindgen::JsValue;
30
31use crate::Identity;
32use crate::invites::{Refusal, code_hash, code_hint, format_code, new_code_body, normalize_code};
33
34const 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.
38pub 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.
42pub 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.
55pub 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.
76pub 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)]
90pub 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.
98pub 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.
113pub 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)]
135pub 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.
144fn 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.
158pub 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.
230pub 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.
247pub 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.
258pub 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.
268pub 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.
275pub 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
284const 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.
290const LIST_LIMIT: usize = 200;
291
292#[derive(Debug, Deserialize)]
293pub 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
308impl 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
324impl 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)]
622mod 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}