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

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