Skip to content
947 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 email_proven: false,
383 }
384 }
385
386 /// The shared link an account was made with, if it was.
387 pub(crate) async fn shared_source(&self, user_id: &str) -> Result<Option<SharedInviteSource>> {
388 #[derive(Deserialize)]
389 struct Row {
390 id: String,
391 label: String,
392 }
393 Ok(self
394 .db
395 .prepare(
396 "SELECT s.id, s.label FROM shared_invite_uses u JOIN shared_invites s ON s.id = u.shared_invite_id
397 WHERE u.user_id = ?",
398 )
399 .bind(&[user_id.into()])?
400 .first::<Row>(None)
401 .await?
402 .map(|row| SharedInviteSource { id: row.id, label: row.label }))
403 }
404
405 /// A shared link as staff see it, with its code while it is live.
406 fn shown_shared(&self, row: SharedRow, accounts: Vec<SharedInviteAccount>) -> SharedInvite {
407 let status = row.status(&rfc3339(now_ms()));
408 let code = if status == SharedInviteStatus::Live {
409 row.sealed_code
410 .as_deref()
411 .and_then(|sealed| self.invite_sealer()?.open(sealed, &row.id))
412 } else {
413 None
414 };
415 let domains = row.domains();
416 SharedInvite {
417 id: row.id,
418 label: row.label,
419 code,
420 hint: row.hint,
421 max_uses: row.max_uses as u32,
422 uses: row.uses as u32,
423 domains,
424 status,
425 staff: row.staff,
426 created_at: row.created_at,
427 expires_at: row.expires_at,
428 revoked_at: row.revoked_at,
429 revoked_by: row.revoked_by,
430 accounts,
431 }
432 }
433
434 /// The accounts each of `ids` made, oldest first.
435 async fn shared_accounts(&self, ids: &[String]) -> Result<Vec<(String, SharedInviteAccount)>> {
436 if ids.is_empty() {
437 return Ok(Vec::new());
438 }
439 #[derive(Deserialize)]
440 struct Row {
441 shared_invite_id: String,
442 username: Option<String>,
443 created_at: String,
444 }
445 let marks = vec!["?"; ids.len()].join(", ");
446 let binds: Vec<JsValue> = ids.iter().map(|id| JsValue::from(id.as_str())).collect();
447 Ok(self
448 .db
449 .prepare(format!(
450 "SELECT u.shared_invite_id, us.username, u.created_at FROM shared_invite_uses u
451 LEFT JOIN users us ON us.id = u.user_id
452 WHERE u.shared_invite_id IN ({marks}) ORDER BY u.created_at, u.user_id"
453 ))
454 .bind(&binds)?
455 .all()
456 .await?
457 .results::<Row>()?
458 .into_iter()
459 .map(|row| {
460 (
461 row.shared_invite_id,
462 SharedInviteAccount {
463 username: row.username,
464 joined_at: row.created_at,
465 },
466 )
467 })
468 .collect())
469 }
470
471 /// `admin_shared_invites`.
472 pub async fn admin_shared_invites(&self) -> Result<Vec<SharedInvite>> {
473 let rows = self
474 .db
475 .prepare(format!(
476 "SELECT {COLUMNS} ORDER BY s.created_at DESC, s.id DESC LIMIT {LIST_LIMIT}"
477 ))
478 .all()
479 .await?
480 .results::<SharedRow>()?;
481 let ids: Vec<String> = rows.iter().map(|row| row.id.clone()).collect();
482 let mut accounts = self.shared_accounts(&ids).await?;
483 Ok(rows
484 .into_iter()
485 .map(|row| {
486 let (mine, rest): (Vec<_>, Vec<_>) =
487 accounts.drain(..).partition(|(id, _)| *id == row.id);
488 accounts = rest;
489 let mine = mine.into_iter().map(|(_, account)| account).collect();
490 self.shown_shared(row, mine)
491 })
492 .collect())
493 }
494
495 /// `admin_create_shared_invite`.
496 pub async fn admin_create_shared_invite(
497 &self,
498 a: AdminCreateSharedInviteArgs,
499 ) -> Result<Outcome<SharedInvite>> {
500 let staff = a.staff.trim();
501 if staff.is_empty() {
502 return Ok(Outcome::fail(
503 FailureCode::Forbidden,
504 "Say which staff member is making it.",
505 ));
506 }
507 let now = now_ms();
508 let draft = match check_draft(
509 &a.label,
510 a.max_uses,
511 a.expires_on.as_deref(),
512 &a.domains,
513 now,
514 ) {
515 Ok(draft) => draft,
516 Err(why) => return Ok(Outcome::fail(FailureCode::Invalid, why)),
517 };
518 let body = new_code_body();
519 let code = format_code(&body);
520 let id = new_id("sinv", now);
521 let sealed = self.invite_sealer().map(|sealer| sealer.seal(&code, &id));
522 let domains = draft.domains.join(",");
523 self.db
524 .prepare(
525 "INSERT INTO shared_invites (id, label, code_hash, hint, sealed_code, max_uses, domains, staff, created_at, expires_at)
526 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
527 )
528 .bind(&[
529 id.as_str().into(),
530 draft.label.as_str().into(),
531 code_hash(&body).into(),
532 code_hint(&body).into(),
533 sealed.as_deref().map_or(JsValue::NULL, JsValue::from),
534 f64::from(draft.max_uses).into(),
535 if domains.is_empty() { JsValue::NULL } else { domains.as_str().into() },
536 staff.into(),
537 rfc3339(now).into(),
538 draft.expires_at.as_str().into(),
539 ])?
540 .run()
541 .await?;
542 let only = if draft.domains.is_empty() {
543 String::new()
544 } else {
545 format!(", only {}", draft.domains.join(", "))
546 };
547 self.record_for_staff(
548 AUDIT_ACCOUNT,
549 "shared_invite_created",
550 &format!(
551 "Shared invite link {} ({}) for {}: up to {} accounts until {}{only}",
552 code_hint(&body),
553 id,
554 draft.label,
555 draft.max_uses,
556 &draft.expires_at[..10]
557 ),
558 staff,
559 )
560 .await;
561 let Some(row) = self.shared_by_id(&id).await? else {
562 return Ok(Outcome::fail(
563 FailureCode::Conflict,
564 "The link could not be made. Try again.",
565 ));
566 };
567 let mut shown = self.shown_shared(row, Vec::new());
568 shown.code = Some(code);
569 Ok(Outcome::Ok(shown))
570 }
571
572 /// `admin_revoke_shared_invite`.
573 pub async fn admin_revoke_shared_invite(
574 &self,
575 a: AdminRevokeSharedInviteArgs,
576 ) -> Result<Outcome<SharedInvite>> {
577 let staff = a.staff.trim();
578 if staff.is_empty() {
579 return Ok(Outcome::fail(
580 FailureCode::Forbidden,
581 "Say which staff member is revoking it.",
582 ));
583 }
584 let revoked = self
585 .db
586 .prepare(revoke_sql())
587 .bind(&[staff.into(), a.id.as_str().into()])?
588 .first::<serde_json::Value>(None)
589 .await?;
590 if revoked.is_none() {
591 return Ok(Outcome::fail(
592 FailureCode::Conflict,
593 "That link is revoked already, or there is no such link.",
594 ));
595 }
596 let Some(row) = self.shared_by_id(&a.id).await? else {
597 return Ok(Outcome::fail(
598 FailureCode::NotFound,
599 "Shared invite link not found.",
600 ));
601 };
602 self.record_for_staff(
603 AUDIT_ACCOUNT,
604 "shared_invite_revoked",
605 &format!(
606 "Revoked shared invite link {} ({}) for {} after {} of {} uses",
607 row.hint, row.id, row.label, row.uses as u32, row.max_uses as u32
608 ),
609 staff,
610 )
611 .await;
612 let accounts = self
613 .shared_accounts(std::slice::from_ref(&row.id))
614 .await?
615 .into_iter()
616 .map(|(_, account)| account)
617 .collect();
618 Ok(Outcome::Ok(self.shown_shared(row, accounts)))
619 }
620}
621
622#[cfg(test)]
623mod tests {
624 use super::*;
625
626 const NOW: &str = "2026-10-08T12:00:00.000Z";
627 const LATER: &str = "2026-10-22T12:00:00.000Z";
628 const EARLIER: &str = "2026-10-01T12:00:00.000Z";
629 /// 2026-10-08T12:00:00.000Z.
630 const NOW_MS: u64 = 1_791_460_800_000;
631
632 #[test]
633 fn the_test_clock_is_what_it_says() {
634 assert_eq!(rfc3339(NOW_MS), NOW);
635 }
636
637 #[test]
638 fn a_link_is_live_until_revoked_used_up_or_expired() {
639 assert_eq!(
640 shared_status(false, LATER, 0, 10, NOW),
641 SharedInviteStatus::Live
642 );
643 assert_eq!(
644 shared_status(false, LATER, 9, 10, NOW),
645 SharedInviteStatus::Live
646 );
647 assert_eq!(
648 shared_status(false, LATER, 10, 10, NOW),
649 SharedInviteStatus::UsedUp
650 );
651 assert_eq!(
652 shared_status(false, EARLIER, 3, 10, NOW),
653 SharedInviteStatus::Expired
654 );
655 // It stops at its expiry, not a moment after.
656 assert_eq!(
657 shared_status(false, NOW, 3, 10, NOW),
658 SharedInviteStatus::Expired
659 );
660 assert_eq!(
661 shared_status(true, LATER, 3, 10, NOW),
662 SharedInviteStatus::Revoked
663 );
664 // What staff did comes before what time did.
665 assert_eq!(
666 shared_status(true, EARLIER, 10, 10, NOW),
667 SharedInviteStatus::Revoked
668 );
669 assert_eq!(
670 shared_status(false, EARLIER, 10, 10, NOW),
671 SharedInviteStatus::UsedUp
672 );
673 }
674
675 #[test]
676 fn expired_revoked_and_used_up_links_all_get_the_one_answer() {
677 let none: [String; 0] = [];
678 for status in [
679 SharedInviteStatus::UsedUp,
680 SharedInviteStatus::Expired,
681 SharedInviteStatus::Revoked,
682 ] {
683 let link = SharedAdmits {
684 status,
685 domains: &none,
686 };
687 assert_eq!(
688 shared_admits(Some(&link), "ada@example.com"),
689 Err(Refusal::Invalid),
690 "{status:?}"
691 );
692 // Even at another domain: a dead link says nothing about whom it was for.
693 let domains = ["cloudflare.com".to_owned()];
694 let bound = SharedAdmits {
695 status,
696 domains: &domains,
697 };
698 assert_eq!(
699 shared_admits(Some(&bound), "eve@example.com"),
700 Err(Refusal::Invalid)
701 );
702 }
703 assert_eq!(
704 shared_admits(None, "ada@example.com"),
705 Err(Refusal::Invalid)
706 );
707 let live = SharedAdmits {
708 status: SharedInviteStatus::Live,
709 domains: &none,
710 };
711 assert_eq!(shared_admits(Some(&live), "anyone@anywhere.dev"), Ok(()));
712 }
713
714 #[test]
715 fn a_link_limited_to_domains_admits_only_addresses_there() {
716 let domains = ["cloudflare.com".to_owned(), "flagon.io".to_owned()];
717 let live = SharedAdmits {
718 status: SharedInviteStatus::Live,
719 domains: &domains,
720 };
721 assert_eq!(shared_admits(Some(&live), "judge@cloudflare.com"), Ok(()));
722 assert_eq!(shared_admits(Some(&live), " Judge@CloudFlare.COM "), Ok(()));
723 assert_eq!(shared_admits(Some(&live), "chase@flagon.io"), Ok(()));
724 assert_eq!(
725 shared_admits(Some(&live), "eve@example.com"),
726 Err(Refusal::WrongDomain)
727 );
728 // A subdomain, or a domain that only ends the same, is another domain.
729 assert_eq!(
730 shared_admits(Some(&live), "a@eu.cloudflare.com"),
731 Err(Refusal::WrongDomain)
732 );
733 assert_eq!(
734 shared_admits(Some(&live), "a@notcloudflare.com"),
735 Err(Refusal::WrongDomain)
736 );
737 // The last @ decides.
738 assert_eq!(
739 shared_admits(Some(&live), "\"a@cloudflare.com\"@evil.com"),
740 Err(Refusal::WrongDomain)
741 );
742 assert_eq!(
743 shared_admits(Some(&live), "no-at-sign"),
744 Err(Refusal::WrongDomain)
745 );
746 assert_eq!(
747 wrong_domain(&domains),
748 "This invite is only for email addresses at cloudflare.com or flagon.io. Sign up with your address there."
749 );
750 assert!(
751 wrong_domain(&["a.com".into(), "b.com".into(), "c.com".into()])
752 .contains("a.com, b.com or c.com")
753 );
754 }
755
756 #[test]
757 fn a_use_is_taken_only_under_max_uses_in_the_statement_that_takes_it() {
758 let take = take_use_sql();
759 // The count, the cap, revocation and expiry are all checked in the
760 // insert itself: no read-then-write gap for a race to slip into.
761 assert!(take.starts_with("INSERT INTO shared_invite_uses"));
762 assert!(take.contains("(SELECT count(*) FROM shared_invite_uses u WHERE u.shared_invite_id = s.id) < s.max_uses"));
763 assert!(take.contains("s.revoked_at IS NULL"));
764 assert!(take.contains(&format!("s.expires_at > {SQL_NOW}")));
765 assert!(!take.contains("VALUES"));
766 // The account is made only if this sign-up took the use.
767 let make = make_account_sql("NULL");
768 assert!(make.starts_with("INSERT INTO users"));
769 assert!(make.contains("WHERE EXISTS (SELECT 1 FROM shared_invite_uses WHERE user_id = ?1 AND shared_invite_id = ?5)"));
770 assert!(make.contains("SELECT ?1, ?2, ?3, ?4, NULL"));
771 // The sealed code goes once the last use does.
772 assert!(seal_used_up_sql().contains(">= max_uses"));
773 }
774
775 /// The take-a-use statement's rule, applied to sign-ups one after
776 /// another as D1 runs them (one writer; each batch a transaction).
777 fn race(max_uses: u32, signups: u32, revoked: bool, expires_at: &str) -> u32 {
778 let mut uses = 0;
779 for _ in 0..signups {
780 if shared_status(revoked, expires_at, uses, max_uses, NOW) == SharedInviteStatus::Live {
781 uses += 1;
782 }
783 }
784 uses
785 }
786
787 #[test]
788 fn however_many_race_for_it_a_link_never_passes_max_uses() {
789 assert_eq!(race(1, 50, false, LATER), 1);
790 assert_eq!(race(25, 1000, false, LATER), 25);
791 assert_eq!(race(1000, 999, false, LATER), 999);
792 assert_eq!(race(10, 10, true, LATER), 0);
793 assert_eq!(race(10, 10, false, EARLIER), 0);
794 }
795
796 fn draft(
797 label: &str,
798 max_uses: u32,
799 expires_on: Option<&str>,
800 domains: &[&str],
801 ) -> std::result::Result<SharedDraft, String> {
802 let domains: Vec<String> = domains.iter().map(|d| (*d).to_owned()).collect();
803 check_draft(label, max_uses, expires_on, &domains, NOW_MS)
804 }
805
806 #[test]
807 fn a_link_needs_a_label_and_one_to_a_thousand_uses() {
808 let made = draft(" Cloudflare judges ", 40, None, &[]).unwrap();
809 assert_eq!(made.label, "Cloudflare judges");
810 assert_eq!(made.max_uses, 40);
811 assert!(made.domains.is_empty());
812 assert!(draft("", 10, None, &[]).unwrap_err().contains("label"));
813 assert!(draft(" ", 10, None, &[]).unwrap_err().contains("label"));
814 assert!(draft(&"x".repeat(MAX_SHARED_INVITE_LABEL + 1), 10, None, &[]).is_err());
815 assert!(draft(&"x".repeat(MAX_SHARED_INVITE_LABEL), 10, None, &[]).is_ok());
816 assert!(
817 draft("Judges", 0, None, &[])
818 .unwrap_err()
819 .contains("between 1 and 1000")
820 );
821 assert!(draft("Judges", 1001, None, &[]).is_err());
822 assert!(draft("Judges", 1, None, &[]).is_ok());
823 assert!(draft("Judges", 1000, None, &[]).is_ok());
824 }
825
826 #[test]
827 fn a_link_expires_in_14_days_unless_given_a_day_within_a_year() {
828 assert_eq!(
829 draft("Judges", 10, None, &[]).unwrap().expires_at,
830 "2026-10-22T12:00:00.000Z"
831 );
832 assert_eq!(
833 draft("Judges", 10, Some(""), &[]).unwrap().expires_at,
834 "2026-10-22T12:00:00.000Z"
835 );
836 // A day works until its end, UTC.
837 assert_eq!(
838 draft("Judges", 10, Some("2026-10-14"), &[])
839 .unwrap()
840 .expires_at,
841 "2026-10-14T23:59:59.999Z"
842 );
843 assert_eq!(
844 draft("Judges", 10, Some("2026-10-08"), &[])
845 .unwrap()
846 .expires_at,
847 "2026-10-08T23:59:59.999Z"
848 );
849 assert!(
850 draft("Judges", 10, Some("2026-10-07"), &[])
851 .unwrap_err()
852 .contains("passed")
853 );
854 assert!(draft("Judges", 10, Some("2027-10-08"), &[]).is_ok());
855 assert!(
856 draft("Judges", 10, Some("2027-10-09"), &[])
857 .unwrap_err()
858 .contains("365 days")
859 );
860 for bad in [
861 "2026-02-30",
862 "2026-13-01",
863 "next week",
864 "2026-10-8",
865 "2026/10/14",
866 ] {
867 assert!(
868 draft("Judges", 10, Some(bad), &[])
869 .unwrap_err()
870 .contains("as a date"),
871 "{bad}"
872 );
873 }
874 }
875
876 #[test]
877 fn domains_are_tidied_and_checked() {
878 let made = draft(
879 "Judges",
880 10,
881 None,
882 &["@Cloudflare.com, flagon.io", "cloudflare.com\nexample.dev."],
883 )
884 .unwrap();
885 assert_eq!(made.domains, ["cloudflare.com", "flagon.io", "example.dev"]);
886 assert!(draft("Judges", 10, None, &["not a domain!"]).is_err());
887 assert!(
888 draft("Judges", 10, None, &["localhost"])
889 .unwrap_err()
890 .contains("localhost is not an email domain")
891 );
892 assert!(draft("Judges", 10, None, &["-bad.com"]).is_err());
893 let eleven: Vec<String> = (0..11).map(|n| format!("d{n}.com")).collect();
894 let eleven: Vec<&str> = eleven.iter().map(String::as_str).collect();
895 assert!(
896 draft("Judges", 10, None, &eleven)
897 .unwrap_err()
898 .contains("at most 10")
899 );
900 assert_eq!(
901 normalize_domain(" @EXAMPLE.com. ").as_deref(),
902 Some("example.com")
903 );
904 assert_eq!(
905 domains_of(Some("cloudflare.com,flagon.io")),
906 ["cloudflare.com", "flagon.io"]
907 );
908 assert!(domains_of(None).is_empty());
909 assert!(domains_of(Some("")).is_empty());
910 }
911
912 #[test]
913 fn a_shared_code_is_an_ordinary_invite_code() {
914 let body = new_code_body();
915 let link = format!("https://g1t.sh/register?invite={}", format_code(&body));
916 assert_eq!(normalize_code(&link).as_deref(), Some(body.as_str()));
917 assert_eq!(code_hash(&body).len(), 64);
918 assert_eq!(AUDIT_ACCOUNT, "invites");
919 assert!(g1t_contracts::is_reserved_name(AUDIT_ACCOUNT));
920 }
921
922 #[test]
923 fn revoking_stops_new_accounts_and_forgets_the_code_once() {
924 let revoke = revoke_sql();
925 assert!(revoke.contains("revoked_by = ?1"));
926 assert!(revoke.contains("sealed_code = NULL"));
927 // Revoking twice changes nothing the second time.
928 assert!(revoke.contains("AND revoked_at IS NULL"));
929 // It deletes nothing: the accounts it made, and their uses, stay.
930 assert!(!revoke.contains("DELETE"));
931 let none: [String; 0] = [];
932 let revoked = SharedAdmits { status: shared_status(true, LATER, 0, 10, NOW), domains: &none };
933 assert_eq!(shared_admits(Some(&revoked), "ada@example.com"), Err(Refusal::Invalid));
934 }
935
936 #[test]
937 fn each_use_records_where_the_account_came_from_and_outlives_a_purge() {
938 // The row that takes a use names the account and the link: sudo's
939 // "Joined through <label>".
940 assert!(take_use_sql().contains("INSERT INTO shared_invite_uses (user_id, shared_invite_id, created_at)"));
941 assert!(take_use_sql().contains("SELECT ?1, s.id,"));
942 // Purging the account keeps the use, so it is never given back.
943 let purge = crate::account_deletion::purge_statements();
944 assert!(!purge.is_empty());
945 assert!(purge.iter().all(|(sql, _)| !sql.contains("shared_invite_uses")));
946 }
947}