flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/identity/src/invites.rs

1,853 lines75,921 bytesCodeBlame

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

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