Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
1,909 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.

Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.1//! Invite codes, and the one place every new account is made.
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request2//!
3//! [`Identity::create_account`] is the only way an account comes to exist.
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.4//! Anyone may register; an invite code is optional. An invitation into a
5//! workspace or onto a repository, sent to an address without an account,
6//! carries one: an account made with it gets that invitation once its
7//! address is confirmed. A code that is unknown, spent, revoked, expired or
8//! for another address is ignored and the account is made without it. The
9//! code is spent in the same transaction that makes the account, so two
10//! people racing with one code cannot both use it.
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request11//!
12//! A code is 160 random bits in Crockford base32, shown as `g1t-` and eight
13//! groups of four. Only its SHA-256 is kept to find it, with a copy sealed
14//! under IDENTITY_KEY so whoever made it can copy the link again while it
15//! is pending.
16//!
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.17//! Sign-ups with a password are rate limited per client address
18//! ([`REGISTRATIONS_PER_HOUR`]); new accounts confirm their address before
19//! they can invite anyone.
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request20//!
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.21//! Vars: INVITE_TTL_DAYS.
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request22
23use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.24use g1t_contracts::events::{InviteCreated, InviteRedeemed, JoinedHow};
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request25use g1t_contracts::identity::*;
26use g1t_contracts::time::{SQL_NOW, rfc3339};
27use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, new_id};
28use g1t_kit::now_ms;
29use g1t_secrets::Sealer;
30use serde::Deserialize;
31use worker::Result;
32use worker::wasm_bindgen::JsValue;
33
34use crate::{Identity, crypto};
35
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)36mod invitations;
37
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request38/// Crockford base32, as ids use: no i, l, o or u.
39const ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
40/// 32 characters of 5 bits: 160 random bits.
41const CODE_LENGTH: usize = 32;
42const GROUP: usize = 4;
43
44pub const INVALID: &str =
45 "That invite code is not valid. It may have been used, revoked or expired; ask whoever invited you for a new one.";
46pub const WRONG_EMAIL: &str = "This invite is for a different email address. Use the address it was sent to.";
47const TOO_MANY: &str = "Too many attempts. Try again in an hour.";
48const PEOPLE_ONLY: &str = "Only a person can make invites, not an agent or a workspace's token.";
49const CONFIRM_FIRST: &str = "Confirm your email address before inviting anyone.";
50
51const HOUR_MS: u64 = 60 * 60 * 1000;
52/// Invites one person may make in an hour, whatever their allowance.
53const CREATES_PER_HOUR: u32 = 20;
54/// Wrong codes one client may try in an hour before being turned away.
55const FAILURES_PER_HOUR: u32 = 20;
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.56/// Accounts one client address may make with a password in an hour.
57pub const REGISTRATIONS_PER_HOUR: u32 = 5;
58/// Accounts made with a password by clients that sent no address,
59/// together, in an hour.
60const ANONYMOUS_REGISTRATIONS_PER_HOUR: u32 = 100;
61/// The most invites a workspace's list shows.
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request62const LIST_LIMIT: u32 = 200;
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.63const TOO_MANY_SIGN_UPS: &str = "Too many accounts were made from your network in the last hour. Try again later.";
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request64
65// --- Codes ------------------------------------------------------------------
66
67/// The 32 characters of a code from 20 random bytes.
68fn encode(bytes: &[u8; 20]) -> String {
69 let mut out = String::with_capacity(CODE_LENGTH);
70 let (mut buffer, mut bits) = (0u32, 0u32);
71 for &byte in bytes {
72 buffer = (buffer << 8) | u32::from(byte);
73 bits += 8;
74 while bits >= 5 {
75 bits -= 5;
76 out.push(ALPHABET[((buffer >> bits) & 31) as usize] as char);
77 }
78 buffer &= (1 << bits) - 1;
79 }
80 out
81}
82
83/// A new code's 32 characters.
84pub fn new_code_body() -> String {
85 let mut bytes = [0u8; 20];
86 getrandom::getrandom(&mut bytes).expect("no source of randomness");
87 encode(&bytes)
88}
89
90/// How a code is shown: `g1t-` and groups of four.
91pub fn format_code(body: &str) -> String {
92 let groups: Vec<&str> = body
93 .as_bytes()
94 .chunks(GROUP)
95 .map(|chunk| std::str::from_utf8(chunk).unwrap_or_default())
96 .collect();
97 format!("g1t-{}", groups.join("-"))
98}
99
100/// A code's 32 characters from however it was typed or pasted: any case,
101/// with or without `g1t-`, hyphens or spaces, or a whole invite link.
102/// Letters easily misread are read as Crockford reads them.
103pub fn normalize_code(input: &str) -> Option<String> {
104 let mut text = input.trim().to_ascii_lowercase();
105 // A pasted link: the last path segment, or the `invite` parameter.
106 if let Some(at) = text.find("invite=") {
107 text = text[at + "invite=".len()..].split('&').next().unwrap_or_default().to_owned();
108 } else if let Some(at) = text.rfind('/') {
109 text = text[at + 1..].to_owned();
110 }
111 let text = text.strip_prefix("g1t").unwrap_or(&text);
112 let mut body = String::with_capacity(CODE_LENGTH);
113 for c in text.chars() {
114 let c = match c {
115 '-' | ' ' | '_' => continue,
116 'i' | 'l' => '1',
117 'o' => '0',
118 c if ALPHABET.contains(&(c as u8)) && c.is_ascii() => c,
119 _ => return None,
120 };
121 body.push(c);
122 }
123 (body.len() == CODE_LENGTH).then_some(body)
124}
125
126/// What is stored to find a code.
127pub fn code_hash(body: &str) -> String {
128 crypto::sha256_hex(body)
129}
130
131/// The code's first group, kept to recognise it: 20 of its 160 bits.
132pub fn code_hint(body: &str) -> String {
133 format!("g1t-{}", &body[..GROUP])
134}
135
136// --- Rules --------------------------------------------------------------------
137
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)138/// Where an invite stands at `now`, from its row. A used invite whose
139/// account has not confirmed its address yet is awaiting confirmation
140/// (`applied_at` is null); revoking it then stops it joining anything.
141pub fn status_of(
142 revoked_at: Option<&str>,
143 redeemed_at: Option<&str>,
144 applied_at: Option<&str>,
145 expires_at: &str,
146 now: &str,
147) -> InviteStatus {
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request148 if redeemed_at.is_some() {
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)149 if revoked_at.is_some() {
150 InviteStatus::Revoked
151 } else if applied_at.is_some() {
152 InviteStatus::Redeemed
153 } else {
154 InviteStatus::AwaitingConfirmation
155 }
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request156 } else if revoked_at.is_some() {
157 InviteStatus::Revoked
158 } else if expires_at <= now {
159 InviteStatus::Expired
160 } else {
161 InviteStatus::Pending
162 }
163}
164
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.165/// Whether an invitation can be sent again: only while it waits to be
166/// used. One whose account is confirming its address or answering already
167/// has what it needs; the rest are over.
168pub fn resendable(status: InviteStatus) -> bool {
169 status == InviteStatus::Pending
170}
171
172/// The note an inviter wrote, as the email quotes it: trimmed and cut to
173/// [`MAX_INVITE_MESSAGE`] characters; none when blank.
174pub fn invite_note(message: Option<&str>) -> Option<String> {
175 let note: String = message?.trim().chars().take(MAX_INVITE_MESSAGE).collect();
176 (!note.is_empty()).then_some(note)
177}
178
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)179/// Where an invite stands once the workspace invitation in it is counted:
180/// `base` from [`status_of`]. A declined one is declined; an account
181/// invite whose account is confirmed but has not answered the workspace it
182/// names (`names_workspace`: one that still exists) awaits that answer
183/// until it expires.
184pub fn answered_status(
185 base: InviteStatus,
186 kind: &str,
187 names_workspace: bool,
188 accepted_at: Option<&str>,
189 declined_at: Option<&str>,
190 expires_at: &str,
191 now: &str,
192) -> InviteStatus {
193 if declined_at.is_some() && base != InviteStatus::Revoked {
194 return InviteStatus::Declined;
195 }
196 if base == InviteStatus::Redeemed && kind == "account" && names_workspace && accepted_at.is_none() {
197 return if expires_at <= now { InviteStatus::Expired } else { InviteStatus::AwaitingAnswer };
198 }
199 base
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request200}
201
202/// Why an invite cannot make an account.
203#[derive(Debug, PartialEq, Eq)]
204pub enum Refusal {
205 /// Unknown, used, revoked, expired, or not for making accounts. One
206 /// answer for all, so codes cannot be probed.
207 Invalid,
208 /// It is bound to another address.
209 WrongEmail,
210}
211
212/// The parts of an invite that decide whether it admits someone.
213#[derive(Debug)]
214pub struct Admits<'a> {
215 pub kind: &'a str,
216 pub email: Option<&'a str>,
217 pub status: InviteStatus,
218}
219
220/// Whether an invite lets `email` make an account (`for_account`) or join
221/// its workspace with an existing one.
222pub fn admits(invite: Option<&Admits>, email: &str, for_account: bool) -> std::result::Result<(), Refusal> {
223 let Some(invite) = invite else {
224 return Err(Refusal::Invalid);
225 };
226 if invite.status != InviteStatus::Pending || (for_account && invite.kind != "account") {
227 return Err(Refusal::Invalid);
228 }
229 match invite.email {
230 Some(bound) if !bound.eq_ignore_ascii_case(email.trim()) => Err(Refusal::WrongEmail),
231 _ => Ok(()),
232 }
233}
234
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 confirm235/// The proof for an invite's email link, or None when there is none to
236/// make: no key (a development setup), or no address the invite is bound
237/// to. See [`crypto::invite_proof`].
238pub fn email_proof(key: &[u8], invite_id: &str, bound: Option<&str>) -> Option<String> {
239 let bound = bound.map(str::trim).filter(|bound| !bound.is_empty())?;
240 (!key.is_empty()).then(|| crypto::invite_proof(key, invite_id, bound))
241}
242
243/// Whether `proof` shows that whoever brings it followed the invite's own
244/// email: it is the proof for this invite and the address it is bound to,
245/// and `email`, the address the account is made with, is that address.
246/// Anything else (no proof, a wrong or altered one, another invite's, an
247/// invite bound to no address, a different address) proves nothing, and
248/// the address is confirmed as any other is.
249pub fn proves_email(key: &[u8], invite_id: &str, bound: Option<&str>, email: &str, proof: Option<&str>) -> bool {
250 let (Some(expected), Some(proof)) = (email_proof(key, invite_id, bound), proof.map(str::trim)) else {
251 return false;
252 };
253 let same_address = bound.is_some_and(|bound| bound.trim().to_lowercase() == email.trim().to_lowercase());
254 same_address && crypto::same(&expected, &proof.to_ascii_lowercase())
255}
256
257/// Whether a new account starts with its address confirmed: GitHub
258/// confirmed it (`verified`), or `invite`, the one-person invite that
259/// admitted it, was followed from its own email with `proof` and `email` is
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.260/// the address it was sent to. A code typed in or passed on, or an invite
261/// bound to no address: confirmed as any other is.
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 confirm262pub fn starts_confirmed(key: &[u8], verified: bool, invite: Option<&InviteRow>, email: &str, proof: Option<&str>) -> bool {
263 verified
264 || invite.is_some_and(|row| row.kind == "account" && proves_email(key, &row.id, row.email.as_deref(), email, proof))
265}
266
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)267/// What an invite used to sign up does once its account confirms its
268/// address.
269#[derive(Clone, Debug, PartialEq, Eq)]
270pub 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)271 /// It invites the account to this workspace: a workspace invitation
272 /// now waits for its answer. Nothing is joined without one.
273 Invited { workspace_id: String, slug: String },
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)274 /// It names no workspace; repository invitations sent with it are
275 /// accepted.
276 Nothing,
277 /// It no longer applies, and why, as the person is told.
278 Lapsed(String),
279}
280
281/// [`AwaitingJoin`] for an invite's row at `now`. `row.workspace` is the
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)282/// workspace's slug, None once it was deleted. A workspace on the free
283/// plan still invites: accepting waits until it starts the plan (paid.rs).
284pub fn awaiting_join(row: &InviteRow, now: &str) -> AwaitingJoin {
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)285 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)286 Some(slug) => format!("no longer invites you to {slug}"),
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)287 None => "no longer applies".to_owned(),
288 };
289 if row.revoked_at.is_some() {
290 return AwaitingJoin::Lapsed(format!(
291 "Your email address is confirmed. The invite you signed up with was revoked while you were confirming it, so it {what}."
292 ));
293 }
294 if row.expires_at.as_str() <= now {
295 return AwaitingJoin::Lapsed(format!(
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)296 "Your email address is confirmed. The invite you signed up with expired before you confirmed it, so it {what}. Ask whoever invited you to invite you again."
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)297 ));
298 }
299 match (&row.workspace_id, &row.workspace) {
300 (None, _) => AwaitingJoin::Nothing,
301 (Some(_), None) => AwaitingJoin::Lapsed(
302 "Your email address is confirmed. The workspace your invite was for has been deleted, so the invite no longer applies.".to_owned(),
303 ),
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)304 (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)305 }
306}
307
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request308/// A trimmed, lowercased address, if it looks like one.
309pub fn normalize_email(email: &str) -> Option<String> {
310 let email = email.trim().to_lowercase();
311 let well_formed = email.len() <= 254
312 && email
313 .split_once('@')
314 .is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.') && !domain.contains('@'))
315 && !email.contains(char::is_whitespace);
316 well_formed.then_some(email)
317}
318
319/// An address with most of its local part hidden: `a•••@example.com`.
320pub fn mask_email(email: &str) -> String {
321 match email.split_once('@') {
322 Some((local, domain)) => {
323 let first: String = local.chars().take(1).collect();
324 format!("{first}•••@{domain}")
325 }
326 None => "•••".to_owned(),
327 }
328}
329
330/// The fixed window a moment falls in.
331pub fn bucket(now_ms: u64, window_ms: u64) -> u64 {
332 now_ms / window_ms
333}
334
335// --- Rows ---------------------------------------------------------------------
336
337const COLUMNS: &str = "i.id, i.hint, i.sealed_code, i.email, i.kind, i.workspace_id, w.slug AS workspace,
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.338 i.inviter_id, iu.username AS inviter, i.staff, 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)339 i.revoked_at, i.redeemed_by, ru.username AS redeemer, i.redeemed_at, i.applied_at,
340 i.invitee_id, vu.username AS invitee, i.role, i.accepted_at, i.declined_at
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request341 FROM invites i
342 LEFT JOIN workspaces w ON w.id = i.workspace_id AND w.deleted_at IS NULL
343 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)344 LEFT JOIN users ru ON ru.id = i.redeemed_by
345 LEFT JOIN users vu ON vu.id = i.invitee_id";
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request346
347#[derive(Debug, Deserialize)]
348pub struct InviteRow {
349 pub id: String,
350 pub hint: String,
351 pub sealed_code: Option<String>,
352 pub email: Option<String>,
353 pub kind: String,
354 pub workspace_id: Option<String>,
355 pub workspace: Option<String>,
356 pub inviter_id: Option<String>,
357 pub inviter: Option<String>,
358 pub staff: Option<String>,
359 pub created_at: String,
360 pub expires_at: String,
361 pub revoked_at: Option<String>,
362 pub redeemer: Option<String>,
363 pub redeemed_at: Option<String>,
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)364 #[serde(default)]
365 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)366 /// The account a workspace invitation is for (invitations.rs).
367 #[serde(default)]
368 pub invitee_id: Option<String>,
369 #[serde(default)]
370 pub invitee: Option<String>,
371 /// `owner` or `member`; null is member.
372 #[serde(default)]
373 pub role: Option<String>,
374 #[serde(default)]
375 pub accepted_at: Option<String>,
376 #[serde(default)]
377 pub declined_at: Option<String>,
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request378}
379
380impl InviteRow {
381 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)382 answered_status(
383 status_of(
384 self.revoked_at.as_deref(),
385 self.redeemed_at.as_deref(),
386 self.applied_at.as_deref(),
387 &self.expires_at,
388 now,
389 ),
390 &self.kind,
391 self.workspace.is_some(),
392 self.accepted_at.as_deref(),
393 self.declined_at.as_deref(),
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)394 &self.expires_at,
395 now,
396 )
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request397 }
398
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)399 /// The role accepting it joins with.
400 pub fn joins_as(&self) -> Role {
401 if self.role.as_deref() == Some("owner") { Role::Owner } else { Role::Member }
402 }
403
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request404 fn admits(&self, now: &str) -> Admits<'_> {
405 Admits {
406 kind: &self.kind,
407 email: self.email.as_deref(),
408 status: self.status(now),
409 }
410 }
411}
412
413fn kind_of(kind: &str) -> InviteKind {
414 if kind == "workspace" { InviteKind::Workspace } else { InviteKind::Account }
415}
416
417#[derive(Deserialize)]
418struct Count {
419 n: f64,
420}
421
422#[derive(Deserialize)]
423struct Id {
424 id: String,
425}
426
427/// What a new account is made from.
428pub 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 managers429 /// Checked by the caller: valid, free, and lowercased.
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request430 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 managers431 /// The username as the person wrote it, when its case differs (`Ana`
432 /// for `ana`): kept beside it for showing. None shows `username`.
433 pub display_username: Option<&'a str>,
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request434 /// Lowercased and checked by the caller.
435 pub email: &'a str,
436 /// Empty for an account with no password (made through GitHub).
437 pub password_hash: &'a str,
438 /// Whether the address is confirmed already (GitHub's verified email).
439 pub verified: bool,
440 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 confirm441 /// The proof from the invite email's link ([`proves_email`]): when it
442 /// is the invite's and `email` is the address the invite was sent to,
443 /// the account starts with that address confirmed.
444 pub email_proof: Option<&'a str>,
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request445}
446
447/// What an invite was made for.
448struct Draft<'a> {
449 email: Option<&'a str>,
450 kind: &'a str,
451 /// The workspace using it joins.
452 workspace_id: Option<&'a str>,
453 inviter: Option<&'a User>,
454 staff: Option<&'a str>,
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)455 /// The account a workspace invitation is for, when it has one already.
456 invitee_id: Option<&'a str>,
457 /// The role joining `workspace_id` gives: `member` or `owner`.
458 role: Option<&'a str>,
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request459}
460
461impl Identity {
462 // --- Settings ---
463
464 fn var_number(&self, name: &str) -> Option<u64> {
465 self.env.var(name).ok()?.to_string().trim().parse().ok()
466 }
467
468 fn invite_ttl_days(&self) -> u64 {
469 self.var_number("INVITE_TTL_DAYS").filter(|days| (1..=365).contains(days)).unwrap_or(INVITE_TTL_DAYS)
470 }
471
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)472 pub(crate) fn invite_sealer(&self) -> Option<Sealer> {
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request473 Sealer::new(&self.env.secret("IDENTITY_KEY").ok()?.to_string())
474 }
475
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 confirm476 /// The key invite email proofs are made under: IDENTITY_KEY, or none
477 /// in a development setup without one (then no proof is made, and none
478 /// is accepted).
479 fn proof_key(&self) -> Vec<u8> {
480 self.env.secret("IDENTITY_KEY").map(|key| key.to_string().into_bytes()).unwrap_or_default()
481 }
482
483 /// The proof for the link of an invite emailed to `to`, the address it
484 /// is bound to; never shown anywhere but in that email.
485 pub(crate) fn email_proof_for(&self, invite_id: &str, to: &str) -> Option<String> {
486 email_proof(&self.proof_key(), invite_id, Some(to))
487 }
488
489 /// Whether `proof` shows the invite in `row` was followed from its own
490 /// email, by someone making an account with `email`.
491 fn proven(&self, row: &InviteRow, email: &str, proof: Option<&str>) -> bool {
492 starts_confirmed(&self.proof_key(), false, Some(row), email, proof)
493 }
494
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request495 // --- Rate limits ---
496
497 /// Counts one more hit on `key` this hour; false once past `limit`.
498 async fn hit(&self, key: &str, limit: u32) -> Result<bool> {
499 let now = bucket(now_ms(), HOUR_MS);
500 let hits = self
501 .db
502 .prepare(
503 "INSERT INTO rate_limits (key, bucket, hits) VALUES (?1, ?2, 1)
504 ON CONFLICT (key) DO UPDATE SET
505 hits = CASE WHEN rate_limits.bucket = excluded.bucket THEN rate_limits.hits + 1 ELSE 1 END,
506 bucket = excluded.bucket
507 RETURNING hits AS n",
508 )
509 .bind(&[key.into(), (now as f64).into()])?
510 .first::<Count>(None)
511 .await?
512 .map_or(1.0, |count| count.n);
513 if hits <= 1.0 {
514 // A new window: forget windows gone by.
515 self.db
516 .prepare("DELETE FROM rate_limits WHERE bucket < ?")
517 .bind(&[((now.saturating_sub(1)) as f64).into()])?
518 .run()
519 .await?;
520 }
521 Ok(hits <= f64::from(limit))
522 }
523
524 /// Hits on `key` this hour, without adding one.
525 async fn hits(&self, key: &str) -> Result<u32> {
526 Ok(self
527 .db
528 .prepare("SELECT hits AS n FROM rate_limits WHERE key = ? AND bucket = ?")
529 .bind(&[key.into(), (bucket(now_ms(), HOUR_MS) as f64).into()])?
530 .first::<Count>(None)
531 .await?
532 .map_or(0, |count| count.n as u32))
533 }
534
535 /// Whether `client` has tried too many wrong codes this hour.
536 async fn turned_away(&self, client: Option<&str>) -> Result<bool> {
537 Ok(match client {
538 Some(client) => self.hits(&format!("invite.fail:{}", crypto::sha256_hex(client))).await? >= FAILURES_PER_HOUR,
539 None => false,
540 })
541 }
542
543 async fn count_failure(&self, client: Option<&str>) -> Result<()> {
544 if let Some(client) = client {
545 self.hit(&format!("invite.fail:{}", crypto::sha256_hex(client)), FAILURES_PER_HOUR).await?;
546 }
547 Ok(())
548 }
549
550 // --- Reading ---
551
552 async fn invite_by_code(&self, code: &str) -> Result<Option<InviteRow>> {
553 let Some(body) = normalize_code(code) else {
554 return Ok(None);
555 };
556 self.db
557 .prepare(format!("SELECT {COLUMNS} WHERE i.code_hash = ?"))
558 .bind(&[code_hash(&body).into()])?
559 .first::<InviteRow>(None)
560 .await
561 }
562
563 async fn invite_by_id(&self, id: &str) -> Result<Option<InviteRow>> {
564 self.db
565 .prepare(format!("SELECT {COLUMNS} WHERE i.id = ?"))
566 .bind(&[id.into()])?
567 .first::<InviteRow>(None)
568 .await
569 }
570
571 /// An invite as shown, with its code when `reveal` and it is pending.
572 fn shown(&self, row: InviteRow, reveal: bool, staff_view: bool) -> Invite {
573 let now = rfc3339(now_ms());
574 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)575 let role = row.workspace_id.is_some().then(|| row.joins_as());
576 // An invite sent to an address never says which account has it,
577 // until that account uses it.
578 let invitee = row.invitee.clone().filter(|_| row.email.is_none() || row.redeemed_at.is_some());
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request579 let code = if reveal && status == InviteStatus::Pending {
580 row.sealed_code
581 .as_deref()
582 .and_then(|sealed| self.invite_sealer()?.open(sealed, &row.id))
583 } else {
584 None
585 };
586 Invite {
587 id: row.id,
588 code,
589 hint: row.hint,
590 email: row.email,
591 kind: kind_of(&row.kind),
592 workspace: row.workspace,
593 status,
594 invited_by: row.inviter,
595 redeemed_by: row.redeemer,
596 created_at: row.created_at,
597 expires_at: row.expires_at,
598 redeemed_at: row.redeemed_at,
599 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)600 invitee,
601 role,
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request602 staff: if staff_view { row.staff } else { None },
603 }
604 }
605
606 async fn rows(&self, filter: &str, binds: &[JsValue], limit: u32) -> Result<Vec<InviteRow>> {
607 self.db
608 .prepare(format!("SELECT {COLUMNS} {filter} ORDER BY i.created_at DESC, i.id DESC LIMIT {limit}"))
609 .bind(binds)?
610 .all()
611 .await?
612 .results::<InviteRow>()
613 }
614
615 async fn workspace_id(&self, slug: &str) -> Result<Option<String>> {
616 Ok(self
617 .db
618 .prepare("SELECT id FROM workspaces WHERE slug = ? AND deleted_at IS NULL")
619 .bind(&[slug.trim().to_lowercase().into()])?
620 .first::<Id>(None)
621 .await?
622 .map(|row| row.id))
623 }
624
625 /// Whether an address is any account's: confirmed on one, or the
626 /// address a new account signed up with (emails.rs).
627 async fn email_has_account(&self, email: &str) -> Result<bool> {
628 self.email_in_use(email).await
629 }
630
631 // --- The gate ---
632
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.633 /// Counts one sign-up with a password from `client`; false once that
634 /// address (or, without one, every client that sent none) made
635 /// [`REGISTRATIONS_PER_HOUR`] sign-ups this hour. Asked once the form
636 /// is well formed, before anything is looked up or written.
637 pub(crate) async fn sign_up_allowed(&self, client: Option<&str>) -> Result<bool> {
638 match client.map(str::trim).filter(|client| !client.is_empty()) {
639 Some(client) => self.hit(&format!("register:{}", crypto::sha256_hex(client)), REGISTRATIONS_PER_HOUR).await,
640 None => self.hit("register:anonymous", ANONYMOUS_REGISTRATIONS_PER_HOUR).await,
641 }
642 }
643
644 pub(crate) fn too_many_sign_ups() -> Outcome<SignedIn> {
645 Outcome::fail(FailureCode::Limit, TOO_MANY_SIGN_UPS)
646 }
647
648 /// Makes an account: the only place one is made. `invite_code` is
649 /// optional; a good one admitting `email` is spent in the same
650 /// transaction as the account is made, and anything else is ignored.
651 /// What the invite gives (a workspace invitation, repository
652 /// invitations) is applied once the address is confirmed: at once for
653 /// an address GitHub has confirmed or one proven by the invite email's
654 /// link ([`proves_email`]), otherwise in the transaction that confirms
655 /// it (emails.rs, `confirm_address`).
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request656 pub async fn create_account(&self, new: NewAccount<'_>) -> Result<Outcome<User>> {
657 let code = new.invite_code.map(str::trim).filter(|code| !code.is_empty());
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.658 let invite = match code {
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request659 Some(code) => {
660 let now = rfc3339(now_ms());
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.661 self.invite_by_code(code)
662 .await?
663 .filter(|row| admits(Some(&row.admits(&now)), new.email, true).is_ok())
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request664 }
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.665 None => None,
666 };
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request667
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 confirm668 // An address GitHub has confirmed starts confirmed, and so does the
669 // address an invite was emailed to, when the link followed was the
670 // email's own: its proof is in no code the inviter sees or shares.
671 // The code alone proves nothing (it can be passed on), so without
672 // the proof the new account confirms the address like any other.
673 let verified = starts_confirmed(&self.proof_key(), new.verified, invite.as_ref(), new.email, new.email_proof);
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request674 let user = User {
675 id: new_id("usr", now_ms()),
676 username: new.username.to_owned(),
677 verified,
678 ..User::default()
679 };
680 let verified_at = if verified { SQL_NOW } else { "NULL" };
681 let values = [
682 JsValue::from(user.id.as_str()),
683 new.username.into(),
684 new.email.into(),
685 new.password_hash.into(),
686 ];
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.687 let plain = format!(
688 "INSERT INTO users (id, username, email, password_hash, email_verified_at)
689 VALUES (?, ?, ?, ?, {verified_at})"
690 );
691 let made = match &invite {
692 None => self.db.prepare(&plain).bind(&values)?.run().await.map(|_| ()),
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request693 // Spend the code, then make the account only if this request
694 // spent it: one transaction, so a second use finds it gone.
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.695 Some(row) => {
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request696 let mut insert = values.to_vec();
697 insert.extend([JsValue::from(row.id.as_str()), user.id.as_str().into()]);
698 self.db
699 .batch(vec![
700 self.db
701 .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)702 "UPDATE invites SET redeemed_by = ?1, invitee_id = ?1, redeemed_at = {SQL_NOW}, sealed_code = NULL
703 WHERE id = ?2 AND kind = 'account' AND redeemed_at IS NULL AND revoked_at IS NULL
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request704 AND expires_at > {SQL_NOW}"
705 ))
706 .bind(&[user.id.as_str().into(), row.id.as_str().into()])?,
707 self.db
708 .prepare(format!(
709 "INSERT INTO users (id, username, email, password_hash, email_verified_at)
710 SELECT ?, ?, ?, ?, {verified_at}
711 WHERE EXISTS (SELECT 1 FROM invites WHERE id = ? AND redeemed_by = ?)"
712 ))
713 .bind(&insert)?,
714 ])
715 .await
716 .map(|_| ())
717 }
718 };
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.719 let taken = || Ok(Outcome::fail(FailureCode::Conflict, "That username or email is already registered."));
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request720 if let Err(error) = made {
721 // Someone took the username or email a moment ago; nothing
722 // was written, the code included.
723 if error.to_string().contains("UNIQUE") {
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.724 return taken();
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request725 }
726 return Err(error);
727 }
728 let exists = self
729 .db
730 .prepare("SELECT id FROM users WHERE id = ?")
731 .bind(&[user.id.as_str().into()])?
732 .first::<Id>(None)
733 .await?
734 .is_some();
735 if !exists {
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.736 // Another sign-up spent the code first: the account is made
737 // without it, and confirms its address like any other.
738 let user = User { verified: new.verified, ..user.clone() };
739 let verified_at = if user.verified { SQL_NOW } else { "NULL" };
740 let made = self
741 .db
742 .prepare(format!(
743 "INSERT INTO users (id, username, email, password_hash, email_verified_at)
744 VALUES (?, ?, ?, ?, {verified_at})"
745 ))
746 .bind(&values)?
747 .run()
748 .await;
749 if let Err(error) = made {
750 if error.to_string().contains("UNIQUE") {
751 return taken();
752 }
753 return Err(error);
754 }
755 return self.account_made(user, new.display_username, None).await;
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request756 }
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.757 self.account_made(user, new.display_username, invite).await
758 }
759
760 /// What follows a new account being made: the case its name was
761 /// chosen in, a workspace of its own unless its invite brings it into
762 /// one, and, once its address is confirmed, what its invite gives.
763 async fn account_made(&self, user: User, display_username: Option<&str>, invite: Option<InviteRow>) -> Result<Outcome<User>> {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers764 // The case it was chosen in, beside the lowercased name everything finds it by.
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.765 let user = match display_username.filter(|display| display.eq_ignore_ascii_case(&user.username) && *display != user.username) {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers766 Some(display) => {
767 self.db
768 .prepare("UPDATE users SET display_username = ? WHERE id = ?")
769 .bind(&[display.into(), user.id.as_str().into()])?
770 .run()
771 .await?;
772 User { display_username: Some(display.to_owned()), ..user }
773 }
774 None => user,
775 };
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)776 // Nobody is left without a workspace: one of its own, unless its
777 // invite brings it into one (invitations.rs).
778 self.give_own_workspace(&user, invite.as_ref()).await;
779 // Confirmed already (GitHub, or the invite email): what the invite
780 // gives, now: a workspace it names is an invitation to accept,
781 // never joined without saying yes. Otherwise
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)782 // it waits, spent, for the address to be confirmed.
783 if let Some(row) = invite
784 && user.verified
785 {
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request786 self.after_redeemed(&row, &user, true).await?;
787 }
788 Ok(Outcome::Ok(user))
789 }
790
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)791 /// What using an invite gives, once its account is confirmed, and tells
792 /// the event log and audit log. A new account (`created_account`) is
793 /// invited to the workspace the invite names, to accept or decline
794 /// (invitations.rs): nobody joins a workspace without saying yes. An
795 /// existing account that opened the invite and accepted it
796 /// (`accept_invite`) joins now, with the role it names.
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request797 async fn after_redeemed(&self, row: &InviteRow, user: &User, created_account: bool) -> Result<()> {
798 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)799 if let (Some(workspace_id), Some(slug)) = (&row.workspace_id, &row.workspace) {
800 if created_account {
801 self.db
802 .prepare("UPDATE invites SET expires_at = max(expires_at, ?) WHERE id = ? AND accepted_at IS NULL")
803 .bind(&[self.answer_by().into(), row.id.as_str().into()])?
804 .run()
805 .await?;
806 self.invitation_sent(row, &user.username).await;
807 } else {
808 let role = if row.joins_as() == Role::Owner { "owner" } else { "member" };
809 self.db
810 .batch(vec![
811 self.db
812 .prepare(
813 "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
814 VALUES (?, ?, ?, ?)",
815 )
816 .bind(&[workspace_id.as_str().into(), user.id.as_str().into(), role.into(), rfc3339(now_ms()).into()])?,
817 self.db
818 .prepare(format!("UPDATE invites SET accepted_at = {SQL_NOW} WHERE id = ? AND accepted_at IS NULL"))
819 .bind(&[row.id.as_str().into()])?,
820 ])
821 .await?;
822 joined = Some(slug.clone());
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.823 // They are a member now: everyone listening hears of it.
@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.824 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)825 }
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request826 }
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)827 self.db
828 .prepare(format!("UPDATE invites SET applied_at = {SQL_NOW} WHERE id = ? AND applied_at IS NULL"))
829 .bind(&[row.id.as_str().into()])?
830 .run()
831 .await?;
832 self.settled(row, user, created_account, joined).await;
833 Ok(())
834 }
835
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)836 /// When a workspace invitation made now, or handed to a new account
837 /// now, stops working: the invite TTL from now, RFC 3339.
838 pub(crate) fn answer_by(&self) -> String {
839 rfc3339(now_ms() + self.invite_ttl_days() * 24 * HOUR_MS)
840 }
841
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)842 /// What follows an invite's workspace being joined (`joined`, by slug)
843 /// or not: repository invitations sent with its code are accepted, and
844 /// 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)845 pub(crate) async fn settled(&self, row: &InviteRow, user: &User, created_account: bool, joined: Option<String>) {
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request846 // A code sent with an invitation to collaborate on a repository:
847 // using it accepts (access.rs).
848 if let Err(error) = self.accept_invitations_of_code(&row.id, user).await {
849 worker::console_error!("repository invitations for {} not accepted: {error}", row.id);
850 }
851 self.announce(
852 "invite.redeemed",
853 Some(&user.id),
854 InviteRedeemed {
855 invite_id: row.id.clone(),
856 user_id: user.id.clone(),
857 inviter_id: row.inviter_id.clone(),
858 workspace_id: row.workspace_id.clone(),
859 created_account,
860 },
861 )
862 .await;
863 if let Some(slug) = joined {
864 let message = match &row.inviter {
865 Some(inviter) => format!("Joined with an invite from {inviter}"),
866 None => "Joined with an invite from g1t".to_owned(),
867 };
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)868 let role = if row.joins_as() == Role::Owner { "an owner" } else { "a member" };
Merge main (membership, two-factor, GitHub repo roles) into tokens869 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)870 self.audit_invites(user, "member.added", vec![slug], Surface::Web, format!("{} joined as {role}", user.username)).await;
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request871 }
872 }
873
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)874 /// The invite an account signed up with, while it waits for the account
875 /// to confirm its address: spent, not yet applied.
876 pub(crate) async fn awaiting_invite(&self, user_id: &str) -> Result<Option<InviteRow>> {
877 Ok(self
878 .rows(
879 "WHERE i.redeemed_by = ? AND i.kind = 'account' AND i.redeemed_at IS NOT NULL AND i.applied_at IS NULL",
880 &[user_id.into()],
881 1,
882 )
883 .await?
884 .into_iter()
885 .next())
886 }
887
888 /// What an awaiting invite does now that its account is being
889 /// confirmed, worked out before the batch that confirms it (which
890 /// checks the same again).
891 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)892 awaiting_join(row, &rfc3339(now_ms()))
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)893 }
894
895 /// The statements that apply an awaiting invite, for the batch that
896 /// 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)897 /// account confirmed: give a workspace invitation the invite TTL from
898 /// now to be answered in, only if the account is confirmed now; then
899 /// mark the invite settled, whatever it gave. Nothing is joined here:
900 /// the person accepts the invitation (invitations.rs).
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)901 pub(crate) fn apply_invite_statements(
902 &self,
903 user_id: &str,
904 row: &InviteRow,
905 join: &AwaitingJoin,
906 ) -> Result<Vec<worker::D1PreparedStatement>> {
907 let confirmed = "EXISTS (SELECT 1 FROM users WHERE id = ?1 AND email_verified_at IS NOT NULL)";
908 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)909 if let AwaitingJoin::Invited { .. } = join {
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)910 statements.push(
911 self.db
912 .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)913 "UPDATE invites SET expires_at = max(expires_at, ?3)
914 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)915 ))
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)916 .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)917 );
918 }
919 statements.push(
920 self.db
921 .prepare(format!(
922 "UPDATE invites SET applied_at = {SQL_NOW}
923 WHERE id = ?2 AND redeemed_by = ?1 AND applied_at IS NULL AND {confirmed}"
924 ))
925 .bind(&[user_id.into(), row.id.as_str().into()])?,
926 );
927 Ok(statements)
928 }
929
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)930 /// After the batch: the workspace the account is invited to, by slug,
931 /// if the invitation still waits for its answer (and it is told in
932 /// its inbox); and, unless the invite lapsed, the repository
933 /// invitations, event and audit entries that follow using it.
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)934 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)935 let invited = match join {
936 AwaitingJoin::Invited { slug, .. } => self
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)937 .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)938 .prepare(format!(
939 "SELECT 1 AS n FROM invites WHERE id = ? AND applied_at IS NOT NULL AND revoked_at IS NULL
940 AND accepted_at IS NULL AND declined_at IS NULL AND expires_at > {SQL_NOW}"
941 ))
942 .bind(&[row.id.as_str().into()])?
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)943 .first::<Count>(None)
944 .await?
945 .map(|_| slug.clone()),
946 _ => None,
947 };
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)948 if invited.is_some() {
949 self.invitation_sent(row, &user.username).await;
950 }
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)951 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)952 self.settled(row, user, true, None).await;
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)953 }
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)954 Ok(invited)
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)955 }
956
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request957 // --- People's invites ---
958
959 fn draft_allowed(user: &User) -> Option<&'static str> {
960 if user.kind != PrincipalKind::User || user.acting.is_some() {
961 return Some(PEOPLE_ONLY);
962 }
963 if !user.verified {
964 return Some(CONFIRM_FIRST);
965 }
966 None
967 }
968
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.969 /// Stores a new invite and returns it with its code.
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request970 async fn insert_invite(&self, draft: Draft<'_>) -> Result<Option<Invite>> {
971 let body = new_code_body();
972 let code = format_code(&body);
973 let now = now_ms();
974 let id = new_id("inv", now);
975 let sealed = self.invite_sealer().map(|sealer| sealer.seal(&code, &id));
976 let expires_at = rfc3339(now + self.invite_ttl_days() * 24 * HOUR_MS);
977 let created_at = rfc3339(now);
978 let opt = |value: Option<&str>| value.map_or(JsValue::NULL, JsValue::from);
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.979 let binds = vec![
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request980 JsValue::from(id.as_str()),
981 code_hash(&body).into(),
982 code_hint(&body).into(),
983 opt(sealed.as_deref()),
984 opt(draft.email),
985 draft.kind.into(),
986 opt(draft.workspace_id),
987 opt(draft.inviter.map(|user| user.id.as_str())),
988 opt(draft.staff),
989 created_at.as_str().into(),
990 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)991 opt(draft.invitee_id),
992 opt(draft.role),
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request993 ];
994 let inserted = self
995 .db
996 .prepare(format!(
997 "INSERT INTO invites (id, code_hash, hint, sealed_code, email, kind, workspace_id, inviter_id,
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.998 staff, charged_to, created_at, expires_at, invitee_id, role)
999 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'none', ?, ?, ?, ?)
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1000 RETURNING id"
1001 ))
1002 .bind(&binds)?
1003 .first::<Id>(None)
1004 .await?;
1005 if inserted.is_none() {
1006 return Ok(None);
1007 }
1008 self.announce(
1009 "invite.created",
1010 draft.inviter.map(|user| user.id.as_str()),
1011 InviteCreated {
1012 invite_id: id.clone(),
1013 inviter_id: draft.inviter.map(|user| user.id.clone()),
1014 workspace_id: draft.workspace_id.map(str::to_owned),
1015 bound: draft.email.is_some(),
1016 },
1017 )
1018 .await;
1019 let Some(row) = self.invite_by_id(&id).await? else {
1020 return Ok(None);
1021 };
1022 let mut invite = self.shown(row, false, false);
1023 invite.code = Some(code);
1024 Ok(Some(invite))
1025 }
1026
1027 async fn send_invite_email(
1028 &self,
1029 to: &str,
1030 from: Option<&str>,
1031 workspace: Option<&str>,
1032 existing: bool,
1033 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 confirm1034 invite_id: &str,
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1035 note: Option<&str>,
1036 ) {
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 confirm1037 // An invite that makes an account carries the proof that the link
1038 // came from this email; one for an existing account has nothing
1039 // to prove.
1040 let proof = if existing { None } else { self.email_proof_for(invite_id, to) };
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1041 let invite = crate::email::InviteEmail {
1042 to,
1043 from,
1044 workspace,
1045 joins_existing_account: existing,
1046 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 confirm1047 proof: proof.as_deref(),
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1048 days: self.invite_ttl_days(),
1049 note,
1050 };
1051 if let Err(error) = crate::email::send_invite(&self.env, &invite).await {
1052 worker::console_error!("invite email failed: {error}");
1053 }
1054 }
1055
1056 /// How an invite names the person who sent it: their name, else their
1057 /// username.
1058 async fn display_name(&self, user: &User) -> String {
1059 self.name_of("SELECT display_name AS name FROM users WHERE id = ?", &user.id)
1060 .await
1061 .unwrap_or_else(|| user.username.clone())
1062 }
1063
1064 /// A workspace's name, as an invite shows it; its slug if it has none.
1065 async fn workspace_name(&self, workspace_id: &str, slug: &str) -> String {
1066 self.name_of("SELECT name FROM workspaces WHERE id = ?", workspace_id)
1067 .await
1068 .unwrap_or_else(|| slug.to_owned())
1069 }
1070
1071 /// A name `sql` selects for `id`, if it has one. Only for wording an
1072 /// email, so a failed read is no name.
1073 async fn name_of(&self, sql: &str, id: &str) -> Option<String> {
1074 #[derive(Deserialize)]
1075 struct Name {
1076 name: Option<String>,
1077 }
1078 let read = async { self.db.prepare(sql).bind(&[id.into()])?.first::<Name>(None).await };
1079 read.await
1080 .ok()
1081 .flatten()
1082 .and_then(|row| row.name)
1083 .map(|name| name.trim().to_owned())
1084 .filter(|name| !name.is_empty())
1085 }
1086
1087 /// The address a pending invite is bound to, if it is: signing up with
1088 /// GitHub uses it when GitHub has confirmed it too (github.rs).
1089 pub(crate) async fn bound_email_of(&self, code: &str) -> Result<Option<String>> {
1090 let now = rfc3339(now_ms());
1091 Ok(self
1092 .invite_by_code(code)
1093 .await?
1094 .filter(|row| row.status(&now) == InviteStatus::Pending)
1095 .and_then(|row| row.email))
1096 }
1097
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.1098 /// Revokes a pending invite the person made, or one made for a
1099 /// workspace they own. An invite used to sign up whose
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1100 /// account has not confirmed its address yet can be revoked too: the
1101 /// account stays, and joins nothing when it confirms.
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.1102 async fn revoke_invite(&self, a: RemoveArgs) -> Result<Outcome<Invite>> {
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1103 if a.user.kind != PrincipalKind::User || a.user.acting.is_some() {
1104 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
1105 }
1106 let revoked = self
1107 .db
1108 .prepare(format!(
1109 "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)1110 WHERE id = ?2 AND revoked_at IS NULL AND declined_at IS NULL
1111 AND (redeemed_at IS NULL OR applied_at IS NULL
1112 -- A workspace invitation not yet answered.
1113 OR (workspace_id IS NOT NULL AND accepted_at IS NULL))
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1114 AND (inviter_id = ?1
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.1115 OR workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = ?1 AND role = 'owner'))
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1116 RETURNING id"
1117 ))
1118 .bind(&[a.user.id.as_str().into(), a.id.as_str().into()])?
1119 .first::<Id>(None)
1120 .await?;
1121 let Some(Id { id }) = revoked else {
1122 return Ok(Outcome::fail(FailureCode::NotFound, "There is no pending invite of yours with that id."));
1123 };
1124 let Some(row) = self.invite_by_id(&id).await? else {
1125 return Ok(Outcome::fail(FailureCode::NotFound, "Invite not found."));
1126 };
1127 let logs = match &row.workspace {
1128 Some(slug) => vec![slug.clone()],
1129 None => a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect(),
1130 };
1131 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)1132 self.invitation_revoked(&a.user, &row).await;
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1133 Ok(Outcome::Ok(self.shown(row, false, false)))
1134 }
1135
1136 /// What an invite code is for: who sent it, and which workspace it
1137 /// joins. Any code that cannot be used gets the same answer.
1138 pub async fn check_invite(&self, a: InviteCodeArgs) -> Result<Outcome<InvitePreview>> {
1139 if self.turned_away(a.client.as_deref()).await? {
1140 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1141 }
1142 let now = rfc3339(now_ms());
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)1143 let found = self.invite_by_code(&a.code).await?;
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1144 // A spent code is still a real one (160 random bits): saying what
1145 // became of it tells a guesser nothing.
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)1146 let row = found.filter(|row| a.any_status || row.status(&now) == InviteStatus::Pending);
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1147 let Some(row) = row else {
1148 self.count_failure(a.client.as_deref()).await?;
1149 return Ok(Outcome::fail(FailureCode::NotFound, INVALID));
1150 };
1151 let status = row.status(&now);
1152 let pending = status == InviteStatus::Pending;
1153 // Whether it is the viewer's: for one of their confirmed addresses,
1154 // or, once used, used by them.
1155 let for_viewer = match &a.viewer {
1156 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)1157 (_, InviteStatus::Redeemed | InviteStatus::AwaitingConfirmation) => {
1158 Some(row.redeemer.as_deref() == Some(viewer.username.as_str()))
1159 }
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1160 (Some(bound), _) => {
1161 let mine = self.verified_emails(&viewer.id).await?;
1162 Some(mine.iter().any(|address| address.eq_ignore_ascii_case(bound.trim())))
1163 }
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)1164 // An invitation to someone by username is theirs alone.
1165 (None, _) => row.invitee_id.as_ref().map(|invitee| *invitee == viewer.id),
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1166 },
1167 _ => None,
1168 };
1169 let has_account = match (&row.email, pending) {
1170 (Some(bound), true) => self.email_has_account(bound).await?,
1171 _ => false,
1172 };
1173 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 confirm1174 // Opened from the invite's own email: the account it makes starts
1175 // with the address confirmed. Said only while it can make one.
1176 let email_proven = pending
1177 && !has_account
1178 && row.email.as_deref().is_some_and(|bound| self.proven(&row, bound, a.email_proof.as_deref()));
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1179 #[derive(Deserialize)]
1180 struct From {
1181 username: String,
1182 name: Option<String>,
1183 avatar: Option<String>,
1184 }
1185 let invited_by = match &row.inviter_id {
1186 Some(id) => self
1187 .db
1188 .prepare("SELECT username, display_name AS name, avatar FROM users WHERE id = ?")
1189 .bind(&[id.as_str().into()])?
1190 .first::<From>(None)
1191 .await?
1192 .map(|from| InviteFrom {
1193 username: from.username,
1194 name: from.name,
1195 avatar: from.avatar,
1196 }),
1197 None => None,
1198 };
1199 let workspace = match &row.workspace_id {
1200 Some(id) => self
1201 .db
1202 .prepare("SELECT slug, name, avatar FROM workspaces WHERE id = ?")
1203 .bind(&[id.as_str().into()])?
1204 .first::<ProfileWorkspace>(None)
1205 .await?,
1206 None => None,
1207 };
1208 Ok(Outcome::Ok(InvitePreview {
1209 kind: kind_of(&row.kind),
1210 status,
1211 invited_by,
1212 workspace,
1213 repository,
1214 email: row.email.as_deref().map(mask_email),
1215 address: row.email.clone().filter(|_| pending),
1216 has_account,
1217 for_viewer,
1218 expires_at: row.expires_at,
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 confirm1219 email_proven,
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1220 }))
1221 }
1222
1223 /// A signed-in person uses a workspace invite sent to their address,
1224 /// or one sent with a repository invitation.
1225 pub async fn accept_invite(&self, a: AcceptInviteArgs) -> Result<Outcome<String>> {
1226 if a.user.kind != PrincipalKind::User || a.user.acting.is_some() {
1227 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can accept an invite."));
1228 }
1229 // Any of the person's confirmed addresses can match an invite bound
1230 // to one (emails.rs); the primary otherwise.
1231 let verified = self.verified_emails(&a.user.id).await?;
1232 let Some(primary) = verified.first().cloned() else {
1233 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first, then open the invite again."));
1234 };
1235 let now = rfc3339(now_ms());
1236 let row = self.invite_by_code(&a.code).await?;
1237 let email = row
1238 .as_ref()
1239 .and_then(|row| row.email.as_deref())
1240 .and_then(|bound| verified.iter().find(|address| address.eq_ignore_ascii_case(bound.trim())).cloned())
1241 .unwrap_or(primary);
1242 // What using it gives an account that exists: a workspace, or a
1243 // repository it was sent with.
1244 let repository = match &row {
1245 Some(row) => self.repository_of_code(&row.id).await?,
1246 None => None,
1247 };
1248 let joins = row.as_ref().is_some_and(joins_workspace) || repository.is_some();
1249 if let Err(refusal) = admits(row.as_ref().map(|row| row.admits(&now)).as_ref(), &email, false) {
1250 return Ok(Outcome::fail(
1251 FailureCode::Forbidden,
1252 if refusal == Refusal::WrongEmail { WRONG_EMAIL } else { INVALID },
1253 ));
1254 }
1255 let Some(row) = row.filter(|_| joins) else {
1256 return Ok(Outcome::fail(
1257 FailureCode::Conflict,
1258 "You already have a g1t account, so this invite has nothing more to give you. Pass it on to someone who needs it.",
1259 ));
1260 };
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)1261 // An invitation to one account works for that account only.
1262 if row.invitee_id.as_deref().is_some_and(|invitee| invitee != a.user.id) {
1263 return Ok(Outcome::fail(
1264 FailureCode::Forbidden,
1265 "This invitation is for a different g1t account. Sign in as the account it was sent to.",
1266 ));
1267 }
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1268 // What the workspace asks of its members (security.rs); nothing yet.
1269 if let Some(slug) = row.workspace.as_deref()
1270 && let Some(why) = self.policy_refusal(&a.user.id, slug).await?
1271 {
1272 return Ok(Outcome::fail(FailureCode::Forbidden, why));
1273 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1274 // An invite sent before the workspace was free waits until it
1275 // starts the plan (paid.rs); the code is not used up.
1276 let joins_slug = row.workspace.clone().or_else(|| {
1277 repository.as_ref().and_then(|r| r.name.split_once('/').map(|(workspace, _)| workspace.to_owned()))
1278 });
1279 if let Some(slug) = joins_slug.as_deref()
1280 && let Some(refused) = self.free_workspace_refusal(slug).await?
1281 {
1282 return Ok(refused);
1283 }
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1284 let claimed = self
1285 .db
1286 .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)1287 "UPDATE invites SET redeemed_by = ?1, invitee_id = COALESCE(invitee_id, ?1), redeemed_at = {SQL_NOW}, sealed_code = NULL
1288 WHERE id = ?2 AND redeemed_at IS NULL AND revoked_at IS NULL AND declined_at IS NULL AND expires_at > {SQL_NOW}
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1289 RETURNING id"
1290 ))
1291 .bind(&[a.user.id.as_str().into(), row.id.as_str().into()])?
1292 .first::<Id>(None)
1293 .await?;
1294 if claimed.is_none() {
1295 return Ok(Outcome::fail(FailureCode::Forbidden, INVALID));
1296 }
1297 let lands = row
1298 .workspace
1299 .clone()
1300 .or_else(|| repository.map(|repository| repository.name))
1301 .unwrap_or_default();
1302 self.after_redeemed(&row, &a.user, false).await?;
1303 Ok(Outcome::Ok(lands))
1304 }
1305
1306 // --- Workspace invitations ---
1307
1308 pub async fn invite_member(&self, a: InviteMemberArgs) -> Result<Outcome<Invite>> {
1309 let slug = a.slug.trim().to_lowercase();
1310 if let Some(reason) = Self::draft_allowed(&a.actor) {
1311 return Ok(Outcome::fail(FailureCode::Forbidden, reason));
1312 }
1313 if a.actor.role_in(&slug) != Some(Role::Owner) {
1314 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can invite people to a workspace."));
1315 }
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)1316 // A username, or an address; an address typed in the username's
1317 // place is an address.
1318 let username = a
1319 .username
1320 .as_deref()
1321 .map(|name| name.trim().trim_start_matches('@').to_lowercase())
1322 .filter(|name| !name.is_empty() && !name.contains('@'));
1323 let email = match (&username, normalize_email(a.username.as_deref().unwrap_or(&a.email))) {
1324 (Some(_), _) => None,
1325 (None, Some(email)) => Some(email),
1326 (None, None) => return Ok(Outcome::fail(FailureCode::Invalid, "Enter a g1t username or a valid email address.")),
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1327 };
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)1328 let role = a.role.unwrap_or(Role::Member);
1329 let role_name = if role == Role::Owner { "owner" } else { "member" };
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1330 let Some(workspace_id) = self.workspace_id(&slug).await? else {
1331 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1332 };
Merge Stripe Tax, the card fee on card payments, and one free workspace per person1333 // A free workspace invites no one until it starts the plan (paid.rs).
1334 if let Some(refused) = self.free_workspace_refusal(&slug).await? {
1335 return Ok(refused);
1336 }
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)1337 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.1338 // A note from the inviter goes in the email, cut to its limit.
1339 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)1340 // Someone on g1t, by username: an invitation to accept or decline
1341 // (invites/invitations.rs).
1342 let Some(email) = email else {
1343 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.1344 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)1345 };
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1346 if !self.hit(&format!("invite.create:{}", a.actor.id), CREATES_PER_HOUR).await? {
1347 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1348 }
1349 let pending = self
1350 .rows(
1351 &format!(
1352 "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)1353 AND i.redeemed_at IS NULL AND i.revoked_at IS NULL AND i.declined_at IS NULL AND i.expires_at > {SQL_NOW}"
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1354 ),
1355 &[workspace_id.as_str().into(), email.as_str().into()],
1356 1,
1357 )
1358 .await?;
1359 if !pending.is_empty() {
1360 return Ok(Outcome::fail(
1361 FailureCode::Conflict,
1362 "There is already a pending invite for that address. Revoke it to send a new one.",
1363 ));
1364 }
1365 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)1366 // The account that has confirmed the address, which the invitation
1367 // is for. Never shown to the inviter: the answer does not say
1368 // whether the address has an account.
1369 let invitee = self.user_with_verified_email(&email).await?;
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1370 let draft = if has_account {
1371 Draft {
1372 email: Some(&email),
1373 kind: "workspace",
1374 workspace_id: Some(&workspace_id),
1375 inviter: Some(&a.actor),
1376 staff: 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)1377 invitee_id: invitee.as_deref(),
1378 role: Some(role_name),
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1379 }
1380 } else {
1381 Draft {
1382 email: Some(&email),
1383 kind: "account",
1384 workspace_id: Some(&workspace_id),
1385 inviter: Some(&a.actor),
1386 staff: 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)1387 invitee_id: None,
1388 role: Some(role_name),
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1389 }
1390 };
1391 let Some(invite) = self.insert_invite(draft).await? else {
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.1392 return Ok(Outcome::fail(FailureCode::Conflict, "The invitation could not be made. Try again."));
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1393 };
1394 if let Some(code) = &invite.code {
1395 let from = self.display_name(&a.actor).await;
1396 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.1397 self.send_invite_email(&email, Some(&from), Some(&workspace), has_account, code, &invite.id, note.as_deref()).await;
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1398 }
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)1399 // Someone on g1t hears of it in their inbox too.
1400 if invitee.is_some()
1401 && let Some(row) = self.invite_by_id(&invite.id).await?
1402 && let Some(username) = row.invitee.clone()
1403 {
1404 self.invitation_sent(&row, &username).await;
1405 }
1406 self.audit_invites(&a.actor, "invite.created", vec![slug.clone()], surface, format!("Invited {email} to {slug} as {role_name}"))
1407 .await;
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1408 Ok(Outcome::Ok(invite))
1409 }
1410
1411 /// An invite code for an address without an account, invited to
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.1412 /// collaborate on one repository (access.rs). The code joins no
1413 /// workspace; redeeming it accepts the repository invitation that
1414 /// names it.
1415 pub(crate) async fn repo_invite_code(&self, actor: &User, email: &str) -> Result<Outcome<Invite>> {
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1416 if let Some(reason) = Self::draft_allowed(actor) {
1417 return Ok(Outcome::fail(FailureCode::Forbidden, reason));
1418 }
1419 if !self.hit(&format!("invite.create:{}", actor.id), CREATES_PER_HOUR).await? {
1420 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1421 }
1422 let draft = Draft {
1423 email: Some(email),
1424 kind: "account",
1425 workspace_id: None,
1426 inviter: Some(actor),
1427 staff: 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)1428 invitee_id: None,
1429 role: None,
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1430 };
1431 Ok(match self.insert_invite(draft).await? {
1432 Some(invite) => Outcome::Ok(invite),
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.1433 None => Outcome::fail(FailureCode::Conflict, "The invitation could not be made. Try again."),
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1434 })
1435 }
1436
1437 /// Revokes an invite code made for a repository invitation, when that
1438 /// invitation is revoked. Only a pending code changes.
1439 pub(crate) async fn revoke_code(&self, invite_id: &str) -> Result<()> {
1440 self.db
1441 .prepare(format!(
1442 "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL
1443 WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL"
1444 ))
1445 .bind(&[invite_id.into()])?
1446 .run()
1447 .await?;
1448 Ok(())
1449 }
1450
1451 pub async fn workspace_invites(&self, a: ListMembersArgs) -> Result<Outcome<Vec<Invite>>> {
1452 let slug = a.slug.trim().to_lowercase();
1453 if !a.viewer.as_ref().is_some_and(|viewer| viewer.role_in(&slug) == Some(Role::Owner)) {
1454 return Ok(Outcome::fail(FailureCode::Forbidden, "Only owners can see a workspace's invites."));
1455 }
1456 let Some(workspace_id) = self.workspace_id(&slug).await? else {
1457 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1458 };
1459 let rows = self.rows("WHERE i.workspace_id = ?", &[workspace_id.as_str().into()], LIST_LIMIT).await?;
1460 Ok(Outcome::Ok(rows.into_iter().map(|row| self.shown(row, true, false)).collect()))
1461 }
1462
1463 pub async fn revoke_workspace_invite(&self, a: WorkspaceInviteArgs) -> Result<Outcome<Invite>> {
1464 let slug = a.slug.trim().to_lowercase();
1465 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
1466 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can revoke a workspace's invites."));
1467 }
1468 self.revoke_invite(RemoveArgs { user: a.actor, id: a.id }).await
1469 }
1470
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.1471 /// Sends one of the workspace's pending invitations again: the same
1472 /// email to the address it is bound to, or to the invited account's
1473 /// confirmed address, and the inbox notice again for an account. The
1474 /// invitation itself does not change. Counted against the owner's
1475 /// hourly allowance of invites made, so a list cannot be used to flood
1476 /// an inbox.
1477 pub async fn resend_workspace_invite(&self, a: WorkspaceInviteArgs) -> Result<Outcome<Invite>> {
1478 let slug = a.slug.trim().to_lowercase();
1479 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
1480 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can send a workspace's invitations again."));
1481 }
1482 let row = self.invite_by_id(a.id.trim()).await?.filter(|row| row.workspace.as_deref() == Some(slug.as_str()));
1483 let Some(row) = row else {
1484 return Ok(Outcome::fail(FailureCode::NotFound, "There is no invitation to this workspace with that id."));
1485 };
1486 let now = rfc3339(now_ms());
1487 if !resendable(row.status(&now)) {
1488 return Ok(Outcome::fail(FailureCode::Conflict, "Only a pending invitation can be sent again."));
1489 }
1490 if !self.hit(&format!("invite.create:{}", a.actor.id), CREATES_PER_HOUR).await? {
1491 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1492 }
1493 let Some(workspace_id) = row.workspace_id.as_deref() else {
1494 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1495 };
1496 let code = row.sealed_code.as_deref().and_then(|sealed| self.invite_sealer()?.open(sealed, &row.id));
1497 // Where it goes: the address it is bound to, else the invited
1498 // account's confirmed address. An account invite (one that also
1499 // makes the account) carries the proof the link came from its email.
1500 let to = match &row.email {
1501 Some(email) => Some(email.clone()),
1502 None => match &row.invitee_id {
1503 Some(invitee) => self.verified_email_of(invitee).await?,
1504 None => None,
1505 },
1506 };
1507 if let (Some(to), Some(code)) = (&to, &code) {
1508 let from = self.display_name(&a.actor).await;
1509 let workspace = self.workspace_name(workspace_id, &slug).await;
1510 self.send_invite_email(to, Some(&from), Some(&workspace), row.kind == "workspace", code, &row.id, None).await;
1511 }
1512 if let Some(username) = row.invitee.as_deref().filter(|_| row.email.is_none()) {
1513 self.invitation_sent(&row, username).await;
1514 }
1515 self.audit_invites(&a.actor, "invite.resent", vec![slug.clone()], Surface::Web, format!("Sent invite {} again", row.hint)).await;
1516 Ok(Outcome::Ok(self.shown(row, true, false)))
1517 }
1518
1519 /// The confirmed primary address of the account `user_id`, if it has one.
1520 async fn verified_email_of(&self, user_id: &str) -> Result<Option<String>> {
1521 #[derive(Deserialize)]
1522 struct Address {
1523 email: Option<String>,
1524 }
1525 Ok(self
1526 .db
1527 .prepare("SELECT email FROM users WHERE id = ? AND email_verified_at IS NOT NULL AND deleted_at IS NULL")
1528 .bind(&[user_id.into()])?
1529 .first::<Address>(None)
1530 .await?
1531 .and_then(|row| row.email))
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1532 }
1533
1534 // --- Staff ---
1535
1536 pub async fn admin_invites(&self, a: AdminInvitesArgs) -> Result<Vec<Invite>> {
1537 let query = a.query.as_deref().map(str::trim).filter(|query| !query.is_empty());
1538 let rows = match query {
1539 None => self.rows("", &[], ADMIN_INVITES_LIMIT as u32).await?,
1540 Some(query) => {
1541 // A code, or its start: matched by its hint.
1542 let prefix = query.to_lowercase();
1543 let prefix = prefix.strip_prefix("g1t-").unwrap_or(&prefix).replace('-', "");
1544 let hint = (prefix.len() >= GROUP && prefix.chars().all(|c| ALPHABET.contains(&(c as u8))))
1545 .then(|| code_hint(&prefix));
1546 let pattern = crate::admin::like_pattern(Some(query)).unwrap_or_default();
1547 let mut binds: Vec<JsValue> = vec![pattern.as_str().into(), pattern.as_str().into(), pattern.as_str().into()];
1548 let mut filter = "WHERE (lower(i.email) LIKE ? ESCAPE '\\' OR iu.username LIKE ? ESCAPE '\\' OR ru.username LIKE ? ESCAPE '\\'".to_owned();
1549 if let Some(hint) = hint {
1550 filter.push_str(" OR i.hint = ?");
1551 binds.push(hint.into());
1552 }
1553 filter.push(')');
1554 self.rows(&filter, &binds, ADMIN_INVITES_LIMIT as u32).await?
1555 }
1556 };
1557 Ok(rows.into_iter().map(|row| self.shown(row, false, true)).collect())
1558 }
1559
1560 pub async fn admin_revoke_invite(&self, a: AdminRevokeInviteArgs) -> Result<Outcome<Invite>> {
1561 let revoked = self
1562 .db
1563 .prepare(format!(
1564 "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL
1565 WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL RETURNING id"
1566 ))
1567 .bind(&[a.id.as_str().into()])?
1568 .first::<Id>(None)
1569 .await?;
1570 if revoked.is_none() {
1571 return Ok(Outcome::fail(FailureCode::Conflict, "Only a pending invite can be revoked."));
1572 }
1573 worker::console_log!("invite {} revoked by staff {}", a.id, a.staff);
1574 Ok(match self.invite_by_id(&a.id).await? {
1575 Some(row) => Outcome::Ok(self.shown(row, false, true)),
1576 None => Outcome::fail(FailureCode::NotFound, "Invite not found."),
1577 })
1578 }
1579
1580 // --- Audit ---
1581
1582 async fn audit_invites(&self, actor: &User, action: &str, workspaces: Vec<String>, surface: Surface, message: String) {
1583 let Ok(events) = self.env.service("EVENTS") else {
1584 return;
1585 };
1586 let entries: Vec<NewAuditEntry> = workspaces
1587 .into_iter()
1588 .map(|workspace| NewAuditEntry {
1589 actor: AuditActor::of(actor),
1590 action: action.to_owned(),
1591 surface,
1592 target: AuditTarget {
1593 workspace,
1594 ..AuditTarget::default()
1595 },
1596 outcome: AuditOutcome::Allowed,
1597 rule: "invite".to_owned(),
1598 result: Some("ok".to_owned()),
1599 message: Some(message.clone()),
1600 request_id: new_id("req", now_ms()),
1601 })
1602 .collect();
1603 if entries.is_empty() {
1604 return;
1605 }
1606 let recorded: Result<u32> = g1t_kit::call(&events, "audit_record", &RecordAuditArgs { entries }).await;
1607 if let Err(error) = recorded {
1608 worker::console_error!("{action} not recorded: {error}");
1609 }
1610 }
1611}
1612
1613/// Whether using the invite joins a workspace.
1614fn joins_workspace(row: &InviteRow) -> bool {
1615 row.workspace_id.is_some()
1616}
1617
1618#[cfg(test)]
1619mod tests {
1620 use super::*;
1621
1622 #[test]
1623 fn codes_carry_160_bits_in_eight_groups() {
1624 assert_eq!(encode(&[0u8; 20]), "0".repeat(32));
1625 assert_eq!(encode(&[0xff; 20]), "z".repeat(32));
1626 let body = new_code_body();
1627 assert_eq!(body.len(), CODE_LENGTH);
1628 assert!(body.bytes().all(|b| ALPHABET.contains(&b)));
1629 let code = format_code(&body);
1630 assert!(code.starts_with("g1t-"));
1631 assert_eq!(code.split('-').count(), 9);
1632 assert_eq!(code.len(), 4 + 32 + 7);
1633 // Every bit is used: one bit set shows in exactly one character.
1634 let mut bytes = [0u8; 20];
1635 bytes[19] = 1;
1636 assert_eq!(encode(&bytes), format!("{}1", "0".repeat(31)));
1637 }
1638
1639 #[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.1640 fn only_a_pending_invitation_is_sent_again() {
1641 assert!(resendable(InviteStatus::Pending));
1642 for over in [
1643 InviteStatus::AwaitingConfirmation,
1644 InviteStatus::AwaitingAnswer,
1645 InviteStatus::Redeemed,
1646 InviteStatus::Declined,
1647 InviteStatus::Expired,
1648 InviteStatus::Revoked,
1649 ] {
1650 assert!(!resendable(over), "{over:?}");
1651 }
1652 }
1653
1654 #[test]
1655 fn an_inviters_note_is_trimmed_cut_and_dropped_when_blank() {
1656 assert_eq!(invite_note(None), None);
1657 assert_eq!(invite_note(Some(" ")), None);
1658 assert_eq!(invite_note(Some(" Welcome aboard ")).as_deref(), Some("Welcome aboard"));
1659 let long = "x".repeat(MAX_INVITE_MESSAGE + 40);
1660 assert_eq!(invite_note(Some(&long)).map(|note| note.chars().count()), Some(MAX_INVITE_MESSAGE));
1661 }
1662
1663 #[test]
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1664 fn codes_are_not_repeated() {
1665 let codes: std::collections::HashSet<String> = (0..2000).map(|_| new_code_body()).collect();
1666 assert_eq!(codes.len(), 2000);
1667 }
1668
1669 #[test]
1670 fn a_code_reads_however_it_is_typed_or_pasted() {
1671 let body = "k7m2q9xd4hpwabcd0123456789efghjk";
1672 let shown = format_code(body);
1673 for typed in [
1674 shown.clone(),
1675 shown.to_uppercase(),
1676 body.to_owned(),
1677 format!(" {} ", shown.replace('-', " ")),
1678 format!("https://g1t.sh/invite/{shown}"),
1679 format!("https://g1t.sh/register?invite={shown}&next=/"),
1680 ] {
1681 assert_eq!(normalize_code(&typed).as_deref(), Some(body), "{typed}");
1682 }
1683 // Letters people misread are read as Crockford reads them.
1684 assert_eq!(normalize_code(&"o".repeat(32)), Some("0".repeat(32)));
1685 assert_eq!(normalize_code(&"il".repeat(16)), Some("1".repeat(32)));
1686 assert_eq!(normalize_code("g1t-k7m2"), None);
1687 assert_eq!(normalize_code(&format!("{body}0")), None);
1688 assert_eq!(normalize_code(&"u".repeat(32)), None);
1689 assert_eq!(normalize_code(""), None);
1690 }
1691
1692 #[test]
1693 fn only_the_hash_and_a_short_hint_are_kept() {
1694 let body = "k7m2q9xd4hpwabcd0123456789efghjk";
1695 assert_eq!(code_hash(body), crypto::sha256_hex(body));
1696 assert_eq!(code_hash(body).len(), 64);
1697 assert_ne!(code_hash(body), code_hash(&body.replace('k', "m")));
1698 assert_eq!(code_hint(body), "g1t-k7m2");
1699 // The same code typed differently finds the same row.
1700 let typed = normalize_code(&format_code(body).to_uppercase()).unwrap();
1701 assert_eq!(code_hash(&typed), code_hash(body));
1702 }
1703
1704 const NOW: &str = "2026-10-05T12:00:00.000Z";
1705 const LATER: &str = "2026-11-04T12:00:00.000Z";
1706 const EARLIER: &str = "2026-10-01T12:00:00.000Z";
1707
1708 #[test]
1709 fn an_invite_is_pending_until_used_revoked_or_expired() {
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1710 assert_eq!(status_of(None, None, None, LATER, NOW), InviteStatus::Pending);
1711 assert_eq!(status_of(None, None, None, EARLIER, NOW), InviteStatus::Expired);
1712 assert_eq!(status_of(None, None, None, NOW, NOW), InviteStatus::Expired);
1713 assert_eq!(status_of(Some(EARLIER), None, None, LATER, NOW), InviteStatus::Revoked);
1714 assert_eq!(status_of(None, Some(EARLIER), Some(EARLIER), EARLIER, NOW), InviteStatus::Redeemed);
1715 }
1716
1717 #[test]
1718 fn an_invite_used_to_sign_up_awaits_the_account_confirming_its_address() {
1719 // Spent, not applied: waiting, even past its expiry.
1720 assert_eq!(status_of(None, Some(EARLIER), None, LATER, NOW), InviteStatus::AwaitingConfirmation);
1721 assert_eq!(status_of(None, Some(EARLIER), None, EARLIER, NOW), InviteStatus::AwaitingConfirmation);
1722 // Revoked while waiting: revoked, whatever happens when it settles.
1723 assert_eq!(status_of(Some(NOW), Some(EARLIER), None, LATER, NOW), InviteStatus::Revoked);
1724 assert_eq!(status_of(Some(NOW), Some(EARLIER), Some(NOW), LATER, NOW), InviteStatus::Revoked);
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.1725 // A spent invite cannot be used again while it waits.
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1726 let waiting = invite("account", None, InviteStatus::AwaitingConfirmation);
1727 assert_eq!(admits(Some(&waiting), "anyone@example.com", true), Err(Refusal::Invalid));
1728 }
1729
1730 fn row(workspace: Option<(&str, Option<&str>)>, revoked: bool, expires_at: &str) -> InviteRow {
1731 InviteRow {
1732 id: "inv_1".into(),
1733 hint: "g1t-k7m2".into(),
1734 sealed_code: None,
1735 email: Some("ada@example.com".into()),
1736 kind: "account".into(),
1737 workspace_id: workspace.map(|(id, _)| id.to_owned()),
1738 workspace: workspace.and_then(|(_, slug)| slug.map(str::to_owned)),
1739 inviter_id: Some("usr_owner".into()),
1740 inviter: Some("bo".into()),
1741 staff: None,
1742 created_at: EARLIER.into(),
1743 expires_at: expires_at.into(),
1744 revoked_at: revoked.then(|| NOW.to_owned()),
1745 redeemer: Some("ada".into()),
1746 redeemed_at: Some(EARLIER.into()),
1747 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)1748 invitee_id: Some("usr_ada".into()),
1749 invitee: Some("ada".into()),
1750 role: None,
1751 accepted_at: None,
1752 declined_at: None,
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1753 }
1754 }
1755
1756 #[test]
1757 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)1758 // Confirming no longer joins anything by itself: the workspace the
1759 // invite named becomes an invitation the person accepts or declines
1760 // (invites/invitations.rs), and accepting joins it.
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1761 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)1762 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)1763 // 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)1764 assert_eq!(awaiting_join(&row(None, false, LATER), NOW), AwaitingJoin::Nothing);
1765 // Confirmed and not yet answered: awaiting the answer.
1766 let confirmed = InviteRow { applied_at: Some(NOW.into()), ..good };
1767 assert_eq!(confirmed.status(NOW), InviteStatus::AwaitingAnswer);
1768 // Accepted: used.
1769 let accepted = InviteRow { accepted_at: Some(NOW.into()), ..confirmed };
1770 assert_eq!(accepted.status(NOW), InviteStatus::Redeemed);
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1771 }
1772
1773 #[test]
1774 fn a_revoked_or_expired_invite_or_a_deleted_workspace_lapses_and_the_address_is_confirmed_anyway() {
1775 let lapsed = |join: AwaitingJoin| match join {
1776 AwaitingJoin::Lapsed(why) => why,
1777 other => panic!("expected a lapse, got {other:?}"),
1778 };
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)1779 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)1780 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)1781 assert!(revoked.contains("was revoked") && revoked.contains("no longer invites you to acme"));
1782 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)1783 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)1784 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)1785 // 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)1786 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)1787 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)1788 // A free workspace still invites; accepting waits for its plan.
1789 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)1790 // 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)1791 assert!(lapsed(awaiting_join(&row(None, true, EARLIER), NOW)).contains("no longer applies"));
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1792 }
1793
1794 fn invite(kind: &'static str, email: Option<&'static str>, status: InviteStatus) -> Admits<'static> {
1795 Admits { kind, email, status }
1796 }
1797
1798 #[test]
1799 fn an_invite_admits_only_its_address_while_pending() {
1800 let open = invite("account", None, InviteStatus::Pending);
1801 assert_eq!(admits(Some(&open), "anyone@example.com", true), Ok(()));
1802 let bound = invite("account", Some("ada@example.com"), InviteStatus::Pending);
1803 assert_eq!(admits(Some(&bound), "ada@example.com", true), Ok(()));
1804 assert_eq!(admits(Some(&bound), " ADA@Example.com ", true), Ok(()));
1805 assert_eq!(admits(Some(&bound), "eve@example.com", true), Err(Refusal::WrongEmail));
1806 for status in [InviteStatus::Redeemed, InviteStatus::Revoked, InviteStatus::Expired] {
1807 assert_eq!(admits(Some(&invite("account", None, status)), "a@example.com", true), Err(Refusal::Invalid));
1808 // A dead code says nothing about whom it was for.
1809 assert_eq!(
1810 admits(Some(&invite("account", Some("ada@example.com"), status)), "eve@example.com", true),
1811 Err(Refusal::Invalid)
1812 );
1813 }
1814 assert_eq!(admits(None, "a@example.com", true), Err(Refusal::Invalid));
1815 }
1816
1817 #[test]
1818 fn a_workspace_invite_never_makes_an_account() {
1819 let join = invite("workspace", Some("ada@example.com"), InviteStatus::Pending);
1820 assert_eq!(admits(Some(&join), "ada@example.com", true), Err(Refusal::Invalid));
1821 assert_eq!(admits(Some(&join), "ada@example.com", false), Ok(()));
1822 assert_eq!(admits(Some(&join), "eve@example.com", false), Err(Refusal::WrongEmail));
1823 // An account invite for a workspace can be accepted by the address
1824 // once it has an account.
1825 let account = invite("account", Some("ada@example.com"), InviteStatus::Pending);
1826 assert_eq!(admits(Some(&account), "ada@example.com", false), Ok(()));
1827 }
1828
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 confirm1829 const KEY: &[u8] = b"identity key";
1830
1831 #[test]
1832 fn an_invite_emails_proof_is_for_its_invite_and_address_only() {
1833 let proof = email_proof(KEY, "inv_1", Some("ada@example.com")).unwrap();
1834 assert_eq!(proof.len(), 64);
1835 let proves = |id: &str, bound: Option<&str>, email: &str, proof: Option<&str>| proves_email(KEY, id, bound, email, proof);
1836 // The right invite and address, however the address is written.
1837 assert!(proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof)));
1838 assert!(proves("inv_1", Some("Ada@Example.com"), " ADA@example.com ", Some(&proof)));
1839 assert!(proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof.to_uppercase())));
1840 // Another address: the account confirms that one itself.
1841 assert!(!proves("inv_1", Some("ada@example.com"), "eve@example.com", Some(&proof)));
1842 // Another invite's proof, even for the same address.
1843 assert!(!proves("inv_2", Some("ada@example.com"), "ada@example.com", Some(&proof)));
1844 // Tampered, cut short, empty or missing.
1845 let mut tampered = proof.clone().into_bytes();
1846 tampered[10] = if tampered[10] == b'0' { b'1' } else { b'0' };
1847 let tampered = String::from_utf8(tampered).unwrap();
1848 assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&tampered)));
1849 assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&proof[..32])));
1850 assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some("")));
1851 assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", None));
1852 // An invite bound to no address has no proof to give.
1853 assert_eq!(email_proof(KEY, "inv_1", None), None);
1854 assert!(!proves("inv_1", None, "ada@example.com", Some(&proof)));
1855 // Made under another key: not ours.
1856 let foreign = email_proof(b"another key", "inv_1", Some("ada@example.com")).unwrap();
1857 assert!(!proves("inv_1", Some("ada@example.com"), "ada@example.com", Some(&foreign)));
1858 // Without a key (development) none is made, and none is taken.
1859 assert_eq!(email_proof(b"", "inv_1", Some("ada@example.com")), None);
1860 let unkeyed = crypto::invite_proof(b"", "inv_1", "ada@example.com");
1861 assert!(!proves_email(b"", "inv_1", Some("ada@example.com"), "ada@example.com", Some(&unkeyed)));
1862 }
1863
1864 #[test]
1865 fn an_account_starts_confirmed_only_from_the_invite_email_to_its_address() {
1866 let invite = row(None, false, LATER);
1867 let proof = email_proof(KEY, &invite.id, invite.email.as_deref()).unwrap();
1868 // From the invite email, with the address it was sent to.
1869 assert!(starts_confirmed(KEY, false, Some(&invite), "ada@example.com", Some(&proof)));
1870 // The code alone (typed in, or a link passed on), or a bad proof.
1871 assert!(!starts_confirmed(KEY, false, Some(&invite), "ada@example.com", None));
1872 assert!(!starts_confirmed(KEY, false, Some(&invite), "ada@example.com", Some("0123")));
1873 // A different address than the invite's.
1874 assert!(!starts_confirmed(KEY, false, Some(&invite), "eve@example.com", Some(&proof)));
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.1875 // No invite, or one bound to no 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 confirm1876 assert!(!starts_confirmed(KEY, false, None, "ada@example.com", Some(&proof)));
1877 let unbound = InviteRow { email: None, ..row(None, false, LATER) };
1878 assert!(!starts_confirmed(KEY, false, Some(&unbound), "ada@example.com", Some(&proof)));
1879 // A workspace invite makes no account.
1880 let join = InviteRow { kind: "workspace".into(), ..row(None, false, LATER) };
1881 assert!(!starts_confirmed(KEY, false, Some(&join), "ada@example.com", Some(&proof)));
1882 // GitHub's confirmed address, whatever else.
1883 assert!(starts_confirmed(KEY, true, None, "ada@example.com", None));
1884 }
1885
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1886 #[test]
1887 fn addresses_are_checked_and_masked() {
1888 assert_eq!(normalize_email(" Ada@Example.COM ").as_deref(), Some("ada@example.com"));
1889 for bad in ["", "ada", "ada@", "@example.com", "ada@example", "a b@example.com", "ada@.com", "ada@example.", "a@b@c.com"] {
1890 assert_eq!(normalize_email(bad), None, "{bad}");
1891 }
1892 assert_eq!(mask_email("ada@example.com"), "a•••@example.com");
1893 assert_eq!(mask_email("x@example.com"), "x•••@example.com");
1894 }
1895
1896 #[test]
1897 fn rate_limits_count_in_hour_long_windows() {
1898 assert_eq!(bucket(0, HOUR_MS), 0);
1899 assert_eq!(bucket(HOUR_MS - 1, HOUR_MS), 0);
1900 assert_eq!(bucket(HOUR_MS, HOUR_MS), 1);
1901 // The limits stop guessing long before a code could be found, and
1902 // leave room for people who mistype.
1903 assert!((5..=100).contains(&FAILURES_PER_HOUR));
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.1904 // A household or an office can still sign up a few people, and a
1905 // script cannot make accounts by the thousand.
1906 assert!((3..=20).contains(&REGISTRATIONS_PER_HOUR));
1907 const { assert!(ANONYMOUS_REGISTRATIONS_PER_HOUR >= REGISTRATIONS_PER_HOUR) };
Compare: any two branches, the commits one has that the other does not, and the diff, with Open a pull request1908 }
1909}

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