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