Skip to content

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

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