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