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