Skip to content
2,855 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};
@g1t welcomes you when you join a workspace, and that is the one introduction any agent makes. Identity now says when a person becomes a member, workspace.member_joined, by invitation, by an invite code, or by creating the workspace, and webhooks can hear it; the agents service hears it too, and for anyone who joined, never the creator, the workspace's @g1t opens your direct message and says in its own voice who it is, what to ask it, how agents answer, and where things are, once per person per workspace and charged to @g1t like any reply, with a fixed welcome when no model can be used. The agents, workspaces, authentication and webhooks guides say so.34use g1t_contracts::events::{InviteCreated, InviteRedeemed, JoinedHow, WaitlistRequested};
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look35use 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());
@g1t welcomes you when you join a workspace, and that is the one introduction any agent makes. Identity now says when a person becomes a member, workspace.member_joined, by invitation, by an invite code, or by creating the workspace, and webhooks can hear it; the agents service hears it too, and for anyone who joined, never the creator, the workspace's @g1t opens your direct message and says in its own voice who it is, what to ask it, how agents answer, and where things are, once per person per workspace and charged to @g1t like any reply, with a fixed welcome when no model can be used. The agents, workspaces, authentication and webhooks guides say so.1041 // They are a member now: the workspace's @g1t welcomes them (agents service).
1042 self.announce_member_joined(workspace_id, slug, user, row.joins_as(), JoinedHow::Joined).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)1043 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1044 }
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1045 self.db
1046 .prepare(format!("UPDATE invites SET applied_at = {SQL_NOW} WHERE id = ? AND applied_at IS NULL"))
1047 .bind(&[row.id.as_str().into()])?
1048 .run()
1049 .await?;
1050 self.settled(row, user, created_account, joined).await;
1051 Ok(())
1052 }
1053
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)1054 /// When a workspace invitation made now, or handed to a new account
1055 /// now, stops working: the invite TTL from now, RFC 3339.
1056 pub(crate) fn answer_by(&self) -> String {
1057 rfc3339(now_ms() + self.invite_ttl_days() * 24 * HOUR_MS)
1058 }
1059
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1060 /// What follows an invite's workspace being joined (`joined`, by slug)
1061 /// or not: repository invitations sent with its code are accepted, and
1062 /// 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)1063 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 look1064 // A code sent with an invitation to collaborate on a repository:
1065 // using it accepts (access.rs).
1066 if let Err(error) = self.accept_invitations_of_code(&row.id, user).await {
1067 worker::console_error!("repository invitations for {} not accepted: {error}", row.id);
1068 }
1069 self.announce(
1070 "invite.redeemed",
1071 Some(&user.id),
1072 InviteRedeemed {
1073 invite_id: row.id.clone(),
1074 user_id: user.id.clone(),
1075 inviter_id: row.inviter_id.clone(),
1076 workspace_id: row.workspace_id.clone(),
1077 created_account,
1078 },
1079 )
1080 .await;
1081 if let Some(slug) = joined {
1082 let message = match &row.inviter {
1083 Some(inviter) => format!("Joined with an invite from {inviter}"),
1084 None => "Joined with an invite from g1t".to_owned(),
1085 };
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 let role = if row.joins_as() == Role::Owner { "an owner" } else { "a member" };
Merge main (membership, two-factor, GitHub repo roles) into tokens1087 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)1088 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 look1089 }
1090 }
1091
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1092 /// The invite an account signed up with, while it waits for the account
1093 /// to confirm its address: spent, not yet applied.
1094 pub(crate) async fn awaiting_invite(&self, user_id: &str) -> Result<Option<InviteRow>> {
1095 Ok(self
1096 .rows(
1097 "WHERE i.redeemed_by = ? AND i.kind = 'account' AND i.redeemed_at IS NOT NULL AND i.applied_at IS NULL",
1098 &[user_id.into()],
1099 1,
1100 )
1101 .await?
1102 .into_iter()
1103 .next())
1104 }
1105
1106 /// What an awaiting invite does now that its account is being
1107 /// confirmed, worked out before the batch that confirms it (which
1108 /// checks the same again).
1109 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)1110 awaiting_join(row, &rfc3339(now_ms()))
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1111 }
1112
1113 /// The statements that apply an awaiting invite, for the batch that
1114 /// 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)1115 /// account confirmed: give a workspace invitation the invite TTL from
1116 /// now to be answered in, only if the account is confirmed now; then
1117 /// mark the invite settled, whatever it gave. Nothing is joined here:
1118 /// the person accepts the invitation (invitations.rs).
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1119 pub(crate) fn apply_invite_statements(
1120 &self,
1121 user_id: &str,
1122 row: &InviteRow,
1123 join: &AwaitingJoin,
1124 ) -> Result<Vec<worker::D1PreparedStatement>> {
1125 let confirmed = "EXISTS (SELECT 1 FROM users WHERE id = ?1 AND email_verified_at IS NOT NULL)";
1126 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)1127 if let AwaitingJoin::Invited { .. } = join {
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1128 statements.push(
1129 self.db
1130 .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)1131 "UPDATE invites SET expires_at = max(expires_at, ?3)
1132 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)1133 ))
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)1134 .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)1135 );
1136 }
1137 statements.push(
1138 self.db
1139 .prepare(format!(
1140 "UPDATE invites SET applied_at = {SQL_NOW}
1141 WHERE id = ?2 AND redeemed_by = ?1 AND applied_at IS NULL AND {confirmed}"
1142 ))
1143 .bind(&[user_id.into(), row.id.as_str().into()])?,
1144 );
1145 Ok(statements)
1146 }
1147
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)1148 /// After the batch: the workspace the account is invited to, by slug,
1149 /// if the invitation still waits for its answer (and it is told in
1150 /// its inbox); and, unless the invite lapsed, the repository
1151 /// invitations, event and audit entries that follow using it.
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1152 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)1153 let invited = match join {
1154 AwaitingJoin::Invited { slug, .. } => self
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1155 .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)1156 .prepare(format!(
1157 "SELECT 1 AS n FROM invites WHERE id = ? AND applied_at IS NOT NULL AND revoked_at IS NULL
1158 AND accepted_at IS NULL AND declined_at IS NULL AND expires_at > {SQL_NOW}"
1159 ))
1160 .bind(&[row.id.as_str().into()])?
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1161 .first::<Count>(None)
1162 .await?
1163 .map(|_| slug.clone()),
1164 _ => None,
1165 };
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)1166 if invited.is_some() {
1167 self.invitation_sent(row, &user.username).await;
1168 }
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1169 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)1170 self.settled(row, user, true, None).await;
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1171 }
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)1172 Ok(invited)
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1173 }
1174
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1175 // --- People's invites ---
1176
1177 fn draft_allowed(user: &User) -> Option<&'static str> {
1178 if user.kind != PrincipalKind::User || user.acting.is_some() {
1179 return Some(PEOPLE_ONLY);
1180 }
1181 if !user.verified {
1182 return Some(CONFIRM_FIRST);
1183 }
1184 None
1185 }
1186
1187 /// Stores a new invite and returns it with its code, or None when the
1188 /// allowance ran out between reading it and writing.
1189 async fn insert_invite(&self, draft: Draft<'_>) -> Result<Option<Invite>> {
1190 let body = new_code_body();
1191 let code = format_code(&body);
1192 let now = now_ms();
1193 let id = new_id("inv", now);
1194 let sealed = self.invite_sealer().map(|sealer| sealer.seal(&code, &id));
1195 let expires_at = rfc3339(now + self.invite_ttl_days() * 24 * HOUR_MS);
1196 let created_at = rfc3339(now);
1197 let opt = |value: Option<&str>| value.map_or(JsValue::NULL, JsValue::from);
1198 let mut binds = vec![
1199 JsValue::from(id.as_str()),
1200 code_hash(&body).into(),
1201 code_hint(&body).into(),
1202 opt(sealed.as_deref()),
1203 opt(draft.email),
1204 draft.kind.into(),
1205 opt(draft.workspace_id),
1206 opt(draft.inviter.map(|user| user.id.as_str())),
1207 opt(draft.staff),
1208 draft.charged_to.into(),
1209 opt(draft.charged_workspace_id),
1210 created_at.as_str().into(),
1211 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)1212 opt(draft.invitee_id),
1213 opt(draft.role),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1214 ];
1215 // The allowance is checked in the insert itself, so two invites made
1216 // at once cannot both take the last one.
1217 let guard = match (draft.charged_to, draft.limit) {
1218 ("user", Some(limit)) => {
1219 binds.extend([opt(draft.inviter.map(|user| user.id.as_str())), f64::from(limit).into()]);
1220 format!(
1221 "WHERE (SELECT count(*) FROM invites i WHERE i.inviter_id = ? AND i.charged_to = 'user' AND {}) < ?",
1222 counted_sql()
1223 )
1224 }
1225 ("workspace", Some(limit)) => {
1226 binds.extend([opt(draft.charged_workspace_id), f64::from(limit).into()]);
1227 format!(
1228 "WHERE (SELECT count(*) FROM invites i WHERE i.charged_workspace_id = ? AND i.charged_to = 'workspace' AND {}) < ?",
1229 counted_sql()
1230 )
1231 }
1232 _ => String::new(),
1233 };
1234 let inserted = self
1235 .db
1236 .prepare(format!(
1237 "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)1238 staff, charged_to, charged_workspace_id, created_at, expires_at, invitee_id, role)
1239 SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? {guard}
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1240 RETURNING id"
1241 ))
1242 .bind(&binds)?
1243 .first::<Id>(None)
1244 .await?;
1245 if inserted.is_none() {
1246 return Ok(None);
1247 }
1248 self.announce(
1249 "invite.created",
1250 draft.inviter.map(|user| user.id.as_str()),
1251 InviteCreated {
1252 invite_id: id.clone(),
1253 inviter_id: draft.inviter.map(|user| user.id.clone()),
1254 workspace_id: draft.workspace_id.map(str::to_owned),
1255 bound: draft.email.is_some(),
1256 },
1257 )
1258 .await;
1259 let Some(row) = self.invite_by_id(&id).await? else {
1260 return Ok(None);
1261 };
1262 let mut invite = self.shown(row, false, false);
1263 invite.code = Some(code);
1264 Ok(Some(invite))
1265 }
1266
1267 fn out_of_invites() -> Outcome<Invite> {
1268 Outcome::fail(
1269 FailureCode::Limit,
1270 "You have no invites left. Need more? Contact us at hey@flagon.io with the subject [g1t Invites].",
1271 )
1272 }
1273
1274 pub async fn create_invite(&self, a: CreateInviteArgs) -> Result<Outcome<Invite>> {
1275 if let Some(reason) = Self::draft_allowed(&a.user) {
1276 return Ok(Outcome::fail(FailureCode::Forbidden, reason));
1277 }
1278 let email = match a.email.as_deref().map(str::trim).filter(|email| !email.is_empty()) {
1279 Some(email) => match normalize_email(email) {
1280 Some(email) => Some(email),
1281 None => return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL)),
1282 },
1283 None => None,
1284 };
1285 if !self.hit(&format!("invite.create:{}", a.user.id), CREATES_PER_HOUR).await? {
1286 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1287 }
1288 if let Some(email) = &email {
1289 if self.email_has_account(email).await? {
1290 return Ok(Outcome::fail(
1291 FailureCode::Conflict,
1292 "That address already has a g1t account. Add them to a workspace from its People page instead.",
1293 ));
1294 }
1295 let pending = self
1296 .rows(
1297 &format!(
1298 "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}"
1299 ),
1300 &[a.user.id.as_str().into(), email.as_str().into()],
1301 1,
1302 )
1303 .await?;
1304 if !pending.is_empty() {
1305 return Ok(Outcome::fail(
1306 FailureCode::Conflict,
1307 "You already have a pending invite for that address. Revoke it to send a new one.",
1308 ));
1309 }
1310 }
1311 // A workspace's granted invites, for its owners.
1312 let (workspace_id, charged_to, limit) = match a.workspace.as_deref().map(str::trim).filter(|slug| !slug.is_empty()) {
1313 Some(slug) => {
1314 let slug = slug.to_lowercase();
1315 if a.user.role_in(&slug) != Some(Role::Owner) {
1316 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a workspace's owners can use its invites."));
1317 }
1318 let Some(id) = self.workspace_id(&slug).await? else {
1319 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1320 };
1321 let allowance = self.workspace_allowance(&id).await?;
1322 if allowance.exhausted() {
1323 return Ok(Outcome::fail(
1324 FailureCode::Limit,
1325 format!("{slug} has no invites left. Need more? Contact us at hey@flagon.io with the subject [g1t Invites]."),
1326 ));
1327 }
1328 (Some(id), "workspace", allowance.limit)
1329 }
1330 None => {
1331 let allowance = self.user_allowance(&a.user.id).await?;
1332 if allowance.exhausted() {
1333 return Ok(Self::out_of_invites());
1334 }
1335 (None, "user", allowance.limit)
1336 }
1337 };
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)1338 // The workspace it brings them into, if any (invitations.rs).
1339 let joins = match self.joinable_workspace(&a.user, a.join.as_deref()).await? {
1340 Outcome::Ok(joins) => joins,
1341 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1342 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1343 let draft = Draft {
1344 email: email.as_deref(),
1345 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)1346 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 look1347 inviter: Some(&a.user),
1348 staff: None,
1349 charged_to,
1350 charged_workspace_id: workspace_id.as_deref(),
1351 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)1352 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)1353 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 look1354 };
1355 let Some(invite) = self.insert_invite(draft).await? else {
1356 return Ok(Self::out_of_invites());
1357 };
1358 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 quotas1359 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)1360 let workspace = match &joins {
1361 Some((id, slug)) => Some(self.workspace_name(id, slug).await),
1362 None => None,
1363 };
1364 self.send_invite_email(email, Some(&from), workspace.as_deref(), false, code, &invite.id, None).await;
1365 }
1366 let mut logs: Vec<String> = a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect();
1367 if let Some((_, slug)) = &joins {
1368 logs = vec![slug.clone()];
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1369 }
1370 self.audit_invites(&a.user, "invite.created", logs, a.surface.unwrap_or(Surface::Web), format!("Created invite {}", invite.hint))
1371 .await;
1372 Ok(Outcome::Ok(invite))
1373 }
1374
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1375 async fn send_invite_email(
1376 &self,
1377 to: &str,
1378 from: Option<&str>,
1379 workspace: Option<&str>,
1380 existing: bool,
1381 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 confirm1382 invite_id: &str,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1383 note: Option<&str>,
1384 ) {
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 confirm1385 // An invite that makes an account carries the proof that the link
1386 // came from this email; one for an existing account has nothing
1387 // to prove.
1388 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 quotas1389 let invite = crate::email::InviteEmail {
1390 to,
1391 from,
1392 workspace,
1393 joins_existing_account: existing,
1394 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 confirm1395 proof: proof.as_deref(),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1396 days: self.invite_ttl_days(),
1397 note,
1398 };
1399 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 look1400 worker::console_error!("invite email failed: {error}");
1401 }
1402 }
1403
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1404 /// How an invite names the person who sent it: their name, else their
1405 /// username.
1406 async fn display_name(&self, user: &User) -> String {
1407 self.name_of("SELECT display_name AS name FROM users WHERE id = ?", &user.id)
1408 .await
1409 .unwrap_or_else(|| user.username.clone())
1410 }
1411
1412 /// A workspace's name, as an invite shows it; its slug if it has none.
1413 async fn workspace_name(&self, workspace_id: &str, slug: &str) -> String {
1414 self.name_of("SELECT name FROM workspaces WHERE id = ?", workspace_id)
1415 .await
1416 .unwrap_or_else(|| slug.to_owned())
1417 }
1418
1419 /// A name `sql` selects for `id`, if it has one. Only for wording an
1420 /// email, so a failed read is no name.
1421 async fn name_of(&self, sql: &str, id: &str) -> Option<String> {
1422 #[derive(Deserialize)]
1423 struct Name {
1424 name: Option<String>,
1425 }
1426 let read = async { self.db.prepare(sql).bind(&[id.into()])?.first::<Name>(None).await };
1427 read.await
1428 .ok()
1429 .flatten()
1430 .and_then(|row| row.name)
1431 .map(|name| name.trim().to_owned())
1432 .filter(|name| !name.is_empty())
1433 }
1434
1435 /// The address a pending invite is bound to, if it is: signing up with
1436 /// GitHub uses it when GitHub has confirmed it too (github.rs).
1437 pub(crate) async fn bound_email_of(&self, code: &str) -> Result<Option<String>> {
1438 let now = rfc3339(now_ms());
1439 Ok(self
1440 .invite_by_code(code)
1441 .await?
1442 .filter(|row| row.status(&now) == InviteStatus::Pending)
1443 .and_then(|row| row.email))
1444 }
1445
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1446 pub async fn list_invites(&self, a: UserArgs) -> Result<InvitesOverview> {
1447 let invites: Vec<Invite> = self
1448 .rows("WHERE i.inviter_id = ?", &[a.user.id.as_str().into()], LIST_LIMIT)
1449 .await?
1450 .into_iter()
1451 .map(|row| self.shown(row, true, false))
1452 .collect();
1453 let mut workspaces = Vec::new();
1454 for membership in a.user.workspaces.iter().filter(|membership| membership.role == Role::Owner) {
1455 if let Some(id) = self.workspace_id(&membership.slug).await?
1456 && self.granted(GrantTarget::Workspace, &id).await? != 0
1457 {
1458 workspaces.push(WorkspaceAllowance {
1459 slug: membership.slug.clone(),
1460 allowance: self.workspace_allowance(&id).await?,
1461 });
1462 }
1463 }
1464 Ok(InvitesOverview {
1465 mode: self.registration_mode(),
1466 allowance: self.user_allowance(&a.user.id).await?,
1467 workspaces,
1468 invites,
1469 })
1470 }
1471
1472 /// 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)1473 /// charged to) a workspace they own. An invite used to sign up whose
1474 /// account has not confirmed its address yet can be revoked too: the
1475 /// account stays, and joins nothing when it confirms.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1476 pub async fn revoke_invite(&self, a: RemoveArgs) -> Result<Outcome<Invite>> {
1477 if a.user.kind != PrincipalKind::User || a.user.acting.is_some() {
1478 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
1479 }
1480 let revoked = self
1481 .db
1482 .prepare(format!(
1483 "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)1484 WHERE id = ?2 AND revoked_at IS NULL AND declined_at IS NULL
1485 AND (redeemed_at IS NULL OR applied_at IS NULL
1486 -- A workspace invitation not yet answered.
1487 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 look1488 AND (inviter_id = ?1
1489 OR workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = ?1 AND role = 'owner')
1490 OR charged_workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = ?1 AND role = 'owner'))
1491 RETURNING id"
1492 ))
1493 .bind(&[a.user.id.as_str().into(), a.id.as_str().into()])?
1494 .first::<Id>(None)
1495 .await?;
1496 let Some(Id { id }) = revoked else {
1497 return Ok(Outcome::fail(FailureCode::NotFound, "There is no pending invite of yours with that id."));
1498 };
1499 let Some(row) = self.invite_by_id(&id).await? else {
1500 return Ok(Outcome::fail(FailureCode::NotFound, "Invite not found."));
1501 };
1502 let logs = match &row.workspace {
1503 Some(slug) => vec![slug.clone()],
1504 None => a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect(),
1505 };
1506 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)1507 self.invitation_revoked(&a.user, &row).await;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1508 Ok(Outcome::Ok(self.shown(row, false, false)))
1509 }
1510
1511 /// What an invite code is for: who sent it, and which workspace it
1512 /// joins. Any code that cannot be used gets the same answer.
1513 pub async fn check_invite(&self, a: InviteCodeArgs) -> Result<Outcome<InvitePreview>> {
1514 if self.turned_away(a.client.as_deref()).await? {
1515 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1516 }
1517 let now = rfc3339(now_ms());
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)1518 let found = self.invite_by_code(&a.code).await?;
1519 // A shared invite link's code, while it is live: its label and
1520 // domains are for the sign-up page. Expired, revoked and used up
1521 // get the one answer below, whatever `any_status` asks.
1522 if found.is_none()
1523 && let Some(link) = self.shared_by_code(&a.code).await?
1524 && link.status(&now) == SharedInviteStatus::Live
1525 {
1526 return Ok(Outcome::Ok(self.shared_preview(&link)));
1527 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1528 // A spent code is still a real one (160 random bits): saying what
1529 // became of it tells a guesser nothing.
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)1530 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 look1531 let Some(row) = row else {
1532 self.count_failure(a.client.as_deref()).await?;
1533 return Ok(Outcome::fail(FailureCode::NotFound, INVALID));
1534 };
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1535 let status = row.status(&now);
1536 let pending = status == InviteStatus::Pending;
1537 // Whether it is the viewer's: for one of their confirmed addresses,
1538 // or, once used, used by them.
1539 let for_viewer = match &a.viewer {
1540 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)1541 (_, InviteStatus::Redeemed | InviteStatus::AwaitingConfirmation) => {
1542 Some(row.redeemer.as_deref() == Some(viewer.username.as_str()))
1543 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1544 (Some(bound), _) => {
1545 let mine = self.verified_emails(&viewer.id).await?;
1546 Some(mine.iter().any(|address| address.eq_ignore_ascii_case(bound.trim())))
1547 }
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)1548 // An invitation to someone by username is theirs alone.
1549 (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 quotas1550 },
1551 _ => None,
1552 };
1553 let has_account = match (&row.email, pending) {
1554 (Some(bound), true) => self.email_has_account(bound).await?,
1555 _ => false,
1556 };
1557 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 confirm1558 // Opened from the invite's own email: the account it makes starts
1559 // with the address confirmed. Said only while it can make one.
1560 let email_proven = pending
1561 && !has_account
1562 && 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 look1563 #[derive(Deserialize)]
1564 struct From {
1565 username: String,
1566 name: Option<String>,
1567 avatar: Option<String>,
1568 }
1569 let invited_by = match &row.inviter_id {
1570 Some(id) => self
1571 .db
1572 .prepare("SELECT username, display_name AS name, avatar FROM users WHERE id = ?")
1573 .bind(&[id.as_str().into()])?
1574 .first::<From>(None)
1575 .await?
1576 .map(|from| InviteFrom {
1577 username: from.username,
1578 name: from.name,
1579 avatar: from.avatar,
1580 }),
1581 None => None,
1582 };
1583 let workspace = match &row.workspace_id {
1584 Some(id) => self
1585 .db
1586 .prepare("SELECT slug, name, avatar FROM workspaces WHERE id = ?")
1587 .bind(&[id.as_str().into()])?
1588 .first::<ProfileWorkspace>(None)
1589 .await?,
1590 None => None,
1591 };
1592 Ok(Outcome::Ok(InvitePreview {
1593 kind: kind_of(&row.kind),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1594 status,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1595 invited_by,
1596 workspace,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1597 repository,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1598 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 quotas1599 address: row.email.clone().filter(|_| pending),
1600 has_account,
1601 for_viewer,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1602 expires_at: row.expires_at,
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)1603 shared_label: None,
1604 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 confirm1605 email_proven,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1606 }))
1607 }
1608
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1609 /// A signed-in person uses a workspace invite sent to their address,
1610 /// or one sent with a repository invitation.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1611 pub async fn accept_invite(&self, a: AcceptInviteArgs) -> Result<Outcome<String>> {
1612 if a.user.kind != PrincipalKind::User || a.user.acting.is_some() {
1613 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can accept an invite."));
1614 }
1615 // Any of the person's confirmed addresses can match an invite bound
1616 // to one (emails.rs); the primary otherwise.
1617 let verified = self.verified_emails(&a.user.id).await?;
1618 let Some(primary) = verified.first().cloned() else {
1619 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first, then open the invite again."));
1620 };
1621 let now = rfc3339(now_ms());
1622 let row = self.invite_by_code(&a.code).await?;
1623 let email = row
1624 .as_ref()
1625 .and_then(|row| row.email.as_deref())
1626 .and_then(|bound| verified.iter().find(|address| address.eq_ignore_ascii_case(bound.trim())).cloned())
1627 .unwrap_or(primary);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1628 // What using it gives an account that exists: a workspace, or a
1629 // repository it was sent with.
1630 let repository = match &row {
1631 Some(row) => self.repository_of_code(&row.id).await?,
1632 None => None,
1633 };
1634 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 look1635 if let Err(refusal) = admits(row.as_ref().map(|row| row.admits(&now)).as_ref(), &email, false) {
1636 return Ok(Outcome::fail(
1637 FailureCode::Forbidden,
1638 if refusal == Refusal::WrongEmail { WRONG_EMAIL } else { INVALID },
1639 ));
1640 }
1641 let Some(row) = row.filter(|_| joins) else {
1642 return Ok(Outcome::fail(
1643 FailureCode::Conflict,
1644 "You already have a g1t account, so this invite has nothing more to give you. Pass it on to someone who needs it.",
1645 ));
1646 };
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)1647 // An invitation to one account works for that account only.
1648 if row.invitee_id.as_deref().is_some_and(|invitee| invitee != a.user.id) {
1649 return Ok(Outcome::fail(
1650 FailureCode::Forbidden,
1651 "This invitation is for a different g1t account. Sign in as the account it was sent to.",
1652 ));
1653 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1654 // What the workspace asks of its members (security.rs); nothing yet.
1655 if let Some(slug) = row.workspace.as_deref()
1656 && let Some(why) = self.policy_refusal(&a.user.id, slug).await?
1657 {
1658 return Ok(Outcome::fail(FailureCode::Forbidden, why));
1659 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1660 // An invite sent before the workspace was free waits until it
1661 // starts the plan (paid.rs); the code is not used up.
1662 let joins_slug = row.workspace.clone().or_else(|| {
1663 repository.as_ref().and_then(|r| r.name.split_once('/').map(|(workspace, _)| workspace.to_owned()))
1664 });
1665 if let Some(slug) = joins_slug.as_deref()
1666 && let Some(refused) = self.free_workspace_refusal(slug).await?
1667 {
1668 return Ok(refused);
1669 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1670 let claimed = self
1671 .db
1672 .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)1673 "UPDATE invites SET redeemed_by = ?1, invitee_id = COALESCE(invitee_id, ?1), redeemed_at = {SQL_NOW}, sealed_code = NULL
1674 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 look1675 RETURNING id"
1676 ))
1677 .bind(&[a.user.id.as_str().into(), row.id.as_str().into()])?
1678 .first::<Id>(None)
1679 .await?;
1680 if claimed.is_none() {
1681 return Ok(Outcome::fail(FailureCode::Forbidden, INVALID));
1682 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1683 let lands = row
1684 .workspace
1685 .clone()
1686 .or_else(|| repository.map(|repository| repository.name))
1687 .unwrap_or_default();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1688 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 quotas1689 Ok(Outcome::Ok(lands))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1690 }
1691
1692 // --- Workspace invitations ---
1693
1694 pub async fn invite_member(&self, a: InviteMemberArgs) -> Result<Outcome<Invite>> {
1695 let slug = a.slug.trim().to_lowercase();
1696 if let Some(reason) = Self::draft_allowed(&a.actor) {
1697 return Ok(Outcome::fail(FailureCode::Forbidden, reason));
1698 }
1699 if a.actor.role_in(&slug) != Some(Role::Owner) {
1700 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can invite people to a workspace."));
1701 }
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)1702 // A username, or an address; an address typed in the username's
1703 // place is an address.
1704 let username = a
1705 .username
1706 .as_deref()
1707 .map(|name| name.trim().trim_start_matches('@').to_lowercase())
1708 .filter(|name| !name.is_empty() && !name.contains('@'));
1709 let email = match (&username, normalize_email(a.username.as_deref().unwrap_or(&a.email))) {
1710 (Some(_), _) => None,
1711 (None, Some(email)) => Some(email),
1712 (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 look1713 };
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)1714 let role = a.role.unwrap_or(Role::Member);
1715 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 look1716 let Some(workspace_id) = self.workspace_id(&slug).await? else {
1717 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1718 };
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1719 // A free workspace invites no one until it starts the plan (paid.rs).
1720 if let Some(refused) = self.free_workspace_refusal(&slug).await? {
1721 return Ok(refused);
1722 }
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)1723 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.1724 // A note from the inviter goes in the email, cut to its limit.
1725 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)1726 // Someone on g1t, by username: an invitation to accept or decline
1727 // (invites/invitations.rs).
1728 let Some(email) = email else {
1729 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.1730 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)1731 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1732 if !self.hit(&format!("invite.create:{}", a.actor.id), CREATES_PER_HOUR).await? {
1733 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1734 }
1735 let pending = self
1736 .rows(
1737 &format!(
1738 "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)1739 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 look1740 ),
1741 &[workspace_id.as_str().into(), email.as_str().into()],
1742 1,
1743 )
1744 .await?;
1745 if !pending.is_empty() {
1746 return Ok(Outcome::fail(
1747 FailureCode::Conflict,
1748 "There is already a pending invite for that address. Revoke it to send a new one.",
1749 ));
1750 }
1751 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)1752 // The account that has confirmed the address, which the invitation
1753 // is for. Never shown to the inviter: the answer does not say
1754 // whether the address has an account.
1755 let invitee = self.user_with_verified_email(&email).await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1756 let draft = if has_account {
1757 // Costs nothing: the person is on g1t already.
1758 Draft {
1759 email: Some(&email),
1760 kind: "workspace",
1761 workspace_id: Some(&workspace_id),
1762 inviter: Some(&a.actor),
1763 staff: None,
1764 charged_to: "none",
1765 charged_workspace_id: None,
1766 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)1767 invitee_id: invitee.as_deref(),
1768 role: Some(role_name),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1769 }
1770 } 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)1771 // While g1t is invite-only, the invitation also lets the address
1772 // make its account, so it costs an invite: the workspace's shared
1773 // ones first, then the owner's own. Once anyone can sign up, an
1774 // account needs no invite and it costs nothing.
1775 let (charged_to, charged_workspace_id, limit) = if self.invites_required() {
1776 let shared = self.workspace_allowance(&workspace_id).await?;
1777 if shared.remaining.is_some_and(|left| left > 0) {
1778 ("workspace", Some(workspace_id.as_str()), shared.limit)
1779 } else {
1780 let own = self.user_allowance(&a.actor.id).await?;
1781 if own.exhausted() {
1782 return Ok(Self::out_of_invites());
1783 }
1784 ("user", None, own.limit)
1785 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1786 } 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)1787 ("none", None, None)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1788 };
1789 Draft {
1790 email: Some(&email),
1791 kind: "account",
1792 workspace_id: Some(&workspace_id),
1793 inviter: Some(&a.actor),
1794 staff: None,
1795 charged_to,
1796 charged_workspace_id,
1797 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)1798 invitee_id: None,
1799 role: Some(role_name),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1800 }
1801 };
1802 let Some(invite) = self.insert_invite(draft).await? else {
1803 return Ok(Self::out_of_invites());
1804 };
1805 if let Some(code) = &invite.code {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1806 let from = self.display_name(&a.actor).await;
1807 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.1808 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 look1809 }
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)1810 // Someone on g1t hears of it in their inbox too.
1811 if invitee.is_some()
1812 && let Some(row) = self.invite_by_id(&invite.id).await?
1813 && let Some(username) = row.invitee.clone()
1814 {
1815 self.invitation_sent(&row, &username).await;
1816 }
1817 self.audit_invites(&a.actor, "invite.created", vec![slug.clone()], surface, format!("Invited {email} to {slug} as {role_name}"))
1818 .await;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1819 Ok(Outcome::Ok(invite))
1820 }
1821
1822 /// An invite code for an address without an account, invited to
1823 /// collaborate on one repository of `workspace_id` (access.rs). Charged
1824 /// as a workspace invite is: the workspace's shared invites first, then
1825 /// the inviter's own. The code joins no workspace; redeeming it accepts
1826 /// the repository invitation that names it.
1827 pub(crate) async fn repo_invite_code(&self, actor: &User, email: &str, workspace_id: &str) -> Result<Outcome<Invite>> {
1828 if let Some(reason) = Self::draft_allowed(actor) {
1829 return Ok(Outcome::fail(FailureCode::Forbidden, reason));
1830 }
1831 if !self.hit(&format!("invite.create:{}", actor.id), CREATES_PER_HOUR).await? {
1832 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1833 }
1834 let shared = self.workspace_allowance(workspace_id).await?;
1835 let (charged_to, charged_workspace_id, limit) = if shared.remaining.is_some_and(|left| left > 0) {
1836 ("workspace", Some(workspace_id), shared.limit)
1837 } else {
1838 let own = self.user_allowance(&actor.id).await?;
1839 if own.exhausted() {
1840 return Ok(Self::out_of_invites());
1841 }
1842 ("user", None, own.limit)
1843 };
1844 let draft = Draft {
1845 email: Some(email),
1846 kind: "account",
1847 workspace_id: None,
1848 inviter: Some(actor),
1849 staff: None,
1850 charged_to,
1851 charged_workspace_id,
1852 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)1853 invitee_id: None,
1854 role: None,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1855 };
1856 Ok(match self.insert_invite(draft).await? {
1857 Some(invite) => Outcome::Ok(invite),
1858 None => Self::out_of_invites(),
1859 })
1860 }
1861
1862 /// Revokes an invite code made for a repository invitation, when that
1863 /// invitation is revoked. Only a pending code changes.
1864 pub(crate) async fn revoke_code(&self, invite_id: &str) -> Result<()> {
1865 self.db
1866 .prepare(format!(
1867 "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL
1868 WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL"
1869 ))
1870 .bind(&[invite_id.into()])?
1871 .run()
1872 .await?;
1873 Ok(())
1874 }
1875
1876 pub async fn workspace_invites(&self, a: ListMembersArgs) -> Result<Outcome<Vec<Invite>>> {
1877 let slug = a.slug.trim().to_lowercase();
1878 if !a.viewer.as_ref().is_some_and(|viewer| viewer.role_in(&slug) == Some(Role::Owner)) {
1879 return Ok(Outcome::fail(FailureCode::Forbidden, "Only owners can see a workspace's invites."));
1880 }
1881 let Some(workspace_id) = self.workspace_id(&slug).await? else {
1882 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1883 };
1884 let rows = self.rows("WHERE i.workspace_id = ?", &[workspace_id.as_str().into()], LIST_LIMIT).await?;
1885 Ok(Outcome::Ok(rows.into_iter().map(|row| self.shown(row, true, false)).collect()))
1886 }
1887
1888 pub async fn revoke_workspace_invite(&self, a: WorkspaceInviteArgs) -> Result<Outcome<Invite>> {
1889 let slug = a.slug.trim().to_lowercase();
1890 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
1891 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can revoke a workspace's invites."));
1892 }
1893 self.revoke_invite(RemoveArgs { user: a.actor, id: a.id }).await
1894 }
1895
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.1896 /// Sends one of the workspace's pending invitations again: the same
1897 /// email to the address it is bound to, or to the invited account's
1898 /// confirmed address, and the inbox notice again for an account. The
1899 /// invitation itself does not change. Counted against the owner's
1900 /// hourly allowance of invites made, so a list cannot be used to flood
1901 /// an inbox.
1902 pub async fn resend_workspace_invite(&self, a: WorkspaceInviteArgs) -> Result<Outcome<Invite>> {
1903 let slug = a.slug.trim().to_lowercase();
1904 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
1905 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can send a workspace's invitations again."));
1906 }
1907 let row = self.invite_by_id(a.id.trim()).await?.filter(|row| row.workspace.as_deref() == Some(slug.as_str()));
1908 let Some(row) = row else {
1909 return Ok(Outcome::fail(FailureCode::NotFound, "There is no invitation to this workspace with that id."));
1910 };
1911 let now = rfc3339(now_ms());
1912 if !resendable(row.status(&now)) {
1913 return Ok(Outcome::fail(FailureCode::Conflict, "Only a pending invitation can be sent again."));
1914 }
1915 if !self.hit(&format!("invite.create:{}", a.actor.id), CREATES_PER_HOUR).await? {
1916 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1917 }
1918 let Some(workspace_id) = row.workspace_id.as_deref() else {
1919 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1920 };
1921 let code = row.sealed_code.as_deref().and_then(|sealed| self.invite_sealer()?.open(sealed, &row.id));
1922 // Where it goes: the address it is bound to, else the invited
1923 // account's confirmed address. An account invite (one that also
1924 // makes the account) carries the proof the link came from its email.
1925 let to = match &row.email {
1926 Some(email) => Some(email.clone()),
1927 None => match &row.invitee_id {
1928 Some(invitee) => self.verified_email_of(invitee).await?,
1929 None => None,
1930 },
1931 };
1932 if let (Some(to), Some(code)) = (&to, &code) {
1933 let from = self.display_name(&a.actor).await;
1934 let workspace = self.workspace_name(workspace_id, &slug).await;
1935 self.send_invite_email(to, Some(&from), Some(&workspace), row.kind == "workspace", code, &row.id, None).await;
1936 }
1937 if let Some(username) = row.invitee.as_deref().filter(|_| row.email.is_none()) {
1938 self.invitation_sent(&row, username).await;
1939 }
1940 self.audit_invites(&a.actor, "invite.resent", vec![slug.clone()], Surface::Web, format!("Sent invite {} again", row.hint)).await;
1941 Ok(Outcome::Ok(self.shown(row, true, false)))
1942 }
1943
1944 /// The confirmed primary address of the account `user_id`, if it has one.
1945 async fn verified_email_of(&self, user_id: &str) -> Result<Option<String>> {
1946 #[derive(Deserialize)]
1947 struct Address {
1948 email: Option<String>,
1949 }
1950 Ok(self
1951 .db
1952 .prepare("SELECT email FROM users WHERE id = ? AND email_verified_at IS NOT NULL AND deleted_at IS NULL")
1953 .bind(&[user_id.into()])?
1954 .first::<Address>(None)
1955 .await?
1956 .and_then(|row| row.email))
1957 }
1958
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1959 // --- The waitlist ---
1960
1961 pub async fn request_access(&self, a: RequestAccessArgs) -> Result<Outcome<bool>> {
1962 let Some(email) = normalize_email(&a.email) else {
1963 return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL));
1964 };
1965 let allowed = match a.client.as_deref().filter(|client| !client.is_empty()) {
1966 Some(client) => self.hit(&format!("waitlist:{}", crypto::sha256_hex(client)), REQUESTS_PER_HOUR).await?,
1967 None => self.hit("waitlist:anonymous", ANONYMOUS_REQUESTS_PER_HOUR).await?,
1968 };
1969 if !allowed {
1970 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1971 }
1972 let about: String = a.about.trim().chars().take(MAX_WAITLIST_ABOUT).collect();
1973 let now = rfc3339(now_ms());
1974 #[derive(Deserialize)]
1975 struct Upserted {
1976 id: String,
1977 created_at: String,
1978 }
1979 let row = self
1980 .db
1981 .prepare(
1982 "INSERT INTO waitlist (id, email, about, status, created_at, updated_at)
1983 VALUES (?1, ?2, ?3, 'waiting', ?4, ?4)
1984 ON CONFLICT (email) DO UPDATE SET
1985 about = COALESCE(excluded.about, waitlist.about), updated_at = excluded.updated_at
1986 RETURNING id, created_at",
1987 )
1988 .bind(&[
1989 new_id("wl", now_ms()).into(),
1990 email.as_str().into(),
1991 if about.is_empty() { JsValue::NULL } else { about.as_str().into() },
1992 now.as_str().into(),
1993 ])?
1994 .first::<Upserted>(None)
1995 .await?;
1996 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 quotas1997 self.announce("waitlist.requested", None, WaitlistRequested { entry_id: row.id.clone() }).await;
1998 self.acknowledge_request(&row.id, &email).await?;
1999 self.notify_staff_of_requests().await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2000 }
2001 Ok(Outcome::Ok(true))
2002 }
2003
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2004 /// The one confirmation an address gets for asking: claimed in the
2005 /// database first, so a repeat request (or two at once) never sends a
2006 /// second, and capped across everyone, since anyone can type any
2007 /// address.
2008 async fn acknowledge_request(&self, id: &str, email: &str) -> Result<()> {
2009 if !self.hit("waitlist.ack", CONFIRMATIONS_PER_HOUR).await? {
2010 return Ok(());
2011 }
2012 let claimed = self
2013 .db
2014 .prepare(format!(
2015 "UPDATE waitlist SET acknowledged_at = {SQL_NOW} WHERE id = ? AND acknowledged_at IS NULL RETURNING id"
2016 ))
2017 .bind(&[id.into()])?
2018 .first::<Id>(None)
2019 .await?;
2020 if claimed.is_none() {
2021 return Ok(());
2022 }
2023 if let Err(error) = crate::email::send_waitlist_confirmation(&self.env, email).await {
2024 worker::console_error!("waitlist confirmation failed: {error}");
2025 // Not sent: leave it unclaimed, so staff can see it was not.
2026 self.db
2027 .prepare("UPDATE waitlist SET acknowledged_at = NULL WHERE id = ?")
2028 .bind(&[id.into()])?
2029 .run()
2030 .await?;
2031 }
2032 Ok(())
2033 }
2034
2035 /// Where staff hear about new requests: WAITLIST_NOTIFY_EMAIL, unset or
2036 /// empty for nobody.
2037 fn waitlist_notify_email(&self) -> Option<String> {
2038 let to = self.env.var("WAITLIST_NOTIFY_EMAIL").ok()?.to_string();
2039 normalize_email(&to)
2040 }
2041
2042 /// Tells staff about every request they have not heard about, unless a
2043 /// summary went in the last 15 minutes: then the next request after
2044 /// that brings them all in one. The rows are claimed before sending, so
2045 /// two requests at once send one summary.
2046 pub(crate) async fn notify_staff_of_requests(&self) -> Result<()> {
2047 let Some(to) = self.waitlist_notify_email() else {
2048 return Ok(());
2049 };
2050 #[derive(Deserialize)]
2051 struct Last {
2052 at: Option<String>,
2053 }
2054 let last = self
2055 .db
2056 .prepare("SELECT max(notified_at) AS at FROM waitlist")
2057 .first::<Last>(None)
2058 .await?
2059 .and_then(|last| last.at);
2060 let now = now_ms();
2061 if !summary_due(last.as_deref(), &rfc3339(now.saturating_sub(SUMMARY_EVERY_MS))) {
2062 return Ok(());
2063 }
2064 let stamp = rfc3339(now);
2065 #[derive(Deserialize)]
2066 struct New {
2067 email: String,
2068 about: Option<String>,
2069 created_at: String,
2070 }
2071 let mut new = self
2072 .db
2073 .prepare(
2074 "UPDATE waitlist SET notified_at = ? WHERE notified_at IS NULL AND status = 'waiting'
2075 RETURNING email, about, created_at",
2076 )
2077 .bind(&[stamp.as_str().into()])?
2078 .all()
2079 .await?
2080 .results::<New>()?;
2081 if new.is_empty() {
2082 return Ok(());
2083 }
2084 new.sort_by(|a, b| a.created_at.cmp(&b.created_at));
2085 let waiting = self
2086 .db
2087 .prepare("SELECT count(*) AS n FROM waitlist WHERE status = 'waiting'")
2088 .first::<Count>(None)
2089 .await?
2090 .map_or(0, |count| count.n as u32);
2091 let new: Vec<crate::email::Requested> = new
2092 .into_iter()
2093 .map(|row| crate::email::Requested { email: row.email, about: row.about })
2094 .collect();
2095 if let Err(error) = crate::email::send_waitlist_summary(&self.env, &to, &new, waiting).await {
2096 worker::console_error!("waitlist summary failed: {error}");
2097 // Not sent: the next request tries again with these too.
2098 self.db
2099 .prepare("UPDATE waitlist SET notified_at = NULL WHERE notified_at = ?")
2100 .bind(&[stamp.as_str().into()])?
2101 .run()
2102 .await?;
2103 }
2104 Ok(())
2105 }
2106
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2107 // --- Staff ---
2108
2109 pub async fn admin_waitlist(&self, a: AdminWaitlistArgs) -> Result<Vec<WaitlistEntry>> {
2110 let mut filters = Vec::new();
2111 let mut binds: Vec<JsValue> = Vec::new();
2112 if let Some(status) = a.status {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2113 filters.push("wl.status = ?".to_owned());
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2114 binds.push(status.as_str().into());
2115 }
2116 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 quotas2117 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 look2118 binds.push(pattern.as_str().into());
2119 binds.push(pattern.as_str().into());
2120 }
2121 let filter = if filters.is_empty() { String::new() } else { format!("WHERE {}", filters.join(" AND ")) };
2122 Ok(self
2123 .db
2124 .prepare(format!(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2125 "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 look2126 ))
2127 .bind(&binds)?
2128 .all()
2129 .await?
2130 .results::<WaitlistRow>()?
2131 .into_iter()
2132 .map(WaitlistEntry::from)
2133 .collect())
2134 }
2135
2136 async fn waitlist_entry(&self, id: &str) -> Result<Option<WaitlistRow>> {
2137 self.db
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2138 .prepare(format!("SELECT {WAITLIST_COLUMNS} WHERE wl.id = ?"))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2139 .bind(&[id.into()])?
2140 .first::<WaitlistRow>(None)
2141 .await
2142 }
2143
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2144 /// How many requests are waiting, for sudo's navigation.
2145 pub async fn admin_waitlist_pending(&self) -> Result<u32> {
2146 Ok(self
2147 .db
2148 .prepare("SELECT count(*) AS n FROM waitlist WHERE status = 'waiting'")
2149 .first::<Count>(None)
2150 .await?
2151 .map_or(0, |count| count.n as u32))
2152 }
2153
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2154 pub async fn admin_decide_waitlist(&self, a: AdminDecideWaitlistArgs) -> Result<Outcome<WaitlistEntry>> {
2155 let Some(entry) = self.waitlist_entry(&a.id).await? else {
2156 return Ok(Outcome::fail(FailureCode::NotFound, "That request is not on the waitlist."));
2157 };
2158 let staff = a.staff.trim();
2159 if staff.is_empty() {
2160 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member decided."));
2161 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2162 if entry.status != "waiting" {
2163 return Ok(Outcome::fail(
2164 FailureCode::Conflict,
2165 format!("{} was already {} by {}.", entry.email, entry.status, entry.decided_by.as_deref().unwrap_or("staff")),
2166 ));
2167 }
2168 let note: String = a.note.as_deref().unwrap_or_default().trim().chars().take(MAX_WAITLIST_NOTE).collect();
2169 let note = (!note.is_empty()).then_some(note);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2170 let mut invite_id = JsValue::NULL;
2171 if a.approve {
2172 if self.email_has_account(&entry.email).await? {
2173 return Ok(Outcome::fail(FailureCode::Conflict, "That address already has a g1t account."));
2174 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2175 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 look2176 Outcome::Ok(invite) => invite,
2177 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
2178 };
2179 invite_id = minted.id.as_str().into();
2180 }
2181 self.db
2182 .prepare(format!(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2183 "UPDATE waitlist SET status = ?, invite_id = COALESCE(?, invite_id), decided_by = ?, decided_at = {SQL_NOW},
2184 note = ?, notified_at = COALESCE(notified_at, {SQL_NOW})
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2185 WHERE id = ?"
2186 ))
2187 .bind(&[
2188 if a.approve { "invited" } else { "dismissed" }.into(),
2189 invite_id,
2190 staff.into(),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2191 note.as_deref().map_or(JsValue::NULL, JsValue::from),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2192 entry.id.as_str().into(),
2193 ])?
2194 .run()
2195 .await?;
2196 Ok(match self.waitlist_entry(&entry.id).await? {
2197 Some(row) => Outcome::Ok(row.into()),
2198 None => Outcome::fail(FailureCode::NotFound, "That request is not on the waitlist."),
2199 })
2200 }
2201
2202 pub async fn admin_invites(&self, a: AdminInvitesArgs) -> Result<Vec<Invite>> {
2203 let query = a.query.as_deref().map(str::trim).filter(|query| !query.is_empty());
2204 let rows = match query {
2205 None => self.rows("", &[], ADMIN_INVITES_LIMIT as u32).await?,
2206 Some(query) => {
2207 // A code, or its start: matched by its hint.
2208 let prefix = query.to_lowercase();
2209 let prefix = prefix.strip_prefix("g1t-").unwrap_or(&prefix).replace('-', "");
2210 let hint = (prefix.len() >= GROUP && prefix.chars().all(|c| ALPHABET.contains(&(c as u8))))
2211 .then(|| code_hint(&prefix));
2212 let pattern = crate::admin::like_pattern(Some(query)).unwrap_or_default();
2213 let mut binds: Vec<JsValue> = vec![pattern.as_str().into(), pattern.as_str().into(), pattern.as_str().into()];
2214 let mut filter = "WHERE (lower(i.email) LIKE ? ESCAPE '\\' OR iu.username LIKE ? ESCAPE '\\' OR ru.username LIKE ? ESCAPE '\\'".to_owned();
2215 if let Some(hint) = hint {
2216 filter.push_str(" OR i.hint = ?");
2217 binds.push(hint.into());
2218 }
2219 filter.push(')');
2220 self.rows(&filter, &binds, ADMIN_INVITES_LIMIT as u32).await?
2221 }
2222 };
2223 Ok(rows.into_iter().map(|row| self.shown(row, false, true)).collect())
2224 }
2225
2226 pub async fn admin_revoke_invite(&self, a: AdminRevokeInviteArgs) -> Result<Outcome<Invite>> {
2227 let revoked = self
2228 .db
2229 .prepare(format!(
2230 "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL
2231 WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL RETURNING id"
2232 ))
2233 .bind(&[a.id.as_str().into()])?
2234 .first::<Id>(None)
2235 .await?;
2236 if revoked.is_none() {
2237 return Ok(Outcome::fail(FailureCode::Conflict, "Only a pending invite can be revoked."));
2238 }
2239 worker::console_log!("invite {} revoked by staff {}", a.id, a.staff);
2240 Ok(match self.invite_by_id(&a.id).await? {
2241 Some(row) => Outcome::Ok(self.shown(row, false, true)),
2242 None => Outcome::fail(FailureCode::NotFound, "Invite not found."),
2243 })
2244 }
2245
2246 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 quotas2247 self.mint_staff_invite(a.email, &a.staff, None).await
2248 }
2249
2250 /// An invite staff make, emailed with `note` when it is for an address.
2251 async fn mint_staff_invite(&self, email: Option<String>, staff: &str, note: Option<&str>) -> Result<Outcome<Invite>> {
2252 let staff = staff.trim();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2253 if staff.is_empty() {
2254 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is minting it."));
2255 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2256 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 look2257 Some(email) => match normalize_email(email) {
2258 Some(email) => Some(email),
2259 None => return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL)),
2260 },
2261 None => None,
2262 };
2263 let draft = Draft {
2264 email: email.as_deref(),
2265 kind: "account",
2266 workspace_id: None,
2267 inviter: None,
2268 staff: Some(staff),
2269 charged_to: "none",
2270 charged_workspace_id: None,
2271 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)2272 invitee_id: None,
2273 role: None,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2274 };
2275 let Some(mut invite) = self.insert_invite(draft).await? else {
2276 return Ok(Outcome::fail(FailureCode::Conflict, "The invite could not be made. Try again."));
2277 };
2278 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 confirm2279 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 look2280 }
2281 invite.staff = Some(staff.to_owned());
2282 Ok(Outcome::Ok(invite))
2283 }
2284
2285 pub async fn admin_grant_invites(&self, a: AdminGrantInvitesArgs) -> Result<Outcome<Allowance>> {
2286 if a.amount == 0 || a.amount.abs() > MAX_INVITE_GRANT {
2287 return Ok(Outcome::fail(FailureCode::Invalid, format!("Grant between 1 and {MAX_INVITE_GRANT} invites, or take some back with a negative number.")));
2288 }
2289 let staff = a.staff.trim();
2290 if staff.is_empty() {
2291 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member granted them."));
2292 }
2293 let name = a.name.trim().to_lowercase();
2294 let target_id = match a.target {
2295 GrantTarget::User => self
2296 .db
2297 .prepare("SELECT id FROM users WHERE username = ?")
2298 .bind(&[name.as_str().into()])?
2299 .first::<Id>(None)
2300 .await?
2301 .map(|row| row.id),
2302 GrantTarget::Workspace => self.workspace_id(&name).await?,
2303 };
2304 let Some(target_id) = target_id else {
2305 return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no {} named {name}.", a.target.as_str())));
2306 };
2307 let note = a.note.trim();
2308 self.db
2309 .prepare(
2310 "INSERT INTO invite_grants (id, target_kind, target_id, amount, note, granted_by, created_at)
2311 VALUES (?, ?, ?, ?, ?, ?, ?)",
2312 )
2313 .bind(&[
2314 new_id("igr", now_ms()).into(),
2315 a.target.as_str().into(),
2316 target_id.as_str().into(),
2317 f64::from(a.amount).into(),
2318 if note.is_empty() { JsValue::NULL } else { note.into() },
2319 staff.into(),
2320 rfc3339(now_ms()).into(),
2321 ])?
2322 .run()
2323 .await?;
2324 Ok(Outcome::Ok(match a.target {
2325 GrantTarget::User => self.user_allowance(&target_id).await?,
2326 GrantTarget::Workspace => self.workspace_allowance(&target_id).await?,
2327 }))
2328 }
2329
2330 async fn grants(&self, target: GrantTarget, id: &str) -> Result<Vec<InviteGrant>> {
2331 #[derive(Deserialize)]
2332 struct Row {
2333 amount: f64,
2334 note: Option<String>,
2335 granted_by: String,
2336 created_at: String,
2337 }
2338 Ok(self
2339 .db
2340 .prepare(
2341 "SELECT amount, note, granted_by, created_at FROM invite_grants
2342 WHERE target_kind = ? AND target_id = ? ORDER BY created_at DESC LIMIT 100",
2343 )
2344 .bind(&[target.as_str().into(), id.into()])?
2345 .all()
2346 .await?
2347 .results::<Row>()?
2348 .into_iter()
2349 .map(|row| InviteGrant {
2350 amount: row.amount as i32,
2351 note: row.note,
2352 granted_by: row.granted_by,
2353 created_at: row.created_at,
2354 })
2355 .collect())
2356 }
2357
2358 /// Whom `user_id` invited, `depth` levels down.
2359 async fn invited_by_user(&self, user_id: &str, depth: usize) -> Result<Vec<InviteTreeNode>> {
2360 #[derive(Deserialize)]
2361 struct Row {
2362 id: String,
2363 username: String,
2364 redeemed_at: String,
2365 }
2366 let rows = self
2367 .db
2368 .prepare(
2369 "SELECT u.id, u.username, i.redeemed_at FROM invites i JOIN users u ON u.id = i.redeemed_by
2370 WHERE i.inviter_id = ? AND i.kind = 'account' ORDER BY i.redeemed_at LIMIT 200",
2371 )
2372 .bind(&[user_id.into()])?
2373 .all()
2374 .await?
2375 .results::<Row>()?;
2376 let mut nodes = Vec::with_capacity(rows.len());
2377 for row in rows {
2378 let invited = if depth > 1 { Box::pin(self.invited_by_user(&row.id, depth - 1)).await? } else { Vec::new() };
2379 nodes.push(InviteTreeNode {
2380 username: row.username,
2381 joined_at: row.redeemed_at,
2382 invited,
2383 });
2384 }
2385 Ok(nodes)
2386 }
2387
2388 pub async fn admin_invite_tree(&self, a: UsernameArgs) -> Result<Option<InviteTree>> {
2389 let name = a.username.trim().to_lowercase();
2390 let Some(user) = self
2391 .db
2392 .prepare("SELECT id FROM users WHERE username = ?")
2393 .bind(&[name.as_str().into()])?
2394 .first::<Id>(None)
2395 .await?
2396 else {
2397 return Ok(None);
2398 };
2399 // Up the tree: who invited them, and who invited that person.
2400 #[derive(Deserialize)]
2401 struct Parent {
2402 inviter_id: Option<String>,
2403 inviter: Option<String>,
2404 staff: Option<String>,
2405 }
2406 let mut invited_by = Vec::new();
2407 let mut staff = None;
2408 let mut current = user.id.clone();
2409 for _ in 0..20 {
2410 let parent = self
2411 .db
2412 .prepare(
2413 "SELECT i.inviter_id, u.username AS inviter, i.staff FROM invites i
2414 LEFT JOIN users u ON u.id = i.inviter_id
2415 WHERE i.redeemed_by = ? AND i.kind = 'account' LIMIT 1",
2416 )
2417 .bind(&[current.as_str().into()])?
2418 .first::<Parent>(None)
2419 .await?;
2420 let Some(parent) = parent else { break };
2421 if invited_by.is_empty() {
2422 staff = parent.staff.clone();
2423 }
2424 match (parent.inviter_id, parent.inviter) {
2425 (Some(id), Some(username)) if !invited_by.contains(&username) => {
2426 invited_by.push(username);
2427 current = id;
2428 }
2429 _ => break,
2430 }
2431 }
2432 let invites = self
2433 .rows("WHERE i.inviter_id = ?", &[user.id.as_str().into()], LIST_LIMIT)
2434 .await?
2435 .into_iter()
2436 .map(|row| self.shown(row, false, true))
2437 .collect();
2438 Ok(Some(InviteTree {
2439 username: name,
2440 invited_by,
2441 staff,
2442 allowance: self.user_allowance(&user.id).await?,
2443 grants: self.grants(GrantTarget::User, &user.id).await?,
2444 invites,
2445 invited: self.invited_by_user(&user.id, TREE_DEPTH).await?,
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)2446 shared: self.shared_source(&user.id).await?,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2447 }))
2448 }
2449
2450 pub async fn admin_workspace_invites(&self, a: SlugArgs) -> Result<Option<InviteTree>> {
2451 let slug = a.slug.trim().to_lowercase();
2452 let Some(id) = self.workspace_id(&slug).await? else {
2453 return Ok(None);
2454 };
2455 let invites = self
2456 .rows("WHERE i.workspace_id = ?1 OR i.charged_workspace_id = ?1", &[id.as_str().into()], LIST_LIMIT)
2457 .await?
2458 .into_iter()
2459 .map(|row| self.shown(row, false, true))
2460 .collect();
2461 Ok(Some(InviteTree {
2462 username: slug,
2463 invited_by: Vec::new(),
2464 staff: None,
2465 allowance: self.workspace_allowance(&id).await?,
2466 grants: self.grants(GrantTarget::Workspace, &id).await?,
2467 invites,
2468 invited: Vec::new(),
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)2469 shared: None,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2470 }))
2471 }
2472
2473 // --- Audit ---
2474
2475 async fn audit_invites(&self, actor: &User, action: &str, workspaces: Vec<String>, surface: Surface, message: String) {
2476 let Ok(events) = self.env.service("EVENTS") else {
2477 return;
2478 };
2479 let entries: Vec<NewAuditEntry> = workspaces
2480 .into_iter()
2481 .map(|workspace| NewAuditEntry {
2482 actor: AuditActor::of(actor),
2483 action: action.to_owned(),
2484 surface,
2485 target: AuditTarget {
2486 workspace,
2487 ..AuditTarget::default()
2488 },
2489 outcome: AuditOutcome::Allowed,
2490 rule: "invite".to_owned(),
2491 result: Some("ok".to_owned()),
2492 message: Some(message.clone()),
2493 request_id: new_id("req", now_ms()),
2494 })
2495 .collect();
2496 if entries.is_empty() {
2497 return;
2498 }
2499 let recorded: Result<u32> = g1t_kit::call(&events, "audit_record", &RecordAuditArgs { entries }).await;
2500 if let Err(error) = recorded {
2501 worker::console_error!("{action} not recorded: {error}");
2502 }
2503 }
2504}
2505
2506/// Whether using the invite joins a workspace.
2507fn joins_workspace(row: &InviteRow) -> bool {
2508 row.workspace_id.is_some()
2509}
2510
2511#[cfg(test)]
2512mod tests {
2513 use super::*;
2514
2515 #[test]
2516 fn codes_carry_160_bits_in_eight_groups() {
2517 assert_eq!(encode(&[0u8; 20]), "0".repeat(32));
2518 assert_eq!(encode(&[0xff; 20]), "z".repeat(32));
2519 let body = new_code_body();
2520 assert_eq!(body.len(), CODE_LENGTH);
2521 assert!(body.bytes().all(|b| ALPHABET.contains(&b)));
2522 let code = format_code(&body);
2523 assert!(code.starts_with("g1t-"));
2524 assert_eq!(code.split('-').count(), 9);
2525 assert_eq!(code.len(), 4 + 32 + 7);
2526 // Every bit is used: one bit set shows in exactly one character.
2527 let mut bytes = [0u8; 20];
2528 bytes[19] = 1;
2529 assert_eq!(encode(&bytes), format!("{}1", "0".repeat(31)));
2530 }
2531
2532 #[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.2533 fn only_a_pending_invitation_is_sent_again() {
2534 assert!(resendable(InviteStatus::Pending));
2535 for over in [
2536 InviteStatus::AwaitingConfirmation,
2537 InviteStatus::AwaitingAnswer,
2538 InviteStatus::Redeemed,
2539 InviteStatus::Declined,
2540 InviteStatus::Expired,
2541 InviteStatus::Revoked,
2542 ] {
2543 assert!(!resendable(over), "{over:?}");
2544 }
2545 }
2546
2547 #[test]
2548 fn an_inviters_note_is_trimmed_cut_and_dropped_when_blank() {
2549 assert_eq!(invite_note(None), None);
2550 assert_eq!(invite_note(Some(" ")), None);
2551 assert_eq!(invite_note(Some(" Welcome aboard ")).as_deref(), Some("Welcome aboard"));
2552 let long = "x".repeat(MAX_INVITE_MESSAGE + 40);
2553 assert_eq!(invite_note(Some(&long)).map(|note| note.chars().count()), Some(MAX_INVITE_MESSAGE));
2554 }
2555
2556 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2557 fn codes_are_not_repeated() {
2558 let codes: std::collections::HashSet<String> = (0..2000).map(|_| new_code_body()).collect();
2559 assert_eq!(codes.len(), 2000);
2560 }
2561
2562 #[test]
2563 fn a_code_reads_however_it_is_typed_or_pasted() {
2564 let body = "k7m2q9xd4hpwabcd0123456789efghjk";
2565 let shown = format_code(body);
2566 for typed in [
2567 shown.clone(),
2568 shown.to_uppercase(),
2569 body.to_owned(),
2570 format!(" {} ", shown.replace('-', " ")),
2571 format!("https://g1t.sh/invite/{shown}"),
2572 format!("https://g1t.sh/register?invite={shown}&next=/"),
2573 ] {
2574 assert_eq!(normalize_code(&typed).as_deref(), Some(body), "{typed}");
2575 }
2576 // Letters people misread are read as Crockford reads them.
2577 assert_eq!(normalize_code(&"o".repeat(32)), Some("0".repeat(32)));
2578 assert_eq!(normalize_code(&"il".repeat(16)), Some("1".repeat(32)));
2579 assert_eq!(normalize_code("g1t-k7m2"), None);
2580 assert_eq!(normalize_code(&format!("{body}0")), None);
2581 assert_eq!(normalize_code(&"u".repeat(32)), None);
2582 assert_eq!(normalize_code(""), None);
2583 }
2584
2585 #[test]
2586 fn only_the_hash_and_a_short_hint_are_kept() {
2587 let body = "k7m2q9xd4hpwabcd0123456789efghjk";
2588 assert_eq!(code_hash(body), crypto::sha256_hex(body));
2589 assert_eq!(code_hash(body).len(), 64);
2590 assert_ne!(code_hash(body), code_hash(&body.replace('k', "m")));
2591 assert_eq!(code_hint(body), "g1t-k7m2");
2592 // The same code typed differently finds the same row.
2593 let typed = normalize_code(&format_code(body).to_uppercase()).unwrap();
2594 assert_eq!(code_hash(&typed), code_hash(body));
2595 }
2596
2597 const NOW: &str = "2026-10-05T12:00:00.000Z";
2598 const LATER: &str = "2026-11-04T12:00:00.000Z";
2599 const EARLIER: &str = "2026-10-01T12:00:00.000Z";
2600
2601 #[test]
2602 fn an_invite_is_pending_until_used_revoked_or_expired() {
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)2603 assert_eq!(status_of(None, None, None, LATER, NOW), InviteStatus::Pending);
2604 assert_eq!(status_of(None, None, None, EARLIER, NOW), InviteStatus::Expired);
2605 assert_eq!(status_of(None, None, None, NOW, NOW), InviteStatus::Expired);
2606 assert_eq!(status_of(Some(EARLIER), None, None, LATER, NOW), InviteStatus::Revoked);
2607 assert_eq!(status_of(None, Some(EARLIER), Some(EARLIER), EARLIER, NOW), InviteStatus::Redeemed);
2608 }
2609
2610 #[test]
2611 fn an_invite_used_to_sign_up_awaits_the_account_confirming_its_address() {
2612 // Spent, not applied: waiting, even past its expiry.
2613 assert_eq!(status_of(None, Some(EARLIER), None, LATER, NOW), InviteStatus::AwaitingConfirmation);
2614 assert_eq!(status_of(None, Some(EARLIER), None, EARLIER, NOW), InviteStatus::AwaitingConfirmation);
2615 // Revoked while waiting: revoked, whatever happens when it settles.
2616 assert_eq!(status_of(Some(NOW), Some(EARLIER), None, LATER, NOW), InviteStatus::Revoked);
2617 assert_eq!(status_of(Some(NOW), Some(EARLIER), Some(NOW), LATER, NOW), InviteStatus::Revoked);
2618 // A spent invite still counts against the allowance while it waits,
2619 // and cannot be used again.
2620 assert!(counts_against_allowance(InviteStatus::AwaitingConfirmation));
2621 let waiting = invite("account", None, InviteStatus::AwaitingConfirmation);
2622 assert_eq!(admits(Some(&waiting), "anyone@example.com", true), Err(Refusal::Invalid));
2623 }
2624
2625 fn row(workspace: Option<(&str, Option<&str>)>, revoked: bool, expires_at: &str) -> InviteRow {
2626 InviteRow {
2627 id: "inv_1".into(),
2628 hint: "g1t-k7m2".into(),
2629 sealed_code: None,
2630 email: Some("ada@example.com".into()),
2631 kind: "account".into(),
2632 workspace_id: workspace.map(|(id, _)| id.to_owned()),
2633 workspace: workspace.and_then(|(_, slug)| slug.map(str::to_owned)),
2634 inviter_id: Some("usr_owner".into()),
2635 inviter: Some("bo".into()),
2636 staff: None,
2637 charged_to: "user".into(),
2638 created_at: EARLIER.into(),
2639 expires_at: expires_at.into(),
2640 revoked_at: revoked.then(|| NOW.to_owned()),
2641 redeemer: Some("ada".into()),
2642 redeemed_at: Some(EARLIER.into()),
2643 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)2644 invitee_id: Some("usr_ada".into()),
2645 invitee: Some("ada".into()),
2646 role: None,
2647 accepted_at: None,
2648 declined_at: None,
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)2649 }
2650 }
2651
2652 #[test]
2653 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)2654 // Confirming no longer joins anything by itself: the workspace the
2655 // invite named becomes an invitation the person accepts or declines
2656 // (invites/invitations.rs), and accepting joins it.
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)2657 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)2658 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)2659 // 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)2660 assert_eq!(awaiting_join(&row(None, false, LATER), NOW), AwaitingJoin::Nothing);
2661 // Confirmed and not yet answered: awaiting the answer.
2662 let confirmed = InviteRow { applied_at: Some(NOW.into()), ..good };
2663 assert_eq!(confirmed.status(NOW), InviteStatus::AwaitingAnswer);
2664 // Accepted: used.
2665 let accepted = InviteRow { accepted_at: Some(NOW.into()), ..confirmed };
2666 assert_eq!(accepted.status(NOW), InviteStatus::Redeemed);
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)2667 }
2668
2669 #[test]
2670 fn a_revoked_or_expired_invite_or_a_deleted_workspace_lapses_and_the_address_is_confirmed_anyway() {
2671 let lapsed = |join: AwaitingJoin| match join {
2672 AwaitingJoin::Lapsed(why) => why,
2673 other => panic!("expected a lapse, got {other:?}"),
2674 };
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 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)2676 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)2677 assert!(revoked.contains("was revoked") && revoked.contains("no longer invites you to acme"));
2678 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)2679 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)2680 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)2681 // 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)2682 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)2683 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)2684 // A free workspace still invites; accepting waits for its plan.
2685 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)2686 // 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)2687 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 look2688 }
2689
2690 #[test]
2691 fn revoked_and_expired_invites_give_the_allowance_back() {
2692 assert!(counts_against_allowance(InviteStatus::Pending));
2693 assert!(counts_against_allowance(InviteStatus::Redeemed));
2694 assert!(!counts_against_allowance(InviteStatus::Revoked));
2695 assert!(!counts_against_allowance(InviteStatus::Expired));
2696 // The SQL says the same: used, or neither revoked nor expired.
2697 let sql = counted_sql();
2698 assert!(sql.contains("i.redeemed_at IS NOT NULL OR (i.revoked_at IS NULL AND i.expires_at >"));
2699 }
2700
2701 #[test]
2702 fn allowances_are_five_plus_grants_or_unlimited_for_staff() {
2703 assert_eq!(limit_for(INVITES_PER_USER, 0, false), Some(5));
2704 assert_eq!(limit_for(5, 10, false), Some(15));
2705 assert_eq!(limit_for(5, -3, false), Some(2));
2706 assert_eq!(limit_for(5, -30, false), Some(0));
2707 assert_eq!(limit_for(5, 0, true), None);
2708 // A workspace has only what staff granted it.
2709 assert_eq!(limit_for(0, 0, false), Some(0));
2710 assert_eq!(limit_for(0, 25, false), Some(25));
2711 let full = Allowance::new(Some(5), 5);
2712 assert!(full.exhausted());
2713 assert_eq!(full.remaining, Some(0));
2714 let over = Allowance::new(Some(2), 4);
2715 assert_eq!(over.remaining, Some(0));
2716 let open = Allowance::new(None, 400);
2717 assert!(!open.exhausted());
2718 assert_eq!(open.remaining, None);
2719 assert_eq!(Allowance::new(Some(5), 3).remaining, Some(2));
2720 }
2721
2722 fn invite(kind: &'static str, email: Option<&'static str>, status: InviteStatus) -> Admits<'static> {
2723 Admits { kind, email, status }
2724 }
2725
2726 #[test]
2727 fn an_invite_admits_only_its_address_while_pending() {
2728 let open = invite("account", None, InviteStatus::Pending);
2729 assert_eq!(admits(Some(&open), "anyone@example.com", true), Ok(()));
2730 let bound = invite("account", Some("ada@example.com"), InviteStatus::Pending);
2731 assert_eq!(admits(Some(&bound), "ada@example.com", true), Ok(()));
2732 assert_eq!(admits(Some(&bound), " ADA@Example.com ", true), Ok(()));
2733 assert_eq!(admits(Some(&bound), "eve@example.com", true), Err(Refusal::WrongEmail));
2734 for status in [InviteStatus::Redeemed, InviteStatus::Revoked, InviteStatus::Expired] {
2735 assert_eq!(admits(Some(&invite("account", None, status)), "a@example.com", true), Err(Refusal::Invalid));
2736 // A dead code says nothing about whom it was for.
2737 assert_eq!(
2738 admits(Some(&invite("account", Some("ada@example.com"), status)), "eve@example.com", true),
2739 Err(Refusal::Invalid)
2740 );
2741 }
2742 assert_eq!(admits(None, "a@example.com", true), Err(Refusal::Invalid));
2743 }
2744
2745 #[test]
2746 fn a_workspace_invite_never_makes_an_account() {
2747 let join = invite("workspace", Some("ada@example.com"), InviteStatus::Pending);
2748 assert_eq!(admits(Some(&join), "ada@example.com", true), Err(Refusal::Invalid));
2749 assert_eq!(admits(Some(&join), "ada@example.com", false), Ok(()));
2750 assert_eq!(admits(Some(&join), "eve@example.com", false), Err(Refusal::WrongEmail));
2751 // An account invite for a workspace can be accepted by the address
2752 // once it has an account.
2753 let account = invite("account", Some("ada@example.com"), InviteStatus::Pending);
2754 assert_eq!(admits(Some(&account), "ada@example.com", false), Ok(()));
2755 }
2756
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 confirm2757 const KEY: &[u8] = b"identity key";
2758
2759 #[test]
2760 fn an_invite_emails_proof_is_for_its_invite_and_address_only() {
2761 let proof = email_proof(KEY, "inv_1", Some("ada@example.com")).unwrap();
2762 assert_eq!(proof.len(), 64);
2763 let proves = |id: &str, bound: Option<&str>, email: &str, proof: Option<&str>| proves_email(KEY, id, bound, email, proof);
2764 // The right invite and address, however the address is written.
2765 assert!(proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof)));
2766 assert!(proves("inv_1", Some("Ada@Example.com"), " ADA@example.com ", Some(&proof)));
2767 assert!(proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof.to_uppercase())));
2768 // Another address: the account confirms that one itself.
2769 assert!(!proves("inv_1", Some("ada@example.com"), "eve@example.com", Some(&proof)));
2770 // Another invite's proof, even for the same address.
2771 assert!(!proves("inv_2", Some("ada@example.com"), "ada@example.com", Some(&proof)));
2772 // Tampered, cut short, empty or missing.
2773 let mut tampered = proof.clone().into_bytes();
2774 tampered[10] = if tampered[10] == b'0' { b'1' } else { b'0' };
2775 let tampered = String::from_utf8(tampered).unwrap();
2776 assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&tampered)));
2777 assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof[..32])));
2778 assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some("")));
2779 assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", None));
2780 // An invite bound to no address has no proof to give.
2781 assert_eq!(email_proof(KEY, "inv_1", None), None);
2782 assert!(!proves("inv_1", None, "ada@example.com", Some(&proof)));
2783 // Made under another key: not ours.
2784 let foreign = email_proof(b"another key", "inv_1", Some("ada@example.com")).unwrap();
2785 assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&foreign)));
2786 // Without a key (development) none is made, and none is taken.
2787 assert_eq!(email_proof(b"", "inv_1", Some("ada@example.com")), None);
2788 let unkeyed = crypto::invite_proof(b"", "inv_1", "ada@example.com");
2789 assert!(!proves_email(b"", "inv_1", Some("ada@example.com"), "ada@example.com", Some(&unkeyed)));
2790 }
2791
2792 #[test]
2793 fn an_account_starts_confirmed_only_from_the_invite_email_to_its_address() {
2794 let invite = row(None, false, LATER);
2795 let proof = email_proof(KEY, &invite.id, invite.email.as_deref()).unwrap();
2796 // From the invite email, with the address it was sent to.
2797 assert!(starts_confirmed(KEY, false, Some(&invite), "ada@example.com", Some(&proof)));
2798 // The code alone (typed in, or a link passed on), or a bad proof.
2799 assert!(!starts_confirmed(KEY, false, Some(&invite), "ada@example.com", None));
2800 assert!(!starts_confirmed(KEY, false, Some(&invite), "ada@example.com", Some("0123")));
2801 // A different address than the invite's.
2802 assert!(!starts_confirmed(KEY, false, Some(&invite), "eve@example.com", Some(&proof)));
2803 // No invite (open registration, or a shared link), or one bound to no address.
2804 assert!(!starts_confirmed(KEY, false, None, "ada@example.com", Some(&proof)));
2805 let unbound = InviteRow { email: None, ..row(None, false, LATER) };
2806 assert!(!starts_confirmed(KEY, false, Some(&unbound), "ada@example.com", Some(&proof)));
2807 // A workspace invite makes no account.
2808 let join = InviteRow { kind: "workspace".into(), ..row(None, false, LATER) };
2809 assert!(!starts_confirmed(KEY, false, Some(&join), "ada@example.com", Some(&proof)));
2810 // GitHub's confirmed address, whatever else.
2811 assert!(starts_confirmed(KEY, true, None, "ada@example.com", None));
2812 }
2813
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2814 #[test]
2815 fn addresses_are_checked_and_masked() {
2816 assert_eq!(normalize_email(" Ada@Example.COM ").as_deref(), Some("ada@example.com"));
2817 for bad in ["", "ada", "ada@", "@example.com", "ada@example", "a b@example.com", "ada@.com", "ada@example.", "a@b@c.com"] {
2818 assert_eq!(normalize_email(bad), None, "{bad}");
2819 }
2820 assert_eq!(mask_email("ada@example.com"), "a•••@example.com");
2821 assert_eq!(mask_email("x@example.com"), "x•••@example.com");
2822 }
2823
2824 #[test]
2825 fn rate_limits_count_in_hour_long_windows() {
2826 assert_eq!(bucket(0, HOUR_MS), 0);
2827 assert_eq!(bucket(HOUR_MS - 1, HOUR_MS), 0);
2828 assert_eq!(bucket(HOUR_MS, HOUR_MS), 1);
2829 // The limits stop guessing long before a code could be found, and
2830 // leave room for people who mistype.
2831 assert!((5..=100).contains(&FAILURES_PER_HOUR));
2832 const { assert!(CREATES_PER_HOUR >= INVITES_PER_USER) };
2833 const { assert!(REQUESTS_PER_HOUR >= 1) };
2834 }
2835
2836 #[test]
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2837 fn staff_hear_about_requests_at_most_every_15_minutes() {
2838 assert_eq!(SUMMARY_EVERY_MS, 15 * 60 * 1000);
2839 let since = "2026-10-05T11:45:00.000Z";
2840 assert!(summary_due(None, since));
2841 assert!(summary_due(Some("2026-10-05T11:30:00.000Z"), since));
2842 assert!(summary_due(Some(since), since));
2843 assert!(!summary_due(Some("2026-10-05T11:50:00.000Z"), since));
2844 const { assert!(CONFIRMATIONS_PER_HOUR >= ANONYMOUS_REQUESTS_PER_HOUR) };
2845 }
2846
2847 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2848 fn registration_is_invite_only_unless_opened() {
2849 assert_eq!(RegistrationMode::parse(None), RegistrationMode::Invite);
2850 assert_eq!(RegistrationMode::parse(Some("invite")), RegistrationMode::Invite);
2851 assert_eq!(RegistrationMode::parse(Some("")), RegistrationMode::Invite);
2852 assert_eq!(RegistrationMode::parse(Some("opne")), RegistrationMode::Invite);
2853 assert_eq!(RegistrationMode::parse(Some(" Open ")), RegistrationMode::Open);
2854 }
2855}

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