Skip to content
2,750 linesCodeBlameRaw

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

This file's history is long; its oldest lines are credited to the oldest commit read.