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

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