Skip to content
2,853 linesCodeBlameRaw

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

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

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