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