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