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,376 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

Merge branch 'worktree-agent-a985c01f43538af01'1//! A person's email addresses: adding, confirming, choosing the primary and
2//! the backup, removing, keeping them private, and finding whose an address
3//! is.
4//!
5//! `user_emails` holds every address. `users.primary_email_id` names the
6//! primary, and `users.email` and `users.email_verified_at` are kept as a
7//! copy of it (other code reads them, and "the account is confirmed" means
8//! its primary is). Every change to the primary here writes all three.
9//!
10//! A confirmed address belongs to one account: a unique index on confirmed
11//! rows makes sure of it. Unconfirmed rows may repeat across accounts; the
12//! first account to follow its confirmation link keeps the address, in one
13//! transaction that confirms its row only if nobody else's is confirmed,
14//! and then drops everyone else's unconfirmed row for it. An account whose
15//! primary was dropped that way (it never confirmed it) is left without one
16//! until it confirms another address, which becomes primary on its own.
17//!
18//! Sensitive changes (adding, removing, primary, backup) need proof that it
19//! is the person (`security.rs`), are written to their security log, are
20//! announced as `user.email_*` events, and are told to every confirmed
21//! address, the removed one included.
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)22//!
23//! A confirmation email carries a six-digit code and a link, either one
24//! enough. Both live in one `email_tokens` row, bound to the account and
25//! the address: the link's token as its id (a SHA-256), the code as an
26//! HMAC under IDENTITY_KEY ([`crypto::code_hash`]). Using either deletes
27//! the row, so the other stops working too, and sending another email for
28//! the address deletes the rows before it. Both work for
29//! [`CONFIRM_TTL_SECONDS`]. Wrong codes are throttled per account and per
30//! client (throttle.rs).
31//!
32//! An account with no confirmed primary is pending: the site, the API and
33//! git let it do nothing but confirm its address, change it, or sign out.
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)34//! The invite it signed up with was spent then, and what it gives is
35//! applied in the same transaction that confirms the address (invites.rs,
36//! `apply_invite_statements`): the workspace it names becomes a workspace
37//! invitation the person accepts or declines (invitations.rs).
Merge branch 'worktree-agent-a985c01f43538af01'38
39use std::collections::HashMap;
40
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)41use g1t_contracts::account_deletion::{GHOST_ID, GHOST_USERNAME};
Merge branch 'worktree-agent-a985c01f43538af01'42use g1t_contracts::accounts::*;
43use g1t_contracts::events::UserEmailChanged;
44use g1t_contracts::identity::{UserArgs, UsernameArgs};
45use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after};
46use g1t_contracts::{FailureCode, Outcome, new_id};
47use g1t_kit::now_ms;
48use serde::Deserialize;
49use worker::Result;
50use worker::wasm_bindgen::JsValue;
51
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)52use crate::email::Confirming;
53use crate::invites::AwaitingJoin;
Merge branch 'worktree-agent-a985c01f43538af01'54use crate::security::{PEOPLE_ONLY, is_person};
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)55use crate::throttle::{self, CODE_ACCOUNT, CODE_CLIENT, CODE_THROTTLED, CONFIRM_ACCOUNT};
56use crate::{Identity, crypto, email};
57
58/// The one answer to a code that does not confirm anything: wrong, used,
59/// expired, or for an address no longer on the account.
60pub const WRONG_CODE: &str = "That code is not right, or it has expired. Check the latest email from g1t, or send a new code.";
61
62/// The answer when a confirmation email was sent less than a minute ago.
63pub const SENT_RECENTLY: &str = "We sent an email less than a minute ago. Check your inbox, then try again.";
Merge branch 'worktree-agent-a985c01f43538af01'64
65/// One row of `user_emails`.
66#[derive(Clone, Debug, Deserialize)]
67pub struct EmailRow {
68 pub id: String,
69 pub email: String,
70 pub display: String,
71 pub verified_at: Option<String>,
72 pub sent_at: Option<String>,
73 pub created_at: String,
74}
75
76/// What `users` says about a person's addresses.
77#[derive(Debug, Deserialize)]
78struct AccountRow {
79 id: String,
80 username: String,
81 primary_email_id: Option<String>,
82 backup_email_id: Option<String>,
83 private_email: u8,
84 block_private_pushes: u8,
85 #[serde(default)]
86 created_at: String,
87}
88
89const ACCOUNT_COLUMNS: &str =
90 "id, username, primary_email_id, backup_email_id, private_email, block_private_pushes, created_at";
91
92/// The order addresses are shown in: the primary, confirmed ones, the rest;
93/// oldest first within each.
94fn sorted(mut rows: Vec<EmailRow>, primary: Option<&str>) -> Vec<EmailRow> {
95 rows.sort_by(|a, b| {
96 let rank = |row: &EmailRow| (Some(row.id.as_str()) != primary, row.verified_at.is_none());
97 rank(a).cmp(&rank(b)).then_with(|| a.created_at.cmp(&b.created_at)).then_with(|| a.id.cmp(&b.id))
98 });
99 rows
100}
101
102fn states(rows: &[EmailRow], primary: Option<&str>) -> Vec<EmailState> {
103 rows.iter()
104 .map(|row| EmailState {
105 email: row.email.clone(),
106 verified: row.verified_at.is_some(),
107 primary: Some(row.id.as_str()) == primary,
108 })
109 .collect()
110}
111
112/// The address commits g1t makes for a person carry: their noreply address
113/// while they keep their address private or have no confirmed primary.
114fn commit_email(private: bool, primary: Option<&EmailRow>, noreply: &str) -> String {
115 match primary {
116 Some(row) if !private && row.verified_at.is_some() => row.email.clone(),
117 _ => noreply.to_owned(),
118 }
119}
120
121fn view(account: &AccountRow, rows: Vec<EmailRow>) -> AccountEmails {
122 let primary = account.primary_email_id.as_deref();
123 let rows = sorted(rows, primary);
124 let noreply = noreply_address(&account.id, &account.username);
125 let private = account.private_email != 0;
126 let commit = commit_email(private, rows.iter().find(|row| Some(row.id.as_str()) == primary), &noreply);
127 AccountEmails {
128 emails: rows
129 .into_iter()
130 .map(|row| AccountEmail {
131 primary: Some(row.id.as_str()) == primary,
132 backup: Some(row.id.as_str()) == account.backup_email_id.as_deref(),
133 verified: row.verified_at.is_some(),
134 email: row.display,
135 created_at: row.created_at,
136 verified_at: row.verified_at,
137 })
138 .collect(),
139 private_email: private,
140 block_private_pushes: account.block_private_pushes != 0,
141 noreply,
142 commit_email: commit,
143 limit: MAX_EMAILS as u32,
144 }
145}
146
147/// Whether pushes by this person are checked for their addresses: only
148/// while the address is private and they asked for pushes to be blocked.
149fn guards_pushes(account: &AccountRow) -> bool {
150 account.private_email != 0 && account.block_private_pushes != 0
151}
152
153/// Whether a confirmation link may be sent again to an address last sent
154/// one at `sent_at`, at `now_ms`.
155pub fn may_resend(sent_at: Option<&str>, now_ms: u64) -> bool {
156 !is_recent(sent_at, now_ms, RESEND_SECONDS)
157}
158
159impl Identity {
160 async fn account_row(&self, user_id: &str) -> Result<Option<AccountRow>> {
161 self.db
162 .prepare(format!("SELECT {ACCOUNT_COLUMNS} FROM users WHERE id = ?"))
163 .bind(&[user_id.into()])?
164 .first::<AccountRow>(None)
165 .await
166 }
167
168 pub async fn email_rows(&self, user_id: &str) -> Result<Vec<EmailRow>> {
169 self.db
170 .prepare(
171 "SELECT id, email, display, verified_at, sent_at, created_at FROM user_emails
172 WHERE user_id = ? ORDER BY created_at, id",
173 )
174 .bind(&[user_id.into()])?
175 .all()
176 .await?
177 .results::<EmailRow>()
178 }
179
180 async fn emails_view(&self, user_id: &str) -> Result<Outcome<AccountEmails>> {
181 let Some(account) = self.account_row(user_id).await? else {
182 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
183 };
184 let rows = self.email_rows(user_id).await?;
185 Ok(Outcome::Ok(view(&account, rows)))
186 }
187
188 /// A person's confirmed addresses, lowercased, the primary first.
189 pub async fn verified_emails(&self, user_id: &str) -> Result<Vec<String>> {
190 let account = self.account_row(user_id).await?;
191 let primary = account.as_ref().and_then(|account| account.primary_email_id.as_deref());
192 Ok(sorted(self.email_rows(user_id).await?, primary)
193 .into_iter()
194 .filter(|row| row.verified_at.is_some())
195 .map(|row| row.email)
196 .collect())
197 }
198
199 /// The account that has confirmed `email`, by id. The one helper every
200 /// "does this address belong to someone" question goes through (signing
201 /// in with GitHub, invites, password resets, signing in by email).
202 pub async fn user_with_verified_email(&self, email: &str) -> Result<Option<String>> {
203 #[derive(Deserialize)]
204 struct Owner {
205 user_id: String,
206 }
207 let Some(email) = normalize_email(email) else {
208 return Ok(None);
209 };
210 Ok(self
211 .db
212 .prepare("SELECT user_id FROM user_emails WHERE email = ? AND verified_at IS NOT NULL")
213 .bind(&[email.as_str().into()])?
214 .first::<Owner>(None)
215 .await?
216 .map(|owner| owner.user_id))
217 }
218
219 /// Whether `email` is any account's: confirmed, or the unconfirmed
220 /// primary a new account signed up with.
221 pub async fn email_in_use(&self, email: &str) -> Result<bool> {
222 let Some(email) = normalize_email(email) else {
223 return Ok(false);
224 };
225 let found = self
226 .db
227 .prepare(
228 "SELECT e.id FROM user_emails e JOIN users u ON u.id = e.user_id
229 WHERE e.email = ?1 AND (e.verified_at IS NOT NULL OR u.primary_email_id = e.id) LIMIT 1",
230 )
231 .bind(&[email.as_str().into()])?
232 .first::<serde_json::Value>(None)
233 .await?;
234 Ok(found.is_some())
235 }
236
237 /// `list_emails`.
238 pub async fn list_emails(&self, a: UserArgs) -> Result<Outcome<AccountEmails>> {
239 if !is_person(&a.user) {
240 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
241 }
242 self.emails_view(&a.user.id).await
243 }
244
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)245 /// The key confirmation codes are kept under: IDENTITY_KEY, or none in
246 /// a development setup without one.
247 fn code_key(&self) -> Vec<u8> {
248 self.env.secret("IDENTITY_KEY").map(|key| key.to_string().into_bytes()).unwrap_or_default()
249 }
250
251 /// Stores a new code and link for one address, ending any sent before
252 /// for it, and emails them.
253 async fn send_confirmation(&self, user_id: &str, username: &str, row: &EmailRow, confirming: Confirming) -> Result<()> {
Merge branch 'worktree-agent-a985c01f43538af01'254 let token = crypto::random_hex(32);
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)255 let code = crypto::random_digits(CONFIRM_CODE_DIGITS);
256 let id = crypto::sha256_hex(&token);
257 let code_hash = crypto::code_hash(&self.code_key(), &id, &code);
Merge branch 'worktree-agent-a985c01f43538af01'258 self.db
259 .batch(vec![
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)260 // A new email ends the code and link of the one before.
261 self.db
262 .prepare("DELETE FROM email_tokens WHERE user_id = ? AND kind = 'verify' AND email_id = ?")
263 .bind(&[user_id.into(), row.id.as_str().into()])?,
Merge branch 'worktree-agent-a985c01f43538af01'264 self.db
265 .prepare(format!(
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)266 "INSERT INTO email_tokens (id, user_id, kind, expires_at, email_id, code_hash)
267 VALUES (?, ?, 'verify', {}, ?, ?)",
268 sql_after(CONFIRM_TTL_SECONDS)
Merge branch 'worktree-agent-a985c01f43538af01'269 ))
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)270 .bind(&[id.as_str().into(), user_id.into(), row.id.as_str().into(), code_hash.as_str().into()])?,
Merge branch 'worktree-agent-a985c01f43538af01'271 self.db
272 .prepare(format!("UPDATE user_emails SET sent_at = {SQL_NOW} WHERE id = ?"))
273 .bind(&[row.id.as_str().into()])?,
274 ])
275 .await?;
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)276 email::send_confirmation(&self.env, &row.display, username, confirming, &token, &code).await
Merge branch 'worktree-agent-a985c01f43538af01'277 }
278
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)279 /// Sends a new account its first code and link, to its primary.
280 pub async fn send_primary_confirmation(&self, user_id: &str, username: &str) -> Result<()> {
281 let Some(account) = self.account_row(user_id).await? else {
282 return Ok(());
283 };
284 let rows = self.email_rows(user_id).await?;
285 let Some(row) = rows.iter().find(|row| Some(row.id.as_str()) == account.primary_email_id.as_deref()) else {
286 return Ok(());
287 };
288 if row.verified_at.is_some() {
289 return Ok(());
290 }
291 self.send_confirmation(user_id, username, row, Confirming::NewAccount).await
292 }
293
Merge branch 'worktree-agent-a985c01f43538af01'294 /// `add_email`.
295 pub async fn add_email(&self, a: AccountEmailArgs) -> Result<Outcome<AccountEmails>> {
296 if !is_person(&a.user) {
297 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
298 }
299 let typed = a.email.trim();
300 let Some(email) = normalize_email(typed) else {
301 return Ok(Outcome::fail(FailureCode::Invalid, "Enter a valid email address."));
302 };
303 if parse_noreply(&email).is_some() || email.ends_with("@users.g1t.sh") {
304 return Ok(Outcome::fail(FailureCode::Invalid, "That is a g1t noreply address; add an address you receive mail at."));
305 }
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.306 if self.email_blocked(&email).await? {
307 return Ok(Outcome::fail(FailureCode::Forbidden, crate::suspension::BLOCKED));
308 }
Merge branch 'worktree-agent-a985c01f43538af01'309 if let Some(refusal) = self.proof(&a.user.id, &a.reauth).await?.refusal() {
310 return Ok(refusal);
311 }
312 let rows = self.email_rows(&a.user.id).await?;
313 if let Some(row) = rows.iter().find(|row| row.email == email) {
314 if row.verified_at.is_some() {
315 return Ok(Outcome::fail(FailureCode::Conflict, "That address is already on your account."));
316 }
317 // Added before and not confirmed: adding it again sends the link again.
318 if may_resend(row.sent_at.as_deref(), now_ms()) && self.allow(CONFIRM_ACCOUNT, &a.user.id).await? {
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)319 self.send_confirmation(&a.user.id, &a.user.username, row, Confirming::AddedAddress).await?;
Merge branch 'worktree-agent-a985c01f43538af01'320 }
321 return self.emails_view(&a.user.id).await;
322 }
323 if rows.len() >= MAX_EMAILS {
324 return Ok(Outcome::fail(
325 FailureCode::Invalid,
326 format!("An account can have {MAX_EMAILS} addresses. Remove one first."),
327 ));
328 }
329 if self.user_with_verified_email(&email).await?.is_some() {
330 return Ok(Outcome::fail(FailureCode::Conflict, "That address is confirmed on another g1t account."));
331 }
332 let now = now_ms();
333 let row = EmailRow {
334 id: new_id("eml", now),
335 email: email.clone(),
336 display: typed.to_owned(),
337 verified_at: None,
338 sent_at: None,
339 created_at: rfc3339(now),
340 };
341 let inserted = self
342 .db
343 .prepare(
344 "INSERT INTO user_emails (id, user_id, email, display, created_at)
345 SELECT ?1, ?2, ?3, ?4, ?5
346 WHERE (SELECT count(*) FROM user_emails WHERE user_id = ?2) < ?6",
347 )
348 .bind(&[
349 row.id.as_str().into(),
350 a.user.id.as_str().into(),
351 row.email.as_str().into(),
352 row.display.as_str().into(),
353 row.created_at.as_str().into(),
354 (MAX_EMAILS as f64).into(),
355 ])?
356 .run()
357 .await;
358 if let Err(error) = inserted {
359 // The same address added twice at once.
360 if error.to_string().contains("UNIQUE") {
361 return self.emails_view(&a.user.id).await;
362 }
363 return Err(error);
364 }
365 if !self.allow(CONFIRM_ACCOUNT, &a.user.id).await? {
366 worker::console_log!("confirmation email held back: too many this hour");
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)367 } else if let Err(error) = self.send_confirmation(&a.user.id, &a.user.username, &row, Confirming::AddedAddress).await {
Merge branch 'worktree-agent-a985c01f43538af01'368 worker::console_error!("confirmation email failed: {error}");
369 }
370 self.log_security(&a.user.id, "email_added", Some(&row.display), None).await;
371 self.tell_addresses(&a.user.id, &a.user.username, &format!("{} was added", row.display), None).await;
372 self.announce_email("user.email_added", &a.user.id, false).await;
373 self.emails_view(&a.user.id).await
374 }
375
376 /// `resend_email_verification`.
377 pub async fn resend_email_verification(&self, a: AccountEmailArgs) -> Result<Outcome<bool>> {
378 if !is_person(&a.user) {
379 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
380 }
381 let email = normalize_email(&a.email).unwrap_or_default();
382 let rows = self.email_rows(&a.user.id).await?;
383 let Some(row) = rows.iter().find(|row| row.email == email) else {
384 return Ok(Outcome::fail(FailureCode::NotFound, "That address is not on your account."));
385 };
386 if row.verified_at.is_some() {
387 return Ok(Outcome::fail(FailureCode::Conflict, "That address is already confirmed."));
388 }
389 if !may_resend(row.sent_at.as_deref(), now_ms()) {
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)390 return Ok(Outcome::fail(FailureCode::Conflict, SENT_RECENTLY));
Merge branch 'worktree-agent-a985c01f43538af01'391 }
392 if !self.allow(CONFIRM_ACCOUNT, &a.user.id).await? {
393 return Ok(Outcome::fail(FailureCode::Conflict, "Too many confirmation emails this hour. Check your inbox, or try again later."));
394 }
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)395 let confirming = if self.account_confirmed(&a.user.id).await? { Confirming::AddedAddress } else { Confirming::NewAccount };
396 self.send_confirmation(&a.user.id, &a.user.username, row, confirming).await?;
Merge branch 'worktree-agent-a985c01f43538af01'397 Ok(Outcome::Ok(true))
398 }
399
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)400 /// Whether the account has a confirmed primary: whether it is past the
401 /// confirmation page.
402 pub async fn account_confirmed(&self, user_id: &str) -> Result<bool> {
403 let Some(account) = self.account_row(user_id).await? else {
404 return Ok(false);
405 };
406 Ok(self
407 .email_rows(user_id)
408 .await?
409 .iter()
410 .any(|row| Some(row.id.as_str()) == account.primary_email_id.as_deref() && row.verified_at.is_some()))
411 }
412
413 /// The confirmation page's "send a new code": a new code and link for
414 /// the primary of an account that has not confirmed it, or for its
415 /// oldest unconfirmed address when a confirmed account elsewhere took
416 /// its primary. At most one a minute; the ones before stop working.
Merge branch 'worktree-agent-a985c01f43538af01'417 pub async fn resend_primary(&self, user: &g1t_contracts::User) -> Result<Outcome<bool>> {
418 let Some(account) = self.account_row(&user.id).await? else {
419 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
420 };
421 let rows = sorted(self.email_rows(&user.id).await?, account.primary_email_id.as_deref());
422 if rows.iter().any(|row| Some(row.id.as_str()) == account.primary_email_id.as_deref() && row.verified_at.is_some()) {
423 return Ok(Outcome::fail(FailureCode::Conflict, "This account's email is already confirmed."));
424 }
425 let Some(row) = rows.iter().find(|row| row.verified_at.is_none()) else {
426 return Ok(Outcome::fail(FailureCode::Conflict, "Add an email address in your settings first."));
427 };
428 if !may_resend(row.sent_at.as_deref(), now_ms()) {
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)429 return Ok(Outcome::fail(FailureCode::Conflict, SENT_RECENTLY));
430 }
431 self.send_confirmation(&user.id, &account.username, row, Confirming::NewAccount).await?;
432 Ok(Outcome::Ok(true))
433 }
434
435 /// `change_pending_email`: the confirmation page's "wrong address?".
436 /// Only for an account with no confirmed address: its unconfirmed
437 /// addresses are replaced by this one, which becomes the primary and
438 /// gets a new code and link. Needs no password: the account has nothing
439 /// yet that one would protect, and the new address still has to be
440 /// confirmed. Counts against the confirmation emails an hour.
441 pub async fn change_pending_email(&self, a: PendingEmailArgs) -> Result<Outcome<AccountEmails>> {
442 if !is_person(&a.user) {
443 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
444 }
445 let typed = a.email.trim();
446 let Some(email) = normalize_email(typed) else {
447 return Ok(Outcome::fail(FailureCode::Invalid, "Enter a valid email address."));
448 };
449 if parse_noreply(&email).is_some() || email.ends_with("@users.g1t.sh") {
450 return Ok(Outcome::fail(FailureCode::Invalid, "That is a g1t noreply address; use an address you receive mail at."));
451 }
452 let rows = self.email_rows(&a.user.id).await?;
453 if rows.iter().any(|row| row.verified_at.is_some()) {
454 return Ok(Outcome::fail(
455 FailureCode::Conflict,
456 "Your account has a confirmed address already. Change your addresses in your email settings.",
457 ));
Merge branch 'worktree-agent-a985c01f43538af01'458 }
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)459 if self.user_with_verified_email(&email).await?.is_some() {
460 return Ok(Outcome::fail(FailureCode::Conflict, "That address is confirmed on another g1t account."));
461 }
462 // The address it has already: nothing to change; the page's "send a
463 // new code" sends one.
464 if let [only] = rows.as_slice()
465 && only.email == email
466 {
467 return self.emails_view(&a.user.id).await;
468 }
469 if !self.allow(CONFIRM_ACCOUNT, &a.user.id).await? {
470 return Ok(Outcome::fail(FailureCode::Conflict, "Too many confirmation emails this hour. Check your inbox, or try again later."));
471 }
472 let now = now_ms();
473 let row = EmailRow {
474 id: new_id("eml", now),
475 email: email.clone(),
476 display: typed.to_owned(),
477 verified_at: None,
478 sent_at: None,
479 created_at: rfc3339(now),
480 };
481 let user = JsValue::from(a.user.id.as_str());
482 // One transaction: every unconfirmed address and its codes go, the
483 // new one comes in as the primary.
484 let changed = self
485 .db
Merge branch 'worktree-agent-a985c01f43538af01'486 .batch(vec![
487 self.db
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)488 .prepare("DELETE FROM email_tokens WHERE user_id = ? AND kind = 'verify'")
489 .bind(&[user.clone()])?,
490 self.db
491 .prepare("UPDATE users SET primary_email_id = NULL, backup_email_id = NULL, email = NULL, email_verified_at = NULL WHERE id = ?")
492 .bind(&[user.clone()])?,
493 self.db
494 .prepare("DELETE FROM user_emails WHERE user_id = ? AND verified_at IS NULL")
495 .bind(&[user.clone()])?,
Merge branch 'worktree-agent-a985c01f43538af01'496 self.db
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)497 .prepare("INSERT INTO user_emails (id, user_id, email, display, created_at) VALUES (?, ?, ?, ?, ?)")
498 .bind(&[
499 row.id.as_str().into(),
500 user.clone(),
501 row.email.as_str().into(),
502 row.display.as_str().into(),
503 row.created_at.as_str().into(),
504 ])?,
505 self.db
506 .prepare("UPDATE users SET primary_email_id = ?, email = ? WHERE id = ?")
507 .bind(&[row.id.as_str().into(), row.email.as_str().into(), user.clone()])?,
Merge branch 'worktree-agent-a985c01f43538af01'508 ])
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)509 .await;
510 if let Err(error) = changed {
511 if error.to_string().contains("UNIQUE") {
512 return Ok(Outcome::fail(FailureCode::Conflict, "That address is already registered."));
513 }
514 return Err(error);
515 }
516 self.log_security(&a.user.id, "email_changed_before_confirming", Some(&row.display), None).await;
517 if let Err(error) = self.send_confirmation(&a.user.id, &a.user.username, &row, Confirming::NewAccount).await {
518 worker::console_error!("confirmation email failed: {error}");
519 }
520 self.announce_email("user.primary_email_changed", &a.user.id, false).await;
521 self.emails_view(&a.user.id).await
522 }
523
524 /// `confirm_email_code`: the code from a confirmation email, typed by
525 /// the signed-in person it was sent to. Every outstanding code of the
526 /// account is compared, each in constant time; wrong ones are counted
527 /// against the account and the client (throttle.rs). A right one is
528 /// used up with its link, then confirms the address it was sent to.
529 pub async fn confirm_email_code(&self, a: ConfirmEmailCodeArgs) -> Result<Outcome<EmailConfirmed>> {
530 #[derive(Deserialize)]
531 struct Sent {
532 id: String,
533 email_id: Option<String>,
534 code_hash: String,
535 expires_at: String,
536 }
537 if !is_person(&a.user) {
538 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
539 }
540 let account_key = throttle::key(CODE_ACCOUNT, &a.user.id);
541 let client_key = a
542 .client
543 .as_deref()
544 .filter(|client| !client.trim().is_empty())
545 .map(|client| throttle::key(CODE_CLIENT, client));
546 // While either key is locked, no code is even compared.
547 let mut locked = self.locked(&account_key).await?;
548 if !locked && let Some(client_key) = &client_key {
549 locked = self.locked(client_key).await?;
550 }
551 if locked {
552 return Ok(Outcome::fail(FailureCode::Conflict, CODE_THROTTLED));
553 }
554 let now = rfc3339(now_ms());
555 let sent: Vec<Sent> = match tidy_confirm_code(&a.code) {
556 Some(_) => self
557 .db
558 .prepare(
559 "SELECT id, email_id, code_hash, expires_at FROM email_tokens
560 WHERE user_id = ? AND kind = 'verify' AND code_hash IS NOT NULL",
561 )
562 .bind(&[a.user.id.as_str().into()])?
563 .all()
564 .await?
565 .results::<Sent>()?
566 .into_iter()
567 .filter(|sent| still_works(&sent.expires_at, &now))
568 .collect(),
569 None => Vec::new(),
570 };
571 let code = tidy_confirm_code(&a.code).unwrap_or_default();
572 let key = self.code_key();
573 let matched = matching_code(&key, &code, sent.iter().map(|sent| (sent.id.as_str(), sent.code_hash.as_str())))
574 .and_then(|index| sent.get(index));
575 // Used once: whoever deletes the row first has it.
576 let used = match matched {
577 Some(sent) => self
578 .db
579 .prepare("DELETE FROM email_tokens WHERE id = ? AND user_id = ? RETURNING id")
580 .bind(&[sent.id.as_str().into(), a.user.id.as_str().into()])?
581 .first::<serde_json::Value>(None)
582 .await?
583 .is_some(),
584 None => false,
585 };
586 let Some(sent) = matched.filter(|_| used) else {
587 self.count(CODE_ACCOUNT, &account_key).await?;
588 if let Some(client_key) = &client_key {
589 self.count(CODE_CLIENT, client_key).await?;
590 }
591 return Ok(Outcome::fail(FailureCode::Invalid, WRONG_CODE));
592 };
593 self.clear(&account_key).await?;
594 // Any other code and link for the same address go too.
595 self.db
596 .prepare("DELETE FROM email_tokens WHERE user_id = ? AND kind = 'verify' AND email_id IS ?")
597 .bind(&[a.user.id.as_str().into(), sent.email_id.as_deref().map_or(JsValue::NULL, Into::into)])?
598 .run()
Merge branch 'worktree-agent-a985c01f43538af01'599 .await?;
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)600 self.confirm_address(&a.user.id, sent.email_id.as_deref()).await
Merge branch 'worktree-agent-a985c01f43538af01'601 }
602
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)603 /// Confirms an address after its link was followed or its code typed:
604 /// `email_id`, or the primary for links sent before addresses had ids.
605 /// When this confirms the account, the invite it signed up with is
606 /// applied in the same transaction. Returns what it did, or why the
607 /// address could not be confirmed.
608 pub async fn confirm_address(&self, user_id: &str, email_id: Option<&str>) -> Result<Outcome<EmailConfirmed>> {
Merge branch 'worktree-agent-a985c01f43538af01'609 let Some(account) = self.account_row(user_id).await? else {
610 return Ok(Outcome::fail(FailureCode::Invalid, "This confirmation link is not valid or has expired."));
611 };
612 let Some(email_id) = email_id.map(str::to_owned).or(account.primary_email_id.clone()) else {
613 return Ok(Outcome::fail(FailureCode::Invalid, "This confirmation link is not valid or has expired."));
614 };
615 let rows = self.email_rows(user_id).await?;
616 let Some(row) = rows.into_iter().find(|row| row.id == email_id) else {
617 return Ok(Outcome::fail(
618 FailureCode::Conflict,
619 "That address was confirmed by another g1t account first, or was removed from yours.",
620 ));
621 };
622 if row.verified_at.is_some() {
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)623 return Ok(Outcome::Ok(EmailConfirmed {
624 username: account.username,
625 email: row.display,
626 verified: self.account_confirmed(user_id).await?,
627 ..EmailConfirmed::default()
628 }));
Merge branch 'worktree-agent-a985c01f43538af01'629 }
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)630 // The invite the account signed up with, if it waits for this.
631 let awaiting = self.awaiting_invite(user_id).await?;
632 let join = match &awaiting {
633 Some(invite) => Some(self.awaiting_join(invite).await),
634 None => None,
635 };
Merge branch 'worktree-agent-a985c01f43538af01'636 let won = |sql: &str| sql.replace("{WON}", "EXISTS (SELECT 1 FROM user_emails WHERE id = ?1 AND verified_at IS NOT NULL)");
637 let id = JsValue::from(row.id.as_str());
638 let user = JsValue::from(user_id);
639 let address = JsValue::from(row.email.as_str());
640 // One transaction. Confirm this row only if no account has the
641 // address confirmed; then, only if it was, drop everyone else's
642 // claim to it, and make it this account's primary when the account
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)643 // has no confirmed primary; then, if that confirmed the account,
644 // apply the invite it signed up with.
645 let mut statements = vec![
Merge branch 'worktree-agent-a985c01f43538af01'646 self.db
647 .prepare(format!(
648 "UPDATE user_emails SET verified_at = {SQL_NOW}
649 WHERE id = ?1 AND verified_at IS NULL
650 AND NOT EXISTS (SELECT 1 FROM user_emails WHERE email = ?2 AND verified_at IS NOT NULL)"
651 ))
652 .bind(&[id.clone(), address.clone()])?,
653 self.db
654 .prepare(won(
655 "UPDATE users SET email = NULL, email_verified_at = NULL, primary_email_id = NULL
656 WHERE id <> ?3 AND {WON} AND primary_email_id IN (
657 SELECT id FROM user_emails WHERE email = ?2 AND verified_at IS NULL AND user_id <> ?3)",
658 ))
659 .bind(&[id.clone(), address.clone(), user.clone()])?,
660 self.db
661 .prepare(won(
662 "UPDATE users SET backup_email_id = NULL
663 WHERE id <> ?3 AND {WON} AND backup_email_id IN (
664 SELECT id FROM user_emails WHERE email = ?2 AND verified_at IS NULL AND user_id <> ?3)",
665 ))
666 .bind(&[id.clone(), address.clone(), user.clone()])?,
667 self.db
668 .prepare(won(
669 "DELETE FROM user_emails WHERE email = ?2 AND verified_at IS NULL AND user_id <> ?3 AND {WON}",
670 ))
671 .bind(&[id.clone(), address.clone(), user.clone()])?,
672 self.db
673 .prepare(won(
674 "UPDATE users SET primary_email_id = ?1, email = ?2,
675 email_verified_at = (SELECT verified_at FROM user_emails WHERE id = ?1)
676 WHERE id = ?3 AND {WON} AND (
677 primary_email_id IS NULL OR primary_email_id = ?1
678 OR NOT EXISTS (SELECT 1 FROM user_emails WHERE id = users.primary_email_id AND verified_at IS NOT NULL))",
679 ))
680 .bind(&[id.clone(), address.clone(), user.clone()])?,
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)681 ];
682 if let (Some(invite), Some(join)) = (&awaiting, &join) {
683 statements.extend(self.apply_invite_statements(user_id, invite, join)?);
684 }
685 self.db.batch(statements).await?;
Merge branch 'worktree-agent-a985c01f43538af01'686 let confirmed = self
687 .email_rows(user_id)
688 .await?
689 .into_iter()
690 .any(|current| current.id == row.id && current.verified_at.is_some());
691 if !confirmed {
692 return Ok(Outcome::fail(
693 FailureCode::Conflict,
694 "That address was confirmed by another g1t account first. Use a different address.",
695 ));
696 }
697 self.log_security(user_id, "email_verified", Some(&row.display), None).await;
698 self.announce_email("user.email_verified", user_id, false).await;
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)699 let verified = self.account_confirmed(user_id).await?;
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)700 let (mut invited_to, mut invite_lapsed) = (None, None);
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)701 if let (Some(invite), Some(join), true) = (&awaiting, &join, verified) {
702 let user = g1t_contracts::User {
703 id: user_id.to_owned(),
704 username: account.username.clone(),
705 verified: true,
706 ..g1t_contracts::User::default()
707 };
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)708 invited_to = self.after_applied(invite, &user, join).await?;
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)709 invite_lapsed = match join {
710 AwaitingJoin::Lapsed(why) => Some(why.clone()),
711 // Revoked between the read and the transaction.
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)712 AwaitingJoin::Invited { .. } if invited_to.is_none() => Some(
713 "Your email address is confirmed. The invite you signed up with no longer applies, so it does not invite you to a workspace."
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)714 .to_owned(),
715 ),
716 _ => None,
717 };
718 }
719 Ok(Outcome::Ok(EmailConfirmed {
720 username: account.username,
721 email: row.display,
722 verified,
Merge workspace invitations: nobody joins a workspace without saying yes, people are found by username, your own invites can bring someone in, and nobody is left without a workspace (identity 0040)723 joined: None,
724 invited_to,
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)725 invite_lapsed,
726 }))
Merge branch 'worktree-agent-a985c01f43538af01'727 }
728
729 /// `remove_email`.
730 pub async fn remove_email(&self, a: AccountEmailArgs) -> Result<Outcome<AccountEmails>> {
731 if !is_person(&a.user) {
732 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
733 }
734 let email = normalize_email(&a.email).unwrap_or_default();
735 let Some(account) = self.account_row(&a.user.id).await? else {
736 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
737 };
738 let rows = self.email_rows(&a.user.id).await?;
739 if let Some(why) = removal_refusal(&states(&rows, account.primary_email_id.as_deref()), &email) {
740 return Ok(Outcome::fail(FailureCode::Conflict, why));
741 }
742 if let Some(refusal) = self.proof(&a.user.id, &a.reauth).await?.refusal() {
743 return Ok(refusal);
744 }
745 let Some(row) = rows.into_iter().find(|row| row.email == email) else {
746 return self.emails_view(&a.user.id).await;
747 };
748 self.delete_address(&a.user.id, &row).await?;
749 self.log_security(&a.user.id, "email_removed", Some(&row.display), None).await;
750 let removed = row.verified_at.is_some().then_some(row.display.as_str());
751 self.tell_addresses(&a.user.id, &a.user.username, &format!("{} was removed", row.display), removed).await;
752 self.announce_email("user.email_removed", &a.user.id, false).await;
753 self.emails_view(&a.user.id).await
754 }
755
756 /// Deletes an address and anything pointing at it. The caller has made
757 /// sure it is not the primary, or has moved the primary already.
758 async fn delete_address(&self, user_id: &str, row: &EmailRow) -> Result<()> {
759 self.db
760 .batch(vec![
761 self.db
762 .prepare("UPDATE users SET backup_email_id = NULL WHERE id = ? AND backup_email_id = ?")
763 .bind(&[user_id.into(), row.id.as_str().into()])?,
764 self.db
765 .prepare("DELETE FROM email_tokens WHERE user_id = ? AND email_id = ?")
766 .bind(&[user_id.into(), row.id.as_str().into()])?,
767 self.db
768 .prepare("DELETE FROM user_emails WHERE id = ? AND user_id = ?")
769 .bind(&[row.id.as_str().into(), user_id.into()])?,
770 ])
771 .await?;
772 Ok(())
773 }
774
775 /// Makes a confirmed address the primary, keeping `users` in step.
776 async fn set_primary(&self, user_id: &str, row: &EmailRow) -> Result<()> {
777 self.db
778 .prepare(
779 "UPDATE users SET primary_email_id = ?1, email = ?2,
780 email_verified_at = (SELECT verified_at FROM user_emails WHERE id = ?1),
781 backup_email_id = CASE WHEN backup_email_id = ?1 THEN NULL ELSE backup_email_id END
782 WHERE id = ?3 AND EXISTS (SELECT 1 FROM user_emails WHERE id = ?1 AND user_id = ?3 AND verified_at IS NOT NULL)",
783 )
784 .bind(&[row.id.as_str().into(), row.email.as_str().into(), user_id.into()])?
785 .run()
786 .await?;
787 Ok(())
788 }
789
790 /// `update_email_settings`.
791 pub async fn update_email_settings(&self, a: EmailSettingsArgs) -> Result<Outcome<AccountEmails>> {
792 if !is_person(&a.user) {
793 return Ok(Outcome::fail(FailureCode::Forbidden, PEOPLE_ONLY));
794 }
795 let Some(account) = self.account_row(&a.user.id).await? else {
796 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
797 };
798 let rows = self.email_rows(&a.user.id).await?;
799 let find = |email: &str| {
800 let email = normalize_email(email).unwrap_or_default();
801 rows.iter().find(|row| row.email == email).cloned()
802 };
803 let primary = match a.primary.as_deref().map(str::trim).filter(|email| !email.is_empty()) {
804 None => None,
805 Some(email) => {
806 let normalized = normalize_email(email).unwrap_or_default();
807 if let Some(why) = primary_refusal(&states(&rows, account.primary_email_id.as_deref()), &normalized) {
808 return Ok(Outcome::fail(FailureCode::Conflict, why));
809 }
810 find(email).filter(|row| Some(row.id.as_str()) != account.primary_email_id.as_deref())
811 }
812 };
813 // Some(None): the primary only.
814 let backup: Option<Option<EmailRow>> = match a.backup.as_deref().map(str::trim) {
815 None => None,
816 Some("") => (account.backup_email_id.is_some()).then_some(None),
817 Some(email) => {
818 let Some(row) = find(email).filter(|row| row.verified_at.is_some()) else {
819 return Ok(Outcome::fail(FailureCode::Conflict, "Only a confirmed address on your account can be the backup."));
820 };
821 let will_be_primary = primary.as_ref().map_or(account.primary_email_id.clone(), |row| Some(row.id.clone()));
822 if Some(&row.id) == will_be_primary.as_ref() {
823 return Ok(Outcome::fail(FailureCode::Conflict, "That is your primary address; choose another for the backup."));
824 }
825 (account.backup_email_id.as_deref() != Some(row.id.as_str())).then_some(Some(row))
826 }
827 };
828 if (primary.is_some() || backup.is_some())
829 && let Some(refusal) = self.proof(&a.user.id, &a.reauth).await?.refusal()
830 {
831 return Ok(refusal);
832 }
833 if let Some(row) = &primary {
834 let old = rows.iter().find(|old| Some(old.id.as_str()) == account.primary_email_id.as_deref());
835 self.set_primary(&a.user.id, row).await?;
836 self.log_security(&a.user.id, "primary_email_changed", Some(&row.display), None).await;
837 // Every confirmed address hears of it, the old primary included.
838 self.tell_addresses(
839 &a.user.id,
840 &a.user.username,
841 &format!("{} is now the primary address", row.display),
842 old.filter(|old| old.verified_at.is_some()).map(|old| old.display.as_str()),
843 )
844 .await;
845 self.announce_email("user.primary_email_changed", &a.user.id, false).await;
846 }
847 if let Some(choice) = &backup {
848 self.db
849 .prepare("UPDATE users SET backup_email_id = ? WHERE id = ?")
850 .bind(&[
851 choice.as_ref().map_or(JsValue::NULL, |row| row.id.as_str().into()),
852 a.user.id.as_str().into(),
853 ])?
854 .run()
855 .await?;
856 let said = choice.as_ref().map_or("primary only".to_owned(), |row| row.display.clone());
857 self.log_security(&a.user.id, "backup_email_changed", Some(&said), None).await;
858 let change = match choice {
859 Some(row) => format!("{} now gets security notices too", row.display),
860 None => "Security notices now go to the primary address only".to_owned(),
861 };
862 self.tell_addresses(&a.user.id, &a.user.username, &change, None).await;
863 }
864 let private = a.private_email.filter(|private| *private != (account.private_email != 0));
865 let block = a.block_private_pushes.filter(|block| *block != (account.block_private_pushes != 0));
866 if private.is_some() || block.is_some() {
867 self.db
868 .prepare(
869 "UPDATE users SET private_email = COALESCE(?, private_email),
870 block_private_pushes = COALESCE(?, block_private_pushes) WHERE id = ?",
871 )
872 .bind(&[
873 private.map_or(JsValue::NULL, |on| (on as u8 as f64).into()),
874 block.map_or(JsValue::NULL, |on| (on as u8 as f64).into()),
875 a.user.id.as_str().into(),
876 ])?
877 .run()
878 .await?;
879 let mut said = Vec::new();
880 if let Some(on) = private {
881 said.push(if on { "address kept private" } else { "address used on web commits" });
882 }
883 if let Some(on) = block {
884 said.push(if on { "pushes that expose it refused" } else { "pushes that expose it allowed" });
885 }
886 self.log_security(&a.user.id, "email_privacy_changed", Some(&said.join("; ")), None).await;
887 }
888 self.emails_view(&a.user.id).await
889 }
890
891 /// Who gets a security notice: every confirmed address when `all`,
892 /// otherwise the primary and the backup.
893 pub async fn notice_recipients(&self, user_id: &str, all: bool) -> Result<Vec<String>> {
894 let Some(account) = self.account_row(user_id).await? else {
895 return Ok(Vec::new());
896 };
897 let rows = sorted(self.email_rows(user_id).await?, account.primary_email_id.as_deref());
898 Ok(rows
899 .into_iter()
900 .filter(|row| row.verified_at.is_some())
901 .filter(|row| {
902 all || Some(row.id.as_str()) == account.primary_email_id.as_deref()
903 || Some(row.id.as_str()) == account.backup_email_id.as_deref()
904 })
905 .map(|row| row.display)
906 .collect())
907 }
908
909 /// Tells every confirmed address of an account, and `also` (an address
910 /// that has just left it), what changed. Best effort.
911 pub async fn tell_addresses(&self, user_id: &str, username: &str, change: &str, also: Option<&str>) {
912 let mut to = self.notice_recipients(user_id, true).await.unwrap_or_default();
913 if let Some(also) = also
914 && !to.iter().any(|known| known.eq_ignore_ascii_case(also))
915 {
916 to.push(also.to_owned());
917 }
918 for address in to {
919 if let Err(error) = email::send_security_notice(&self.env, &address, username, change).await {
920 worker::console_error!("security notice failed: {error}");
921 }
922 }
923 }
924
925 /// Tells the primary and the backup what changed. Best effort.
926 pub async fn tell_primary_and_backup(&self, user_id: &str, username: &str, change: &str) {
927 for address in self.notice_recipients(user_id, false).await.unwrap_or_default() {
928 if let Err(error) = email::send_security_notice(&self.env, &address, username, change).await {
929 worker::console_error!("security notice failed: {error}");
930 }
931 }
932 }
933
934 async fn announce_email(&self, kind: &'static str, user_id: &str, by_staff: bool) {
935 let actor = (!by_staff).then_some(user_id);
936 self.announce(
937 kind,
938 actor,
939 UserEmailChanged {
940 user_id: user_id.to_owned(),
941 by_staff,
942 },
943 )
944 .await;
945 }
946
947 // --- Signing in and resetting by any confirmed address ---
948
949 /// The account a password reset for `email` goes to, the address it is
950 /// sent to and that address's id: a confirmed address, or else the
951 /// unconfirmed address a new account signed up with (following the link
952 /// confirms it).
953 pub async fn reset_target(&self, email: &str) -> Result<Option<ResetTarget>> {
954 let Some(email) = normalize_email(email) else {
955 return Ok(None);
956 };
957 let confirmed = self
958 .db
959 .prepare(
960 "SELECT u.id AS user_id, u.username, e.id AS email_id, e.display FROM user_emails e
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)961 JOIN users u ON u.id = e.user_id WHERE e.email = ? AND e.verified_at IS NOT NULL AND u.deleted_at IS NULL",
Merge branch 'worktree-agent-a985c01f43538af01'962 )
963 .bind(&[email.as_str().into()])?
964 .first::<ResetTarget>(None)
965 .await?;
966 if confirmed.is_some() {
967 return Ok(confirmed);
968 }
969 self.db
970 .prepare(
971 "SELECT u.id AS user_id, u.username, e.id AS email_id, e.display FROM user_emails e
972 JOIN users u ON u.primary_email_id = e.id
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)973 WHERE e.email = ? AND e.verified_at IS NULL AND u.deleted_at IS NULL ORDER BY u.created_at, u.id LIMIT 1",
Merge branch 'worktree-agent-a985c01f43538af01'974 )
975 .bind(&[email.as_str().into()])?
976 .first::<ResetTarget>(None)
977 .await
978 }
979
980 // --- Commits ---
981
982 /// `email_owners`: whose commits these are, by author address.
983 pub async fn email_owners(&self, a: EmailOwnersArgs) -> Result<HashMap<String, EmailOwner>> {
984 #[derive(Deserialize)]
985 struct Row {
986 email: String,
987 id: String,
988 username: String,
989 avatar: Option<String>,
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)990 /// 1 for an account that is deleted: purged (its username is
991 /// in `deleted_users`) or in its window to be restored.
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)992 #[serde(default)]
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)993 gone: u8,
Merge branch 'worktree-agent-a985c01f43538af01'994 }
995 let mut owners = HashMap::new();
996 let mut plain: Vec<String> = Vec::new();
997 let mut by_name: Vec<(String, Option<String>, String)> = Vec::new();
998 for email in a.emails.iter().take(200) {
999 let Some(email) = normalize_email(email) else { continue };
1000 if let Some((suffix, username)) = parse_noreply(&email) {
1001 by_name.push((username, Some(suffix), email));
1002 } else if let Some(username) = email.strip_suffix("@users.g1t.sh") {
1003 // What g1t put on the commits it made before noreply
1004 // addresses existed.
1005 by_name.push((username.to_owned(), None, email.clone()));
1006 } else if !plain.contains(&email) {
1007 plain.push(email);
1008 }
1009 }
1010 if !plain.is_empty() {
1011 let marks = vec!["?"; plain.len()].join(", ");
1012 let bind: Vec<JsValue> = plain.iter().map(|email| email.as_str().into()).collect();
1013 let rows = self
1014 .db
1015 .prepare(format!(
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)1016 "SELECT e.email, u.id, u.username, u.avatar, u.deleted_at IS NOT NULL AS gone
1017 FROM user_emails e JOIN users u ON u.id = e.user_id
1018 WHERE e.verified_at IS NOT NULL AND e.email IN ({marks})"
Merge branch 'worktree-agent-a985c01f43538af01'1019 ))
1020 .bind(&bind)?
1021 .all()
1022 .await?
1023 .results::<Row>()?;
1024 for row in rows {
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)1025 owners.insert(row.email, shown_owner(row.id, row.username, row.avatar, row.gone != 0));
Merge branch 'worktree-agent-a985c01f43538af01'1026 }
1027 }
1028 if !by_name.is_empty() {
1029 let names: Vec<&str> = by_name.iter().map(|(name, _, _)| name.as_str()).collect();
1030 let marks = vec!["?"; names.len()].join(", ");
1031 let bind: Vec<JsValue> = names.iter().map(|name| (*name).into()).collect();
1032 let rows = self
1033 .db
1034 .prepare(format!(
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)1035 "SELECT username AS email, id, username, avatar, deleted_at IS NOT NULL AS gone FROM users
1036 WHERE username IN ({marks})
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)1037 UNION ALL
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)1038 SELECT username AS email, user_id AS id, username, NULL AS avatar, 1 AS gone FROM deleted_users
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)1039 WHERE username IN ({marks})"
Merge branch 'worktree-agent-a985c01f43538af01'1040 ))
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)1041 .bind(&[bind.clone(), bind].concat())?
Merge branch 'worktree-agent-a985c01f43538af01'1042 .all()
1043 .await?
1044 .results::<Row>()?;
1045 for (username, suffix, email) in by_name {
1046 if let Some(row) = rows.iter().find(|row| row.username == username)
1047 && suffix.as_deref().is_none_or(|suffix| id_suffix(&row.id) == suffix)
1048 {
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)1049 owners.insert(email, shown_owner(row.id.clone(), row.username.clone(), row.avatar.clone(), row.gone != 0));
Merge branch 'worktree-agent-a985c01f43538af01'1050 }
1051 }
1052 }
1053 Ok(owners)
1054 }
1055
1056 /// `commit_identity`.
1057 pub async fn commit_identity(&self, a: CommitIdentityArgs) -> Result<Option<CommitIdentity>> {
1058 #[derive(Deserialize)]
1059 struct Row {
1060 id: String,
1061 username: String,
1062 display_name: Option<String>,
1063 private_email: u8,
1064 primary: Option<String>,
1065 verified: u8,
1066 }
1067 let row = self
1068 .db
1069 .prepare(
1070 "SELECT u.id, u.username, u.display_name, u.private_email, e.email AS \"primary\",
1071 e.verified_at IS NOT NULL AS verified
1072 FROM users u LEFT JOIN user_emails e ON e.id = u.primary_email_id WHERE u.id = ?",
1073 )
1074 .bind(&[a.user_id.as_str().into()])?
1075 .first::<Row>(None)
1076 .await?;
1077 Ok(row.map(|row| {
1078 let noreply = noreply_address(&row.id, &row.username);
1079 let email = match row.primary {
1080 Some(primary) if row.private_email == 0 && row.verified != 0 => primary,
1081 _ => noreply,
1082 };
1083 let name = row.display_name.filter(|name| !name.trim().is_empty()).unwrap_or(row.username);
1084 CommitIdentity { name, email }
1085 }))
1086 }
1087
1088 /// `push_email_guard`: the addresses a push by this person must not
1089 /// publish, while they keep their address private and block pushes
1090 /// that expose it. One read of the account and one of its addresses.
1091 pub async fn push_email_guard(&self, a: CommitIdentityArgs) -> Result<Option<PushEmailGuard>> {
1092 let Some(account) = self.account_row(&a.user_id).await? else {
1093 return Ok(None);
1094 };
1095 if !guards_pushes(&account) {
1096 return Ok(None);
1097 }
1098 let rows = self.email_rows(&a.user_id).await?;
1099 Ok(Some(PushEmailGuard {
1100 emails: rows.into_iter().filter(|row| row.verified_at.is_some()).map(|row| row.email.to_lowercase()).collect(),
1101 noreply: noreply_address(&account.id, &account.username),
1102 }))
1103 }
1104
1105 // --- Staff ---
1106
1107 /// `admin_user`.
1108 pub async fn admin_user(&self, a: UsernameArgs) -> Result<Option<AdminUser>> {
1109 #[derive(Deserialize)]
1110 struct Id {
1111 id: String,
1112 }
1113 let found = self
1114 .db
1115 .prepare("SELECT id FROM users WHERE username = ?")
1116 .bind(&[a.username.trim().to_lowercase().into()])?
1117 .first::<Id>(None)
1118 .await?;
1119 let Some(found) = found else {
1120 return Ok(None);
1121 };
1122 self.admin_user_by_id(&found.id).await
1123 }
1124
1125 async fn admin_user_by_id(&self, user_id: &str) -> Result<Option<AdminUser>> {
1126 let Some(account) = self.account_row(user_id).await? else {
1127 return Ok(None);
1128 };
1129 let rows = self.email_rows(user_id).await?;
1130 let log = self.security_events(user_id, true).await?;
1131 let emails = view(&account, rows);
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)1132 // Whether it can be deleted, and its deletion while it waits to be
Merge sudo: delete an account with the workspaces it alone owns, purge each1133 // purged (account_deletion.rs). For each workspace it owns alone,
1134 // whether staff could delete it with the account: protected, or
1135 // billing that cannot settle.
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)1136 let deleted = self.deleted_account(&account.id).await?;
Merge sudo: delete an account with the workspaces it alone owns, purge each1137 let deletion = self
1138 .account_deletion_facts(&account.id, &account.username, crate::account_deletion::AskBilling::Staff)
1139 .await?;
Merge branch 'worktree-agent-a985c01f43538af01'1140 Ok(Some(AdminUser {
1141 id: account.id,
1142 username: account.username,
1143 created_at: account.created_at,
1144 emails: emails.emails,
1145 private_email: emails.private_email,
1146 log,
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)1147 deletion,
1148 deleted,
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)1149 joined_through: self.shared_source(user_id).await?,
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.1150 suspended: self.user_suspension(user_id).await?,
Merge branch 'worktree-agent-a985c01f43538af01'1151 }))
1152 }
1153
1154 /// `admin_remove_email`.
1155 pub async fn admin_remove_email(&self, a: AdminRemoveEmailArgs) -> Result<Outcome<AdminUser>> {
1156 let reason = a.reason.trim();
1157 if reason.is_empty() {
1158 return Ok(Outcome::fail(FailureCode::Invalid, "Say why the address is being removed; the person sees it."));
1159 }
1160 if a.staff.trim().is_empty() {
1161 return Ok(Outcome::fail(FailureCode::Invalid, "Staff changes name who made them."));
1162 }
1163 let Some(user) = self.admin_user(UsernameArgs { username: a.username.clone() }).await? else {
1164 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
1165 };
1166 let Some(account) = self.account_row(&user.id).await? else {
1167 return Ok(Outcome::fail(FailureCode::NotFound, "No such account."));
1168 };
1169 let email = normalize_email(&a.email).unwrap_or_default();
1170 let rows = sorted(self.email_rows(&user.id).await?, account.primary_email_id.as_deref());
1171 let Some(row) = rows.iter().find(|row| row.email == email).cloned() else {
1172 return Ok(Outcome::fail(FailureCode::NotFound, "That address is not on this account."));
1173 };
1174 let confirmed: Vec<&EmailRow> = rows.iter().filter(|other| other.verified_at.is_some()).collect();
1175 if row.verified_at.is_some() && confirmed.len() <= 1 {
1176 return Ok(Outcome::fail(
1177 FailureCode::Conflict,
1178 "That is the account's only confirmed address. The person has to add and confirm another first.",
1179 ));
1180 }
1181 let was_primary = account.primary_email_id.as_deref() == Some(row.id.as_str());
1182 if was_primary {
1183 match confirmed.iter().find(|other| other.id != row.id) {
1184 Some(next) => self.set_primary(&user.id, next).await?,
1185 None => {
1186 // An unconfirmed primary on an account with no
1187 // confirmed address: it is left without one.
1188 self.db
1189 .prepare("UPDATE users SET primary_email_id = NULL, email = NULL, email_verified_at = NULL WHERE id = ?")
1190 .bind(&[user.id.as_str().into()])?
1191 .run()
1192 .await?;
1193 }
1194 }
1195 }
1196 self.delete_address(&user.id, &row).await?;
1197 let staff = (a.staff.trim(), reason);
1198 self.log_security(&user.id, "email_removed", Some(&row.display), Some(staff)).await;
1199 let removed = row.verified_at.is_some().then_some(row.display.as_str());
1200 self.tell_addresses(&user.id, &user.username, &format!("g1t staff removed {} ({reason})", row.display), removed)
1201 .await;
1202 self.announce_email("user.email_removed", &user.id, true).await;
1203 if was_primary {
1204 self.announce_email("user.primary_email_changed", &user.id, true).await;
1205 }
1206 Ok(match self.admin_user_by_id(&user.id).await? {
1207 Some(user) => Outcome::Ok(user),
1208 None => Outcome::fail(FailureCode::NotFound, "No such account."),
1209 })
1210 }
1211}
1212
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1213/// Whether a code and link that stop working at `expires_at` still work
1214/// at `now` (both RFC 3339, which sort as they read).
1215pub fn still_works(expires_at: &str, now: &str) -> bool {
1216 expires_at > now
1217}
1218
1219/// Which of the account's outstanding codes `code` is, by index: each
1220/// `(token id, code hash)` is compared in constant time, and every one is
1221/// compared whatever matched before it.
1222pub fn matching_code<'a>(key: &[u8], code: &str, sent: impl Iterator<Item = (&'a str, &'a str)>) -> Option<usize> {
1223 let mut found = None;
1224 for (index, (id, hash)) in sent.enumerate() {
1225 let expected = crypto::code_hash(key, id, code);
1226 if crypto::same(&expected, hash) && found.is_none() {
1227 found = Some(index);
1228 }
1229 }
1230 if code.is_empty() { None } else { found }
1231}
1232
Merge branch 'worktree-agent-a985c01f43538af01'1233/// Where a password reset goes.
1234#[derive(Debug, Deserialize)]
1235pub struct ResetTarget {
1236 pub user_id: String,
1237 pub username: String,
1238 pub email_id: String,
1239 pub display: String,
1240}
1241
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)1242/// Who an address's commits are shown as: the account, or ghost for a
1243/// deleted one. An account in its window to be restored is ghost already,
1244/// as it will be once purged (account_deletion.rs), and is itself again if
1245/// staff restore it, since nothing about it is changed here.
1246fn shown_owner(id: String, username: String, avatar: Option<String>, gone: bool) -> EmailOwner {
1247 if gone {
1248 EmailOwner { id: GHOST_ID.to_owned(), username: GHOST_USERNAME.to_owned(), avatar: None }
1249 } else {
1250 EmailOwner { id, username, avatar }
1251 }
1252}
1253
Merge branch 'worktree-agent-a985c01f43538af01'1254#[cfg(test)]
1255mod tests {
1256 use super::*;
1257
Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039)1258 #[test]
1259 fn a_deleted_account_is_ghost_on_its_commits_until_restored() {
1260 let live = shown_owner("usr_ana".into(), "ana".into(), Some("abc".into()), false);
1261 assert_eq!((live.id.as_str(), live.username.as_str(), live.avatar.as_deref()), ("usr_ana", "ana", Some("abc")));
1262 // Deleted and restorable, or purged: ghost, with nothing of the account.
1263 let gone = shown_owner("usr_ana".into(), "ana".into(), Some("abc".into()), true);
1264 assert_eq!((gone.id.as_str(), gone.username.as_str(), gone.avatar), (GHOST_ID, GHOST_USERNAME, None));
1265 }
1266
Merge branch 'worktree-agent-a985c01f43538af01'1267 fn row(id: &str, email: &str, verified: bool, created: &str) -> EmailRow {
1268 EmailRow {
1269 id: id.into(),
1270 email: email.into(),
1271 display: email.to_uppercase(),
1272 verified_at: verified.then(|| "2026-10-01T00:00:00.000Z".to_owned()),
1273 sent_at: None,
1274 created_at: created.into(),
1275 }
1276 }
1277
1278 fn account(primary: Option<&str>, backup: Option<&str>, private: bool) -> AccountRow {
1279 AccountRow {
1280 id: "usr_01j9zq4m8x7k2v5n3b6c1d0efg".into(),
1281 username: "ada".into(),
1282 primary_email_id: primary.map(Into::into),
1283 backup_email_id: backup.map(Into::into),
1284 private_email: private as u8,
1285 block_private_pushes: 0,
1286 created_at: String::new(),
1287 }
1288 }
1289
1290 #[test]
1291 fn the_primary_comes_first_then_confirmed_then_the_rest() {
1292 let rows = vec![
1293 row("e1", "old@x.io", false, "2026-01-01"),
1294 row("e2", "work@x.io", true, "2026-02-01"),
1295 row("e3", "home@x.io", true, "2026-03-01"),
1296 ];
1297 let order: Vec<String> = sorted(rows, Some("e3")).into_iter().map(|row| row.id).collect();
1298 assert_eq!(order, ["e3", "e2", "e1"]);
1299 }
1300
1301 #[test]
1302 fn the_view_marks_primary_and_backup_and_shows_addresses_as_typed() {
1303 let rows = vec![row("e1", "a@x.io", true, "1"), row("e2", "b@x.io", true, "2"), row("e3", "c@x.io", false, "3")];
1304 let seen = view(&account(Some("e1"), Some("e2"), true), rows);
1305 assert_eq!(seen.emails[0].email, "A@X.IO");
1306 assert!(seen.emails[0].primary && !seen.emails[0].backup);
1307 assert!(seen.emails[1].backup && !seen.emails[1].primary);
1308 assert!(!seen.emails[2].verified);
1309 assert_eq!(seen.noreply, "6c1d0efg+ada@users.noreply.g1t.sh");
1310 assert_eq!(seen.commit_email, seen.noreply);
1311 assert_eq!(seen.limit, 10);
1312 }
1313
1314 #[test]
1315 fn commits_use_the_primary_only_when_the_person_allows_it_and_it_is_confirmed() {
1316 let confirmed = row("e1", "a@x.io", true, "1");
1317 let unconfirmed = row("e2", "b@x.io", false, "2");
1318 assert_eq!(commit_email(false, Some(&confirmed), "n@noreply"), "a@x.io");
1319 assert_eq!(commit_email(true, Some(&confirmed), "n@noreply"), "n@noreply");
1320 assert_eq!(commit_email(false, Some(&unconfirmed), "n@noreply"), "n@noreply");
1321 assert_eq!(commit_email(false, None, "n@noreply"), "n@noreply");
1322 }
1323
1324 #[test]
1325 fn pushes_are_guarded_only_while_private_and_blocking() {
1326 let mut ada = account(None, None, true);
1327 assert!(!guards_pushes(&ada));
1328 ada.block_private_pushes = 1;
1329 assert!(guards_pushes(&ada));
1330 ada.private_email = 0;
1331 assert!(!guards_pushes(&ada));
1332 }
1333
1334 #[test]
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)1335 fn a_code_works_for_its_own_link_and_address_only_and_not_after_a_new_email() {
1336 let key = b"identity key".as_slice();
1337 let first = ("link-1", crypto::code_hash(key, "link-1", "482913"));
1338 let other_address = ("link-2", crypto::code_hash(key, "link-2", "100200"));
1339 fn sent<'a>(rows: &'a [(&'static str, String)]) -> Vec<(&'static str, &'a str)> {
1340 rows.iter().map(|(id, hash)| (*id, hash.as_str())).collect()
1341 }
1342 let rows = vec![first.clone(), other_address.clone()];
1343 assert_eq!(matching_code(key, "482913", sent(&rows).into_iter()), Some(0));
1344 assert_eq!(matching_code(key, "100200", sent(&rows).into_iter()), Some(1));
1345 // A wrong code, an empty one, or the right one under another key.
1346 assert_eq!(matching_code(key, "482914", sent(&rows).into_iter()), None);
1347 assert_eq!(matching_code(key, "", sent(&rows).into_iter()), None);
1348 assert_eq!(matching_code(b"another key", "482913", sent(&rows).into_iter()), None);
1349 // Used, or replaced by a new email: the row is gone, and the new
1350 // pair's code is bound to its own link, so the old code fails.
1351 let resent = vec![("link-3", crypto::code_hash(key, "link-3", "731055")), other_address];
1352 assert_eq!(matching_code(key, "482913", sent(&resent).into_iter()), None);
1353 assert_eq!(matching_code(key, "731055", sent(&resent).into_iter()), Some(0));
1354 }
1355
1356 #[test]
1357 fn a_code_and_link_stop_working_after_an_hour() {
1358 let sent = 1_800_000_000_000;
1359 let expires = rfc3339(sent + CONFIRM_TTL_SECONDS * 1000);
1360 assert!(still_works(&expires, &rfc3339(sent)));
1361 assert!(still_works(&expires, &rfc3339(sent + 59 * 60 * 1000)));
1362 assert!(!still_works(&expires, &rfc3339(sent + 60 * 60 * 1000)));
1363 assert!(!still_works(&expires, &rfc3339(sent + 61 * 60 * 1000)));
1364 // Long enough to switch to a mail app, short enough that six
1365 // digits are not worth guessing.
1366 assert!((30 * 60..=60 * 60).contains(&CONFIRM_TTL_SECONDS));
1367 }
1368
1369 #[test]
Merge branch 'worktree-agent-a985c01f43538af01'1370 fn a_link_can_be_sent_again_after_a_minute() {
1371 let now = 1_800_000_000_000;
1372 assert!(may_resend(None, now));
1373 assert!(!may_resend(Some(&rfc3339(now - 30_000)), now));
1374 assert!(may_resend(Some(&rfc3339(now - 61_000)), now));
1375 }
1376}

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