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