flagon-io/g1t

public

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/services/identity/src/invites.rs

2,117 lines86,584 bytesCodeBlame

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 look1//! 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
21//! into a workspace always makes an invite bound to it, and costs one only
22//! when the address has no account, so the answer never says which.
23//!
24//! Vars: REGISTRATION_MODE (`invite` | `open`), INVITES_PER_USER,
25//! INVITE_TTL_DAYS, INVITE_STAFF_WORKSPACES (comma separated slugs).
26
27use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
28use g1t_contracts::events::{InviteCreated, InviteRedeemed, WaitlistRequested};
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
40/// Crockford base32, as ids use: no i, l, o or u.
41const ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
42/// 32 characters of 5 bits: 160 random bits.
43const CODE_LENGTH: usize = 32;
44const GROUP: usize = 4;
45
46pub const INVALID: &str =
47 "That invite code is not valid. It may have been used, revoked or expired; ask whoever invited you for a new one.";
48pub const WRONG_EMAIL: &str = "This invite is for a different email address. Use the address it was sent to.";
49pub const MISSING: &str = "g1t is invite-only for now. Enter your invite code, or request access.";
50const TOO_MANY: &str = "Too many attempts. Try again in an hour.";
51const PEOPLE_ONLY: &str = "Only a person can make invites, not an agent or a workspace's token.";
52const CONFIRM_FIRST: &str = "Confirm your email address before inviting anyone.";
53const BAD_EMAIL: &str = "Enter a valid email address.";
54
55const HOUR_MS: u64 = 60 * 60 * 1000;
56/// Invites one person may make in an hour, whatever their allowance.
57const CREATES_PER_HOUR: u32 = 20;
58/// Wrong codes one client may try in an hour before being turned away.
59const FAILURES_PER_HOUR: u32 = 20;
60/// Access requests from one client in an hour.
61const REQUESTS_PER_HOUR: u32 = 5;
62/// Access requests from clients that sent no address, together, in an hour.
63const ANONYMOUS_REQUESTS_PER_HOUR: u32 = 200;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas64/// Confirmations of access requests, to everyone together, in an hour.
65const CONFIRMATIONS_PER_HOUR: u32 = 300;
66/// The least time between two summaries of new requests to staff.
67const SUMMARY_EVERY_MS: u64 = 15 * 60 * 1000;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look68/// The most invites a person's or workspace's list shows.
69const LIST_LIMIT: u32 = 200;
70/// How far down the invite tree staff see.
71const TREE_DEPTH: usize = 3;
72
73// --- Codes ------------------------------------------------------------------
74
75/// The 32 characters of a code from 20 random bytes.
76fn encode(bytes: &[u8; 20]) -> String {
77 let mut out = String::with_capacity(CODE_LENGTH);
78 let (mut buffer, mut bits) = (0u32, 0u32);
79 for &byte in bytes {
80 buffer = (buffer << 8) | u32::from(byte);
81 bits += 8;
82 while bits >= 5 {
83 bits -= 5;
84 out.push(ALPHABET[((buffer >> bits) & 31) as usize] as char);
85 }
86 buffer &= (1 << bits) - 1;
87 }
88 out
89}
90
91/// A new code's 32 characters.
92pub fn new_code_body() -> String {
93 let mut bytes = [0u8; 20];
94 getrandom::getrandom(&mut bytes).expect("no source of randomness");
95 encode(&bytes)
96}
97
98/// How a code is shown: `g1t-` and groups of four.
99pub fn format_code(body: &str) -> String {
100 let groups: Vec<&str> = body
101 .as_bytes()
102 .chunks(GROUP)
103 .map(|chunk| std::str::from_utf8(chunk).unwrap_or_default())
104 .collect();
105 format!("g1t-{}", groups.join("-"))
106}
107
108/// A code's 32 characters from however it was typed or pasted: any case,
109/// with or without `g1t-`, hyphens or spaces, or a whole invite link.
110/// Letters easily misread are read as Crockford reads them.
111pub fn normalize_code(input: &str) -> Option<String> {
112 let mut text = input.trim().to_ascii_lowercase();
113 // A pasted link: the last path segment, or the `invite` parameter.
114 if let Some(at) = text.find("invite=") {
115 text = text[at + "invite=".len()..].split('&').next().unwrap_or_default().to_owned();
116 } else if let Some(at) = text.rfind('/') {
117 text = text[at + 1..].to_owned();
118 }
119 let text = text.strip_prefix("g1t").unwrap_or(&text);
120 let mut body = String::with_capacity(CODE_LENGTH);
121 for c in text.chars() {
122 let c = match c {
123 '-' | ' ' | '_' => continue,
124 'i' | 'l' => '1',
125 'o' => '0',
126 c if ALPHABET.contains(&(c as u8)) && c.is_ascii() => c,
127 _ => return None,
128 };
129 body.push(c);
130 }
131 (body.len() == CODE_LENGTH).then_some(body)
132}
133
134/// What is stored to find a code.
135pub fn code_hash(body: &str) -> String {
136 crypto::sha256_hex(body)
137}
138
139/// The code's first group, kept to recognise it: 20 of its 160 bits.
140pub fn code_hint(body: &str) -> String {
141 format!("g1t-{}", &body[..GROUP])
142}
143
144// --- Rules --------------------------------------------------------------------
145
146/// Where an invite stands at `now`, from its row.
147pub fn status_of(revoked_at: Option<&str>, redeemed_at: Option<&str>, expires_at: &str, now: &str) -> InviteStatus {
148 if redeemed_at.is_some() {
149 InviteStatus::Redeemed
150 } else if revoked_at.is_some() {
151 InviteStatus::Revoked
152 } else if expires_at <= now {
153 InviteStatus::Expired
154 } else {
155 InviteStatus::Pending
156 }
157}
158
159/// Whether an invite in this state uses up one of an allowance: pending
160/// and used ones do; a revoked or expired one never used gives it back.
161#[cfg(test)]
162pub fn counts_against_allowance(status: InviteStatus) -> bool {
163 matches!(status, InviteStatus::Pending | InviteStatus::Redeemed)
164}
165
166/// The SQL condition that matches [`counts_against_allowance`] for rows of
167/// `invites` aliased `i`.
168fn counted_sql() -> String {
169 format!("(i.redeemed_at IS NOT NULL OR (i.revoked_at IS NULL AND i.expires_at > {SQL_NOW}))")
170}
171
172/// How many invites someone may have out: the default plus staff grants,
173/// never below zero; None for no limit.
174pub fn limit_for(default: u32, granted: i64, unlimited: bool) -> Option<u32> {
175 if unlimited {
176 return None;
177 }
178 Some((i64::from(default) + granted).clamp(0, i64::from(u32::MAX)) as u32)
179}
180
181/// Why an invite cannot make an account.
182#[derive(Debug, PartialEq, Eq)]
183pub enum Refusal {
184 /// Unknown, used, revoked, expired, or not for making accounts. One
185 /// answer for all, so codes cannot be probed.
186 Invalid,
187 /// It is bound to another address.
188 WrongEmail,
189}
190
191/// The parts of an invite that decide whether it admits someone.
192#[derive(Debug)]
193pub struct Admits<'a> {
194 pub kind: &'a str,
195 pub email: Option<&'a str>,
196 pub status: InviteStatus,
197}
198
199/// Whether an invite lets `email` make an account (`for_account`) or join
200/// its workspace with an existing one.
201pub fn admits(invite: Option<&Admits>, email: &str, for_account: bool) -> std::result::Result<(), Refusal> {
202 let Some(invite) = invite else {
203 return Err(Refusal::Invalid);
204 };
205 if invite.status != InviteStatus::Pending || (for_account && invite.kind != "account") {
206 return Err(Refusal::Invalid);
207 }
208 match invite.email {
209 Some(bound) if !bound.eq_ignore_ascii_case(email.trim()) => Err(Refusal::WrongEmail),
210 _ => Ok(()),
211 }
212}
213
214/// A trimmed, lowercased address, if it looks like one.
215pub fn normalize_email(email: &str) -> Option<String> {
216 let email = email.trim().to_lowercase();
217 let well_formed = email.len() <= 254
218 && email
219 .split_once('@')
220 .is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.') && !domain.starts_with('.') && !domain.ends_with('.') && !domain.contains('@'))
221 && !email.contains(char::is_whitespace);
222 well_formed.then_some(email)
223}
224
225/// An address with most of its local part hidden: `a•••@example.com`.
226pub fn mask_email(email: &str) -> String {
227 match email.split_once('@') {
228 Some((local, domain)) => {
229 let first: String = local.chars().take(1).collect();
230 format!("{first}•••@{domain}")
231 }
232 None => "•••".to_owned(),
233 }
234}
235
236/// The fixed window a moment falls in.
237pub fn bucket(now_ms: u64, window_ms: u64) -> u64 {
238 now_ms / window_ms
239}
240
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas241/// Whether staff may be sent a summary of new requests: none was sent yet,
242/// or the last went before `since` (15 minutes ago). RFC 3339 times.
243pub fn summary_due(last: Option<&str>, since: &str) -> bool {
244 last.is_none_or(|last| last <= since)
245}
246
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look247// --- Rows ---------------------------------------------------------------------
248
249const COLUMNS: &str = "i.id, i.hint, i.sealed_code, i.email, i.kind, i.workspace_id, w.slug AS workspace,
250 i.inviter_id, iu.username AS inviter, i.staff, i.charged_to, i.charged_workspace_id, i.created_at, i.expires_at,
251 i.revoked_at, i.redeemed_by, ru.username AS redeemer, i.redeemed_at
252 FROM invites i
253 LEFT JOIN workspaces w ON w.id = i.workspace_id
254 LEFT JOIN users iu ON iu.id = i.inviter_id
255 LEFT JOIN users ru ON ru.id = i.redeemed_by";
256
257#[derive(Debug, Deserialize)]
258pub struct InviteRow {
259 pub id: String,
260 pub hint: String,
261 pub sealed_code: Option<String>,
262 pub email: Option<String>,
263 pub kind: String,
264 pub workspace_id: Option<String>,
265 pub workspace: Option<String>,
266 pub inviter_id: Option<String>,
267 pub inviter: Option<String>,
268 pub staff: Option<String>,
269 pub charged_to: String,
270 pub created_at: String,
271 pub expires_at: String,
272 pub revoked_at: Option<String>,
273 pub redeemer: Option<String>,
274 pub redeemed_at: Option<String>,
275}
276
277impl InviteRow {
278 pub fn status(&self, now: &str) -> InviteStatus {
279 status_of(self.revoked_at.as_deref(), self.redeemed_at.as_deref(), &self.expires_at, now)
280 }
281
282 fn admits(&self, now: &str) -> Admits<'_> {
283 Admits {
284 kind: &self.kind,
285 email: self.email.as_deref(),
286 status: self.status(now),
287 }
288 }
289}
290
291fn kind_of(kind: &str) -> InviteKind {
292 if kind == "workspace" { InviteKind::Workspace } else { InviteKind::Account }
293}
294
295fn charge_of(charged_to: &str) -> InviteCharge {
296 match charged_to {
297 "user" => InviteCharge::User,
298 "workspace" => InviteCharge::Workspace,
299 _ => InviteCharge::None,
300 }
301}
302
303#[derive(Deserialize)]
304struct Count {
305 n: f64,
306}
307
308#[derive(Deserialize)]
309struct Id {
310 id: String,
311}
312
313#[derive(Deserialize)]
314struct WaitlistRow {
315 id: String,
316 email: String,
317 about: Option<String>,
318 status: String,
319 invite_id: Option<String>,
320 decided_by: Option<String>,
321 decided_at: Option<String>,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas322 #[serde(default)]
323 note: Option<String>,
324 #[serde(default)]
325 joined_as: Option<String>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look326 created_at: String,
327 updated_at: String,
328}
329
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas330const WAITLIST_COLUMNS: &str = "wl.id, wl.email, wl.about, wl.status, wl.invite_id, wl.decided_by, wl.decided_at, wl.note,
331 ju.username AS joined_as, wl.created_at, wl.updated_at
332 FROM waitlist wl
333 LEFT JOIN invites wi ON wi.id = wl.invite_id
334 LEFT JOIN users ju ON ju.id = wi.redeemed_by";
335
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look336impl From<WaitlistRow> for WaitlistEntry {
337 fn from(row: WaitlistRow) -> Self {
338 WaitlistEntry {
339 id: row.id,
340 email: row.email,
341 about: row.about,
342 status: match row.status.as_str() {
343 "invited" => WaitlistStatus::Invited,
344 "dismissed" => WaitlistStatus::Dismissed,
345 _ => WaitlistStatus::Waiting,
346 },
347 invite_id: row.invite_id,
348 decided_by: row.decided_by,
349 decided_at: row.decided_at,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas350 note: row.note,
351 joined_as: row.joined_as,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look352 created_at: row.created_at,
353 updated_at: row.updated_at,
354 }
355 }
356}
357
358/// What a new account is made from.
359pub struct NewAccount<'a> {
360 /// Checked by the caller: valid, and free.
361 pub username: &'a str,
362 /// Lowercased and checked by the caller.
363 pub email: &'a str,
364 /// Empty for an account with no password (made through GitHub).
365 pub password_hash: &'a str,
366 /// Whether the address is confirmed already (GitHub's verified email).
367 pub verified: bool,
368 pub invite_code: Option<&'a str>,
369 /// Who is asking, for rate limits.
370 pub client: Option<&'a str>,
371}
372
373/// What an invite was made for.
374struct Draft<'a> {
375 email: Option<&'a str>,
376 kind: &'a str,
377 /// The workspace using it joins.
378 workspace_id: Option<&'a str>,
379 inviter: Option<&'a User>,
380 staff: Option<&'a str>,
381 /// `user`, `workspace` or `none`; the workspace for `workspace`; and
382 /// the limit when there is one.
383 charged_to: &'a str,
384 charged_workspace_id: Option<&'a str>,
385 limit: Option<u32>,
386}
387
388impl Identity {
389 // --- Settings ---
390
391 pub fn registration_mode(&self) -> RegistrationMode {
392 RegistrationMode::parse(self.env.var("REGISTRATION_MODE").ok().map(|v| v.to_string()).as_deref())
393 }
394
395 /// Whether new accounts need an invite code.
396 pub fn invites_required(&self) -> bool {
397 self.registration_mode() == RegistrationMode::Invite
398 }
399
400 fn var_number(&self, name: &str) -> Option<u64> {
401 self.env.var(name).ok()?.to_string().trim().parse().ok()
402 }
403
404 fn invites_per_user(&self) -> u32 {
405 self.var_number("INVITES_PER_USER").map_or(INVITES_PER_USER, |n| n.min(u64::from(u32::MAX)) as u32)
406 }
407
408 fn invite_ttl_days(&self) -> u64 {
409 self.var_number("INVITE_TTL_DAYS").filter(|days| (1..=365).contains(days)).unwrap_or(INVITE_TTL_DAYS)
410 }
411
412 /// The workspaces whose owners invite without limit: g1t's own.
413 fn staff_workspaces(&self) -> Vec<String> {
414 self.env
415 .var("INVITE_STAFF_WORKSPACES")
416 .map(|v| v.to_string())
417 .unwrap_or_default()
418 .split(',')
419 .map(|slug| slug.trim().to_lowercase())
420 .filter(|slug| !slug.is_empty())
421 .collect()
422 }
423
424 fn invite_sealer(&self) -> Option<Sealer> {
425 Sealer::new(&self.env.secret("IDENTITY_KEY").ok()?.to_string())
426 }
427
428 // --- Rate limits ---
429
430 /// Counts one more hit on `key` this hour; false once past `limit`.
431 async fn hit(&self, key: &str, limit: u32) -> Result<bool> {
432 let now = bucket(now_ms(), HOUR_MS);
433 let hits = self
434 .db
435 .prepare(
436 "INSERT INTO rate_limits (key, bucket, hits) VALUES (?1, ?2, 1)
437 ON CONFLICT (key) DO UPDATE SET
438 hits = CASE WHEN rate_limits.bucket = excluded.bucket THEN rate_limits.hits + 1 ELSE 1 END,
439 bucket = excluded.bucket
440 RETURNING hits AS n",
441 )
442 .bind(&[key.into(), (now as f64).into()])?
443 .first::<Count>(None)
444 .await?
445 .map_or(1.0, |count| count.n);
446 if hits <= 1.0 {
447 // A new window: forget windows gone by.
448 self.db
449 .prepare("DELETE FROM rate_limits WHERE bucket < ?")
450 .bind(&[((now.saturating_sub(1)) as f64).into()])?
451 .run()
452 .await?;
453 }
454 Ok(hits <= f64::from(limit))
455 }
456
457 /// Hits on `key` this hour, without adding one.
458 async fn hits(&self, key: &str) -> Result<u32> {
459 Ok(self
460 .db
461 .prepare("SELECT hits AS n FROM rate_limits WHERE key = ? AND bucket = ?")
462 .bind(&[key.into(), (bucket(now_ms(), HOUR_MS) as f64).into()])?
463 .first::<Count>(None)
464 .await?
465 .map_or(0, |count| count.n as u32))
466 }
467
468 /// Whether `client` has tried too many wrong codes this hour.
469 async fn turned_away(&self, client: Option<&str>) -> Result<bool> {
470 Ok(match client {
471 Some(client) => self.hits(&format!("invite.fail:{}", crypto::sha256_hex(client))).await? >= FAILURES_PER_HOUR,
472 None => false,
473 })
474 }
475
476 async fn count_failure(&self, client: Option<&str>) -> Result<()> {
477 if let Some(client) = client {
478 self.hit(&format!("invite.fail:{}", crypto::sha256_hex(client)), FAILURES_PER_HOUR).await?;
479 }
480 Ok(())
481 }
482
483 // --- Reading ---
484
485 async fn invite_by_code(&self, code: &str) -> Result<Option<InviteRow>> {
486 let Some(body) = normalize_code(code) else {
487 return Ok(None);
488 };
489 self.db
490 .prepare(format!("SELECT {COLUMNS} WHERE i.code_hash = ?"))
491 .bind(&[code_hash(&body).into()])?
492 .first::<InviteRow>(None)
493 .await
494 }
495
496 async fn invite_by_id(&self, id: &str) -> Result<Option<InviteRow>> {
497 self.db
498 .prepare(format!("SELECT {COLUMNS} WHERE i.id = ?"))
499 .bind(&[id.into()])?
500 .first::<InviteRow>(None)
501 .await
502 }
503
504 /// An invite as shown, with its code when `reveal` and it is pending.
505 fn shown(&self, row: InviteRow, reveal: bool, staff_view: bool) -> Invite {
506 let now = rfc3339(now_ms());
507 let status = row.status(&now);
508 let code = if reveal && status == InviteStatus::Pending {
509 row.sealed_code
510 .as_deref()
511 .and_then(|sealed| self.invite_sealer()?.open(sealed, &row.id))
512 } else {
513 None
514 };
515 Invite {
516 id: row.id,
517 code,
518 hint: row.hint,
519 email: row.email,
520 kind: kind_of(&row.kind),
521 workspace: row.workspace,
522 status,
523 charged_to: charge_of(&row.charged_to),
524 invited_by: row.inviter,
525 redeemed_by: row.redeemer,
526 created_at: row.created_at,
527 expires_at: row.expires_at,
528 redeemed_at: row.redeemed_at,
529 revoked_at: row.revoked_at,
530 staff: if staff_view { row.staff } else { None },
531 }
532 }
533
534 async fn rows(&self, filter: &str, binds: &[JsValue], limit: u32) -> Result<Vec<InviteRow>> {
535 self.db
536 .prepare(format!("SELECT {COLUMNS} {filter} ORDER BY i.created_at DESC, i.id DESC LIMIT {limit}"))
537 .bind(binds)?
538 .all()
539 .await?
540 .results::<InviteRow>()
541 }
542
543 async fn granted(&self, target: GrantTarget, id: &str) -> Result<i64> {
544 Ok(self
545 .db
546 .prepare("SELECT COALESCE(SUM(amount), 0) AS n FROM invite_grants WHERE target_kind = ? AND target_id = ?")
547 .bind(&[target.as_str().into(), id.into()])?
548 .first::<Count>(None)
549 .await?
550 .map_or(0, |count| count.n as i64))
551 }
552
553 async fn is_invite_staff(&self, user_id: &str) -> Result<bool> {
554 let staff = self.staff_workspaces();
555 if staff.is_empty() {
556 return Ok(false);
557 }
558 let marks = vec!["?"; staff.len()].join(", ");
559 let mut binds: Vec<JsValue> = vec![user_id.into()];
560 binds.extend(staff.iter().map(|slug| JsValue::from(slug.as_str())));
561 Ok(self
562 .db
563 .prepare(format!(
564 "SELECT count(*) AS n FROM workspace_members m JOIN workspaces w ON w.id = m.workspace_id
565 WHERE m.user_id = ? AND m.role = 'owner' AND w.slug IN ({marks})"
566 ))
567 .bind(&binds)?
568 .first::<Count>(None)
569 .await?
570 .is_some_and(|count| count.n > 0.0))
571 }
572
573 async fn used(&self, column: &'static str, id: &str, charged_to: &str) -> Result<u32> {
574 Ok(self
575 .db
576 .prepare(format!(
577 "SELECT count(*) AS n FROM invites i WHERE i.{column} = ? AND i.charged_to = ? AND {}",
578 counted_sql()
579 ))
580 .bind(&[id.into(), charged_to.into()])?
581 .first::<Count>(None)
582 .await?
583 .map_or(0, |count| count.n as u32))
584 }
585
586 /// A person's own allowance.
587 pub async fn user_allowance(&self, user_id: &str) -> Result<Allowance> {
588 let unlimited = self.is_invite_staff(user_id).await?;
589 let granted = self.granted(GrantTarget::User, user_id).await?;
590 let used = self.used("inviter_id", user_id, "user").await?;
591 Ok(Allowance::new(limit_for(self.invites_per_user(), granted, unlimited), used))
592 }
593
594 /// A workspace's shared allowance: only what staff granted it.
595 async fn workspace_allowance(&self, workspace_id: &str) -> Result<Allowance> {
596 let granted = self.granted(GrantTarget::Workspace, workspace_id).await?;
597 let used = self.used("charged_workspace_id", workspace_id, "workspace").await?;
598 Ok(Allowance::new(limit_for(0, granted, false), used))
599 }
600
601 async fn workspace_id(&self, slug: &str) -> Result<Option<String>> {
602 Ok(self
603 .db
604 .prepare("SELECT id FROM workspaces WHERE slug = ?")
605 .bind(&[slug.trim().to_lowercase().into()])?
606 .first::<Id>(None)
607 .await?
608 .map(|row| row.id))
609 }
610
611 /// Whether an address is any account's: confirmed on one, or the
612 /// address a new account signed up with (emails.rs).
613 async fn email_has_account(&self, email: &str) -> Result<bool> {
614 self.email_in_use(email).await
615 }
616
617 // --- The gate ---
618
619 /// Makes an account: the only place one is made. While registration is
620 /// invite-only, `invite_code` must admit `email`; the code is spent in
621 /// the same transaction as the account is made. An invite for a
622 /// workspace also joins it. In open mode a code is used if it is good
623 /// and otherwise ignored.
624 pub async fn create_account(&self, new: NewAccount<'_>) -> Result<Outcome<User>> {
625 let required = self.invites_required();
626 let code = new.invite_code.map(str::trim).filter(|code| !code.is_empty());
627 let mut invite = None;
628 match code {
629 None if required => return Ok(Outcome::fail(FailureCode::Forbidden, MISSING)),
630 None => {}
631 Some(code) => {
632 if required && self.turned_away(new.client).await? {
633 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
634 }
635 let row = self.invite_by_code(code).await?;
636 let now = rfc3339(now_ms());
637 match admits(row.as_ref().map(|row| row.admits(&now)).as_ref(), new.email, true) {
638 Ok(()) => invite = row,
639 Err(_) if !required => {}
640 Err(refusal) => {
641 self.count_failure(new.client).await?;
642 let message = if refusal == Refusal::WrongEmail { WRONG_EMAIL } else { INVALID };
643 return Ok(Outcome::fail(FailureCode::Forbidden, message));
644 }
645 }
646 }
647 }
648
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas649 // An invite bound to this address arrived there: following its link
650 // proves the address as well as a confirmation link would, so no
651 // second email asks for it.
652 let verified = new.verified || invite.as_ref().is_some_and(|row| row.email.is_some());
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look653 let user = User {
654 id: new_id("usr", now_ms()),
655 username: new.username.to_owned(),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas656 verified,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look657 ..User::default()
658 };
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas659 let verified_at = if verified { SQL_NOW } else { "NULL" };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look660 let values = [
661 JsValue::from(user.id.as_str()),
662 new.username.into(),
663 new.email.into(),
664 new.password_hash.into(),
665 ];
666 let made = match &invite {
667 None => {
668 self.db
669 .prepare(format!(
670 "INSERT INTO users (id, username, email, password_hash, email_verified_at)
671 VALUES (?, ?, ?, ?, {verified_at})"
672 ))
673 .bind(&values)?
674 .run()
675 .await
676 .map(|_| ())
677 }
678 // Spend the code, then make the account only if this request
679 // spent it: one transaction, so a second use finds it gone.
680 Some(row) => {
681 let mut insert = values.to_vec();
682 insert.extend([JsValue::from(row.id.as_str()), user.id.as_str().into()]);
683 self.db
684 .batch(vec![
685 self.db
686 .prepare(format!(
687 "UPDATE invites SET redeemed_by = ?, redeemed_at = {SQL_NOW}, sealed_code = NULL
688 WHERE id = ? AND kind = 'account' AND redeemed_at IS NULL AND revoked_at IS NULL
689 AND expires_at > {SQL_NOW}"
690 ))
691 .bind(&[user.id.as_str().into(), row.id.as_str().into()])?,
692 self.db
693 .prepare(format!(
694 "INSERT INTO users (id, username, email, password_hash, email_verified_at)
695 SELECT ?, ?, ?, ?, {verified_at}
696 WHERE EXISTS (SELECT 1 FROM invites WHERE id = ? AND redeemed_by = ?)"
697 ))
698 .bind(&insert)?,
699 ])
700 .await
701 .map(|_| ())
702 }
703 };
704 if let Err(error) = made {
705 // Someone took the username or email a moment ago; nothing
706 // was written, the code included.
707 if error.to_string().contains("UNIQUE") {
708 return Ok(Outcome::fail(FailureCode::Conflict, "That username or email is already registered."));
709 }
710 return Err(error);
711 }
712 let exists = self
713 .db
714 .prepare("SELECT id FROM users WHERE id = ?")
715 .bind(&[user.id.as_str().into()])?
716 .first::<Id>(None)
717 .await?
718 .is_some();
719 if !exists {
720 // Another sign-up spent the code first.
721 self.count_failure(new.client).await?;
722 return Ok(Outcome::fail(FailureCode::Forbidden, INVALID));
723 }
724 if let Some(row) = invite {
725 self.after_redeemed(&row, &user, true).await?;
726 }
727 Ok(Outcome::Ok(user))
728 }
729
730 /// Joins the invite's workspace, and tells the event log and audit log.
731 async fn after_redeemed(&self, row: &InviteRow, user: &User, created_account: bool) -> Result<()> {
732 let mut joined = None;
733 if let (Some(workspace_id), Some(slug)) = (&row.workspace_id, &row.workspace) {
734 self.db
735 .prepare(
736 "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
737 VALUES (?, ?, 'member', ?)",
738 )
739 .bind(&[workspace_id.as_str().into(), user.id.as_str().into(), rfc3339(now_ms()).into()])?
740 .run()
741 .await?;
742 joined = Some(slug.clone());
743 }
744 // A code sent with an invitation to collaborate on a repository:
745 // using it accepts (access.rs).
746 if let Err(error) = self.accept_invitations_of_code(&row.id, user).await {
747 worker::console_error!("repository invitations for {} not accepted: {error}", row.id);
748 }
749 self.announce(
750 "invite.redeemed",
751 Some(&user.id),
752 InviteRedeemed {
753 invite_id: row.id.clone(),
754 user_id: user.id.clone(),
755 inviter_id: row.inviter_id.clone(),
756 workspace_id: row.workspace_id.clone(),
757 created_account,
758 },
759 )
760 .await;
761 if let Some(slug) = joined {
762 let message = match &row.inviter {
763 Some(inviter) => format!("Joined with an invite from {inviter}"),
764 None => "Joined with an invite from g1t".to_owned(),
765 };
766 self.audit_invites(user, "invite.redeemed", vec![slug], Surface::Web, message).await;
767 }
768 Ok(())
769 }
770
771 // --- People's invites ---
772
773 fn draft_allowed(user: &User) -> Option<&'static str> {
774 if user.kind != PrincipalKind::User || user.acting.is_some() {
775 return Some(PEOPLE_ONLY);
776 }
777 if !user.verified {
778 return Some(CONFIRM_FIRST);
779 }
780 None
781 }
782
783 /// Stores a new invite and returns it with its code, or None when the
784 /// allowance ran out between reading it and writing.
785 async fn insert_invite(&self, draft: Draft<'_>) -> Result<Option<Invite>> {
786 let body = new_code_body();
787 let code = format_code(&body);
788 let now = now_ms();
789 let id = new_id("inv", now);
790 let sealed = self.invite_sealer().map(|sealer| sealer.seal(&code, &id));
791 let expires_at = rfc3339(now + self.invite_ttl_days() * 24 * HOUR_MS);
792 let created_at = rfc3339(now);
793 let opt = |value: Option<&str>| value.map_or(JsValue::NULL, JsValue::from);
794 let mut binds = vec![
795 JsValue::from(id.as_str()),
796 code_hash(&body).into(),
797 code_hint(&body).into(),
798 opt(sealed.as_deref()),
799 opt(draft.email),
800 draft.kind.into(),
801 opt(draft.workspace_id),
802 opt(draft.inviter.map(|user| user.id.as_str())),
803 opt(draft.staff),
804 draft.charged_to.into(),
805 opt(draft.charged_workspace_id),
806 created_at.as_str().into(),
807 expires_at.as_str().into(),
808 ];
809 // The allowance is checked in the insert itself, so two invites made
810 // at once cannot both take the last one.
811 let guard = match (draft.charged_to, draft.limit) {
812 ("user", Some(limit)) => {
813 binds.extend([opt(draft.inviter.map(|user| user.id.as_str())), f64::from(limit).into()]);
814 format!(
815 "WHERE (SELECT count(*) FROM invites i WHERE i.inviter_id = ? AND i.charged_to = 'user' AND {}) < ?",
816 counted_sql()
817 )
818 }
819 ("workspace", Some(limit)) => {
820 binds.extend([opt(draft.charged_workspace_id), f64::from(limit).into()]);
821 format!(
822 "WHERE (SELECT count(*) FROM invites i WHERE i.charged_workspace_id = ? AND i.charged_to = 'workspace' AND {}) < ?",
823 counted_sql()
824 )
825 }
826 _ => String::new(),
827 };
828 let inserted = self
829 .db
830 .prepare(format!(
831 "INSERT INTO invites (id, code_hash, hint, sealed_code, email, kind, workspace_id, inviter_id,
832 staff, charged_to, charged_workspace_id, created_at, expires_at)
833 SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? {guard}
834 RETURNING id"
835 ))
836 .bind(&binds)?
837 .first::<Id>(None)
838 .await?;
839 if inserted.is_none() {
840 return Ok(None);
841 }
842 self.announce(
843 "invite.created",
844 draft.inviter.map(|user| user.id.as_str()),
845 InviteCreated {
846 invite_id: id.clone(),
847 inviter_id: draft.inviter.map(|user| user.id.clone()),
848 workspace_id: draft.workspace_id.map(str::to_owned),
849 bound: draft.email.is_some(),
850 },
851 )
852 .await;
853 let Some(row) = self.invite_by_id(&id).await? else {
854 return Ok(None);
855 };
856 let mut invite = self.shown(row, false, false);
857 invite.code = Some(code);
858 Ok(Some(invite))
859 }
860
861 fn out_of_invites() -> Outcome<Invite> {
862 Outcome::fail(
863 FailureCode::Limit,
864 "You have no invites left. Need more? Contact us at hey@flagon.io with the subject [g1t Invites].",
865 )
866 }
867
868 pub async fn create_invite(&self, a: CreateInviteArgs) -> Result<Outcome<Invite>> {
869 if let Some(reason) = Self::draft_allowed(&a.user) {
870 return Ok(Outcome::fail(FailureCode::Forbidden, reason));
871 }
872 let email = match a.email.as_deref().map(str::trim).filter(|email| !email.is_empty()) {
873 Some(email) => match normalize_email(email) {
874 Some(email) => Some(email),
875 None => return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL)),
876 },
877 None => None,
878 };
879 if !self.hit(&format!("invite.create:{}", a.user.id), CREATES_PER_HOUR).await? {
880 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
881 }
882 if let Some(email) = &email {
883 if self.email_has_account(email).await? {
884 return Ok(Outcome::fail(
885 FailureCode::Conflict,
886 "That address already has a g1t account. Add them to a workspace from its People page instead.",
887 ));
888 }
889 let pending = self
890 .rows(
891 &format!(
892 "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}"
893 ),
894 &[a.user.id.as_str().into(), email.as_str().into()],
895 1,
896 )
897 .await?;
898 if !pending.is_empty() {
899 return Ok(Outcome::fail(
900 FailureCode::Conflict,
901 "You already have a pending invite for that address. Revoke it to send a new one.",
902 ));
903 }
904 }
905 // A workspace's granted invites, for its owners.
906 let (workspace_id, charged_to, limit) = match a.workspace.as_deref().map(str::trim).filter(|slug| !slug.is_empty()) {
907 Some(slug) => {
908 let slug = slug.to_lowercase();
909 if a.user.role_in(&slug) != Some(Role::Owner) {
910 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a workspace's owners can use its invites."));
911 }
912 let Some(id) = self.workspace_id(&slug).await? else {
913 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
914 };
915 let allowance = self.workspace_allowance(&id).await?;
916 if allowance.exhausted() {
917 return Ok(Outcome::fail(
918 FailureCode::Limit,
919 format!("{slug} has no invites left. Need more? Contact us at hey@flagon.io with the subject [g1t Invites]."),
920 ));
921 }
922 (Some(id), "workspace", allowance.limit)
923 }
924 None => {
925 let allowance = self.user_allowance(&a.user.id).await?;
926 if allowance.exhausted() {
927 return Ok(Self::out_of_invites());
928 }
929 (None, "user", allowance.limit)
930 }
931 };
932 let draft = Draft {
933 email: email.as_deref(),
934 kind: "account",
935 workspace_id: None,
936 inviter: Some(&a.user),
937 staff: None,
938 charged_to,
939 charged_workspace_id: workspace_id.as_deref(),
940 limit,
941 };
942 let Some(invite) = self.insert_invite(draft).await? else {
943 return Ok(Self::out_of_invites());
944 };
945 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 quotas946 let from = self.display_name(&a.user).await;
947 self.send_invite_email(email, Some(&from), None, false, code, None).await;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look948 }
949 let logs: Vec<String> = a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect();
950 self.audit_invites(&a.user, "invite.created", logs, a.surface.unwrap_or(Surface::Web), format!("Created invite {}", invite.hint))
951 .await;
952 Ok(Outcome::Ok(invite))
953 }
954
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas955 async fn send_invite_email(
956 &self,
957 to: &str,
958 from: Option<&str>,
959 workspace: Option<&str>,
960 existing: bool,
961 code: &str,
962 note: Option<&str>,
963 ) {
964 let invite = crate::email::InviteEmail {
965 to,
966 from,
967 workspace,
968 joins_existing_account: existing,
969 code,
970 days: self.invite_ttl_days(),
971 note,
972 };
973 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 look974 worker::console_error!("invite email failed: {error}");
975 }
976 }
977
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas978 /// How an invite names the person who sent it: their name, else their
979 /// username.
980 async fn display_name(&self, user: &User) -> String {
981 self.name_of("SELECT display_name AS name FROM users WHERE id = ?", &user.id)
982 .await
983 .unwrap_or_else(|| user.username.clone())
984 }
985
986 /// A workspace's name, as an invite shows it; its slug if it has none.
987 async fn workspace_name(&self, workspace_id: &str, slug: &str) -> String {
988 self.name_of("SELECT name FROM workspaces WHERE id = ?", workspace_id)
989 .await
990 .unwrap_or_else(|| slug.to_owned())
991 }
992
993 /// A name `sql` selects for `id`, if it has one. Only for wording an
994 /// email, so a failed read is no name.
995 async fn name_of(&self, sql: &str, id: &str) -> Option<String> {
996 #[derive(Deserialize)]
997 struct Name {
998 name: Option<String>,
999 }
1000 let read = async { self.db.prepare(sql).bind(&[id.into()])?.first::<Name>(None).await };
1001 read.await
1002 .ok()
1003 .flatten()
1004 .and_then(|row| row.name)
1005 .map(|name| name.trim().to_owned())
1006 .filter(|name| !name.is_empty())
1007 }
1008
1009 /// The address a pending invite is bound to, if it is: signing up with
1010 /// GitHub uses it when GitHub has confirmed it too (github.rs).
1011 pub(crate) async fn bound_email_of(&self, code: &str) -> Result<Option<String>> {
1012 let now = rfc3339(now_ms());
1013 Ok(self
1014 .invite_by_code(code)
1015 .await?
1016 .filter(|row| row.status(&now) == InviteStatus::Pending)
1017 .and_then(|row| row.email))
1018 }
1019
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1020 pub async fn list_invites(&self, a: UserArgs) -> Result<InvitesOverview> {
1021 let invites: Vec<Invite> = self
1022 .rows("WHERE i.inviter_id = ?", &[a.user.id.as_str().into()], LIST_LIMIT)
1023 .await?
1024 .into_iter()
1025 .map(|row| self.shown(row, true, false))
1026 .collect();
1027 let mut workspaces = Vec::new();
1028 for membership in a.user.workspaces.iter().filter(|membership| membership.role == Role::Owner) {
1029 if let Some(id) = self.workspace_id(&membership.slug).await?
1030 && self.granted(GrantTarget::Workspace, &id).await? != 0
1031 {
1032 workspaces.push(WorkspaceAllowance {
1033 slug: membership.slug.clone(),
1034 allowance: self.workspace_allowance(&id).await?,
1035 });
1036 }
1037 }
1038 Ok(InvitesOverview {
1039 mode: self.registration_mode(),
1040 allowance: self.user_allowance(&a.user.id).await?,
1041 workspaces,
1042 invites,
1043 })
1044 }
1045
1046 /// Revokes a pending invite the person made, or one made for (or
1047 /// charged to) a workspace they own.
1048 pub async fn revoke_invite(&self, a: RemoveArgs) -> Result<Outcome<Invite>> {
1049 if a.user.kind != PrincipalKind::User || a.user.acting.is_some() {
1050 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
1051 }
1052 let revoked = self
1053 .db
1054 .prepare(format!(
1055 "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL
1056 WHERE id = ?2 AND redeemed_at IS NULL AND revoked_at IS NULL
1057 AND (inviter_id = ?1
1058 OR workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = ?1 AND role = 'owner')
1059 OR charged_workspace_id IN (SELECT workspace_id FROM workspace_members WHERE user_id = ?1 AND role = 'owner'))
1060 RETURNING id"
1061 ))
1062 .bind(&[a.user.id.as_str().into(), a.id.as_str().into()])?
1063 .first::<Id>(None)
1064 .await?;
1065 let Some(Id { id }) = revoked else {
1066 return Ok(Outcome::fail(FailureCode::NotFound, "There is no pending invite of yours with that id."));
1067 };
1068 let Some(row) = self.invite_by_id(&id).await? else {
1069 return Ok(Outcome::fail(FailureCode::NotFound, "Invite not found."));
1070 };
1071 let logs = match &row.workspace {
1072 Some(slug) => vec![slug.clone()],
1073 None => a.user.workspaces.iter().map(|membership| membership.slug.clone()).collect(),
1074 };
1075 self.audit_invites(&a.user, "invite.revoked", logs, Surface::Web, format!("Revoked invite {}", row.hint)).await;
1076 Ok(Outcome::Ok(self.shown(row, false, false)))
1077 }
1078
1079 /// What an invite code is for: who sent it, and which workspace it
1080 /// joins. Any code that cannot be used gets the same answer.
1081 pub async fn check_invite(&self, a: InviteCodeArgs) -> Result<Outcome<InvitePreview>> {
1082 if self.turned_away(a.client.as_deref()).await? {
1083 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1084 }
1085 let now = rfc3339(now_ms());
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1086 // A spent code is still a real one (160 random bits): saying what
1087 // became of it tells a guesser nothing.
1088 let row = self
1089 .invite_by_code(&a.code)
1090 .await?
1091 .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 look1092 let Some(row) = row else {
1093 self.count_failure(a.client.as_deref()).await?;
1094 return Ok(Outcome::fail(FailureCode::NotFound, INVALID));
1095 };
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1096 let status = row.status(&now);
1097 let pending = status == InviteStatus::Pending;
1098 // Whether it is the viewer's: for one of their confirmed addresses,
1099 // or, once used, used by them.
1100 let for_viewer = match &a.viewer {
1101 Some(viewer) if viewer.kind == PrincipalKind::User => match (&row.email, status) {
1102 (_, InviteStatus::Redeemed) => Some(row.redeemer.as_deref() == Some(viewer.username.as_str())),
1103 (Some(bound), _) => {
1104 let mine = self.verified_emails(&viewer.id).await?;
1105 Some(mine.iter().any(|address| address.eq_ignore_ascii_case(bound.trim())))
1106 }
1107 (None, _) => None,
1108 },
1109 _ => None,
1110 };
1111 let has_account = match (&row.email, pending) {
1112 (Some(bound), true) => self.email_has_account(bound).await?,
1113 _ => false,
1114 };
1115 let repository = self.repository_of_code(&row.id).await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1116 #[derive(Deserialize)]
1117 struct From {
1118 username: String,
1119 name: Option<String>,
1120 avatar: Option<String>,
1121 }
1122 let invited_by = match &row.inviter_id {
1123 Some(id) => self
1124 .db
1125 .prepare("SELECT username, display_name AS name, avatar FROM users WHERE id = ?")
1126 .bind(&[id.as_str().into()])?
1127 .first::<From>(None)
1128 .await?
1129 .map(|from| InviteFrom {
1130 username: from.username,
1131 name: from.name,
1132 avatar: from.avatar,
1133 }),
1134 None => None,
1135 };
1136 let workspace = match &row.workspace_id {
1137 Some(id) => self
1138 .db
1139 .prepare("SELECT slug, name, avatar FROM workspaces WHERE id = ?")
1140 .bind(&[id.as_str().into()])?
1141 .first::<ProfileWorkspace>(None)
1142 .await?,
1143 None => None,
1144 };
1145 Ok(Outcome::Ok(InvitePreview {
1146 kind: kind_of(&row.kind),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1147 status,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1148 invited_by,
1149 workspace,
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1150 repository,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1151 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 quotas1152 address: row.email.clone().filter(|_| pending),
1153 has_account,
1154 for_viewer,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1155 expires_at: row.expires_at,
1156 }))
1157 }
1158
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1159 /// A signed-in person uses a workspace invite sent to their address,
1160 /// or one sent with a repository invitation.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1161 pub async fn accept_invite(&self, a: AcceptInviteArgs) -> Result<Outcome<String>> {
1162 if a.user.kind != PrincipalKind::User || a.user.acting.is_some() {
1163 return Ok(Outcome::fail(FailureCode::Forbidden, "Only a person can accept an invite."));
1164 }
1165 // Any of the person's confirmed addresses can match an invite bound
1166 // to one (emails.rs); the primary otherwise.
1167 let verified = self.verified_emails(&a.user.id).await?;
1168 let Some(primary) = verified.first().cloned() else {
1169 return Ok(Outcome::fail(FailureCode::Forbidden, "Confirm your email address first, then open the invite again."));
1170 };
1171 let now = rfc3339(now_ms());
1172 let row = self.invite_by_code(&a.code).await?;
1173 let email = row
1174 .as_ref()
1175 .and_then(|row| row.email.as_deref())
1176 .and_then(|bound| verified.iter().find(|address| address.eq_ignore_ascii_case(bound.trim())).cloned())
1177 .unwrap_or(primary);
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1178 // What using it gives an account that exists: a workspace, or a
1179 // repository it was sent with.
1180 let repository = match &row {
1181 Some(row) => self.repository_of_code(&row.id).await?,
1182 None => None,
1183 };
1184 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 look1185 if let Err(refusal) = admits(row.as_ref().map(|row| row.admits(&now)).as_ref(), &email, false) {
1186 return Ok(Outcome::fail(
1187 FailureCode::Forbidden,
1188 if refusal == Refusal::WrongEmail { WRONG_EMAIL } else { INVALID },
1189 ));
1190 }
1191 let Some(row) = row.filter(|_| joins) else {
1192 return Ok(Outcome::fail(
1193 FailureCode::Conflict,
1194 "You already have a g1t account, so this invite has nothing more to give you. Pass it on to someone who needs it.",
1195 ));
1196 };
1197 // What the workspace asks of its members (security.rs); nothing yet.
1198 if let Some(slug) = row.workspace.as_deref()
1199 && let Some(why) = self.policy_refusal(&a.user.id, slug).await?
1200 {
1201 return Ok(Outcome::fail(FailureCode::Forbidden, why));
1202 }
1203 let claimed = self
1204 .db
1205 .prepare(format!(
1206 "UPDATE invites SET redeemed_by = ?, redeemed_at = {SQL_NOW}, sealed_code = NULL
1207 WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL AND expires_at > {SQL_NOW}
1208 RETURNING id"
1209 ))
1210 .bind(&[a.user.id.as_str().into(), row.id.as_str().into()])?
1211 .first::<Id>(None)
1212 .await?;
1213 if claimed.is_none() {
1214 return Ok(Outcome::fail(FailureCode::Forbidden, INVALID));
1215 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1216 let lands = row
1217 .workspace
1218 .clone()
1219 .or_else(|| repository.map(|repository| repository.name))
1220 .unwrap_or_default();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1221 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 quotas1222 Ok(Outcome::Ok(lands))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1223 }
1224
1225 // --- Workspace invitations ---
1226
1227 pub async fn invite_member(&self, a: InviteMemberArgs) -> Result<Outcome<Invite>> {
1228 let slug = a.slug.trim().to_lowercase();
1229 if let Some(reason) = Self::draft_allowed(&a.actor) {
1230 return Ok(Outcome::fail(FailureCode::Forbidden, reason));
1231 }
1232 if a.actor.role_in(&slug) != Some(Role::Owner) {
1233 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can invite people to a workspace."));
1234 }
1235 let Some(email) = normalize_email(&a.email) else {
1236 return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL));
1237 };
1238 let Some(workspace_id) = self.workspace_id(&slug).await? else {
1239 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1240 };
1241 if !self.hit(&format!("invite.create:{}", a.actor.id), CREATES_PER_HOUR).await? {
1242 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1243 }
1244 let pending = self
1245 .rows(
1246 &format!(
1247 "WHERE i.workspace_id = ? AND i.email = ?
1248 AND i.redeemed_at IS NULL AND i.revoked_at IS NULL AND i.expires_at > {SQL_NOW}"
1249 ),
1250 &[workspace_id.as_str().into(), email.as_str().into()],
1251 1,
1252 )
1253 .await?;
1254 if !pending.is_empty() {
1255 return Ok(Outcome::fail(
1256 FailureCode::Conflict,
1257 "There is already a pending invite for that address. Revoke it to send a new one.",
1258 ));
1259 }
1260 let has_account = self.email_has_account(&email).await?;
1261 let draft = if has_account {
1262 // Costs nothing: the person is on g1t already.
1263 Draft {
1264 email: Some(&email),
1265 kind: "workspace",
1266 workspace_id: Some(&workspace_id),
1267 inviter: Some(&a.actor),
1268 staff: None,
1269 charged_to: "none",
1270 charged_workspace_id: None,
1271 limit: None,
1272 }
1273 } else {
1274 let shared = self.workspace_allowance(&workspace_id).await?;
1275 let (charged_to, charged_workspace_id, limit) = if shared.remaining.is_some_and(|left| left > 0) {
1276 ("workspace", Some(workspace_id.as_str()), shared.limit)
1277 } else {
1278 let own = self.user_allowance(&a.actor.id).await?;
1279 if own.exhausted() {
1280 return Ok(Self::out_of_invites());
1281 }
1282 ("user", None, own.limit)
1283 };
1284 Draft {
1285 email: Some(&email),
1286 kind: "account",
1287 workspace_id: Some(&workspace_id),
1288 inviter: Some(&a.actor),
1289 staff: None,
1290 charged_to,
1291 charged_workspace_id,
1292 limit,
1293 }
1294 };
1295 let Some(invite) = self.insert_invite(draft).await? else {
1296 return Ok(Self::out_of_invites());
1297 };
1298 if let Some(code) = &invite.code {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1299 let from = self.display_name(&a.actor).await;
1300 let workspace = self.workspace_name(&workspace_id, &slug).await;
1301 self.send_invite_email(&email, Some(&from), Some(&workspace), has_account, code, None).await;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1302 }
1303 self.audit_invites(
1304 &a.actor,
1305 "invite.created",
1306 vec![slug.clone()],
1307 a.surface.unwrap_or(Surface::Web),
1308 format!("Invited {email} to {slug}"),
1309 )
1310 .await;
1311 Ok(Outcome::Ok(invite))
1312 }
1313
1314 /// An invite code for an address without an account, invited to
1315 /// collaborate on one repository of `workspace_id` (access.rs). Charged
1316 /// as a workspace invite is: the workspace's shared invites first, then
1317 /// the inviter's own. The code joins no workspace; redeeming it accepts
1318 /// the repository invitation that names it.
1319 pub(crate) async fn repo_invite_code(&self, actor: &User, email: &str, workspace_id: &str) -> Result<Outcome<Invite>> {
1320 if let Some(reason) = Self::draft_allowed(actor) {
1321 return Ok(Outcome::fail(FailureCode::Forbidden, reason));
1322 }
1323 if !self.hit(&format!("invite.create:{}", actor.id), CREATES_PER_HOUR).await? {
1324 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1325 }
1326 let shared = self.workspace_allowance(workspace_id).await?;
1327 let (charged_to, charged_workspace_id, limit) = if shared.remaining.is_some_and(|left| left > 0) {
1328 ("workspace", Some(workspace_id), shared.limit)
1329 } else {
1330 let own = self.user_allowance(&actor.id).await?;
1331 if own.exhausted() {
1332 return Ok(Self::out_of_invites());
1333 }
1334 ("user", None, own.limit)
1335 };
1336 let draft = Draft {
1337 email: Some(email),
1338 kind: "account",
1339 workspace_id: None,
1340 inviter: Some(actor),
1341 staff: None,
1342 charged_to,
1343 charged_workspace_id,
1344 limit,
1345 };
1346 Ok(match self.insert_invite(draft).await? {
1347 Some(invite) => Outcome::Ok(invite),
1348 None => Self::out_of_invites(),
1349 })
1350 }
1351
1352 /// Revokes an invite code made for a repository invitation, when that
1353 /// invitation is revoked. Only a pending code changes.
1354 pub(crate) async fn revoke_code(&self, invite_id: &str) -> Result<()> {
1355 self.db
1356 .prepare(format!(
1357 "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL
1358 WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL"
1359 ))
1360 .bind(&[invite_id.into()])?
1361 .run()
1362 .await?;
1363 Ok(())
1364 }
1365
1366 pub async fn workspace_invites(&self, a: ListMembersArgs) -> Result<Outcome<Vec<Invite>>> {
1367 let slug = a.slug.trim().to_lowercase();
1368 if !a.viewer.as_ref().is_some_and(|viewer| viewer.role_in(&slug) == Some(Role::Owner)) {
1369 return Ok(Outcome::fail(FailureCode::Forbidden, "Only owners can see a workspace's invites."));
1370 }
1371 let Some(workspace_id) = self.workspace_id(&slug).await? else {
1372 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
1373 };
1374 let rows = self.rows("WHERE i.workspace_id = ?", &[workspace_id.as_str().into()], LIST_LIMIT).await?;
1375 Ok(Outcome::Ok(rows.into_iter().map(|row| self.shown(row, true, false)).collect()))
1376 }
1377
1378 pub async fn revoke_workspace_invite(&self, a: WorkspaceInviteArgs) -> Result<Outcome<Invite>> {
1379 let slug = a.slug.trim().to_lowercase();
1380 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
1381 return Ok(Outcome::fail(FailureCode::Forbidden, "Only an owner can revoke a workspace's invites."));
1382 }
1383 self.revoke_invite(RemoveArgs { user: a.actor, id: a.id }).await
1384 }
1385
1386 // --- The waitlist ---
1387
1388 pub async fn request_access(&self, a: RequestAccessArgs) -> Result<Outcome<bool>> {
1389 let Some(email) = normalize_email(&a.email) else {
1390 return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL));
1391 };
1392 let allowed = match a.client.as_deref().filter(|client| !client.is_empty()) {
1393 Some(client) => self.hit(&format!("waitlist:{}", crypto::sha256_hex(client)), REQUESTS_PER_HOUR).await?,
1394 None => self.hit("waitlist:anonymous", ANONYMOUS_REQUESTS_PER_HOUR).await?,
1395 };
1396 if !allowed {
1397 return Ok(Outcome::fail(FailureCode::Conflict, TOO_MANY));
1398 }
1399 let about: String = a.about.trim().chars().take(MAX_WAITLIST_ABOUT).collect();
1400 let now = rfc3339(now_ms());
1401 #[derive(Deserialize)]
1402 struct Upserted {
1403 id: String,
1404 created_at: String,
1405 }
1406 let row = self
1407 .db
1408 .prepare(
1409 "INSERT INTO waitlist (id, email, about, status, created_at, updated_at)
1410 VALUES (?1, ?2, ?3, 'waiting', ?4, ?4)
1411 ON CONFLICT (email) DO UPDATE SET
1412 about = COALESCE(excluded.about, waitlist.about), updated_at = excluded.updated_at
1413 RETURNING id, created_at",
1414 )
1415 .bind(&[
1416 new_id("wl", now_ms()).into(),
1417 email.as_str().into(),
1418 if about.is_empty() { JsValue::NULL } else { about.as_str().into() },
1419 now.as_str().into(),
1420 ])?
1421 .first::<Upserted>(None)
1422 .await?;
1423 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 quotas1424 self.announce("waitlist.requested", None, WaitlistRequested { entry_id: row.id.clone() }).await;
1425 self.acknowledge_request(&row.id, &email).await?;
1426 self.notify_staff_of_requests().await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1427 }
1428 Ok(Outcome::Ok(true))
1429 }
1430
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1431 /// The one confirmation an address gets for asking: claimed in the
1432 /// database first, so a repeat request (or two at once) never sends a
1433 /// second, and capped across everyone, since anyone can type any
1434 /// address.
1435 async fn acknowledge_request(&self, id: &str, email: &str) -> Result<()> {
1436 if !self.hit("waitlist.ack", CONFIRMATIONS_PER_HOUR).await? {
1437 return Ok(());
1438 }
1439 let claimed = self
1440 .db
1441 .prepare(format!(
1442 "UPDATE waitlist SET acknowledged_at = {SQL_NOW} WHERE id = ? AND acknowledged_at IS NULL RETURNING id"
1443 ))
1444 .bind(&[id.into()])?
1445 .first::<Id>(None)
1446 .await?;
1447 if claimed.is_none() {
1448 return Ok(());
1449 }
1450 if let Err(error) = crate::email::send_waitlist_confirmation(&self.env, email).await {
1451 worker::console_error!("waitlist confirmation failed: {error}");
1452 // Not sent: leave it unclaimed, so staff can see it was not.
1453 self.db
1454 .prepare("UPDATE waitlist SET acknowledged_at = NULL WHERE id = ?")
1455 .bind(&[id.into()])?
1456 .run()
1457 .await?;
1458 }
1459 Ok(())
1460 }
1461
1462 /// Where staff hear about new requests: WAITLIST_NOTIFY_EMAIL, unset or
1463 /// empty for nobody.
1464 fn waitlist_notify_email(&self) -> Option<String> {
1465 let to = self.env.var("WAITLIST_NOTIFY_EMAIL").ok()?.to_string();
1466 normalize_email(&to)
1467 }
1468
1469 /// Tells staff about every request they have not heard about, unless a
1470 /// summary went in the last 15 minutes: then the next request after
1471 /// that brings them all in one. The rows are claimed before sending, so
1472 /// two requests at once send one summary.
1473 pub(crate) async fn notify_staff_of_requests(&self) -> Result<()> {
1474 let Some(to) = self.waitlist_notify_email() else {
1475 return Ok(());
1476 };
1477 #[derive(Deserialize)]
1478 struct Last {
1479 at: Option<String>,
1480 }
1481 let last = self
1482 .db
1483 .prepare("SELECT max(notified_at) AS at FROM waitlist")
1484 .first::<Last>(None)
1485 .await?
1486 .and_then(|last| last.at);
1487 let now = now_ms();
1488 if !summary_due(last.as_deref(), &rfc3339(now.saturating_sub(SUMMARY_EVERY_MS))) {
1489 return Ok(());
1490 }
1491 let stamp = rfc3339(now);
1492 #[derive(Deserialize)]
1493 struct New {
1494 email: String,
1495 about: Option<String>,
1496 created_at: String,
1497 }
1498 let mut new = self
1499 .db
1500 .prepare(
1501 "UPDATE waitlist SET notified_at = ? WHERE notified_at IS NULL AND status = 'waiting'
1502 RETURNING email, about, created_at",
1503 )
1504 .bind(&[stamp.as_str().into()])?
1505 .all()
1506 .await?
1507 .results::<New>()?;
1508 if new.is_empty() {
1509 return Ok(());
1510 }
1511 new.sort_by(|a, b| a.created_at.cmp(&b.created_at));
1512 let waiting = self
1513 .db
1514 .prepare("SELECT count(*) AS n FROM waitlist WHERE status = 'waiting'")
1515 .first::<Count>(None)
1516 .await?
1517 .map_or(0, |count| count.n as u32);
1518 let new: Vec<crate::email::Requested> = new
1519 .into_iter()
1520 .map(|row| crate::email::Requested { email: row.email, about: row.about })
1521 .collect();
1522 if let Err(error) = crate::email::send_waitlist_summary(&self.env, &to, &new, waiting).await {
1523 worker::console_error!("waitlist summary failed: {error}");
1524 // Not sent: the next request tries again with these too.
1525 self.db
1526 .prepare("UPDATE waitlist SET notified_at = NULL WHERE notified_at = ?")
1527 .bind(&[stamp.as_str().into()])?
1528 .run()
1529 .await?;
1530 }
1531 Ok(())
1532 }
1533
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1534 // --- Staff ---
1535
1536 pub async fn admin_waitlist(&self, a: AdminWaitlistArgs) -> Result<Vec<WaitlistEntry>> {
1537 let mut filters = Vec::new();
1538 let mut binds: Vec<JsValue> = Vec::new();
1539 if let Some(status) = a.status {
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1540 filters.push("wl.status = ?".to_owned());
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1541 binds.push(status.as_str().into());
1542 }
1543 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 quotas1544 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 look1545 binds.push(pattern.as_str().into());
1546 binds.push(pattern.as_str().into());
1547 }
1548 let filter = if filters.is_empty() { String::new() } else { format!("WHERE {}", filters.join(" AND ")) };
1549 Ok(self
1550 .db
1551 .prepare(format!(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1552 "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 look1553 ))
1554 .bind(&binds)?
1555 .all()
1556 .await?
1557 .results::<WaitlistRow>()?
1558 .into_iter()
1559 .map(WaitlistEntry::from)
1560 .collect())
1561 }
1562
1563 async fn waitlist_entry(&self, id: &str) -> Result<Option<WaitlistRow>> {
1564 self.db
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1565 .prepare(format!("SELECT {WAITLIST_COLUMNS} WHERE wl.id = ?"))
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1566 .bind(&[id.into()])?
1567 .first::<WaitlistRow>(None)
1568 .await
1569 }
1570
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1571 /// How many requests are waiting, for sudo's navigation.
1572 pub async fn admin_waitlist_pending(&self) -> Result<u32> {
1573 Ok(self
1574 .db
1575 .prepare("SELECT count(*) AS n FROM waitlist WHERE status = 'waiting'")
1576 .first::<Count>(None)
1577 .await?
1578 .map_or(0, |count| count.n as u32))
1579 }
1580
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1581 pub async fn admin_decide_waitlist(&self, a: AdminDecideWaitlistArgs) -> Result<Outcome<WaitlistEntry>> {
1582 let Some(entry) = self.waitlist_entry(&a.id).await? else {
1583 return Ok(Outcome::fail(FailureCode::NotFound, "That request is not on the waitlist."));
1584 };
1585 let staff = a.staff.trim();
1586 if staff.is_empty() {
1587 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member decided."));
1588 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1589 if entry.status != "waiting" {
1590 return Ok(Outcome::fail(
1591 FailureCode::Conflict,
1592 format!("{} was already {} by {}.", entry.email, entry.status, entry.decided_by.as_deref().unwrap_or("staff")),
1593 ));
1594 }
1595 let note: String = a.note.as_deref().unwrap_or_default().trim().chars().take(MAX_WAITLIST_NOTE).collect();
1596 let note = (!note.is_empty()).then_some(note);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1597 let mut invite_id = JsValue::NULL;
1598 if a.approve {
1599 if self.email_has_account(&entry.email).await? {
1600 return Ok(Outcome::fail(FailureCode::Conflict, "That address already has a g1t account."));
1601 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1602 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 look1603 Outcome::Ok(invite) => invite,
1604 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
1605 };
1606 invite_id = minted.id.as_str().into();
1607 }
1608 self.db
1609 .prepare(format!(
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1610 "UPDATE waitlist SET status = ?, invite_id = COALESCE(?, invite_id), decided_by = ?, decided_at = {SQL_NOW},
1611 note = ?, notified_at = COALESCE(notified_at, {SQL_NOW})
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1612 WHERE id = ?"
1613 ))
1614 .bind(&[
1615 if a.approve { "invited" } else { "dismissed" }.into(),
1616 invite_id,
1617 staff.into(),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1618 note.as_deref().map_or(JsValue::NULL, JsValue::from),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1619 entry.id.as_str().into(),
1620 ])?
1621 .run()
1622 .await?;
1623 Ok(match self.waitlist_entry(&entry.id).await? {
1624 Some(row) => Outcome::Ok(row.into()),
1625 None => Outcome::fail(FailureCode::NotFound, "That request is not on the waitlist."),
1626 })
1627 }
1628
1629 pub async fn admin_invites(&self, a: AdminInvitesArgs) -> Result<Vec<Invite>> {
1630 let query = a.query.as_deref().map(str::trim).filter(|query| !query.is_empty());
1631 let rows = match query {
1632 None => self.rows("", &[], ADMIN_INVITES_LIMIT as u32).await?,
1633 Some(query) => {
1634 // A code, or its start: matched by its hint.
1635 let prefix = query.to_lowercase();
1636 let prefix = prefix.strip_prefix("g1t-").unwrap_or(&prefix).replace('-', "");
1637 let hint = (prefix.len() >= GROUP && prefix.chars().all(|c| ALPHABET.contains(&(c as u8))))
1638 .then(|| code_hint(&prefix));
1639 let pattern = crate::admin::like_pattern(Some(query)).unwrap_or_default();
1640 let mut binds: Vec<JsValue> = vec![pattern.as_str().into(), pattern.as_str().into(), pattern.as_str().into()];
1641 let mut filter = "WHERE (lower(i.email) LIKE ? ESCAPE '\\' OR iu.username LIKE ? ESCAPE '\\' OR ru.username LIKE ? ESCAPE '\\'".to_owned();
1642 if let Some(hint) = hint {
1643 filter.push_str(" OR i.hint = ?");
1644 binds.push(hint.into());
1645 }
1646 filter.push(')');
1647 self.rows(&filter, &binds, ADMIN_INVITES_LIMIT as u32).await?
1648 }
1649 };
1650 Ok(rows.into_iter().map(|row| self.shown(row, false, true)).collect())
1651 }
1652
1653 pub async fn admin_revoke_invite(&self, a: AdminRevokeInviteArgs) -> Result<Outcome<Invite>> {
1654 let revoked = self
1655 .db
1656 .prepare(format!(
1657 "UPDATE invites SET revoked_at = {SQL_NOW}, sealed_code = NULL
1658 WHERE id = ? AND redeemed_at IS NULL AND revoked_at IS NULL RETURNING id"
1659 ))
1660 .bind(&[a.id.as_str().into()])?
1661 .first::<Id>(None)
1662 .await?;
1663 if revoked.is_none() {
1664 return Ok(Outcome::fail(FailureCode::Conflict, "Only a pending invite can be revoked."));
1665 }
1666 worker::console_log!("invite {} revoked by staff {}", a.id, a.staff);
1667 Ok(match self.invite_by_id(&a.id).await? {
1668 Some(row) => Outcome::Ok(self.shown(row, false, true)),
1669 None => Outcome::fail(FailureCode::NotFound, "Invite not found."),
1670 })
1671 }
1672
1673 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 quotas1674 self.mint_staff_invite(a.email, &a.staff, None).await
1675 }
1676
1677 /// An invite staff make, emailed with `note` when it is for an address.
1678 async fn mint_staff_invite(&self, email: Option<String>, staff: &str, note: Option<&str>) -> Result<Outcome<Invite>> {
1679 let staff = staff.trim();
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1680 if staff.is_empty() {
1681 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member is minting it."));
1682 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1683 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 look1684 Some(email) => match normalize_email(email) {
1685 Some(email) => Some(email),
1686 None => return Ok(Outcome::fail(FailureCode::Invalid, BAD_EMAIL)),
1687 },
1688 None => None,
1689 };
1690 let draft = Draft {
1691 email: email.as_deref(),
1692 kind: "account",
1693 workspace_id: None,
1694 inviter: None,
1695 staff: Some(staff),
1696 charged_to: "none",
1697 charged_workspace_id: None,
1698 limit: None,
1699 };
1700 let Some(mut invite) = self.insert_invite(draft).await? else {
1701 return Ok(Outcome::fail(FailureCode::Conflict, "The invite could not be made. Try again."));
1702 };
1703 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 quotas1704 self.send_invite_email(email, None, None, false, code, note).await;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1705 }
1706 invite.staff = Some(staff.to_owned());
1707 Ok(Outcome::Ok(invite))
1708 }
1709
1710 pub async fn admin_grant_invites(&self, a: AdminGrantInvitesArgs) -> Result<Outcome<Allowance>> {
1711 if a.amount == 0 || a.amount.abs() > MAX_INVITE_GRANT {
1712 return Ok(Outcome::fail(FailureCode::Invalid, format!("Grant between 1 and {MAX_INVITE_GRANT} invites, or take some back with a negative number.")));
1713 }
1714 let staff = a.staff.trim();
1715 if staff.is_empty() {
1716 return Ok(Outcome::fail(FailureCode::Forbidden, "Say which staff member granted them."));
1717 }
1718 let name = a.name.trim().to_lowercase();
1719 let target_id = match a.target {
1720 GrantTarget::User => self
1721 .db
1722 .prepare("SELECT id FROM users WHERE username = ?")
1723 .bind(&[name.as_str().into()])?
1724 .first::<Id>(None)
1725 .await?
1726 .map(|row| row.id),
1727 GrantTarget::Workspace => self.workspace_id(&name).await?,
1728 };
1729 let Some(target_id) = target_id else {
1730 return Ok(Outcome::fail(FailureCode::NotFound, format!("There is no {} named {name}.", a.target.as_str())));
1731 };
1732 let note = a.note.trim();
1733 self.db
1734 .prepare(
1735 "INSERT INTO invite_grants (id, target_kind, target_id, amount, note, granted_by, created_at)
1736 VALUES (?, ?, ?, ?, ?, ?, ?)",
1737 )
1738 .bind(&[
1739 new_id("igr", now_ms()).into(),
1740 a.target.as_str().into(),
1741 target_id.as_str().into(),
1742 f64::from(a.amount).into(),
1743 if note.is_empty() { JsValue::NULL } else { note.into() },
1744 staff.into(),
1745 rfc3339(now_ms()).into(),
1746 ])?
1747 .run()
1748 .await?;
1749 Ok(Outcome::Ok(match a.target {
1750 GrantTarget::User => self.user_allowance(&target_id).await?,
1751 GrantTarget::Workspace => self.workspace_allowance(&target_id).await?,
1752 }))
1753 }
1754
1755 async fn grants(&self, target: GrantTarget, id: &str) -> Result<Vec<InviteGrant>> {
1756 #[derive(Deserialize)]
1757 struct Row {
1758 amount: f64,
1759 note: Option<String>,
1760 granted_by: String,
1761 created_at: String,
1762 }
1763 Ok(self
1764 .db
1765 .prepare(
1766 "SELECT amount, note, granted_by, created_at FROM invite_grants
1767 WHERE target_kind = ? AND target_id = ? ORDER BY created_at DESC LIMIT 100",
1768 )
1769 .bind(&[target.as_str().into(), id.into()])?
1770 .all()
1771 .await?
1772 .results::<Row>()?
1773 .into_iter()
1774 .map(|row| InviteGrant {
1775 amount: row.amount as i32,
1776 note: row.note,
1777 granted_by: row.granted_by,
1778 created_at: row.created_at,
1779 })
1780 .collect())
1781 }
1782
1783 /// Whom `user_id` invited, `depth` levels down.
1784 async fn invited_by_user(&self, user_id: &str, depth: usize) -> Result<Vec<InviteTreeNode>> {
1785 #[derive(Deserialize)]
1786 struct Row {
1787 id: String,
1788 username: String,
1789 redeemed_at: String,
1790 }
1791 let rows = self
1792 .db
1793 .prepare(
1794 "SELECT u.id, u.username, i.redeemed_at FROM invites i JOIN users u ON u.id = i.redeemed_by
1795 WHERE i.inviter_id = ? AND i.kind = 'account' ORDER BY i.redeemed_at LIMIT 200",
1796 )
1797 .bind(&[user_id.into()])?
1798 .all()
1799 .await?
1800 .results::<Row>()?;
1801 let mut nodes = Vec::with_capacity(rows.len());
1802 for row in rows {
1803 let invited = if depth > 1 { Box::pin(self.invited_by_user(&row.id, depth - 1)).await? } else { Vec::new() };
1804 nodes.push(InviteTreeNode {
1805 username: row.username,
1806 joined_at: row.redeemed_at,
1807 invited,
1808 });
1809 }
1810 Ok(nodes)
1811 }
1812
1813 pub async fn admin_invite_tree(&self, a: UsernameArgs) -> Result<Option<InviteTree>> {
1814 let name = a.username.trim().to_lowercase();
1815 let Some(user) = self
1816 .db
1817 .prepare("SELECT id FROM users WHERE username = ?")
1818 .bind(&[name.as_str().into()])?
1819 .first::<Id>(None)
1820 .await?
1821 else {
1822 return Ok(None);
1823 };
1824 // Up the tree: who invited them, and who invited that person.
1825 #[derive(Deserialize)]
1826 struct Parent {
1827 inviter_id: Option<String>,
1828 inviter: Option<String>,
1829 staff: Option<String>,
1830 }
1831 let mut invited_by = Vec::new();
1832 let mut staff = None;
1833 let mut current = user.id.clone();
1834 for _ in 0..20 {
1835 let parent = self
1836 .db
1837 .prepare(
1838 "SELECT i.inviter_id, u.username AS inviter, i.staff FROM invites i
1839 LEFT JOIN users u ON u.id = i.inviter_id
1840 WHERE i.redeemed_by = ? AND i.kind = 'account' LIMIT 1",
1841 )
1842 .bind(&[current.as_str().into()])?
1843 .first::<Parent>(None)
1844 .await?;
1845 let Some(parent) = parent else { break };
1846 if invited_by.is_empty() {
1847 staff = parent.staff.clone();
1848 }
1849 match (parent.inviter_id, parent.inviter) {
1850 (Some(id), Some(username)) if !invited_by.contains(&username) => {
1851 invited_by.push(username);
1852 current = id;
1853 }
1854 _ => break,
1855 }
1856 }
1857 let invites = self
1858 .rows("WHERE i.inviter_id = ?", &[user.id.as_str().into()], LIST_LIMIT)
1859 .await?
1860 .into_iter()
1861 .map(|row| self.shown(row, false, true))
1862 .collect();
1863 Ok(Some(InviteTree {
1864 username: name,
1865 invited_by,
1866 staff,
1867 allowance: self.user_allowance(&user.id).await?,
1868 grants: self.grants(GrantTarget::User, &user.id).await?,
1869 invites,
1870 invited: self.invited_by_user(&user.id, TREE_DEPTH).await?,
1871 }))
1872 }
1873
1874 pub async fn admin_workspace_invites(&self, a: SlugArgs) -> Result<Option<InviteTree>> {
1875 let slug = a.slug.trim().to_lowercase();
1876 let Some(id) = self.workspace_id(&slug).await? else {
1877 return Ok(None);
1878 };
1879 let invites = self
1880 .rows("WHERE i.workspace_id = ?1 OR i.charged_workspace_id = ?1", &[id.as_str().into()], LIST_LIMIT)
1881 .await?
1882 .into_iter()
1883 .map(|row| self.shown(row, false, true))
1884 .collect();
1885 Ok(Some(InviteTree {
1886 username: slug,
1887 invited_by: Vec::new(),
1888 staff: None,
1889 allowance: self.workspace_allowance(&id).await?,
1890 grants: self.grants(GrantTarget::Workspace, &id).await?,
1891 invites,
1892 invited: Vec::new(),
1893 }))
1894 }
1895
1896 // --- Audit ---
1897
1898 async fn audit_invites(&self, actor: &User, action: &str, workspaces: Vec<String>, surface: Surface, message: String) {
1899 let Ok(events) = self.env.service("EVENTS") else {
1900 return;
1901 };
1902 let entries: Vec<NewAuditEntry> = workspaces
1903 .into_iter()
1904 .map(|workspace| NewAuditEntry {
1905 actor: AuditActor::of(actor),
1906 action: action.to_owned(),
1907 surface,
1908 target: AuditTarget {
1909 workspace,
1910 ..AuditTarget::default()
1911 },
1912 outcome: AuditOutcome::Allowed,
1913 rule: "invite".to_owned(),
1914 result: Some("ok".to_owned()),
1915 message: Some(message.clone()),
1916 request_id: new_id("req", now_ms()),
1917 })
1918 .collect();
1919 if entries.is_empty() {
1920 return;
1921 }
1922 let recorded: Result<u32> = g1t_kit::call(&events, "audit_record", &RecordAuditArgs { entries }).await;
1923 if let Err(error) = recorded {
1924 worker::console_error!("{action} not recorded: {error}");
1925 }
1926 }
1927}
1928
1929/// Whether using the invite joins a workspace.
1930fn joins_workspace(row: &InviteRow) -> bool {
1931 row.workspace_id.is_some()
1932}
1933
1934#[cfg(test)]
1935mod tests {
1936 use super::*;
1937
1938 #[test]
1939 fn codes_carry_160_bits_in_eight_groups() {
1940 assert_eq!(encode(&[0u8; 20]), "0".repeat(32));
1941 assert_eq!(encode(&[0xff; 20]), "z".repeat(32));
1942 let body = new_code_body();
1943 assert_eq!(body.len(), CODE_LENGTH);
1944 assert!(body.bytes().all(|b| ALPHABET.contains(&b)));
1945 let code = format_code(&body);
1946 assert!(code.starts_with("g1t-"));
1947 assert_eq!(code.split('-').count(), 9);
1948 assert_eq!(code.len(), 4 + 32 + 7);
1949 // Every bit is used: one bit set shows in exactly one character.
1950 let mut bytes = [0u8; 20];
1951 bytes[19] = 1;
1952 assert_eq!(encode(&bytes), format!("{}1", "0".repeat(31)));
1953 }
1954
1955 #[test]
1956 fn codes_are_not_repeated() {
1957 let codes: std::collections::HashSet<String> = (0..2000).map(|_| new_code_body()).collect();
1958 assert_eq!(codes.len(), 2000);
1959 }
1960
1961 #[test]
1962 fn a_code_reads_however_it_is_typed_or_pasted() {
1963 let body = "k7m2q9xd4hpwabcd0123456789efghjk";
1964 let shown = format_code(body);
1965 for typed in [
1966 shown.clone(),
1967 shown.to_uppercase(),
1968 body.to_owned(),
1969 format!(" {} ", shown.replace('-', " ")),
1970 format!("https://g1t.sh/invite/{shown}"),
1971 format!("https://g1t.sh/register?invite={shown}&next=/"),
1972 ] {
1973 assert_eq!(normalize_code(&typed).as_deref(), Some(body), "{typed}");
1974 }
1975 // Letters people misread are read as Crockford reads them.
1976 assert_eq!(normalize_code(&"o".repeat(32)), Some("0".repeat(32)));
1977 assert_eq!(normalize_code(&"il".repeat(16)), Some("1".repeat(32)));
1978 assert_eq!(normalize_code("g1t-k7m2"), None);
1979 assert_eq!(normalize_code(&format!("{body}0")), None);
1980 assert_eq!(normalize_code(&"u".repeat(32)), None);
1981 assert_eq!(normalize_code(""), None);
1982 }
1983
1984 #[test]
1985 fn only_the_hash_and_a_short_hint_are_kept() {
1986 let body = "k7m2q9xd4hpwabcd0123456789efghjk";
1987 assert_eq!(code_hash(body), crypto::sha256_hex(body));
1988 assert_eq!(code_hash(body).len(), 64);
1989 assert_ne!(code_hash(body), code_hash(&body.replace('k', "m")));
1990 assert_eq!(code_hint(body), "g1t-k7m2");
1991 // The same code typed differently finds the same row.
1992 let typed = normalize_code(&format_code(body).to_uppercase()).unwrap();
1993 assert_eq!(code_hash(&typed), code_hash(body));
1994 }
1995
1996 const NOW: &str = "2026-10-05T12:00:00.000Z";
1997 const LATER: &str = "2026-11-04T12:00:00.000Z";
1998 const EARLIER: &str = "2026-10-01T12:00:00.000Z";
1999
2000 #[test]
2001 fn an_invite_is_pending_until_used_revoked_or_expired() {
2002 assert_eq!(status_of(None, None, LATER, NOW), InviteStatus::Pending);
2003 assert_eq!(status_of(None, None, EARLIER, NOW), InviteStatus::Expired);
2004 assert_eq!(status_of(None, None, NOW, NOW), InviteStatus::Expired);
2005 assert_eq!(status_of(Some(EARLIER), None, LATER, NOW), InviteStatus::Revoked);
2006 assert_eq!(status_of(None, Some(EARLIER), EARLIER, NOW), InviteStatus::Redeemed);
2007 }
2008
2009 #[test]
2010 fn revoked_and_expired_invites_give_the_allowance_back() {
2011 assert!(counts_against_allowance(InviteStatus::Pending));
2012 assert!(counts_against_allowance(InviteStatus::Redeemed));
2013 assert!(!counts_against_allowance(InviteStatus::Revoked));
2014 assert!(!counts_against_allowance(InviteStatus::Expired));
2015 // The SQL says the same: used, or neither revoked nor expired.
2016 let sql = counted_sql();
2017 assert!(sql.contains("i.redeemed_at IS NOT NULL OR (i.revoked_at IS NULL AND i.expires_at >"));
2018 }
2019
2020 #[test]
2021 fn allowances_are_five_plus_grants_or_unlimited_for_staff() {
2022 assert_eq!(limit_for(INVITES_PER_USER, 0, false), Some(5));
2023 assert_eq!(limit_for(5, 10, false), Some(15));
2024 assert_eq!(limit_for(5, -3, false), Some(2));
2025 assert_eq!(limit_for(5, -30, false), Some(0));
2026 assert_eq!(limit_for(5, 0, true), None);
2027 // A workspace has only what staff granted it.
2028 assert_eq!(limit_for(0, 0, false), Some(0));
2029 assert_eq!(limit_for(0, 25, false), Some(25));
2030 let full = Allowance::new(Some(5), 5);
2031 assert!(full.exhausted());
2032 assert_eq!(full.remaining, Some(0));
2033 let over = Allowance::new(Some(2), 4);
2034 assert_eq!(over.remaining, Some(0));
2035 let open = Allowance::new(None, 400);
2036 assert!(!open.exhausted());
2037 assert_eq!(open.remaining, None);
2038 assert_eq!(Allowance::new(Some(5), 3).remaining, Some(2));
2039 }
2040
2041 fn invite(kind: &'static str, email: Option<&'static str>, status: InviteStatus) -> Admits<'static> {
2042 Admits { kind, email, status }
2043 }
2044
2045 #[test]
2046 fn an_invite_admits_only_its_address_while_pending() {
2047 let open = invite("account", None, InviteStatus::Pending);
2048 assert_eq!(admits(Some(&open), "anyone@example.com", true), Ok(()));
2049 let bound = invite("account", Some("ada@example.com"), InviteStatus::Pending);
2050 assert_eq!(admits(Some(&bound), "ada@example.com", true), Ok(()));
2051 assert_eq!(admits(Some(&bound), " ADA@Example.com ", true), Ok(()));
2052 assert_eq!(admits(Some(&bound), "eve@example.com", true), Err(Refusal::WrongEmail));
2053 for status in [InviteStatus::Redeemed, InviteStatus::Revoked, InviteStatus::Expired] {
2054 assert_eq!(admits(Some(&invite("account", None, status)), "a@example.com", true), Err(Refusal::Invalid));
2055 // A dead code says nothing about whom it was for.
2056 assert_eq!(
2057 admits(Some(&invite("account", Some("ada@example.com"), status)), "eve@example.com", true),
2058 Err(Refusal::Invalid)
2059 );
2060 }
2061 assert_eq!(admits(None, "a@example.com", true), Err(Refusal::Invalid));
2062 }
2063
2064 #[test]
2065 fn a_workspace_invite_never_makes_an_account() {
2066 let join = invite("workspace", Some("ada@example.com"), InviteStatus::Pending);
2067 assert_eq!(admits(Some(&join), "ada@example.com", true), Err(Refusal::Invalid));
2068 assert_eq!(admits(Some(&join), "ada@example.com", false), Ok(()));
2069 assert_eq!(admits(Some(&join), "eve@example.com", false), Err(Refusal::WrongEmail));
2070 // An account invite for a workspace can be accepted by the address
2071 // once it has an account.
2072 let account = invite("account", Some("ada@example.com"), InviteStatus::Pending);
2073 assert_eq!(admits(Some(&account), "ada@example.com", false), Ok(()));
2074 }
2075
2076 #[test]
2077 fn addresses_are_checked_and_masked() {
2078 assert_eq!(normalize_email(" Ada@Example.COM ").as_deref(), Some("ada@example.com"));
2079 for bad in ["", "ada", "ada@", "@example.com", "ada@example", "a b@example.com", "ada@.com", "ada@example.", "a@b@c.com"] {
2080 assert_eq!(normalize_email(bad), None, "{bad}");
2081 }
2082 assert_eq!(mask_email("ada@example.com"), "a•••@example.com");
2083 assert_eq!(mask_email("x@example.com"), "x•••@example.com");
2084 }
2085
2086 #[test]
2087 fn rate_limits_count_in_hour_long_windows() {
2088 assert_eq!(bucket(0, HOUR_MS), 0);
2089 assert_eq!(bucket(HOUR_MS - 1, HOUR_MS), 0);
2090 assert_eq!(bucket(HOUR_MS, HOUR_MS), 1);
2091 // The limits stop guessing long before a code could be found, and
2092 // leave room for people who mistype.
2093 assert!((5..=100).contains(&FAILURES_PER_HOUR));
2094 const { assert!(CREATES_PER_HOUR >= INVITES_PER_USER) };
2095 const { assert!(REQUESTS_PER_HOUR >= 1) };
2096 }
2097
2098 #[test]
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas2099 fn staff_hear_about_requests_at_most_every_15_minutes() {
2100 assert_eq!(SUMMARY_EVERY_MS, 15 * 60 * 1000);
2101 let since = "2026-10-05T11:45:00.000Z";
2102 assert!(summary_due(None, since));
2103 assert!(summary_due(Some("2026-10-05T11:30:00.000Z"), since));
2104 assert!(summary_due(Some(since), since));
2105 assert!(!summary_due(Some("2026-10-05T11:50:00.000Z"), since));
2106 const { assert!(CONFIRMATIONS_PER_HOUR >= ANONYMOUS_REQUESTS_PER_HOUR) };
2107 }
2108
2109 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look2110 fn registration_is_invite_only_unless_opened() {
2111 assert_eq!(RegistrationMode::parse(None), RegistrationMode::Invite);
2112 assert_eq!(RegistrationMode::parse(Some("invite")), RegistrationMode::Invite);
2113 assert_eq!(RegistrationMode::parse(Some("")), RegistrationMode::Invite);
2114 assert_eq!(RegistrationMode::parse(Some("opne")), RegistrationMode::Invite);
2115 assert_eq!(RegistrationMode::parse(Some(" Open ")), RegistrationMode::Open);
2116 }
2117}