Skip to content
2,440 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//!
Shared invite links: one staff-made link for a group, up to 1000 new accounts (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
Shared invite links: one staff-made link for a group, up to 1000 new accounts (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,
Shared invite links: one staff-made link for a group, up to 1000 new accounts (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 email confirmation gate: a code and a link, nothing until confirmed (identity 0036)236/// What an invite used to sign up does once its account confirms its
237/// address.
238#[derive(Clone, Debug, PartialEq, Eq)]
239pub enum AwaitingJoin {
240 /// Join this workspace.
241 Join { workspace_id: String, slug: String },
242 /// It names no workspace; repository invitations sent with it are
243 /// accepted.
244 Nothing,
245 /// It no longer applies, and why, as the person is told.
246 Lapsed(String),
247}
248
249/// [`AwaitingJoin`] for an invite's row at `now`. `row.workspace` is the
250/// workspace's slug, None once it was deleted; `free`: it is on the free
251/// plan, which adds no members (paid.rs).
252pub fn awaiting_join(row: &InviteRow, now: &str, free: bool) -> AwaitingJoin {
253 let what = match &row.workspace {
254 Some(slug) => format!("did not join you to {slug}"),
255 None => "no longer applies".to_owned(),
256 };
257 if row.revoked_at.is_some() {
258 return AwaitingJoin::Lapsed(format!(
259 "Your email address is confirmed. The invite you signed up with was revoked while you were confirming it, so it {what}."
260 ));
261 }
262 if row.expires_at.as_str() <= now {
263 return AwaitingJoin::Lapsed(format!(
264 "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."
265 ));
266 }
267 match (&row.workspace_id, &row.workspace) {
268 (None, _) => AwaitingJoin::Nothing,
269 (Some(_), None) => AwaitingJoin::Lapsed(
270 "Your email address is confirmed. The workspace your invite was for has been deleted, so the invite no longer applies.".to_owned(),
271 ),
272 (Some(_), Some(slug)) if free => AwaitingJoin::Lapsed(format!(
273 "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."
274 )),
275 (Some(workspace_id), Some(slug)) => AwaitingJoin::Join { workspace_id: workspace_id.clone(), slug: slug.clone() },
276 }
277}
278
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look279/// A trimmed, lowercased address, if it looks like one.
280pub fn normalize_email(email: &str) -> Option<String> {
281 let email = email.trim().to_lowercase();
282 let well_formed = email.len() <= 254
283 && email
284 .split_once('@')
285 .is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.') && !domain.contains('@'))
286 && !email.contains(char::is_whitespace);
287 well_formed.then_some(email)
288}
289
290/// An address with most of its local part hidden: `a•••@example.com`.
291pub fn mask_email(email: &str) -> String {
292 match email.split_once('@') {
293 Some((local, domain)) => {
294 let first: String = local.chars().take(1).collect();
295 format!("{first}•••@{domain}")
296 }
297 None => "•••".to_owned(),
298 }
299}
300
301/// The fixed window a moment falls in.
302pub fn bucket(now_ms: u64, window_ms: u64) -> u64 {
303 now_ms / window_ms
304}
305
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas306/// Whether staff may be sent a summary of new requests: none was sent yet,
307/// or the last went before `since` (15 minutes ago). RFC 3339 times.
308pub fn summary_due(last: Option<&str>, since: &str) -> bool {
309 last.is_none_or(|last| last <= since)
310}
311
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look312// --- Rows ---------------------------------------------------------------------
313
314const COLUMNS: &str = "i.id, i.hint, i.sealed_code, i.email, i.kind, i.workspace_id, w.slug AS workspace,
315 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)316 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 look317 FROM invites i
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member318 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 look319 LEFT JOIN users iu ON iu.id = i.inviter_id
320 LEFT JOIN users ru ON ru.id = i.redeemed_by";
321
322#[derive(Debug, Deserialize)]
323pub struct InviteRow {
324 pub id: String,
325 pub hint: String,
326 pub sealed_code: Option<String>,
327 pub email: Option<String>,
328 pub kind: String,
329 pub workspace_id: Option<String>,
330 pub workspace: Option<String>,
331 pub inviter_id: Option<String>,
332 pub inviter: Option<String>,
333 pub staff: Option<String>,
334 pub charged_to: String,
335 pub created_at: String,
336 pub expires_at: String,
337 pub revoked_at: Option<String>,
338 pub redeemer: Option<String>,
339 pub redeemed_at: Option<String>,
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)340 #[serde(default)]
341 pub applied_at: Option<String>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look342}
343
344impl InviteRow {
345 pub fn status(&self, now: &str) -> InviteStatus {
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)346 status_of(
347 self.revoked_at.as_deref(),
348 self.redeemed_at.as_deref(),
349 self.applied_at.as_deref(),
350 &self.expires_at,
351 now,
352 )
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look353 }
354
355 fn admits(&self, now: &str) -> Admits<'_> {
356 Admits {
357 kind: &self.kind,
358 email: self.email.as_deref(),
359 status: self.status(now),
360 }
361 }
362}
363
364fn kind_of(kind: &str) -> InviteKind {
365 if kind == "workspace" { InviteKind::Workspace } else { InviteKind::Account }
366}
367
368fn charge_of(charged_to: &str) -> InviteCharge {
369 match charged_to {
370 "user" => InviteCharge::User,
371 "workspace" => InviteCharge::Workspace,
372 _ => InviteCharge::None,
373 }
374}
375
376#[derive(Deserialize)]
377struct Count {
378 n: f64,
379}
380
381#[derive(Deserialize)]
382struct Id {
383 id: String,
384}
385
386#[derive(Deserialize)]
387struct WaitlistRow {
388 id: String,
389 email: String,
390 about: Option<String>,
391 status: String,
392 invite_id: Option<String>,
393 decided_by: Option<String>,
394 decided_at: Option<String>,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas395 #[serde(default)]
396 note: Option<String>,
397 #[serde(default)]
398 joined_as: Option<String>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look399 created_at: String,
400 updated_at: String,
401}
402
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas403const WAITLIST_COLUMNS: &str = "wl.id, wl.email, wl.about, wl.status, wl.invite_id, wl.decided_by, wl.decided_at, wl.note,
404 ju.username AS joined_as, wl.created_at, wl.updated_at
405 FROM waitlist wl
406 LEFT JOIN invites wi ON wi.id = wl.invite_id
407 LEFT JOIN users ju ON ju.id = wi.redeemed_by";
408
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look409impl From<WaitlistRow> for WaitlistEntry {
410 fn from(row: WaitlistRow) -> Self {
411 WaitlistEntry {
412 id: row.id,
413 email: row.email,
414 about: row.about,
415 status: match row.status.as_str() {
416 "invited" => WaitlistStatus::Invited,
417 "dismissed" => WaitlistStatus::Dismissed,
418 _ => WaitlistStatus::Waiting,
419 },
420 invite_id: row.invite_id,
421 decided_by: row.decided_by,
422 decided_at: row.decided_at,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas423 note: row.note,
424 joined_as: row.joined_as,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look425 created_at: row.created_at,
426 updated_at: row.updated_at,
427 }
428 }
429}
430
431/// What a new account is made from.
432pub struct NewAccount<'a> {
433 /// Checked by the caller: valid, and free.
434 pub username: &'a str,
435 /// Lowercased and checked by the caller.
436 pub email: &'a str,
437 /// Empty for an account with no password (made through GitHub).
438 pub password_hash: &'a str,
439 /// Whether the address is confirmed already (GitHub's verified email).
440 pub verified: bool,
441 pub invite_code: Option<&'a str>,
442 /// Who is asking, for rate limits.
443 pub client: Option<&'a str>,
444}
445
446/// What an invite was made for.
447struct Draft<'a> {
448 email: Option<&'a str>,
449 kind: &'a str,
450 /// The workspace using it joins.
451 workspace_id: Option<&'a str>,
452 inviter: Option<&'a User>,
453 staff: Option<&'a str>,
454 /// `user`, `workspace` or `none`; the workspace for `workspace`; and
455 /// the limit when there is one.
456 charged_to: &'a str,
457 charged_workspace_id: Option<&'a str>,
458 limit: Option<u32>,
459}
460
461impl Identity {
462 // --- Settings ---
463
464 pub fn registration_mode(&self) -> RegistrationMode {
465 RegistrationMode::parse(self.env.var("REGISTRATION_MODE").ok().map(|v| v.to_string()).as_deref())
466 }
467
468 /// Whether new accounts need an invite code.
469 pub fn invites_required(&self) -> bool {
470 self.registration_mode() == RegistrationMode::Invite
471 }
472
473 fn var_number(&self, name: &str) -> Option<u64> {
474 self.env.var(name).ok()?.to_string().trim().parse().ok()
475 }
476
477 fn invites_per_user(&self) -> u32 {
478 self.var_number("INVITES_PER_USER").map_or(INVITES_PER_USER, |n| n.min(u64::from(u32::MAX)) as u32)
479 }
480
481 fn invite_ttl_days(&self) -> u64 {
482 self.var_number("INVITE_TTL_DAYS").filter(|days| (1..=365).contains(days)).unwrap_or(INVITE_TTL_DAYS)
483 }
484
485 /// The workspaces whose owners invite without limit: g1t's own.
486 fn staff_workspaces(&self) -> Vec<String> {
487 self.env
488 .var("INVITE_STAFF_WORKSPACES")
489 .map(|v| v.to_string())
490 .unwrap_or_default()
491 .split(',')
492 .map(|slug| slug.trim().to_lowercase())
493 .filter(|slug| !slug.is_empty())
494 .collect()
495 }
496
Shared invite links: one staff-made link for a group, up to 1000 new accounts (identity 0038)497 pub(crate) fn invite_sealer(&self) -> Option<Sealer> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look498 Sealer::new(&self.env.secret("IDENTITY_KEY").ok()?.to_string())
499 }
500
501 // --- Rate limits ---
502
503 /// Counts one more hit on `key` this hour; false once past `limit`.
504 async fn hit(&self, key: &str, limit: u32) -> Result<bool> {
505 let now = bucket(now_ms(), HOUR_MS);
506 let hits = self
507 .db
508 .prepare(
509 "INSERT INTO rate_limits (key, bucket, hits) VALUES (?1, ?2, 1)
510 ON CONFLICT (key) DO UPDATE SET
511 hits = CASE WHEN rate_limits.bucket = excluded.bucket THEN rate_limits.hits + 1 ELSE 1 END,
512 bucket = excluded.bucket
513 RETURNING hits AS n",
514 )
515 .bind(&[key.into(), (now as f64).into()])?
516 .first::<Count>(None)
517 .await?
518 .map_or(1.0, |count| count.n);
519 if hits <= 1.0 {
520 // A new window: forget windows gone by.
521 self.db
522 .prepare("DELETE FROM rate_limits WHERE bucket < ?")
523 .bind(&[((now.saturating_sub(1)) as f64).into()])?
524 .run()
525 .await?;
526 }
527 Ok(hits <= f64::from(limit))
528 }
529
530 /// Hits on `key` this hour, without adding one.
531 async fn hits(&self, key: &str) -> Result<u32> {
532 Ok(self
533 .db
534 .prepare("SELECT hits AS n FROM rate_limits WHERE key = ? AND bucket = ?")
535 .bind(&[key.into(), (bucket(now_ms(), HOUR_MS) as f64).into()])?
536 .first::<Count>(None)
537 .await?
538 .map_or(0, |count| count.n as u32))
539 }
540
541 /// Whether `client` has tried too many wrong codes this hour.
542 async fn turned_away(&self, client: Option<&str>) -> Result<bool> {
543 Ok(match client {
544 Some(client) => self.hits(&format!("invite.fail:{}", crypto::sha256_hex(client))).await? >= FAILURES_PER_HOUR,
545 None => false,
546 })
547 }
548
549 async fn count_failure(&self, client: Option<&str>) -> Result<()> {
550 if let Some(client) = client {
551 self.hit(&format!("invite.fail:{}", crypto::sha256_hex(client)), FAILURES_PER_HOUR).await?;
552 }
553 Ok(())
554 }
555
556 // --- Reading ---
557
558 async fn invite_by_code(&self, code: &str) -> Result<Option<InviteRow>> {
559 let Some(body) = normalize_code(code) else {
560 return Ok(None);
561 };
562 self.db
563 .prepare(format!("SELECT {COLUMNS} WHERE i.code_hash = ?"))
564 .bind(&[code_hash(&body).into()])?
565 .first::<InviteRow>(None)
566 .await
567 }
568
569 async fn invite_by_id(&self, id: &str) -> Result<Option<InviteRow>> {
570 self.db
571 .prepare(format!("SELECT {COLUMNS} WHERE i.id = ?"))
572 .bind(&[id.into()])?
573 .first::<InviteRow>(None)
574 .await
575 }
576
577 /// An invite as shown, with its code when `reveal` and it is pending.
578 fn shown(&self, row: InviteRow, reveal: bool, staff_view: bool) -> Invite {
579 let now = rfc3339(now_ms());
580 let status = row.status(&now);
581 let code = if reveal && status == InviteStatus::Pending {
582 row.sealed_code
583 .as_deref()
584 .and_then(|sealed| self.invite_sealer()?.open(sealed, &row.id))
585 } else {
586 None
587 };
588 Invite {
589 id: row.id,
590 code,
591 hint: row.hint,
592 email: row.email,
593 kind: kind_of(&row.kind),
594 workspace: row.workspace,
595 status,
596 charged_to: charge_of(&row.charged_to),
597 invited_by: row.inviter,
598 redeemed_by: row.redeemer,
599 created_at: row.created_at,
600 expires_at: row.expires_at,
601 redeemed_at: row.redeemed_at,
602 revoked_at: row.revoked_at,
603 staff: if staff_view { row.staff } else { None },
604 }
605 }
606
607 async fn rows(&self, filter: &str, binds: &[JsValue], limit: u32) -> Result<Vec<InviteRow>> {
608 self.db
609 .prepare(format!("SELECT {COLUMNS} {filter} ORDER BY i.created_at DESC, i.id DESC LIMIT {limit}"))
610 .bind(binds)?
611 .all()
612 .await?
613 .results::<InviteRow>()
614 }
615
616 async fn granted(&self, target: GrantTarget, id: &str) -> Result<i64> {
617 Ok(self
618 .db
619 .prepare("SELECT COALESCE(SUM(amount), 0) AS n FROM invite_grants WHERE target_kind = ? AND target_id = ?")
620 .bind(&[target.as_str().into(), id.into()])?
621 .first::<Count>(None)
622 .await?
623 .map_or(0, |count| count.n as i64))
624 }
625
626 async fn is_invite_staff(&self, user_id: &str) -> Result<bool> {
627 let staff = self.staff_workspaces();
628 if staff.is_empty() {
629 return Ok(false);
630 }
631 let marks = vec!["?"; staff.len()].join(", ");
632 let mut binds: Vec<JsValue> = vec![user_id.into()];
633 binds.extend(staff.iter().map(|slug| JsValue::from(slug.as_str())));
634 Ok(self
635 .db
636 .prepare(format!(
637 "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 member638 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 look639 ))
640 .bind(&binds)?
641 .first::<Count>(None)
642 .await?
643 .is_some_and(|count| count.n > 0.0))
644 }
645
646 async fn used(&self, column: &'static str, id: &str, charged_to: &str) -> Result<u32> {
647 Ok(self
648 .db
649 .prepare(format!(
650 "SELECT count(*) AS n FROM invites i WHERE i.{column} = ? AND i.charged_to = ? AND {}",
651 counted_sql()
652 ))
653 .bind(&[id.into(), charged_to.into()])?
654 .first::<Count>(None)
655 .await?
656 .map_or(0, |count| count.n as u32))
657 }
658
659 /// A person's own allowance.
660 pub async fn user_allowance(&self, user_id: &str) -> Result<Allowance> {
661 let unlimited = self.is_invite_staff(user_id).await?;
662 let granted = self.granted(GrantTarget::User, user_id).await?;
663 let used = self.used("inviter_id", user_id, "user").await?;
664 Ok(Allowance::new(limit_for(self.invites_per_user(), granted, unlimited), used))
665 }
666
667 /// A workspace's shared allowance: only what staff granted it.
668 async fn workspace_allowance(&self, workspace_id: &str) -> Result<Allowance> {
669 let granted = self.granted(GrantTarget::Workspace, workspace_id).await?;
670 let used = self.used("charged_workspace_id", workspace_id, "workspace").await?;
671 Ok(Allowance::new(limit_for(0, granted, false), used))
672 }
673
674 async fn workspace_id(&self, slug: &str) -> Result<Option<String>> {
675 Ok(self
676 .db
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member677 .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 look678 .bind(&[slug.trim().to_lowercase().into()])?
679 .first::<Id>(None)
680 .await?
681 .map(|row| row.id))
682 }
683
684 /// Whether an address is any account's: confirmed on one, or the
685 /// address a new account signed up with (emails.rs).
686 async fn email_has_account(&self, email: &str) -> Result<bool> {
687 self.email_in_use(email).await
688 }
689
690 // --- The gate ---
691
692 /// Makes an account: the only place one is made. While registration is
693 /// 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)694 /// the same transaction as the account is made. What the invite gives
695 /// (a workspace, repository invitations) is applied once the address
696 /// is confirmed: at once for an address GitHub has confirmed, otherwise
697 /// in the transaction that confirms it (emails.rs, `confirm_address`).
698 /// 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 look699 pub async fn create_account(&self, new: NewAccount<'_>) -> Result<Outcome<User>> {
700 let required = self.invites_required();
701 let code = new.invite_code.map(str::trim).filter(|code| !code.is_empty());
702 let mut invite = None;
Shared invite links: one staff-made link for a group, up to 1000 new accounts (identity 0038)703 // A shared invite link's code instead (shared_invites.rs).
704 let mut shared = None;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look705 match code {
706 None if required => return Ok(Outcome::fail(FailureCode::Forbidden, MISSING)),
707 None => {}
708 Some(code) => {
709 if required && self.turned_away(new.client).await? {
710 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
711 }
712 let row = self.invite_by_code(code).await?;
Shared invite links: one staff-made link for a group, up to 1000 new accounts (identity 0038)713 let link = match row {
714 None => self.shared_by_code(code).await?,
715 Some(_) => None,
716 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look717 let now = rfc3339(now_ms());
Shared invite links: one staff-made link for a group, up to 1000 new accounts (identity 0038)718 let verdict = match &link {
719 Some(link) => {
720 let domains = link.domains();
721 let admits = SharedAdmits { status: link.status(&now), domains: &domains };
722 shared_admits(Some(&admits), new.email)
723 }
724 None => admits(row.as_ref().map(|row| row.admits(&now)).as_ref(), new.email, true),
725 };
726 match verdict {
727 Ok(()) => (invite, shared) = (row, link),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look728 Err(_) if !required => {}
729 Err(refusal) => {
730 self.count_failure(new.client).await?;
Shared invite links: one staff-made link for a group, up to 1000 new accounts (identity 0038)731 let message = match refusal {
732 Refusal::WrongEmail => WRONG_EMAIL.to_owned(),
733 Refusal::WrongDomain => wrong_domain(&link.map(|link| link.domains()).unwrap_or_default()),
734 Refusal::Invalid => INVALID.to_owned(),
735 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look736 return Ok(Outcome::fail(FailureCode::Forbidden, message));
737 }
738 }
739 }
740 }
741
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)742 // Only an address GitHub has confirmed starts confirmed. An invite
743 // bound to the address proves nothing: its link can be forwarded,
744 // so the new account confirms the address like any other.
745 let verified = new.verified;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look746 let user = User {
747 id: new_id("usr", now_ms()),
748 username: new.username.to_owned(),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas749 verified,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look750 ..User::default()
751 };
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas752 let verified_at = if verified { SQL_NOW } else { "NULL" };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look753 let values = [
754 JsValue::from(user.id.as_str()),
755 new.username.into(),
756 new.email.into(),
757 new.password_hash.into(),
758 ];
Shared invite links: one staff-made link for a group, up to 1000 new accounts (identity 0038)759 let made = match (&invite, &shared) {
760 // Take a use of the shared link, then make the account only if
761 // this request took it: one transaction, counted in the
762 // statement that takes it, so racing past its uses is
763 // impossible.
764 (None, Some(link)) => self
765 .db
766 .batch(self.shared_account_statements(link, &values, verified_at)?)
767 .await
768 .map(|_| ()),
769 (None, None) => {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look770 self.db
771 .prepare(format!(
772 "INSERT INTO users (id, username, email, password_hash, email_verified_at)
773 VALUES (?, ?, ?, ?, {verified_at})"
774 ))
775 .bind(&values)?
776 .run()
777 .await
778 .map(|_| ())
779 }
780 // Spend the code, then make the account only if this request
781 // spent it: one transaction, so a second use finds it gone.
Shared invite links: one staff-made link for a group, up to 1000 new accounts (identity 0038)782 (Some(row), _) => {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look783 let mut insert = values.to_vec();
784 insert.extend([JsValue::from(row.id.as_str()), user.id.as_str().into()]);
785 self.db
786 .batch(vec![
787 self.db
788 .prepare(format!(
789 "UPDATE invites SET redeemed_by = ?, redeemed_at = {SQL_NOW}, sealed_code = NULL
790 WHERE id = ? AND kind = 'account' AND redeemed_at IS NULL AND revoked_at IS NULL
791 AND expires_at > {SQL_NOW}"
792 ))
793 .bind(&[user.id.as_str().into(), row.id.as_str().into()])?,
794 self.db
795 .prepare(format!(
796 "INSERT INTO users (id, username, email, password_hash, email_verified_at)
797 SELECT ?, ?, ?, ?, {verified_at}
798 WHERE EXISTS (SELECT 1 FROM invites WHERE id = ? AND redeemed_by = ?)"
799 ))
800 .bind(&insert)?,
801 ])
802 .await
803 .map(|_| ())
804 }
805 };
806 if let Err(error) = made {
807 // Someone took the username or email a moment ago; nothing
808 // was written, the code included.
809 if error.to_string().contains("UNIQUE") {
810 return Ok(Outcome::fail(FailureCode::Conflict, "That username or email is already registered."));
811 }
812 return Err(error);
813 }
814 let exists = self
815 .db
816 .prepare("SELECT id FROM users WHERE id = ?")
817 .bind(&[user.id.as_str().into()])?
818 .first::<Id>(None)
819 .await?
820 .is_some();
821 if !exists {
822 // Another sign-up spent the code first.
823 self.count_failure(new.client).await?;
824 return Ok(Outcome::fail(FailureCode::Forbidden, INVALID));
825 }
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)826 // Confirmed already (GitHub): what the invite gives, now. Otherwise
827 // it waits, spent, for the address to be confirmed.
828 if let Some(row) = invite
829 && user.verified
830 {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look831 self.after_redeemed(&row, &user, true).await?;
832 }
Shared invite links: one staff-made link for a group, up to 1000 new accounts (identity 0038)833 // A shared link gives nothing to wait for: the account makes its
834 // own workspace.
835 if let Some(link) = shared {
836 self.announce(
837 "invite.redeemed",
838 Some(&user.id),
839 InviteRedeemed {
840 invite_id: link.id,
841 user_id: user.id.clone(),
842 inviter_id: None,
843 workspace_id: None,
844 created_account: true,
845 },
846 )
847 .await;
848 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look849 Ok(Outcome::Ok(user))
850 }
851
852 /// Joins the invite's workspace, and tells the event log and audit log.
853 async fn after_redeemed(&self, row: &InviteRow, user: &User, created_account: bool) -> Result<()> {
854 let mut joined = None;
Merge Stripe Tax, the card fee on card payments, and one free workspace per person855 // A free workspace adds no one (paid.rs): a sign-up with an invite
856 // from one sent before still makes the account, without joining.
857 let free = match &row.workspace {
858 Some(slug) => self.is_free_workspace(slug).await,
859 None => false,
860 };
861 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 look862 self.db
863 .prepare(
864 "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
865 VALUES (?, ?, 'member', ?)",
866 )
867 .bind(&[workspace_id.as_str().into(), user.id.as_str().into(), rfc3339(now_ms()).into()])?
868 .run()
869 .await?;
870 joined = Some(slug.clone());
871 }
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)872 self.db
873 .prepare(format!("UPDATE invites SET applied_at = {SQL_NOW} WHERE id = ? AND applied_at IS NULL"))
874 .bind(&[row.id.as_str().into()])?
875 .run()
876 .await?;
877 self.settled(row, user, created_account, joined).await;
878 Ok(())
879 }
880
881 /// What follows an invite's workspace being joined (`joined`, by slug)
882 /// or not: repository invitations sent with its code are accepted, and
883 /// the event log and the workspace's audit log are told.
884 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 look885 // A code sent with an invitation to collaborate on a repository:
886 // using it accepts (access.rs).
887 if let Err(error) = self.accept_invitations_of_code(&row.id, user).await {
888 worker::console_error!("repository invitations for {} not accepted: {error}", row.id);
889 }
890 self.announce(
891 "invite.redeemed",
892 Some(&user.id),
893 InviteRedeemed {
894 invite_id: row.id.clone(),
895 user_id: user.id.clone(),
896 inviter_id: row.inviter_id.clone(),
897 workspace_id: row.workspace_id.clone(),
898 created_account,
899 },
900 )
901 .await;
902 if let Some(slug) = joined {
903 let message = match &row.inviter {
904 Some(inviter) => format!("Joined with an invite from {inviter}"),
905 None => "Joined with an invite from g1t".to_owned(),
906 };
Merge main (membership, two-factor, GitHub repo roles) into tokens907 self.audit_invites(user, "invite.redeemed", vec![slug.clone()], Surface::Web, message).await;
908 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 look909 }
910 }
911
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)912 /// The invite an account signed up with, while it waits for the account
913 /// to confirm its address: spent, not yet applied.
914 pub(crate) async fn awaiting_invite(&self, user_id: &str) -> Result<Option<InviteRow>> {
915 Ok(self
916 .rows(
917 "WHERE i.redeemed_by = ? AND i.kind = 'account' AND i.redeemed_at IS NOT NULL AND i.applied_at IS NULL",
918 &[user_id.into()],
919 1,
920 )
921 .await?
922 .into_iter()
923 .next())
924 }
925
926 /// What an awaiting invite does now that its account is being
927 /// confirmed, worked out before the batch that confirms it (which
928 /// checks the same again).
929 pub(crate) async fn awaiting_join(&self, row: &InviteRow) -> AwaitingJoin {
930 // A free workspace adds no one (paid.rs).
931 let free = match &row.workspace {
932 Some(slug) => self.is_free_workspace(slug).await,
933 None => false,
934 };
935 awaiting_join(row, &rfc3339(now_ms()), free)
936 }
937
938 /// The statements that apply an awaiting invite, for the batch that
939 /// confirms `user_id`'s address, after the statement that marks the
940 /// account confirmed: join the workspace, only if the account is
941 /// confirmed now and the invite and workspace are still good; then mark
942 /// the invite settled, whatever it gave.
943 pub(crate) fn apply_invite_statements(
944 &self,
945 user_id: &str,
946 row: &InviteRow,
947 join: &AwaitingJoin,
948 ) -> Result<Vec<worker::D1PreparedStatement>> {
949 let confirmed = "EXISTS (SELECT 1 FROM users WHERE id = ?1 AND email_verified_at IS NOT NULL)";
950 let mut statements = Vec::new();
951 if let AwaitingJoin::Join { workspace_id, .. } = join {
952 statements.push(
953 self.db
954 .prepare(format!(
955 "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
956 SELECT ?3, ?1, 'member', {SQL_NOW}
957 WHERE {confirmed}
958 AND EXISTS (SELECT 1 FROM workspaces WHERE id = ?3 AND deleted_at IS NULL)
959 AND EXISTS (SELECT 1 FROM invites WHERE id = ?2 AND redeemed_by = ?1
960 AND applied_at IS NULL AND revoked_at IS NULL AND expires_at > {SQL_NOW})"
961 ))
962 .bind(&[user_id.into(), row.id.as_str().into(), workspace_id.as_str().into()])?,
963 );
964 }
965 statements.push(
966 self.db
967 .prepare(format!(
968 "UPDATE invites SET applied_at = {SQL_NOW}
969 WHERE id = ?2 AND redeemed_by = ?1 AND applied_at IS NULL AND {confirmed}"
970 ))
971 .bind(&[user_id.into(), row.id.as_str().into()])?,
972 );
973 Ok(statements)
974 }
975
976 /// After the batch: the workspace joined, by slug, if the account is in
977 /// it now; and, unless the invite lapsed, the repository invitations,
978 /// event and audit entries that follow using it.
979 pub(crate) async fn after_applied(&self, row: &InviteRow, user: &User, join: &AwaitingJoin) -> Result<Option<String>> {
980 let joined = match join {
981 AwaitingJoin::Join { workspace_id, slug } => self
982 .db
983 .prepare("SELECT 1 AS n FROM workspace_members WHERE workspace_id = ? AND user_id = ?")
984 .bind(&[workspace_id.as_str().into(), user.id.as_str().into()])?
985 .first::<Count>(None)
986 .await?
987 .map(|_| slug.clone()),
988 _ => None,
989 };
990 if !matches!(join, AwaitingJoin::Lapsed(_)) {
991 self.settled(row, user, true, joined.clone()).await;
992 }
993 Ok(joined)
994 }
995
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look996 // --- People's invites ---
997
998 fn draft_allowed(user: &User) -> Option<&'static str> {
999 if user.kind != PrincipalKind::User || user.acting.is_some() {
1000 return Some(PEOPLE_ONLY);
1001 }
1002 if !user.verified {
1003 return Some(CONFIRM_FIRST);
1004 }
1005 None
1006 }
1007
1008 /// Stores a new invite and returns it with its code, or None when the
1009 /// allowance ran out between reading it and writing.
1010 async fn insert_invite(&self, draft: Draft<'_>) -> Result<Option<Invite>> {
1011 let body = new_code_body();
1012 let code = format_code(&body);
1013 let now = now_ms();
1014 let id = new_id("inv", now);
1015 let sealed = self.invite_sealer().map(|sealer| sealer.seal(&code, &id));
1016 let expires_at = rfc3339(now + self.invite_ttl_days() * 24 * HOUR_MS);
1017 let created_at = rfc3339(now);
1018 let opt = |value: Option<&str>| value.map_or(JsValue::NULL, JsValue::from);
1019 let mut binds = vec![
1020 JsValue::from(id.as_str()),
1021 code_hash(&body).into(),
1022 code_hint(&body).into(),
1023 opt(sealed.as_deref()),
1024 opt(draft.email),
1025 draft.kind.into(),
1026 opt(draft.workspace_id),
1027 opt(draft.inviter.map(|user| user.id.as_str())),
1028 opt(draft.staff),
1029 draft.charged_to.into(),
1030 opt(draft.charged_workspace_id),
1031 created_at.as_str().into(),
1032 expires_at.as_str().into(),
1033 ];
1034 // The allowance is checked in the insert itself, so two invites made
1035 // at once cannot both take the last one.
1036 let guard = match (draft.charged_to, draft.limit) {
1037 ("user", Some(limit)) => {
1038 binds.extend([opt(draft.inviter.map(|user| user.id.as_str())), f64::from(limit).into()]);
1039 format!(
1040 "WHERE (SELECT count(*) FROM invites i WHERE i.inviter_id = ? AND i.charged_to = 'user' AND {}) < ?",
1041 counted_sql()
1042 )
1043 }
1044 ("workspace", Some(limit)) => {
1045 binds.extend([opt(draft.charged_workspace_id), f64::from(limit).into()]);
1046 format!(
1047 "WHERE (SELECT count(*) FROM invites i WHERE i.charged_workspace_id = ? AND i.charged_to = 'workspace' AND {}) < ?",
1048 counted_sql()
1049 )
1050 }
1051 _ => String::new(),
1052 };
1053 let inserted = self
1054 .db
1055 .prepare(format!(
1056 "INSERT INTO invites (id, code_hash, hint, sealed_code, email, kind, workspace_id, inviter_id,
1057 staff, charged_to, charged_workspace_id, created_at, expires_at)
1058 SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? {guard}
1059 RETURNING id"
1060 ))
1061 .bind(&binds)?
1062 .first::<Id>(None)
1063 .await?;
1064 if inserted.is_none() {
1065 return Ok(None);
1066 }
1067 self.announce(
1068 "invite.created",
1069 draft.inviter.map(|user| user.id.as_str()),
1070 InviteCreated {
1071 invite_id: id.clone(),
1072 inviter_id: draft.inviter.map(|user| user.id.clone()),
1073 workspace_id: draft.workspace_id.map(str::to_owned),
1074 bound: draft.email.is_some(),
1075 },
1076 )
1077 .await;
1078 let Some(row) = self.invite_by_id(&id).await? else {
1079 return Ok(None);
1080 };
1081 let mut invite = self.shown(row, false, false);
1082 invite.code = Some(code);
1083 Ok(Some(invite))
1084 }
1085
1086 fn out_of_invites() -> Outcome<Invite> {
1087 Outcome::fail(
1088 FailureCode::Limit,
1089 "You have no invites left. Need more? Contact us at hey@flagon.io with the subject [g1t Invites].",
1090 )
1091 }
1092
1093 pub async fn create_invite(&self, a: CreateInviteArgs) -> Result<Outcome<Invite>> {
1094 if let Some(reason) = Self::draft_allowed(&a.user) {
1095 return Ok(Outcome::fail(FailureCode::Forbidden, reason));
1096 }
1097 let email = match a.email.as_deref().map(str::trim).filter(|email| !email.is_empty()) {
1098 Some(email) => match normalize_email(email) {
1099 Some(email) => Some(email),
1100 None => return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL)),
1101 },
1102 None => None,
1103 };
1104 if !self.hit(&format!("invite.create:{}", a.user.id), CREATES_PER_HOUR).await? {
1105 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1106 }
1107 if let Some(email) = &email {
1108 if self.email_has_account(email).await? {
1109 return Ok(Outcome::fail(
1110 FailureCode::Conflict,
1111 "That address already has a g1t account. Add them to a workspace from its People page instead.",
1112 ));
1113 }
1114 let pending = self
1115 .rows(
1116 &format!(
1117 "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}"
1118 ),
1119 &[a.user.id.as_str().into(), email.as_str().into()],
1120 1,
1121 )
1122 .await?;
1123 if !pending.is_empty() {
1124 return Ok(Outcome::fail(
1125 FailureCode::Conflict,
1126 "You already have a pending invite for that address. Revoke it to send a new one.",
1127 ));
1128 }
1129 }
1130 // A workspace's granted invites, for its owners.
1131 let (workspace_id, charged_to, limit) = match a.workspace.as_deref().map(str::trim).filter(|slug| !slug.is_empty()) {
1132 Some(slug) => {
1133 let slug = slug.to_lowercase();
1134 if a.user.role_in(&slug) != Some(Role::Owner) {
1135 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a workspace's owners can use its invites."));
1136 }
1137 let Some(id) = self.workspace_id(&slug).await? else {
1138 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1139 };
1140 let allowance = self.workspace_allowance(&id).await?;
1141 if allowance.exhausted() {
1142 return Ok(Outcome::fail(
1143 FailureCode::Limit,
1144 format!("{slug} has no invites left. Need more? Contact us at hey@flagon.io with the subject [g1t Invites]."),
1145 ));
1146 }
1147 (Some(id), "workspace", allowance.limit)
1148 }
1149 None => {
1150 let allowance = self.user_allowance(&a.user.id).await?;
1151 if allowance.exhausted() {
1152 return Ok(Self::out_of_invites());
1153 }
1154 (None, "user", allowance.limit)
1155 }
1156 };
1157 let draft = Draft {
1158 email: email.as_deref(),
1159 kind: "account",
1160 workspace_id: None,
1161 inviter: Some(&a.user),
1162 staff: None,
1163 charged_to,
1164 charged_workspace_id: workspace_id.as_deref(),
1165 limit,
1166 };
1167 let Some(invite) = self.insert_invite(draft).await? else {
1168 return Ok(Self::out_of_invites());
1169 };
1170 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 quotas1171 let from = self.display_name(&a.user).await;
1172 self.send_invite_email(email, Some(&from), None, false, code, None).await;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1173 }
1174 let logs: Vec<String> = a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect();
1175 self.audit_invites(&a.user, "invite.created", logs, a.surface.unwrap_or(Surface::Web), format!("Created invite {}", invite.hint))
1176 .await;
1177 Ok(Outcome::Ok(invite))
1178 }
1179
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1180 async fn send_invite_email(
1181 &self,
1182 to: &str,
1183 from: Option<&str>,
1184 workspace: Option<&str>,
1185 existing: bool,
1186 code: &str,
1187 note: Option<&str>,
1188 ) {
1189 let invite = crate::email::InviteEmail {
1190 to,
1191 from,
1192 workspace,
1193 joins_existing_account: existing,
1194 code,
1195 days: self.invite_ttl_days(),
1196 note,
1197 };
1198 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 look1199 worker::console_error!("invite email failed: {error}");
1200 }
1201 }
1202
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1203 /// How an invite names the person who sent it: their name, else their
1204 /// username.
1205 async fn display_name(&self, user: &User) -> String {
1206 self.name_of("SELECT display_name AS name FROM users WHERE id = ?", &user.id)
1207 .await
1208 .unwrap_or_else(|| user.username.clone())
1209 }
1210
1211 /// A workspace's name, as an invite shows it; its slug if it has none.
1212 async fn workspace_name(&self, workspace_id: &str, slug: &str) -> String {
1213 self.name_of("SELECT name FROM workspaces WHERE id = ?", workspace_id)
1214 .await
1215 .unwrap_or_else(|| slug.to_owned())
1216 }
1217
1218 /// A name `sql` selects for `id`, if it has one. Only for wording an
1219 /// email, so a failed read is no name.
1220 async fn name_of(&self, sql: &str, id: &str) -> Option<String> {
1221 #[derive(Deserialize)]
1222 struct Name {
1223 name: Option<String>,
1224 }
1225 let read = async { self.db.prepare(sql).bind(&[id.into()])?.first::<Name>(None).await };
1226 read.await
1227 .ok()
1228 .flatten()
1229 .and_then(|row| row.name)
1230 .map(|name| name.trim().to_owned())
1231 .filter(|name| !name.is_empty())
1232 }
1233
1234 /// The address a pending invite is bound to, if it is: signing up with
1235 /// GitHub uses it when GitHub has confirmed it too (github.rs).
1236 pub(crate) async fn bound_email_of(&self, code: &str) -> Result<Option<String>> {
1237 let now = rfc3339(now_ms());
1238 Ok(self
1239 .invite_by_code(code)
1240 .await?
1241 .filter(|row| row.status(&now) == InviteStatus::Pending)
1242 .and_then(|row| row.email))
1243 }
1244
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1245 pub async fn list_invites(&self, a: UserArgs) -> Result<InvitesOverview> {
1246 let invites: Vec<Invite> = self
1247 .rows("WHERE i.inviter_id = ?", &[a.user.id.as_str().into()], LIST_LIMIT)
1248 .await?
1249 .into_iter()
1250 .map(|row| self.shown(row, true, false))
1251 .collect();
1252 let mut workspaces = Vec::new();
1253 for membership in a.user.workspaces.iter().filter(|membership| membership.role == Role::Owner) {
1254 if let Some(id) = self.workspace_id(&membership.slug).await?
1255 && self.granted(GrantTarget::Workspace, &id).await? != 0
1256 {
1257 workspaces.push(WorkspaceAllowance {
1258 slug: membership.slug.clone(),
1259 allowance: self.workspace_allowance(&id).await?,
1260 });
1261 }
1262 }
1263 Ok(InvitesOverview {
1264 mode: self.registration_mode(),
1265 allowance: self.user_allowance(&a.user.id).await?,
1266 workspaces,
1267 invites,
1268 })
1269 }
1270
1271 /// 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)1272 /// charged to) a workspace they own. An invite used to sign up whose
1273 /// account has not confirmed its address yet can be revoked too: the
1274 /// account stays, and joins nothing when it confirms.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1275 pub async fn revoke_invite(&self, a: RemoveArgs) -> Result<Outcome<Invite>> {
1276 if a.user.kind != PrincipalKind::User || a.user.acting.is_some() {
1277 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
1278 }
1279 let revoked = self
1280 .db
1281 .prepare(format!(
1282 "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1283 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 look1284 AND (inviter_id = ?1
1285 OR workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = ?1 AND role = 'owner')
1286 OR charged_workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = ?1 AND role = 'owner'))
1287 RETURNING id"
1288 ))
1289 .bind(&[a.user.id.as_str().into(), a.id.as_str().into()])?
1290 .first::<Id>(None)
1291 .await?;
1292 let Some(Id { id }) = revoked else {
1293 return Ok(Outcome::fail(FailureCode::NotFound, "There is no pending invite of yours with that id."));
1294 };
1295 let Some(row) = self.invite_by_id(&id).await? else {
1296 return Ok(Outcome::fail(FailureCode::NotFound, "Invite not found."));
1297 };
1298 let logs = match &row.workspace {
1299 Some(slug) => vec![slug.clone()],
1300 None => a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect(),
1301 };
1302 self.audit_invites(&a.user, "invite.revoked", logs, Surface::Web, format!("Revoked invite {}", row.hint)).await;
1303 Ok(Outcome::Ok(self.shown(row, false, false)))
1304 }
1305
1306 /// What an invite code is for: who sent it, and which workspace it
1307 /// joins. Any code that cannot be used gets the same answer.
1308 pub async fn check_invite(&self, a: InviteCodeArgs) -> Result<Outcome<InvitePreview>> {
1309 if self.turned_away(a.client.as_deref()).await? {
1310 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1311 }
1312 let now = rfc3339(now_ms());
Shared invite links: one staff-made link for a group, up to 1000 new accounts (identity 0038)1313 let found = self.invite_by_code(&a.code).await?;
1314 // A shared invite link's code, while it is live: its label and
1315 // domains are for the sign-up page. Expired, revoked and used up
1316 // get the one answer below, whatever `any_status` asks.
1317 if found.is_none()
1318 && let Some(link) = self.shared_by_code(&a.code).await?
1319 && link.status(&now) == SharedInviteStatus::Live
1320 {
1321 return Ok(Outcome::Ok(self.shared_preview(&link)));
1322 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1323 // A spent code is still a real one (160 random bits): saying what
1324 // became of it tells a guesser nothing.
Shared invite links: one staff-made link for a group, up to 1000 new accounts (identity 0038)1325 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 look1326 let Some(row) = row else {
1327 self.count_failure(a.client.as_deref()).await?;
1328 return Ok(Outcome::fail(FailureCode::NotFound, INVALID));
1329 };
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1330 let status = row.status(&now);
1331 let pending = status == InviteStatus::Pending;
1332 // Whether it is the viewer's: for one of their confirmed addresses,
1333 // or, once used, used by them.
1334 let for_viewer = match &a.viewer {
1335 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)1336 (_, InviteStatus::Redeemed | InviteStatus::AwaitingConfirmation) => {
1337 Some(row.redeemer.as_deref() == Some(viewer.username.as_str()))
1338 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1339 (Some(bound), _) => {
1340 let mine = self.verified_emails(&viewer.id).await?;
1341 Some(mine.iter().any(|address| address.eq_ignore_ascii_case(bound.trim())))
1342 }
1343 (None, _) => None,
1344 },
1345 _ => None,
1346 };
1347 let has_account = match (&row.email, pending) {
1348 (Some(bound), true) => self.email_has_account(bound).await?,
1349 _ => false,
1350 };
1351 let repository = self.repository_of_code(&row.id).await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1352 #[derive(Deserialize)]
1353 struct From {
1354 username: String,
1355 name: Option<String>,
1356 avatar: Option<String>,
1357 }
1358 let invited_by = match &row.inviter_id {
1359 Some(id) => self
1360 .db
1361 .prepare("SELECT username, display_name AS name, avatar FROM users WHERE id = ?")
1362 .bind(&[id.as_str().into()])?
1363 .first::<From>(None)
1364 .await?
1365 .map(|from| InviteFrom {
1366 username: from.username,
1367 name: from.name,
1368 avatar: from.avatar,
1369 }),
1370 None => None,
1371 };
1372 let workspace = match &row.workspace_id {
1373 Some(id) => self
1374 .db
1375 .prepare("SELECT slug, name, avatar FROM workspaces WHERE id = ?")
1376 .bind(&[id.as_str().into()])?
1377 .first::<ProfileWorkspace>(None)
1378 .await?,
1379 None => None,
1380 };
1381 Ok(Outcome::Ok(InvitePreview {
1382 kind: kind_of(&row.kind),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1383 status,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1384 invited_by,
1385 workspace,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1386 repository,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1387 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 quotas1388 address: row.email.clone().filter(|_| pending),
1389 has_account,
1390 for_viewer,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1391 expires_at: row.expires_at,
Shared invite links: one staff-made link for a group, up to 1000 new accounts (identity 0038)1392 shared_label: None,
1393 shared_domains: Vec::new(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1394 }))
1395 }
1396
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1397 /// A signed-in person uses a workspace invite sent to their address,
1398 /// or one sent with a repository invitation.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1399 pub async fn accept_invite(&self, a: AcceptInviteArgs) -> Result<Outcome<String>> {
1400 if a.user.kind != PrincipalKind::User || a.user.acting.is_some() {
1401 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can accept an invite."));
1402 }
1403 // Any of the person's confirmed addresses can match an invite bound
1404 // to one (emails.rs); the primary otherwise.
1405 let verified = self.verified_emails(&a.user.id).await?;
1406 let Some(primary) = verified.first().cloned() else {
1407 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first, then open the invite again."));
1408 };
1409 let now = rfc3339(now_ms());
1410 let row = self.invite_by_code(&a.code).await?;
1411 let email = row
1412 .as_ref()
1413 .and_then(|row| row.email.as_deref())
1414 .and_then(|bound| verified.iter().find(|address| address.eq_ignore_ascii_case(bound.trim())).cloned())
1415 .unwrap_or(primary);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1416 // What using it gives an account that exists: a workspace, or a
1417 // repository it was sent with.
1418 let repository = match &row {
1419 Some(row) => self.repository_of_code(&row.id).await?,
1420 None => None,
1421 };
1422 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 look1423 if let Err(refusal) = admits(row.as_ref().map(|row| row.admits(&now)).as_ref(), &email, false) {
1424 return Ok(Outcome::fail(
1425 FailureCode::Forbidden,
1426 if refusal == Refusal::WrongEmail { WRONG_EMAIL } else { INVALID },
1427 ));
1428 }
1429 let Some(row) = row.filter(|_| joins) else {
1430 return Ok(Outcome::fail(
1431 FailureCode::Conflict,
1432 "You already have a g1t account, so this invite has nothing more to give you. Pass it on to someone who needs it.",
1433 ));
1434 };
1435 // What the workspace asks of its members (security.rs); nothing yet.
1436 if let Some(slug) = row.workspace.as_deref()
1437 && let Some(why) = self.policy_refusal(&a.user.id, slug).await?
1438 {
1439 return Ok(Outcome::fail(FailureCode::Forbidden, why));
1440 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1441 // An invite sent before the workspace was free waits until it
1442 // starts the plan (paid.rs); the code is not used up.
1443 let joins_slug = row.workspace.clone().or_else(|| {
1444 repository.as_ref().and_then(|r| r.name.split_once('/').map(|(workspace, _)| workspace.to_owned()))
1445 });
1446 if let Some(slug) = joins_slug.as_deref()
1447 && let Some(refused) = self.free_workspace_refusal(slug).await?
1448 {
1449 return Ok(refused);
1450 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1451 let claimed = self
1452 .db
1453 .prepare(format!(
1454 "UPDATE invites SET redeemed_by = ?, redeemed_at = {SQL_NOW}, sealed_code = NULL
1455 WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL AND expires_at > {SQL_NOW}
1456 RETURNING id"
1457 ))
1458 .bind(&[a.user.id.as_str().into(), row.id.as_str().into()])?
1459 .first::<Id>(None)
1460 .await?;
1461 if claimed.is_none() {
1462 return Ok(Outcome::fail(FailureCode::Forbidden, INVALID));
1463 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1464 let lands = row
1465 .workspace
1466 .clone()
1467 .or_else(|| repository.map(|repository| repository.name))
1468 .unwrap_or_default();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1469 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 quotas1470 Ok(Outcome::Ok(lands))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1471 }
1472
1473 // --- Workspace invitations ---
1474
1475 pub async fn invite_member(&self, a: InviteMemberArgs) -> Result<Outcome<Invite>> {
1476 let slug = a.slug.trim().to_lowercase();
1477 if let Some(reason) = Self::draft_allowed(&a.actor) {
1478 return Ok(Outcome::fail(FailureCode::Forbidden, reason));
1479 }
1480 if a.actor.role_in(&slug) != Some(Role::Owner) {
1481 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can invite people to a workspace."));
1482 }
1483 let Some(email) = normalize_email(&a.email) else {
1484 return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL));
1485 };
1486 let Some(workspace_id) = self.workspace_id(&slug).await? else {
1487 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1488 };
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1489 // A free workspace invites no one until it starts the plan (paid.rs).
1490 if let Some(refused) = self.free_workspace_refusal(&slug).await? {
1491 return Ok(refused);
1492 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1493 if !self.hit(&format!("invite.create:{}", a.actor.id), CREATES_PER_HOUR).await? {
1494 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1495 }
1496 let pending = self
1497 .rows(
1498 &format!(
1499 "WHERE i.workspace_id = ? AND i.email = ?
1500 AND i.redeemed_at IS NULL AND i.revoked_at IS NULL AND i.expires_at > {SQL_NOW}"
1501 ),
1502 &[workspace_id.as_str().into(), email.as_str().into()],
1503 1,
1504 )
1505 .await?;
1506 if !pending.is_empty() {
1507 return Ok(Outcome::fail(
1508 FailureCode::Conflict,
1509 "There is already a pending invite for that address. Revoke it to send a new one.",
1510 ));
1511 }
1512 let has_account = self.email_has_account(&email).await?;
1513 let draft = if has_account {
1514 // Costs nothing: the person is on g1t already.
1515 Draft {
1516 email: Some(&email),
1517 kind: "workspace",
1518 workspace_id: Some(&workspace_id),
1519 inviter: Some(&a.actor),
1520 staff: None,
1521 charged_to: "none",
1522 charged_workspace_id: None,
1523 limit: None,
1524 }
1525 } else {
1526 let shared = self.workspace_allowance(&workspace_id).await?;
1527 let (charged_to, charged_workspace_id, limit) = if shared.remaining.is_some_and(|left| left > 0) {
1528 ("workspace", Some(workspace_id.as_str()), shared.limit)
1529 } else {
1530 let own = self.user_allowance(&a.actor.id).await?;
1531 if own.exhausted() {
1532 return Ok(Self::out_of_invites());
1533 }
1534 ("user", None, own.limit)
1535 };
1536 Draft {
1537 email: Some(&email),
1538 kind: "account",
1539 workspace_id: Some(&workspace_id),
1540 inviter: Some(&a.actor),
1541 staff: None,
1542 charged_to,
1543 charged_workspace_id,
1544 limit,
1545 }
1546 };
1547 let Some(invite) = self.insert_invite(draft).await? else {
1548 return Ok(Self::out_of_invites());
1549 };
1550 if let Some(code) = &invite.code {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1551 let from = self.display_name(&a.actor).await;
1552 let workspace = self.workspace_name(&workspace_id, &slug).await;
1553 self.send_invite_email(&email, Some(&from), Some(&workspace), has_account, code, None).await;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1554 }
1555 self.audit_invites(
1556 &a.actor,
1557 "invite.created",
1558 vec![slug.clone()],
1559 a.surface.unwrap_or(Surface::Web),
1560 format!("Invited {email} to {slug}"),
1561 )
1562 .await;
1563 Ok(Outcome::Ok(invite))
1564 }
1565
1566 /// An invite code for an address without an account, invited to
1567 /// collaborate on one repository of `workspace_id` (access.rs). Charged
1568 /// as a workspace invite is: the workspace's shared invites first, then
1569 /// the inviter's own. The code joins no workspace; redeeming it accepts
1570 /// the repository invitation that names it.
1571 pub(crate) async fn repo_invite_code(&self, actor: &User, email: &str, workspace_id: &str) -> Result<Outcome<Invite>> {
1572 if let Some(reason) = Self::draft_allowed(actor) {
1573 return Ok(Outcome::fail(FailureCode::Forbidden, reason));
1574 }
1575 if !self.hit(&format!("invite.create:{}", actor.id), CREATES_PER_HOUR).await? {
1576 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1577 }
1578 let shared = self.workspace_allowance(workspace_id).await?;
1579 let (charged_to, charged_workspace_id, limit) = if shared.remaining.is_some_and(|left| left > 0) {
1580 ("workspace", Some(workspace_id), shared.limit)
1581 } else {
1582 let own = self.user_allowance(&actor.id).await?;
1583 if own.exhausted() {
1584 return Ok(Self::out_of_invites());
1585 }
1586 ("user", None, own.limit)
1587 };
1588 let draft = Draft {
1589 email: Some(email),
1590 kind: "account",
1591 workspace_id: None,
1592 inviter: Some(actor),
1593 staff: None,
1594 charged_to,
1595 charged_workspace_id,
1596 limit,
1597 };
1598 Ok(match self.insert_invite(draft).await? {
1599 Some(invite) => Outcome::Ok(invite),
1600 None => Self::out_of_invites(),
1601 })
1602 }
1603
1604 /// Revokes an invite code made for a repository invitation, when that
1605 /// invitation is revoked. Only a pending code changes.
1606 pub(crate) async fn revoke_code(&self, invite_id: &str) -> Result<()> {
1607 self.db
1608 .prepare(format!(
1609 "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL
1610 WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL"
1611 ))
1612 .bind(&[invite_id.into()])?
1613 .run()
1614 .await?;
1615 Ok(())
1616 }
1617
1618 pub async fn workspace_invites(&self, a: ListMembersArgs) -> Result<Outcome<Vec<Invite>>> {
1619 let slug = a.slug.trim().to_lowercase();
1620 if !a.viewer.as_ref().is_some_and(|viewer| viewer.role_in(&slug) == Some(Role::Owner)) {
1621 return Ok(Outcome::fail(FailureCode::Forbidden, "Only owners can see a workspace's invites."));
1622 }
1623 let Some(workspace_id) = self.workspace_id(&slug).await? else {
1624 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1625 };
1626 let rows = self.rows("WHERE i.workspace_id = ?", &[workspace_id.as_str().into()], LIST_LIMIT).await?;
1627 Ok(Outcome::Ok(rows.into_iter().map(|row| self.shown(row, true, false)).collect()))
1628 }
1629
1630 pub async fn revoke_workspace_invite(&self, a: WorkspaceInviteArgs) -> Result<Outcome<Invite>> {
1631 let slug = a.slug.trim().to_lowercase();
1632 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
1633 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can revoke a workspace's invites."));
1634 }
1635 self.revoke_invite(RemoveArgs { user: a.actor, id: a.id }).await
1636 }
1637
1638 // --- The waitlist ---
1639
1640 pub async fn request_access(&self, a: RequestAccessArgs) -> Result<Outcome<bool>> {
1641 let Some(email) = normalize_email(&a.email) else {
1642 return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL));
1643 };
1644 let allowed = match a.client.as_deref().filter(|client| !client.is_empty()) {
1645 Some(client) => self.hit(&format!("waitlist:{}", crypto::sha256_hex(client)), REQUESTS_PER_HOUR).await?,
1646 None => self.hit("waitlist:anonymous", ANONYMOUS_REQUESTS_PER_HOUR).await?,
1647 };
1648 if !allowed {
1649 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1650 }
1651 let about: String = a.about.trim().chars().take(MAX_WAITLIST_ABOUT).collect();
1652 let now = rfc3339(now_ms());
1653 #[derive(Deserialize)]
1654 struct Upserted {
1655 id: String,
1656 created_at: String,
1657 }
1658 let row = self
1659 .db
1660 .prepare(
1661 "INSERT INTO waitlist (id, email, about, status, created_at, updated_at)
1662 VALUES (?1, ?2, ?3, 'waiting', ?4, ?4)
1663 ON CONFLICT (email) DO UPDATE SET
1664 about = COALESCE(excluded.about, waitlist.about), updated_at = excluded.updated_at
1665 RETURNING id, created_at",
1666 )
1667 .bind(&[
1668 new_id("wl", now_ms()).into(),
1669 email.as_str().into(),
1670 if about.is_empty() { JsValue::NULL } else { about.as_str().into() },
1671 now.as_str().into(),
1672 ])?
1673 .first::<Upserted>(None)
1674 .await?;
1675 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 quotas1676 self.announce("waitlist.requested", None, WaitlistRequested { entry_id: row.id.clone() }).await;
1677 self.acknowledge_request(&row.id, &email).await?;
1678 self.notify_staff_of_requests().await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1679 }
1680 Ok(Outcome::Ok(true))
1681 }
1682
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1683 /// The one confirmation an address gets for asking: claimed in the
1684 /// database first, so a repeat request (or two at once) never sends a
1685 /// second, and capped across everyone, since anyone can type any
1686 /// address.
1687 async fn acknowledge_request(&self, id: &str, email: &str) -> Result<()> {
1688 if !self.hit("waitlist.ack", CONFIRMATIONS_PER_HOUR).await? {
1689 return Ok(());
1690 }
1691 let claimed = self
1692 .db
1693 .prepare(format!(
1694 "UPDATE waitlist SET acknowledged_at = {SQL_NOW} WHERE id = ? AND acknowledged_at IS NULL RETURNING id"
1695 ))
1696 .bind(&[id.into()])?
1697 .first::<Id>(None)
1698 .await?;
1699 if claimed.is_none() {
1700 return Ok(());
1701 }
1702 if let Err(error) = crate::email::send_waitlist_confirmation(&self.env, email).await {
1703 worker::console_error!("waitlist confirmation failed: {error}");
1704 // Not sent: leave it unclaimed, so staff can see it was not.
1705 self.db
1706 .prepare("UPDATE waitlist SET acknowledged_at = NULL WHERE id = ?")
1707 .bind(&[id.into()])?
1708 .run()
1709 .await?;
1710 }
1711 Ok(())
1712 }
1713
1714 /// Where staff hear about new requests: WAITLIST_NOTIFY_EMAIL, unset or
1715 /// empty for nobody.
1716 fn waitlist_notify_email(&self) -> Option<String> {
1717 let to = self.env.var("WAITLIST_NOTIFY_EMAIL").ok()?.to_string();
1718 normalize_email(&to)
1719 }
1720
1721 /// Tells staff about every request they have not heard about, unless a
1722 /// summary went in the last 15 minutes: then the next request after
1723 /// that brings them all in one. The rows are claimed before sending, so
1724 /// two requests at once send one summary.
1725 pub(crate) async fn notify_staff_of_requests(&self) -> Result<()> {
1726 let Some(to) = self.waitlist_notify_email() else {
1727 return Ok(());
1728 };
1729 #[derive(Deserialize)]
1730 struct Last {
1731 at: Option<String>,
1732 }
1733 let last = self
1734 .db
1735 .prepare("SELECT max(notified_at) AS at FROM waitlist")
1736 .first::<Last>(None)
1737 .await?
1738 .and_then(|last| last.at);
1739 let now = now_ms();
1740 if !summary_due(last.as_deref(), &rfc3339(now.saturating_sub(SUMMARY_EVERY_MS))) {
1741 return Ok(());
1742 }
1743 let stamp = rfc3339(now);
1744 #[derive(Deserialize)]
1745 struct New {
1746 email: String,
1747 about: Option<String>,
1748 created_at: String,
1749 }
1750 let mut new = self
1751 .db
1752 .prepare(
1753 "UPDATE waitlist SET notified_at = ? WHERE notified_at IS NULL AND status = 'waiting'
1754 RETURNING email, about, created_at",
1755 )
1756 .bind(&[stamp.as_str().into()])?
1757 .all()
1758 .await?
1759 .results::<New>()?;
1760 if new.is_empty() {
1761 return Ok(());
1762 }
1763 new.sort_by(|a, b| a.created_at.cmp(&b.created_at));
1764 let waiting = self
1765 .db
1766 .prepare("SELECT count(*) AS n FROM waitlist WHERE status = 'waiting'")
1767 .first::<Count>(None)
1768 .await?
1769 .map_or(0, |count| count.n as u32);
1770 let new: Vec<crate::email::Requested> = new
1771 .into_iter()
1772 .map(|row| crate::email::Requested { email: row.email, about: row.about })
1773 .collect();
1774 if let Err(error) = crate::email::send_waitlist_summary(&self.env, &to, &new, waiting).await {
1775 worker::console_error!("waitlist summary failed: {error}");
1776 // Not sent: the next request tries again with these too.
1777 self.db
1778 .prepare("UPDATE waitlist SET notified_at = NULL WHERE notified_at = ?")
1779 .bind(&[stamp.as_str().into()])?
1780 .run()
1781 .await?;
1782 }
1783 Ok(())
1784 }
1785
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1786 // --- Staff ---
1787
1788 pub async fn admin_waitlist(&self, a: AdminWaitlistArgs) -> Result<Vec<WaitlistEntry>> {
1789 let mut filters = Vec::new();
1790 let mut binds: Vec<JsValue> = Vec::new();
1791 if let Some(status) = a.status {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1792 filters.push("wl.status = ?".to_owned());
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1793 binds.push(status.as_str().into());
1794 }
1795 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 quotas1796 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 look1797 binds.push(pattern.as_str().into());
1798 binds.push(pattern.as_str().into());
1799 }
1800 let filter = if filters.is_empty() { String::new() } else { format!("WHERE {}", filters.join(" AND ")) };
1801 Ok(self
1802 .db
1803 .prepare(format!(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1804 "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 look1805 ))
1806 .bind(&binds)?
1807 .all()
1808 .await?
1809 .results::<WaitlistRow>()?
1810 .into_iter()
1811 .map(WaitlistEntry::from)
1812 .collect())
1813 }
1814
1815 async fn waitlist_entry(&self, id: &str) -> Result<Option<WaitlistRow>> {
1816 self.db
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1817 .prepare(format!("SELECT {WAITLIST_COLUMNS} WHERE wl.id = ?"))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1818 .bind(&[id.into()])?
1819 .first::<WaitlistRow>(None)
1820 .await
1821 }
1822
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1823 /// How many requests are waiting, for sudo's navigation.
1824 pub async fn admin_waitlist_pending(&self) -> Result<u32> {
1825 Ok(self
1826 .db
1827 .prepare("SELECT count(*) AS n FROM waitlist WHERE status = 'waiting'")
1828 .first::<Count>(None)
1829 .await?
1830 .map_or(0, |count| count.n as u32))
1831 }
1832
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1833 pub async fn admin_decide_waitlist(&self, a: AdminDecideWaitlistArgs) -> Result<Outcome<WaitlistEntry>> {
1834 let Some(entry) = self.waitlist_entry(&a.id).await? else {
1835 return Ok(Outcome::fail(FailureCode::NotFound, "That request is not on the waitlist."));
1836 };
1837 let staff = a.staff.trim();
1838 if staff.is_empty() {
1839 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member decided."));
1840 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1841 if entry.status != "waiting" {
1842 return Ok(Outcome::fail(
1843 FailureCode::Conflict,
1844 format!("{} was already {} by {}.", entry.email, entry.status, entry.decided_by.as_deref().unwrap_or("staff")),
1845 ));
1846 }
1847 let note: String = a.note.as_deref().unwrap_or_default().trim().chars().take(MAX_WAITLIST_NOTE).collect();
1848 let note = (!note.is_empty()).then_some(note);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1849 let mut invite_id = JsValue::NULL;
1850 if a.approve {
1851 if self.email_has_account(&entry.email).await? {
1852 return Ok(Outcome::fail(FailureCode::Conflict, "That address already has a g1t account."));
1853 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1854 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 look1855 Outcome::Ok(invite) => invite,
1856 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1857 };
1858 invite_id = minted.id.as_str().into();
1859 }
1860 self.db
1861 .prepare(format!(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1862 "UPDATE waitlist SET status = ?, invite_id = COALESCE(?, invite_id), decided_by = ?, decided_at = {SQL_NOW},
1863 note = ?, notified_at = COALESCE(notified_at, {SQL_NOW})
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1864 WHERE id = ?"
1865 ))
1866 .bind(&[
1867 if a.approve { "invited" } else { "dismissed" }.into(),
1868 invite_id,
1869 staff.into(),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1870 note.as_deref().map_or(JsValue::NULL, JsValue::from),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1871 entry.id.as_str().into(),
1872 ])?
1873 .run()
1874 .await?;
1875 Ok(match self.waitlist_entry(&entry.id).await? {
1876 Some(row) => Outcome::Ok(row.into()),
1877 None => Outcome::fail(FailureCode::NotFound, "That request is not on the waitlist."),
1878 })
1879 }
1880
1881 pub async fn admin_invites(&self, a: AdminInvitesArgs) -> Result<Vec<Invite>> {
1882 let query = a.query.as_deref().map(str::trim).filter(|query| !query.is_empty());
1883 let rows = match query {
1884 None => self.rows("", &[], ADMIN_INVITES_LIMIT as u32).await?,
1885 Some(query) => {
1886 // A code, or its start: matched by its hint.
1887 let prefix = query.to_lowercase();
1888 let prefix = prefix.strip_prefix("g1t-").unwrap_or(&prefix).replace('-', "");
1889 let hint = (prefix.len() >= GROUP && prefix.chars().all(|c| ALPHABET.contains(&(c as u8))))
1890 .then(|| code_hint(&prefix));
1891 let pattern = crate::admin::like_pattern(Some(query)).unwrap_or_default();
1892 let mut binds: Vec<JsValue> = vec![pattern.as_str().into(), pattern.as_str().into(), pattern.as_str().into()];
1893 let mut filter = "WHERE (lower(i.email) LIKE ? ESCAPE '\\' OR iu.username LIKE ? ESCAPE '\\' OR ru.username LIKE ? ESCAPE '\\'".to_owned();
1894 if let Some(hint) = hint {
1895 filter.push_str(" OR i.hint = ?");
1896 binds.push(hint.into());
1897 }
1898 filter.push(')');
1899 self.rows(&filter, &binds, ADMIN_INVITES_LIMIT as u32).await?
1900 }
1901 };
1902 Ok(rows.into_iter().map(|row| self.shown(row, false, true)).collect())
1903 }
1904
1905 pub async fn admin_revoke_invite(&self, a: AdminRevokeInviteArgs) -> Result<Outcome<Invite>> {
1906 let revoked = self
1907 .db
1908 .prepare(format!(
1909 "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL
1910 WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL RETURNING id"
1911 ))
1912 .bind(&[a.id.as_str().into()])?
1913 .first::<Id>(None)
1914 .await?;
1915 if revoked.is_none() {
1916 return Ok(Outcome::fail(FailureCode::Conflict, "Only a pending invite can be revoked."));
1917 }
1918 worker::console_log!("invite {} revoked by staff {}", a.id, a.staff);
1919 Ok(match self.invite_by_id(&a.id).await? {
1920 Some(row) => Outcome::Ok(self.shown(row, false, true)),
1921 None => Outcome::fail(FailureCode::NotFound, "Invite not found."),
1922 })
1923 }
1924
1925 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 quotas1926 self.mint_staff_invite(a.email, &a.staff, None).await
1927 }
1928
1929 /// An invite staff make, emailed with `note` when it is for an address.
1930 async fn mint_staff_invite(&self, email: Option<String>, staff: &str, note: Option<&str>) -> Result<Outcome<Invite>> {
1931 let staff = staff.trim();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1932 if staff.is_empty() {
1933 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is minting it."));
1934 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1935 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 look1936 Some(email) => match normalize_email(email) {
1937 Some(email) => Some(email),
1938 None => return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL)),
1939 },
1940 None => None,
1941 };
1942 let draft = Draft {
1943 email: email.as_deref(),
1944 kind: "account",
1945 workspace_id: None,
1946 inviter: None,
1947 staff: Some(staff),
1948 charged_to: "none",
1949 charged_workspace_id: None,
1950 limit: None,
1951 };
1952 let Some(mut invite) = self.insert_invite(draft).await? else {
1953 return Ok(Outcome::fail(FailureCode::Conflict, "The invite could not be made. Try again."));
1954 };
1955 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 quotas1956 self.send_invite_email(email, None, None, false, code, note).await;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1957 }
1958 invite.staff = Some(staff.to_owned());
1959 Ok(Outcome::Ok(invite))
1960 }
1961
1962 pub async fn admin_grant_invites(&self, a: AdminGrantInvitesArgs) -> Result<Outcome<Allowance>> {
1963 if a.amount == 0 || a.amount.abs() > MAX_INVITE_GRANT {
1964 return Ok(Outcome::fail(FailureCode::Invalid, format!("Grant between 1 and {MAX_INVITE_GRANT} invites, or take some back with a negative number.")));
1965 }
1966 let staff = a.staff.trim();
1967 if staff.is_empty() {
1968 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member granted them."));
1969 }
1970 let name = a.name.trim().to_lowercase();
1971 let target_id = match a.target {
1972 GrantTarget::User => self
1973 .db
1974 .prepare("SELECT id FROM users WHERE username = ?")
1975 .bind(&[name.as_str().into()])?
1976 .first::<Id>(None)
1977 .await?
1978 .map(|row| row.id),
1979 GrantTarget::Workspace => self.workspace_id(&name).await?,
1980 };
1981 let Some(target_id) = target_id else {
1982 return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no {} named {name}.", a.target.as_str())));
1983 };
1984 let note = a.note.trim();
1985 self.db
1986 .prepare(
1987 "INSERT INTO invite_grants (id, target_kind, target_id, amount, note, granted_by, created_at)
1988 VALUES (?, ?, ?, ?, ?, ?, ?)",
1989 )
1990 .bind(&[
1991 new_id("igr", now_ms()).into(),
1992 a.target.as_str().into(),
1993 target_id.as_str().into(),
1994 f64::from(a.amount).into(),
1995 if note.is_empty() { JsValue::NULL } else { note.into() },
1996 staff.into(),
1997 rfc3339(now_ms()).into(),
1998 ])?
1999 .run()
2000 .await?;
2001 Ok(Outcome::Ok(match a.target {
2002 GrantTarget::User => self.user_allowance(&target_id).await?,
2003 GrantTarget::Workspace => self.workspace_allowance(&target_id).await?,
2004 }))
2005 }
2006
2007 async fn grants(&self, target: GrantTarget, id: &str) -> Result<Vec<InviteGrant>> {
2008 #[derive(Deserialize)]
2009 struct Row {
2010 amount: f64,
2011 note: Option<String>,
2012 granted_by: String,
2013 created_at: String,
2014 }
2015 Ok(self
2016 .db
2017 .prepare(
2018 "SELECT amount, note, granted_by, created_at FROM invite_grants
2019 WHERE target_kind = ? AND target_id = ? ORDER BY created_at DESC LIMIT 100",
2020 )
2021 .bind(&[target.as_str().into(), id.into()])?
2022 .all()
2023 .await?
2024 .results::<Row>()?
2025 .into_iter()
2026 .map(|row| InviteGrant {
2027 amount: row.amount as i32,
2028 note: row.note,
2029 granted_by: row.granted_by,
2030 created_at: row.created_at,
2031 })
2032 .collect())
2033 }
2034
2035 /// Whom `user_id` invited, `depth` levels down.
2036 async fn invited_by_user(&self, user_id: &str, depth: usize) -> Result<Vec<InviteTreeNode>> {
2037 #[derive(Deserialize)]
2038 struct Row {
2039 id: String,
2040 username: String,
2041 redeemed_at: String,
2042 }
2043 let rows = self
2044 .db
2045 .prepare(
2046 "SELECT u.id, u.username, i.redeemed_at FROM invites i JOIN users u ON u.id = i.redeemed_by
2047 WHERE i.inviter_id = ? AND i.kind = 'account' ORDER BY i.redeemed_at LIMIT 200",
2048 )
2049 .bind(&[user_id.into()])?
2050 .all()
2051 .await?
2052 .results::<Row>()?;
2053 let mut nodes = Vec::with_capacity(rows.len());
2054 for row in rows {
2055 let invited = if depth > 1 { Box::pin(self.invited_by_user(&row.id, depth - 1)).await? } else { Vec::new() };
2056 nodes.push(InviteTreeNode {
2057 username: row.username,
2058 joined_at: row.redeemed_at,
2059 invited,
2060 });
2061 }
2062 Ok(nodes)
2063 }
2064
2065 pub async fn admin_invite_tree(&self, a: UsernameArgs) -> Result<Option<InviteTree>> {
2066 let name = a.username.trim().to_lowercase();
2067 let Some(user) = self
2068 .db
2069 .prepare("SELECT id FROM users WHERE username = ?")
2070 .bind(&[name.as_str().into()])?
2071 .first::<Id>(None)
2072 .await?
2073 else {
2074 return Ok(None);
2075 };
2076 // Up the tree: who invited them, and who invited that person.
2077 #[derive(Deserialize)]
2078 struct Parent {
2079 inviter_id: Option<String>,
2080 inviter: Option<String>,
2081 staff: Option<String>,
2082 }
2083 let mut invited_by = Vec::new();
2084 let mut staff = None;
2085 let mut current = user.id.clone();
2086 for _ in 0..20 {
2087 let parent = self
2088 .db
2089 .prepare(
2090 "SELECT i.inviter_id, u.username AS inviter, i.staff FROM invites i
2091 LEFT JOIN users u ON u.id = i.inviter_id
2092 WHERE i.redeemed_by = ? AND i.kind = 'account' LIMIT 1",
2093 )
2094 .bind(&[current.as_str().into()])?
2095 .first::<Parent>(None)
2096 .await?;
2097 let Some(parent) = parent else { break };
2098 if invited_by.is_empty() {
2099 staff = parent.staff.clone();
2100 }
2101 match (parent.inviter_id, parent.inviter) {
2102 (Some(id), Some(username)) if !invited_by.contains(&username) => {
2103 invited_by.push(username);
2104 current = id;
2105 }
2106 _ => break,
2107 }
2108 }
2109 let invites = self
2110 .rows("WHERE i.inviter_id = ?", &[user.id.as_str().into()], LIST_LIMIT)
2111 .await?
2112 .into_iter()
2113 .map(|row| self.shown(row, false, true))
2114 .collect();
2115 Ok(Some(InviteTree {
2116 username: name,
2117 invited_by,
2118 staff,
2119 allowance: self.user_allowance(&user.id).await?,
2120 grants: self.grants(GrantTarget::User, &user.id).await?,
2121 invites,
2122 invited: self.invited_by_user(&user.id, TREE_DEPTH).await?,
Shared invite links: one staff-made link for a group, up to 1000 new accounts (identity 0038)2123 shared: self.shared_source(&user.id).await?,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2124 }))
2125 }
2126
2127 pub async fn admin_workspace_invites(&self, a: SlugArgs) -> Result<Option<InviteTree>> {
2128 let slug = a.slug.trim().to_lowercase();
2129 let Some(id) = self.workspace_id(&slug).await? else {
2130 return Ok(None);
2131 };
2132 let invites = self
2133 .rows("WHERE i.workspace_id = ?1 OR i.charged_workspace_id = ?1", &[id.as_str().into()], LIST_LIMIT)
2134 .await?
2135 .into_iter()
2136 .map(|row| self.shown(row, false, true))
2137 .collect();
2138 Ok(Some(InviteTree {
2139 username: slug,
2140 invited_by: Vec::new(),
2141 staff: None,
2142 allowance: self.workspace_allowance(&id).await?,
2143 grants: self.grants(GrantTarget::Workspace, &id).await?,
2144 invites,
2145 invited: Vec::new(),
Shared invite links: one staff-made link for a group, up to 1000 new accounts (identity 0038)2146 shared: None,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2147 }))
2148 }
2149
2150 // --- Audit ---
2151
2152 async fn audit_invites(&self, actor: &User, action: &str, workspaces: Vec<String>, surface: Surface, message: String) {
2153 let Ok(events) = self.env.service("EVENTS") else {
2154 return;
2155 };
2156 let entries: Vec<NewAuditEntry> = workspaces
2157 .into_iter()
2158 .map(|workspace| NewAuditEntry {
2159 actor: AuditActor::of(actor),
2160 action: action.to_owned(),
2161 surface,
2162 target: AuditTarget {
2163 workspace,
2164 ..AuditTarget::default()
2165 },
2166 outcome: AuditOutcome::Allowed,
2167 rule: "invite".to_owned(),
2168 result: Some("ok".to_owned()),
2169 message: Some(message.clone()),
2170 request_id: new_id("req", now_ms()),
2171 })
2172 .collect();
2173 if entries.is_empty() {
2174 return;
2175 }
2176 let recorded: Result<u32> = g1t_kit::call(&events, "audit_record", &RecordAuditArgs { entries }).await;
2177 if let Err(error) = recorded {
2178 worker::console_error!("{action} not recorded: {error}");
2179 }
2180 }
2181}
2182
2183/// Whether using the invite joins a workspace.
2184fn joins_workspace(row: &InviteRow) -> bool {
2185 row.workspace_id.is_some()
2186}
2187
2188#[cfg(test)]
2189mod tests {
2190 use super::*;
2191
2192 #[test]
2193 fn codes_carry_160_bits_in_eight_groups() {
2194 assert_eq!(encode(&[0u8; 20]), "0".repeat(32));
2195 assert_eq!(encode(&[0xff; 20]), "z".repeat(32));
2196 let body = new_code_body();
2197 assert_eq!(body.len(), CODE_LENGTH);
2198 assert!(body.bytes().all(|b| ALPHABET.contains(&b)));
2199 let code = format_code(&body);
2200 assert!(code.starts_with("g1t-"));
2201 assert_eq!(code.split('-').count(), 9);
2202 assert_eq!(code.len(), 4 + 32 + 7);
2203 // Every bit is used: one bit set shows in exactly one character.
2204 let mut bytes = [0u8; 20];
2205 bytes[19] = 1;
2206 assert_eq!(encode(&bytes), format!("{}1", "0".repeat(31)));
2207 }
2208
2209 #[test]
2210 fn codes_are_not_repeated() {
2211 let codes: std::collections::HashSet<String> = (0..2000).map(|_| new_code_body()).collect();
2212 assert_eq!(codes.len(), 2000);
2213 }
2214
2215 #[test]
2216 fn a_code_reads_however_it_is_typed_or_pasted() {
2217 let body = "k7m2q9xd4hpwabcd0123456789efghjk";
2218 let shown = format_code(body);
2219 for typed in [
2220 shown.clone(),
2221 shown.to_uppercase(),
2222 body.to_owned(),
2223 format!(" {} ", shown.replace('-', " ")),
2224 format!("https://g1t.sh/invite/{shown}"),
2225 format!("https://g1t.sh/register?invite={shown}&next=/"),
2226 ] {
2227 assert_eq!(normalize_code(&typed).as_deref(), Some(body), "{typed}");
2228 }
2229 // Letters people misread are read as Crockford reads them.
2230 assert_eq!(normalize_code(&"o".repeat(32)), Some("0".repeat(32)));
2231 assert_eq!(normalize_code(&"il".repeat(16)), Some("1".repeat(32)));
2232 assert_eq!(normalize_code("g1t-k7m2"), None);
2233 assert_eq!(normalize_code(&format!("{body}0")), None);
2234 assert_eq!(normalize_code(&"u".repeat(32)), None);
2235 assert_eq!(normalize_code(""), None);
2236 }
2237
2238 #[test]
2239 fn only_the_hash_and_a_short_hint_are_kept() {
2240 let body = "k7m2q9xd4hpwabcd0123456789efghjk";
2241 assert_eq!(code_hash(body), crypto::sha256_hex(body));
2242 assert_eq!(code_hash(body).len(), 64);
2243 assert_ne!(code_hash(body), code_hash(&body.replace('k', "m")));
2244 assert_eq!(code_hint(body), "g1t-k7m2");
2245 // The same code typed differently finds the same row.
2246 let typed = normalize_code(&format_code(body).to_uppercase()).unwrap();
2247 assert_eq!(code_hash(&typed), code_hash(body));
2248 }
2249
2250 const NOW: &str = "2026-10-05T12:00:00.000Z";
2251 const LATER: &str = "2026-11-04T12:00:00.000Z";
2252 const EARLIER: &str = "2026-10-01T12:00:00.000Z";
2253
2254 #[test]
2255 fn an_invite_is_pending_until_used_revoked_or_expired() {
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)2256 assert_eq!(status_of(None, None, None, LATER, NOW), InviteStatus::Pending);
2257 assert_eq!(status_of(None, None, None, EARLIER, NOW), InviteStatus::Expired);
2258 assert_eq!(status_of(None, None, None, NOW, NOW), InviteStatus::Expired);
2259 assert_eq!(status_of(Some(EARLIER), None, None, LATER, NOW), InviteStatus::Revoked);
2260 assert_eq!(status_of(None, Some(EARLIER), Some(EARLIER), EARLIER, NOW), InviteStatus::Redeemed);
2261 }
2262
2263 #[test]
2264 fn an_invite_used_to_sign_up_awaits_the_account_confirming_its_address() {
2265 // Spent, not applied: waiting, even past its expiry.
2266 assert_eq!(status_of(None, Some(EARLIER), None, LATER, NOW), InviteStatus::AwaitingConfirmation);
2267 assert_eq!(status_of(None, Some(EARLIER), None, EARLIER, NOW), InviteStatus::AwaitingConfirmation);
2268 // Revoked while waiting: revoked, whatever happens when it settles.
2269 assert_eq!(status_of(Some(NOW), Some(EARLIER), None, LATER, NOW), InviteStatus::Revoked);
2270 assert_eq!(status_of(Some(NOW), Some(EARLIER), Some(NOW), LATER, NOW), InviteStatus::Revoked);
2271 // A spent invite still counts against the allowance while it waits,
2272 // and cannot be used again.
2273 assert!(counts_against_allowance(InviteStatus::AwaitingConfirmation));
2274 let waiting = invite("account", None, InviteStatus::AwaitingConfirmation);
2275 assert_eq!(admits(Some(&waiting), "anyone@example.com", true), Err(Refusal::Invalid));
2276 }
2277
2278 fn row(workspace: Option<(&str, Option<&str>)>, revoked: bool, expires_at: &str) -> InviteRow {
2279 InviteRow {
2280 id: "inv_1".into(),
2281 hint: "g1t-k7m2".into(),
2282 sealed_code: None,
2283 email: Some("ada@example.com".into()),
2284 kind: "account".into(),
2285 workspace_id: workspace.map(|(id, _)| id.to_owned()),
2286 workspace: workspace.and_then(|(_, slug)| slug.map(str::to_owned)),
2287 inviter_id: Some("usr_owner".into()),
2288 inviter: Some("bo".into()),
2289 staff: None,
2290 charged_to: "user".into(),
2291 created_at: EARLIER.into(),
2292 expires_at: expires_at.into(),
2293 revoked_at: revoked.then(|| NOW.to_owned()),
2294 redeemer: Some("ada".into()),
2295 redeemed_at: Some(EARLIER.into()),
2296 applied_at: None,
2297 }
2298 }
2299
2300 #[test]
2301 fn confirming_joins_the_workspace_the_invite_named_while_it_still_applies() {
2302 let good = row(Some(("wsp_1", Some("acme"))), false, LATER);
2303 assert_eq!(
2304 awaiting_join(&good, NOW, false),
2305 AwaitingJoin::Join { workspace_id: "wsp_1".into(), slug: "acme".into() }
2306 );
2307 // No workspace: nothing to join, and what came with it is accepted.
2308 assert_eq!(awaiting_join(&row(None, false, LATER), NOW, false), AwaitingJoin::Nothing);
2309 }
2310
2311 #[test]
2312 fn a_revoked_or_expired_invite_or_a_deleted_workspace_lapses_and_the_address_is_confirmed_anyway() {
2313 let lapsed = |join: AwaitingJoin| match join {
2314 AwaitingJoin::Lapsed(why) => why,
2315 other => panic!("expected a lapse, got {other:?}"),
2316 };
2317 let revoked = lapsed(awaiting_join(&row(Some(("wsp_1", Some("acme"))), true, LATER), NOW, false));
2318 assert!(revoked.starts_with("Your email address is confirmed."));
2319 assert!(revoked.contains("was revoked") && revoked.contains("did not join you to acme"));
2320 let expired = lapsed(awaiting_join(&row(Some(("wsp_1", Some("acme"))), false, EARLIER), NOW, false));
2321 assert!(expired.contains("expired before you confirmed it"));
2322 assert!(lapsed(awaiting_join(&row(Some(("wsp_1", Some("acme"))), false, NOW), NOW, false)).contains("expired"));
2323 // The workspace was deleted: its row no longer joins a slug.
2324 let deleted = lapsed(awaiting_join(&row(Some(("wsp_1", None)), false, LATER), NOW, false));
2325 assert!(deleted.contains("has been deleted"));
2326 let free = lapsed(awaiting_join(&row(Some(("wsp_1", Some("acme"))), false, LATER), NOW, true));
2327 assert!(free.contains("free plan"));
2328 // Revoked beats expired; an invite without a workspace lapses too.
2329 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 look2330 }
2331
2332 #[test]
2333 fn revoked_and_expired_invites_give_the_allowance_back() {
2334 assert!(counts_against_allowance(InviteStatus::Pending));
2335 assert!(counts_against_allowance(InviteStatus::Redeemed));
2336 assert!(!counts_against_allowance(InviteStatus::Revoked));
2337 assert!(!counts_against_allowance(InviteStatus::Expired));
2338 // The SQL says the same: used, or neither revoked nor expired.
2339 let sql = counted_sql();
2340 assert!(sql.contains("i.redeemed_at IS NOT NULL OR (i.revoked_at IS NULL AND i.expires_at >"));
2341 }
2342
2343 #[test]
2344 fn allowances_are_five_plus_grants_or_unlimited_for_staff() {
2345 assert_eq!(limit_for(INVITES_PER_USER, 0, false), Some(5));
2346 assert_eq!(limit_for(5, 10, false), Some(15));
2347 assert_eq!(limit_for(5, -3, false), Some(2));
2348 assert_eq!(limit_for(5, -30, false), Some(0));
2349 assert_eq!(limit_for(5, 0, true), None);
2350 // A workspace has only what staff granted it.
2351 assert_eq!(limit_for(0, 0, false), Some(0));
2352 assert_eq!(limit_for(0, 25, false), Some(25));
2353 let full = Allowance::new(Some(5), 5);
2354 assert!(full.exhausted());
2355 assert_eq!(full.remaining, Some(0));
2356 let over = Allowance::new(Some(2), 4);
2357 assert_eq!(over.remaining, Some(0));
2358 let open = Allowance::new(None, 400);
2359 assert!(!open.exhausted());
2360 assert_eq!(open.remaining, None);
2361 assert_eq!(Allowance::new(Some(5), 3).remaining, Some(2));
2362 }
2363
2364 fn invite(kind: &'static str, email: Option<&'static str>, status: InviteStatus) -> Admits<'static> {
2365 Admits { kind, email, status }
2366 }
2367
2368 #[test]
2369 fn an_invite_admits_only_its_address_while_pending() {
2370 let open = invite("account", None, InviteStatus::Pending);
2371 assert_eq!(admits(Some(&open), "anyone@example.com", true), Ok(()));
2372 let bound = invite("account", Some("ada@example.com"), InviteStatus::Pending);
2373 assert_eq!(admits(Some(&bound), "ada@example.com", true), Ok(()));
2374 assert_eq!(admits(Some(&bound), " ADA@Example.com ", true), Ok(()));
2375 assert_eq!(admits(Some(&bound), "eve@example.com", true), Err(Refusal::WrongEmail));
2376 for status in [InviteStatus::Redeemed, InviteStatus::Revoked, InviteStatus::Expired] {
2377 assert_eq!(admits(Some(&invite("account", None, status)), "a@example.com", true), Err(Refusal::Invalid));
2378 // A dead code says nothing about whom it was for.
2379 assert_eq!(
2380 admits(Some(&invite("account", Some("ada@example.com"), status)), "eve@example.com", true),
2381 Err(Refusal::Invalid)
2382 );
2383 }
2384 assert_eq!(admits(None, "a@example.com", true), Err(Refusal::Invalid));
2385 }
2386
2387 #[test]
2388 fn a_workspace_invite_never_makes_an_account() {
2389 let join = invite("workspace", Some("ada@example.com"), InviteStatus::Pending);
2390 assert_eq!(admits(Some(&join), "ada@example.com", true), Err(Refusal::Invalid));
2391 assert_eq!(admits(Some(&join), "ada@example.com", false), Ok(()));
2392 assert_eq!(admits(Some(&join), "eve@example.com", false), Err(Refusal::WrongEmail));
2393 // An account invite for a workspace can be accepted by the address
2394 // once it has an account.
2395 let account = invite("account", Some("ada@example.com"), InviteStatus::Pending);
2396 assert_eq!(admits(Some(&account), "ada@example.com", false), Ok(()));
2397 }
2398
2399 #[test]
2400 fn addresses_are_checked_and_masked() {
2401 assert_eq!(normalize_email(" Ada@Example.COM ").as_deref(), Some("ada@example.com"));
2402 for bad in ["", "ada", "ada@", "@example.com", "ada@example", "a b@example.com", "ada@.com", "ada@example.", "a@b@c.com"] {
2403 assert_eq!(normalize_email(bad), None, "{bad}");
2404 }
2405 assert_eq!(mask_email("ada@example.com"), "a•••@example.com");
2406 assert_eq!(mask_email("x@example.com"), "x•••@example.com");
2407 }
2408
2409 #[test]
2410 fn rate_limits_count_in_hour_long_windows() {
2411 assert_eq!(bucket(0, HOUR_MS), 0);
2412 assert_eq!(bucket(HOUR_MS - 1, HOUR_MS), 0);
2413 assert_eq!(bucket(HOUR_MS, HOUR_MS), 1);
2414 // The limits stop guessing long before a code could be found, and
2415 // leave room for people who mistype.
2416 assert!((5..=100).contains(&FAILURES_PER_HOUR));
2417 const { assert!(CREATES_PER_HOUR >= INVITES_PER_USER) };
2418 const { assert!(REQUESTS_PER_HOUR >= 1) };
2419 }
2420
2421 #[test]
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2422 fn staff_hear_about_requests_at_most_every_15_minutes() {
2423 assert_eq!(SUMMARY_EVERY_MS, 15 * 60 * 1000);
2424 let since = "2026-10-05T11:45:00.000Z";
2425 assert!(summary_due(None, since));
2426 assert!(summary_due(Some("2026-10-05T11:30:00.000Z"), since));
2427 assert!(summary_due(Some(since), since));
2428 assert!(!summary_due(Some("2026-10-05T11:50:00.000Z"), since));
2429 const { assert!(CONFIRMATIONS_PER_HOUR >= ANONYMOUS_REQUESTS_PER_HOUR) };
2430 }
2431
2432 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2433 fn registration_is_invite_only_unless_opened() {
2434 assert_eq!(RegistrationMode::parse(None), RegistrationMode::Invite);
2435 assert_eq!(RegistrationMode::parse(Some("invite")), RegistrationMode::Invite);
2436 assert_eq!(RegistrationMode::parse(Some("")), RegistrationMode::Invite);
2437 assert_eq!(RegistrationMode::parse(Some("opne")), RegistrationMode::Invite);
2438 assert_eq!(RegistrationMode::parse(Some(" Open ")), RegistrationMode::Open);
2439 }
2440}

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