Skip to content
1,146 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.

Topics sit in About on the files pages, under the description1//! The identity service: accounts, sessions, SSH keys and access tokens.
2//!
3//! Reached only through service bindings; see `g1t_contracts::identity` for
4//! the methods and their arguments.
5
6mod access;
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)7mod account_deletion;
Topics sit in About on the files pages, under the description8mod admin;
Merge branch 'worktree-agent-a8385d293d42c913a'9mod aliases;
Topics sit in About on the files pages, under the description10mod avatars;
11mod crypto;
12mod deletion;
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca13mod deploy_keys;
Topics sit in About on the files pages, under the description14mod device;
15mod directory;
The apps you pin to your dock are kept with your account, per workspace and in your order, so the dock is the same on every device: identity keeps them in dock_pins and answers dock_pins and set_dock_pins, the dock reads them with the rest of the page, pins kept only on this device carry over with your first change, this device's copy still draws the dock when identity can't be reached, a pin that can't be saved says so, and they go when you or the workspace do; the workspaces guide says how.16mod dock;
Topics sit in About on the files pages, under the description17mod email;
18mod emails;
19mod github;
20mod invites;
Merge main (membership, two-factor, GitHub repo roles) into tokens21mod members;
Topics sit in About on the files pages, under the description22mod oauth;
Merge Stripe Tax, the card fee on card payments, and one free workspace per person23mod paid;
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.24mod people;
Topics sit in About on the files pages, under the description25mod profiles;
26mod rename;
Merge branch 'worktree-agent-a3abfcce648e87dca'27mod job_tokens;
Topics sit in About on the files pages, under the description28mod run_credentials;
29mod security;
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)30mod shared_invites;
Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules.31mod sounds;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar32mod teams;
Topics sit in About on the files pages, under the description33mod throttle;
Fine-grained personal tokens, workspace token rules and approvals in identity34mod token_reach;
Topics sit in About on the files pages, under the description35mod tokens;
Merge main (membership, two-factor, GitHub repo roles) into tokens36mod two_factor;
Topics sit in About on the files pages, under the description37mod workspaces;
38
39use g1t_contracts::identity::*;
40use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after};
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers41use g1t_contracts::{FailureCode, Outcome, User, Viewer, claimable_username, new_id};
Topics sit in About on the files pages, under the description42use g1t_kit::{args, now_ms, reply, rpc_method};
43use serde::Deserialize;
44use tokens::TOKEN_PREFIX;
45use worker::wasm_bindgen::JsValue;
46use worker::{Context, D1Database, Env, Request, Response, Result, ScheduleContext, ScheduledEvent, event};
47
48const SESSION_TTL_SECONDS: u64 = 30 * 24 * 60 * 60;
49const RESET_TTL_SECONDS: u64 = 60 * 60;
50const MIN_PASSWORD_LENGTH: usize = 10;
51const PASSWORD_TOO_SHORT: &str = "Use a password of at least 10 characters.";
52
53/// A user as selected from the database; `verified` arrives as 0 or 1.
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers54/// What a username may be, as registration and GitHub sign-up say when one is refused.
55pub(crate) const USERNAME_RULES: &str =
56 "Usernames use letters of either case, digits and single hyphens, up to 39 characters, not starting or ending with a hyphen, and cannot be a reserved word.";
57
Topics sit in About on the files pages, under the description58#[derive(Deserialize)]
59struct Account {
60 id: String,
61 username: String,
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers62 /// Selected where the person is shown: their username as they wrote it.
63 #[serde(default)]
64 display_username: Option<String>,
Topics sit in About on the files pages, under the description65 verified: u8,
66 /// Selected only where the person is being shown to themselves.
67 #[serde(default)]
68 avatar: Option<String>,
69}
70
71impl From<Account> for User {
72 fn from(row: Account) -> Self {
73 User {
74 id: row.id,
75 username: row.username,
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers76 display_username: row.display_username,
Topics sit in About on the files pages, under the description77 verified: row.verified != 0,
78 avatar: row.avatar,
79 ..User::default()
80 }
81 }
82}
83
84#[derive(Deserialize)]
85struct UserRow {
86 id: String,
87 username: String,
88 password_hash: String,
89 verified: u8,
90}
91
92/// The owner of an emailed token.
93#[derive(Deserialize)]
94struct TokenOwner {
95 id: String,
96 username: String,
97 /// The address a link was sent to; null on links from before accounts
98 /// had several, which are for the primary.
99 #[serde(default)]
100 email_id: Option<String>,
101}
102
103#[derive(Deserialize)]
104struct KeyRow {
105 id: String,
106 title: String,
107 fingerprint: String,
108 created_at: String,
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca109 #[serde(default)]
110 last_used_at: Option<String>,
Topics sit in About on the files pages, under the description111}
112
113impl From<KeyRow> for SshKey {
114 fn from(row: KeyRow) -> Self {
115 SshKey {
116 id: row.id,
117 title: row.title,
118 fingerprint: row.fingerprint,
119 created_at: row.created_at,
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca120 last_used_at: row.last_used_at,
Topics sit in About on the files pages, under the description121 }
122 }
123}
124
125struct Identity {
126 db: D1Database,
127 env: Env,
128}
129
130impl Identity {
131 /// Runs a query that returns at most one user, for showing to others:
132 /// without their workspaces.
133 async fn find_public_user(&self, sql: &str, param: &str) -> Result<Viewer> {
134 Ok(self
135 .db
136 .prepare(sql)
137 .bind(&[JsValue::from(param)])?
138 .first::<Account>(None)
139 .await?
140 .map(User::from))
141 }
142
143 /// Attaches the workspaces a user belongs to, so that any service can
144 /// authorize them without asking again.
145 async fn with_workspaces(&self, user: Viewer) -> Result<Viewer> {
146 let Some(mut user) = user else {
147 return Ok(None);
148 };
Merge main (membership, two-factor, GitHub repo roles) into tokens149 let memberships = self.memberships_and_policies(&user.id).await?;
Topics sit in About on the files pages, under the description150 // Roles on single repositories, under the same policy (access.rs).
151 let grants = self.grants_of(&user.id).await?;
Merge main (membership, two-factor, GitHub repo roles) into tokens152 // Access to a workspace is used only within its policy; see security.rs.
153 let within = self.within_policy(&user.id, memberships, grants).await?;
154 user.workspaces = within.memberships;
155 user.grants = within.grants;
156 user.held = within.held;
Topics sit in About on the files pages, under the description157 Ok(Some(user))
158 }
159
160 /// Runs a query that resolves credentials to at most one user.
161 async fn find_user(&self, sql: &str, param: &str) -> Result<Viewer> {
162 let user = self.find_public_user(sql, param).await?;
163 self.with_workspaces(user).await
164 }
165
166 /// Consumes a token of `kind`, returning its owner if it was valid.
167 async fn redeem_email_token(&self, token: &str, kind: &str) -> Result<Option<TokenOwner>> {
168 let id = crypto::sha256_hex(token);
169 let owner = self
170 .db
171 .prepare(format!(
172 "SELECT users.id, users.username, email_tokens.email_id FROM email_tokens
173 JOIN users ON users.id = email_tokens.user_id
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)174 WHERE email_tokens.id = ? AND email_tokens.kind = ? AND users.deleted_at IS NULL
Topics sit in About on the files pages, under the description175 AND email_tokens.expires_at > {SQL_NOW}"
176 ))
177 .bind(&[id.as_str().into(), kind.into()])?
178 .first::<TokenOwner>(None)
179 .await?;
180 if let Some(owner) = &owner {
181 // Every outstanding token of this kind dies with the one used:
182 // every reset link, and every confirmation link for the same
183 // address (another address's links still work).
184 self.db
185 .prepare(
186 "DELETE FROM email_tokens WHERE user_id = ?1 AND kind = ?2
187 AND (?2 = 'reset' OR email_id IS ?3)",
188 )
189 .bind(&[
190 owner.id.as_str().into(),
191 kind.into(),
192 owner.email_id.as_deref().map_or(JsValue::NULL, Into::into),
193 ])?
194 .run()
195 .await?;
196 }
197 Ok(owner)
198 }
199
200 async fn resend_verification(&self, a: UserArgs) -> Result<Outcome<bool>> {
201 if !self.allow(throttle::CONFIRM_ACCOUNT, &a.user.id).await? {
202 return Ok(Outcome::fail(FailureCode::Conflict, "Too many confirmation emails this hour. Check your inbox, or try again later."));
203 }
204 self.resend_primary(&a.user).await
205 }
206
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)207 /// The link in a confirmation email, followed: signed in or not. It
208 /// ends the code sent with it (emails.rs).
209 async fn verify_email(&self, a: EmailTokenArgs) -> Result<Outcome<g1t_contracts::accounts::EmailConfirmed>> {
Topics sit in About on the files pages, under the description210 let Some(owner) = self.redeem_email_token(&a.token, "verify").await? else {
211 return Ok(Outcome::fail(
212 FailureCode::Invalid,
213 "This confirmation link is not valid or has expired.",
214 ));
215 };
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)216 self.confirm_address(&owner.id, owner.email_id.as_deref()).await
Topics sit in About on the files pages, under the description217 }
218
219 /// Any confirmed address of an account can ask for a reset; so can the
220 /// unconfirmed address a new account signed up with. See emails.rs.
221 async fn request_password_reset(&self, a: EmailArgs) -> Result<bool> {
222 let allowed = self.allow(throttle::RESET_EMAIL, &a.email).await?
223 && match a.client.as_deref() {
224 Some(client) => self.allow(throttle::RESET_CLIENT, client).await?,
225 None => true,
226 };
227 if allowed && let Some(target) = self.reset_target(&a.email).await? {
Identity: a password reset for a known address that fails to save or send is logged, never answered, so the reply never says an account exists228 // A failure from here on happens only for a real account, so it
229 // is logged, never answered: the reply below stays the same.
230 if let Err(error) = self.send_reset(&target).await {
231 worker::console_error!("password reset for a known address failed: {error}");
232 }
233 }
234 // The same answer either way, so addresses cannot be probed.
235 Ok(true)
236 }
237
238 /// Saves a reset link for `target` and mails it, telling the account's
239 /// other addresses.
240 async fn send_reset(&self, target: &emails::ResetTarget) -> Result<()> {
241 {
Topics sit in About on the files pages, under the description242 let token = crypto::random_hex(32);
243 self.db
244 .prepare(format!(
245 "INSERT INTO email_tokens (id, user_id, kind, expires_at, email_id)
246 VALUES (?, ?, 'reset', {}, ?)",
247 sql_after(RESET_TTL_SECONDS)
248 ))
249 .bind(&[
250 crypto::sha256_hex(&token).into(),
251 target.user_id.as_str().into(),
252 target.email_id.as_str().into(),
253 ])?
254 .run()
255 .await?;
256 email::send_password_reset(&self.env, &target.display, &target.username, &token).await?;
257 // The primary and the backup hear of it when it went elsewhere.
258 let elsewhere = self.notice_recipients(&target.user_id, false).await?;
259 for address in elsewhere.iter().filter(|address| !address.eq_ignore_ascii_case(&target.display)) {
260 let change = format!("A password reset was asked for through {}", target.display);
261 if let Err(error) = email::send_security_notice(&self.env, address, &target.username, &change).await {
262 worker::console_error!("security notice failed: {error}");
263 }
264 }
265 }
Identity: a password reset for a known address that fails to save or send is logged, never answered, so the reply never says an account exists266 Ok(())
Topics sit in About on the files pages, under the description267 }
268
269 async fn reset_password(&self, a: ResetPasswordArgs) -> Result<Outcome<User>> {
270 if a.password.chars().count() < MIN_PASSWORD_LENGTH {
271 return Ok(Outcome::fail(FailureCode::Invalid, PASSWORD_TOO_SHORT));
272 }
273 let Some(owner) = self.redeem_email_token(&a.token, "reset").await? else {
274 return Ok(Outcome::fail(
275 FailureCode::Invalid,
276 "This reset link is not valid or has expired.",
277 ));
278 };
279 self.db
280 .prepare("UPDATE users SET password_hash = ? WHERE id = ?")
281 .bind(&[
282 crypto::hash_password(&a.password).into(),
283 owner.id.as_str().into(),
284 ])?
285 .run()
286 .await?;
287 // Following an emailed link also proves the address it went to
288 // (unless another account confirmed it first).
289 let _ = self.confirm_address(&owner.id, owner.email_id.as_deref()).await?;
290 // Anyone signed in with the old password is signed out, and nobody
291 // stays locked out by the wrong guesses before it.
292 self.db
293 .prepare("DELETE FROM sessions WHERE user_id = ?")
294 .bind(&[owner.id.as_str().into()])?
295 .run()
296 .await?;
297 self.clear(&throttle::key(throttle::PASSWORD_ACCOUNT, &owner.id)).await?;
298 self.log_security(&owner.id, "password_changed", None, None).await;
299 self.tell_primary_and_backup(&owner.id, &owner.username, "Your password was changed").await;
300 let verified = self
301 .find_public_user(
302 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ?",
303 &owner.id,
304 )
305 .await?
306 .is_some_and(|user| user.verified);
307 Ok(Outcome::Ok(User {
308 id: owner.id,
309 username: owner.username,
310 verified,
311 ..User::default()
312 }))
313 }
314
315 /// The account a login names: a username, or any confirmed address.
316 async fn password_row(&self, login: &str) -> Result<Option<UserRow>> {
317 let login = login.trim().to_lowercase();
318 let (column, value) = if login.contains('@') {
319 match self.user_with_verified_email(&login).await? {
320 Some(id) => ("id", id),
321 None => return Ok(None),
322 }
323 } else {
324 ("username", login)
325 };
326 self.db
327 .prepare(format!(
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)328 "SELECT id, username, password_hash, email_verified_at IS NOT NULL AS verified FROM users
329 WHERE {column} = ? AND deleted_at IS NULL"
Topics sit in About on the files pages, under the description330 ))
331 .bind(&[JsValue::from(value)])?
332 .first::<UserRow>(None)
333 .await
334 }
335
336 /// Checks a password for a login, throttled (see throttle.rs). The
337 /// refusal is one of two messages, the same for every account.
338 async fn checked_password(
339 &self,
340 login: &str,
341 password: &str,
342 client: Option<&str>,
343 ) -> Result<std::result::Result<User, &'static str>> {
344 let row = self.password_row(login).await?;
345 let subject = row.as_ref().map_or_else(|| login.trim().to_lowercase(), |row| row.id.clone());
346 let (account_key, client_key) = Identity::password_keys(&subject, client);
347 if self.password_locked(&account_key, client_key.as_deref()).await? {
348 return Ok(Err(throttle::THROTTLED));
349 }
350 let owner = row.as_ref().map(|row| (row.id.clone(), row.username.clone()));
351 match row.filter(|row| !row.password_hash.is_empty() && crypto::verify_password(password, &row.password_hash)) {
352 Some(row) => {
353 self.clear(&account_key).await?;
354 Ok(Ok(User {
355 id: row.id,
356 username: row.username,
357 verified: row.verified != 0,
358 ..User::default()
359 }))
360 }
361 None => {
362 let owner = owner.as_ref().map(|(id, name)| (id.as_str(), name.as_str()));
363 self.password_failed(&account_key, client_key.as_deref(), owner).await?;
364 Ok(Err("Incorrect username or password."))
365 }
366 }
367 }
368
Merge main (membership, two-factor, GitHub repo roles) into tokens369 /// Git over HTTPS with the account's password. With two-factor
370 /// authentication on, a password alone is never enough: use an access
371 /// token (two_factor.rs).
Topics sit in About on the files pages, under the description372 async fn user_for_password(&self, login: &str, password: &str) -> Result<Viewer> {
373 let user = self.checked_password(login, password, None).await?.ok();
Merge main (membership, two-factor, GitHub repo roles) into tokens374 if let Some(user) = &user
375 && self.two_factor_enabled(&user.id).await?
376 {
377 return Ok(None);
378 }
Topics sit in About on the files pages, under the description379 self.with_workspaces(user).await
380 }
381
382 async fn register(&self, a: RegisterArgs) -> Result<Outcome<SignedIn>> {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers383 // Kept as typed for showing; found, linked and mentioned lowercased.
384 let chosen = claimable_username(&a.username);
385 let username = chosen.as_ref().map_or_else(|| a.username.trim().to_lowercase(), |name| name.canonical.clone());
386 let claimable = chosen.is_some();
Topics sit in About on the files pages, under the description387 let email = a.email.trim().to_lowercase();
388 let invalid = |message: &str| Ok(Outcome::fail(FailureCode::Invalid, message));
389 let invite_code = a.invite_code.as_deref().map(str::trim).filter(|code| !code.is_empty());
390 // The invite first: without one, nothing else on the form matters.
391 if self.invites_required() && invite_code.is_none() {
392 return Ok(Outcome::fail(FailureCode::Forbidden, invites::MISSING));
393 }
394 if !claimable {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers395 return invalid(USERNAME_RULES);
Topics sit in About on the files pages, under the description396 }
397 let well_formed_email = email
398 .split_once('@')
399 .is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.'))
400 && !email.contains(char::is_whitespace);
401 if !well_formed_email {
402 return invalid("Enter a valid email address.");
403 }
404 if a.password.chars().count() < MIN_PASSWORD_LENGTH {
405 return invalid(PASSWORD_TOO_SHORT);
406 }
407 let taken = self
408 .db
409 // Usernames and workspaces share one namespace, so that a name
410 // means the same thing wherever it appears.
411 // An address is taken once an account has confirmed it; an
412 // unconfirmed one goes to whoever confirms it first (emails.rs).
413 .prepare(
414 "SELECT username FROM users WHERE username = ?
415 UNION ALL SELECT email FROM user_emails WHERE email = ? AND verified_at IS NOT NULL
416 UNION ALL SELECT slug FROM workspaces WHERE slug = ?",
417 )
418 .bind(&[
419 username.as_str().into(),
420 email.as_str().into(),
421 username.as_str().into(),
422 ])?
423 .first::<serde_json::Value>(None)
424 .await?;
425 // A renamed workspace's old slug stays reserved for it a while, and
426 // a deleted workspace's for good.
427 if taken.is_some() || self.slug_held(&username).await? || self.slug_deleted(&username).await? {
428 return Ok(Outcome::fail(
429 FailureCode::Conflict,
430 "That username or email is already registered.",
431 ));
432 }
433 let password_hash = crypto::hash_password(&a.password);
434 let user = match self
435 .create_account(invites::NewAccount {
436 username: &username,
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers437 display_username: chosen.as_ref().and_then(|name| name.display_if_cased()),
Topics sit in About on the files pages, under the description438 email: &email,
439 password_hash: &password_hash,
440 verified: false,
441 invite_code,
Merge invite emails that confirm the address: the emailed link carries a proof only that email has, so signing up from it needs no code; shared links and typed codes still confirm442 email_proof: a.email_proof.as_deref(),
Topics sit in About on the files pages, under the description443 client: a.client.as_deref(),
444 })
445 .await?
446 {
447 Outcome::Ok(user) => user,
448 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
449 };
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)450 // The account exists either way; the email can be sent again from
451 // the confirmation page. It carries a code and a link (emails.rs).
Topics sit in About on the files pages, under the description452 if !user.verified
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)453 && let Err(error) = self.send_primary_confirmation(&user.id, &user.username).await
Topics sit in About on the files pages, under the description454 {
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)455 worker::console_error!("confirmation email failed: {error}");
Topics sit in About on the files pages, under the description456 }
457 self.start_session(user).await
458 }
459
460 async fn sign_in(&self, a: SignInArgs) -> Result<Outcome<SignedIn>> {
461 let user = match self.checked_password(&a.username, &a.password, a.client.as_deref()).await? {
462 Ok(user) => user,
463 Err(message) => return Ok(Outcome::fail(FailureCode::Unauthenticated, message)),
464 };
465 let user = self.with_workspaces(Some(user)).await?.unwrap_or_default();
466 self.start_session(user).await
467 }
468
Merge main (membership, two-factor, GitHub repo roles) into tokens469 /// Starts a session for someone who just proved their password (or
470 /// GitHub account). With two-factor authentication on, it starts none:
471 /// it returns a challenge for `two_factor_sign_in` (two_factor.rs).
Topics sit in About on the files pages, under the description472 async fn start_session(&self, user: User) -> Result<Outcome<SignedIn>> {
Merge main (membership, two-factor, GitHub repo roles) into tokens473 if self.two_factor_enabled(&user.id).await? {
474 let challenge = self.issue_challenge(&user.id).await?;
475 return Ok(Outcome::Ok(SignedIn {
476 user: User { workspaces: Vec::new(), grants: Vec::new(), held: Vec::new(), ..user },
477 session_token: String::new(),
478 two_factor_challenge: Some(challenge),
479 }));
480 }
481 self.session_for(user).await
482 }
483
484 /// A new session for `user`, who has proved who they are in full.
485 async fn session_for(&self, user: User) -> Result<Outcome<SignedIn>> {
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)486 // Whichever way it was proved, a deleted account starts none
487 // (account_deletion.rs).
488 if !self.account_live(&user.id).await? {
489 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Incorrect username or password."));
490 }
Topics sit in About on the files pages, under the description491 let session_token = crypto::random_hex(32);
492 self.db
493 .prepare(format!(
494 // Signing in is proof it is the person: see security.rs.
495 "INSERT INTO sessions (id, user_id, expires_at, authenticated_at) VALUES (?, ?, {}, {SQL_NOW})",
496 sql_after(SESSION_TTL_SECONDS)
497 ))
498 .bind(&[
499 crypto::sha256_hex(&session_token).into(),
500 user.id.as_str().into(),
501 ])?
502 .run()
503 .await?;
504 Ok(Outcome::Ok(SignedIn {
505 user,
506 session_token,
Merge main (membership, two-factor, GitHub repo roles) into tokens507 two_factor_challenge: None,
Topics sit in About on the files pages, under the description508 }))
509 }
510
511 async fn sign_out(&self, a: SessionArgs) -> Result<()> {
512 self.db
513 .prepare("DELETE FROM sessions WHERE id = ?")
514 .bind(&[crypto::sha256_hex(&a.session_token).into()])?
515 .run()
516 .await?;
517 Ok(())
518 }
519
520 async fn user_for_session(&self, a: SessionArgs) -> Result<Viewer> {
521 self.find_user(
522 &format!(
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers523 "SELECT users.id, users.username, users.display_username, users.email_verified_at IS NOT NULL AS verified,
Topics sit in About on the files pages, under the description524 users.avatar
525 FROM sessions JOIN users ON users.id = sessions.user_id
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)526 WHERE sessions.id = ? AND sessions.expires_at > {SQL_NOW} AND users.deleted_at IS NULL"
Topics sit in About on the files pages, under the description527 ),
528 &crypto::sha256_hex(&a.session_token),
529 )
530 .await
531 }
532
533 async fn user_for_git_credentials(&self, a: GitCredentialsArgs) -> Result<Viewer> {
534 // Like GitHub, a token alone identifies its user.
535 if a.secret.starts_with(TOKEN_PREFIX) {
536 self.user_for_access_token(&a.secret).await
537 } else {
538 self.user_for_password(&a.username, &a.secret).await
539 }
540 }
541
542 async fn user_for_ssh_key(&self, a: FingerprintArgs) -> Result<Viewer> {
543 self.find_user(
544 "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified FROM ssh_keys
545 JOIN users ON users.id = ssh_keys.user_id
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)546 WHERE fingerprint = ? AND users.deleted_at IS NULL",
Topics sit in About on the files pages, under the description547 &a.fingerprint,
548 )
549 .await
550 }
551
552 async fn user_by_username(&self, a: UsernameArgs) -> Result<Viewer> {
553 self.find_public_user(
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)554 // A deleted account is nobody's to find, mention or add.
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers555 "SELECT id, username, display_username, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ? AND deleted_at IS NULL",
Topics sit in About on the files pages, under the description556 &a.username.to_lowercase(),
557 )
558 .await
559 }
560
Inbox: threads, reasons, subscriptions and watching561 /// `notify_by_email`: an inbox item, emailed to the person it is for,
562 /// only at a confirmed address and only while they can still read the
563 /// repository it is about. Returns whether it was sent.
564 async fn notify_by_email(&self, a: g1t_contracts::inbox::NotifyByEmailArgs) -> Result<bool> {
565 #[derive(Deserialize)]
566 struct Address {
567 email: Option<String>,
568 }
569 let user = self
570 .find_user(
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)571 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ? AND deleted_at IS NULL",
Inbox: threads, reasons, subscriptions and watching572 &a.username.to_lowercase(),
573 )
574 .await?;
575 let Some(user) = user.filter(|user| user.verified) else {
576 return Ok(false);
577 };
578 let readable: Vec<g1t_contracts::repos::Repo> = g1t_kit::call(
579 &self.env.service("REPOS")?,
580 "readable",
581 &g1t_contracts::repos::ReadableArgs {
582 ids: vec![a.repo_id.clone()],
583 viewer: Some(user.clone()),
584 },
585 )
586 .await?;
587 if readable.is_empty() {
588 return Ok(false);
589 }
590 let address = self
591 .db
592 .prepare("SELECT email FROM users WHERE id = ?")
593 .bind(&[user.id.as_str().into()])?
594 .first::<Address>(None)
595 .await?
596 .and_then(|row| row.email)
597 .filter(|email| !email.trim().is_empty());
598 let Some(address) = address else {
599 return Ok(false);
600 };
601 email::send_notification(&self.env, &address, &a).await?;
602 Ok(true)
603 }
604
Topics sit in About on the files pages, under the description605 async fn usernames(&self, a: UsernamesArgs) -> Result<std::collections::HashMap<String, String>> {
606 #[derive(serde::Deserialize)]
607 struct Named {
608 id: String,
609 name: String,
610 }
611 let ids: Vec<String> = a.ids.into_iter().take(200).collect();
612 let mut names = std::collections::HashMap::new();
613 if ids.is_empty() {
614 return Ok(names);
615 }
616 let marks = vec!["?"; ids.len()].join(", ");
617 let bind: Vec<worker::wasm_bindgen::JsValue> = ids.iter().map(|id| id.as_str().into()).collect();
618 for sql in [
619 format!("SELECT id, username AS name FROM users WHERE id IN ({marks})"),
620 format!("SELECT id, slug AS name FROM workspaces WHERE id IN ({marks})"),
621 ] {
622 for row in self.db.prepare(sql).bind(&bind)?.all().await?.results::<Named>()? {
623 names.insert(row.id, row.name);
624 }
625 }
626 Ok(names)
627 }
628
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)629 /// `users_for_audience`: the people behind these ids (at most 50), each
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.630 /// with their workspaces, roles, base permissions and repository
631 /// grants, under each workspace's policy, as a signed-in viewer would
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.632 /// have them. For services that answer only with what every person
633 /// who will read the answer may see. Ids of no live account are left out, so the caller can tell
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.634 /// someone it could not resolve. Reached only by service binding.
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)635 async fn users_for_audience(&self, a: UsernamesArgs) -> Result<Vec<User>> {
636 let mut found = Vec::new();
637 for id in a.ids.iter().take(50) {
638 let user = self
639 .find_user(
640 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ? AND deleted_at IS NULL",
641 id,
642 )
643 .await?;
644 if let Some(user) = user {
645 found.push(user);
646 }
647 }
648 Ok(found)
649 }
650
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97651 /// `accounts`: the accounts behind these ids (at most 200), each with
652 /// its username and avatar, for lists that keep ids, such as who
653 /// starred a repository. Ids of no account are left out.
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar654 /// `display_usernames`: each lowercased username's chosen case, for the
655 /// API, which shows it beside every username it answers with.
656 async fn display_usernames(&self, a: DisplayUsernamesArgs) -> Result<std::collections::HashMap<String, String>> {
657 #[derive(serde::Deserialize)]
658 struct Row {
659 username: String,
660 display_username: String,
661 }
662 let mut names: Vec<String> = a.usernames.iter().map(|name| name.trim().to_lowercase()).filter(|name| !name.is_empty()).collect();
663 names.sort();
664 names.dedup();
665 names.truncate(200);
666 let mut found = std::collections::HashMap::new();
667 if names.is_empty() {
668 return Ok(found);
669 }
670 let marks = vec!["?"; names.len()].join(", ");
671 let bind: Vec<worker::wasm_bindgen::JsValue> = names.iter().map(|name| name.as_str().into()).collect();
672 let rows = self
673 .db
674 .prepare(format!(
675 "SELECT username, display_username FROM users WHERE username IN ({marks}) AND display_username IS NOT NULL AND deleted_at IS NULL"
676 ))
677 .bind(&bind)?
678 .all()
679 .await?
680 .results::<Row>()?;
681 for row in rows {
682 if row.display_username.eq_ignore_ascii_case(&row.username) && row.display_username != row.username {
683 found.insert(row.username, row.display_username);
684 }
685 }
686 Ok(found)
687 }
688
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97689 async fn accounts(&self, a: UsernamesArgs) -> Result<std::collections::HashMap<String, g1t_contracts::accounts::EmailOwner>> {
690 #[derive(serde::Deserialize)]
691 struct Row {
692 id: String,
693 username: String,
694 avatar: Option<String>,
695 }
696 let ids: Vec<String> = a.ids.into_iter().take(200).collect();
697 let mut found = std::collections::HashMap::new();
698 if ids.is_empty() {
699 return Ok(found);
700 }
701 let marks = vec!["?"; ids.len()].join(", ");
702 let bind: Vec<worker::wasm_bindgen::JsValue> = ids.iter().map(|id| id.as_str().into()).collect();
703 let rows = self
704 .db
705 .prepare(format!("SELECT id, username, avatar FROM users WHERE id IN ({marks})"))
706 .bind(&bind)?
707 .all()
708 .await?
709 .results::<Row>()?;
710 for row in rows {
711 found.insert(row.id.clone(), g1t_contracts::accounts::EmailOwner { id: row.id, username: row.username, avatar: row.avatar });
712 }
713 Ok(found)
714 }
715
Topics sit in About on the files pages, under the description716 async fn list_ssh_keys(&self, a: UserArgs) -> Result<Vec<SshKey>> {
717 let rows = self
718 .db
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca719 .prepare("SELECT id, title, fingerprint, created_at, last_used_at FROM ssh_keys WHERE user_id = ? ORDER BY id")
Topics sit in About on the files pages, under the description720 .bind(&[a.user.id.into()])?
721 .all()
722 .await?
723 .results::<KeyRow>()?;
724 Ok(rows.into_iter().map(SshKey::from).collect())
725 }
726
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge727 /// The account (user id) that registered each key, by fingerprint
728 /// (`SHA256:…`). At most 100; unknown keys are left out.
729 async fn ssh_key_owners(&self, a: SshKeyOwnersArgs) -> Result<std::collections::HashMap<String, String>> {
730 #[derive(serde::Deserialize)]
731 struct Row {
732 fingerprint: String,
733 user_id: String,
734 }
735 let fingerprints: Vec<&String> = a.fingerprints.iter().take(100).collect();
736 if fingerprints.is_empty() {
737 return Ok(std::collections::HashMap::new());
738 }
739 let marks = vec!["?"; fingerprints.len()].join(", ");
740 let binds: Vec<JsValue> = fingerprints.iter().map(|fingerprint| fingerprint.as_str().into()).collect();
741 Ok(self
742 .db
743 .prepare(format!("SELECT fingerprint, user_id FROM ssh_keys WHERE fingerprint IN ({marks})"))
744 .bind(&binds)?
745 .all()
746 .await?
747 .results::<Row>()?
748 .into_iter()
749 .map(|row| (row.fingerprint, row.user_id))
750 .collect())
751 }
752
Topics sit in About on the files pages, under the description753 async fn add_ssh_key(&self, a: AddSshKeyArgs) -> Result<Outcome<SshKey>> {
754 let Some(key) = crypto::parse_ssh_key(&a.public_key) else {
755 return Ok(Outcome::fail(
756 FailureCode::Invalid,
757 "That is not a valid OpenSSH public key.",
758 ));
759 };
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca760 // Someone's SSH key, or a repository's deploy key (deploy_keys.rs).
761 if self.key_in_use(&key.fingerprint).await? {
762 return Ok(Outcome::fail(FailureCode::Conflict, g1t_contracts::deploy_keys::KEY_IN_USE));
Topics sit in About on the files pages, under the description763 }
764 let now = now_ms();
765 let title = [a.title.trim(), key.comment.as_str(), "SSH key"]
766 .into_iter()
767 .find(|candidate| !candidate.is_empty())
768 .unwrap_or_default()
769 .to_owned();
770 let row = KeyRow {
771 id: new_id("key", now),
772 title,
773 fingerprint: key.fingerprint,
774 created_at: rfc3339(now),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca775 last_used_at: None,
Topics sit in About on the files pages, under the description776 };
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca777 let inserted = self.db
Topics sit in About on the files pages, under the description778 .prepare(
779 "INSERT INTO ssh_keys (id, user_id, title, public_key, fingerprint, created_at)
780 VALUES (?, ?, ?, ?, ?, ?)",
781 )
782 .bind(&[
783 row.id.as_str().into(),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca784 a.user.id.as_str().into(),
Topics sit in About on the files pages, under the description785 row.title.as_str().into(),
786 key.public_key.into(),
787 row.fingerprint.as_str().into(),
788 row.created_at.as_str().into(),
789 ])?
790 .run()
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca791 .await;
792 // Added at the same moment elsewhere: the trigger or the unique
793 // index refused it.
794 if let Err(error) = inserted {
795 if self.key_in_use(&row.fingerprint).await? {
796 return Ok(Outcome::fail(FailureCode::Conflict, g1t_contracts::deploy_keys::KEY_IN_USE));
797 }
798 return Err(error);
799 }
Merge main (membership, two-factor, GitHub repo roles) into tokens800 let shown = format!("{} ({})", row.title, row.fingerprint);
801 self.log_security(&a.user.id, "ssh_key_added", Some(&shown), None).await;
802 self.audit_account(&a.user, "ssh_key.added", &format!("Added SSH key {shown}")).await;
Topics sit in About on the files pages, under the description803 Ok(Outcome::Ok(row.into()))
804 }
805
Merge main (membership, two-factor, GitHub repo roles) into tokens806 /// Deletes one of the person's SSH keys.
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca807 async fn remove_ssh_key(&self, a: RemoveArgs) -> Result<()> {
Merge main (membership, two-factor, GitHub repo roles) into tokens808 #[derive(Deserialize)]
809 struct Removed {
810 title: String,
811 fingerprint: String,
812 }
813 let removed = self
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca814 .db
Merge main (membership, two-factor, GitHub repo roles) into tokens815 .prepare("DELETE FROM ssh_keys WHERE id = ? AND user_id = ? RETURNING title, fingerprint")
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca816 .bind(&[a.id.as_str().into(), a.user.id.as_str().into()])?
Merge main (membership, two-factor, GitHub repo roles) into tokens817 .first::<Removed>(None)
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca818 .await?;
Merge main (membership, two-factor, GitHub repo roles) into tokens819 if let Some(removed) = removed {
820 let shown = format!("{} ({})", removed.title, removed.fingerprint);
821 self.log_security(&a.user.id, "ssh_key_removed", Some(&shown), None).await;
822 self.audit_account(&a.user, "ssh_key.removed", &format!("Removed SSH key {shown}")).await;
823 }
Topics sit in About on the files pages, under the description824 Ok(())
825 }
826}
827
828/// Every 15 minutes: staff hear about waitlist requests that arrived while
829/// the last summary's window was still open, so none waits on a later one;
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)830/// and deleted workspaces and accounts past their restore window are purged
831/// (deletion.rs, account_deletion.rs).
Topics sit in About on the files pages, under the description832#[event(scheduled)]
833async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
834 let Ok(db) = env.d1("DB") else { return };
835 let identity = Identity { db, env };
836 if let Err(error) = identity.notify_staff_of_requests().await {
837 worker::console_error!("waitlist summary: {error}");
838 }
839 if let Err(error) = identity.purge_due_workspaces().await {
840 worker::console_error!("workspace purge: {error}");
841 }
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)842 // And deleted accounts past theirs (account_deletion.rs).
843 if let Err(error) = identity.purge_due_accounts().await {
844 worker::console_error!("account purge: {error}");
845 }
Merge main (membership, two-factor, GitHub repo roles) into tokens846 // Once: creators of repositories made before they got Admin (members.rs).
847 if let Err(error) = identity.backfill_creator_grants().await {
848 worker::console_error!("creator grants: {error}");
849 }
Topics sit in About on the files pages, under the description850}
851
852#[event(fetch)]
853async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
854 let Some(method) = rpc_method(&request) else {
855 return Response::error("Not found", 404);
856 };
857 // A replica near the caller when it asks for one (crates/kit/src/d1.rs).
858 let (db, served) = g1t_kit::d1::open(&env, "DB", &request)?;
859 let body: serde_json::Value = request.json().await?;
860 let identity = Identity { db, env };
861
862 let answered = match method.as_str() {
863 "register" => {
864 let outcome = identity.register(args(body)?).await?;
865 if let Outcome::Ok(signed_in) = &outcome {
866 identity.announce_user(&signed_in.user.username, Some(&signed_in.user.id)).await;
867 }
868 reply(&outcome)
869 }
870 "sign_in" => reply(&identity.sign_in(args(body)?).await?),
871 "create_workspace" => {
872 let outcome = identity.create_workspace(args(body)?).await?;
873 if let Outcome::Ok(workspace) = &outcome {
874 identity.announce_workspace(&workspace.id, &workspace.slug, None).await;
875 }
876 reply(&outcome)
877 }
878 "get_workspace" => reply(&identity.get_workspace(args(body)?).await?),
879 "list_members" => reply(&identity.list_members(args(body)?).await?),
880 "add_member" => reply(&identity.add_member(args(body)?).await?),
881 "remove_member" => reply(&identity.remove_member(args(body)?).await?),
Merge main (membership, two-factor, GitHub repo roles) into tokens882 // Owners, roles, leaving and member privileges; see members.rs.
883 "update_member" => reply(&identity.update_member(args(body)?).await?),
884 "transfer_ownership" => reply(&identity.transfer_ownership(args(body)?).await?),
885 "leave_workspace" => reply(&identity.leave_workspace(args(body)?).await?),
886 "set_member_privileges" => reply(&identity.set_member_privileges(args(body)?).await?),
887 "set_two_factor_requirement" => reply(&identity.set_two_factor_requirement(args(body)?).await?),
888 "grant_creator" => reply(&identity.grant_creator(args(body)?).await?),
Topics sit in About on the files pages, under the description889 "update_workspace" => {
890 let outcome = identity.update_workspace(args(body)?).await?;
891 if let Outcome::Ok(workspace) = &outcome {
892 identity.announce_workspace(&workspace.id, &workspace.slug, None).await;
893 }
894 reply(&outcome)
895 }
896 "rename_workspace" => reply(&identity.rename_workspace(args(body)?).await?),
897 "check_workspace_rename" => reply(&identity.check_workspace_rename(args(body)?).await?),
898 "resolve_slug" => reply(&identity.resolve_slug(args(body)?).await?),
Merge branch 'worktree-agent-a8385d293d42c913a'899 "resolve_alias" => reply(&identity.resolve_alias(args(body)?).await?),
Topics sit in About on the files pages, under the description900 "check_workspace_deletion" => reply(&identity.check_workspace_deletion(args(body)?).await?),
901 "delete_workspace" => reply(&identity.delete_workspace(args(body)?).await?),
902 "transfer_repo_scopes" => reply(&identity.transfer_repo_scopes(args(body)?).await?),
903 "set_workspace_avatar" => {
904 let outcome = identity.set_workspace_avatar(args(body)?).await?;
905 if let Outcome::Ok(workspace) = &outcome {
906 identity.announce_workspace(&workspace.id, &workspace.slug, None).await;
907 }
908 reply(&outcome)
909 }
910 "set_user_avatar" => {
911 let a: SetUserAvatarArgs = args(body)?;
912 let (username, id) = (a.user.username.clone(), a.user.id.clone());
913 let outcome = identity.set_user_avatar(a).await?;
914 if matches!(outcome, Outcome::Ok(_)) {
915 identity.announce_user(&username, Some(&id)).await;
916 }
917 reply(&outcome)
918 }
919 "list_workspace_tokens" => reply(&identity.list_workspace_tokens(args(body)?).await?),
920 "remove_workspace_token" => reply(&identity.remove_workspace_token(args(body)?).await?),
921 // Signing in with GitHub; see github.rs.
922 "github_enabled" => reply(&identity.github_enabled()),
923 "github_start" => reply(&identity.github_start(args(body)?).await?),
924 "github_finish" => reply(&identity.github_finish(args(body)?).await?),
925 "github_pending" => reply(&identity.github_pending(args(body)?).await?),
926 "github_sign_up" => reply(&identity.github_sign_up(args(body)?).await?),
927 "github_claim" => reply(&identity.github_claim(args(body)?).await?),
928 "github_account" => reply(&identity.github_account(args(body)?).await?),
929 "github_unlink" => reply(&identity.github_unlink(args(body)?).await?),
930 "github_user_token" => reply(&identity.github_user_token(args(body)?).await?),
931 "github_revoked" => reply(&identity.github_revoked(args(body)?).await?),
932 "github_usernames" => reply(&identity.github_usernames(args(body)?).await?),
933 "oauth_authorize" => reply(&identity.oauth_authorize(args(body)?).await?),
934 "oauth_exchange" => reply(&identity.oauth_exchange(args(body)?).await?),
935 "oauth_refresh" => reply(&identity.oauth_refresh(args(body)?).await?),
936 "list_oauth_grants" => reply(&identity.list_oauth_grants(args(body)?).await?),
937 "revoke_oauth_grant" => reply(&identity.revoke_oauth_grant(args(body)?).await?),
938 "update_oauth_grant" => reply(&identity.update_oauth_grant(args(body)?).await?),
939 "device_start" => reply(&identity.device_start(args(body)?).await?),
940 "device_lookup" => reply(&identity.device_lookup(args(body)?).await?),
941 "device_resolve" => reply(&identity.device_resolve(args(body)?).await?),
942 "device_claim" => reply(&identity.device_claim(args(body)?).await?),
943 "resend_verification" => reply(&identity.resend_verification(args(body)?).await?),
944 "verify_email" => reply(&identity.verify_email(args(body)?).await?),
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)945 "confirm_email_code" => reply(&identity.confirm_email_code(args(body)?).await?),
946 "change_pending_email" => reply(&identity.change_pending_email(args(body)?).await?),
Topics sit in About on the files pages, under the description947 "request_password_reset" => reply(&identity.request_password_reset(args(body)?).await?),
948 "reset_password" => reply(&identity.reset_password(args(body)?).await?),
949 // A person's email addresses; see emails.rs and security.rs.
950 "list_emails" => reply(&identity.list_emails(args(body)?).await?),
951 "add_email" => reply(&identity.add_email(args(body)?).await?),
952 "remove_email" => reply(&identity.remove_email(args(body)?).await?),
953 "resend_email_verification" => reply(&identity.resend_email_verification(args(body)?).await?),
954 "update_email_settings" => reply(&identity.update_email_settings(args(body)?).await?),
955 "reauthenticate" => reply(&identity.reauthenticate(args(body)?).await?),
Merge main (membership, two-factor, GitHub repo roles) into tokens956 // Two-factor authentication; see two_factor.rs.
957 "two_factor_status" => reply(&identity.two_factor_status(args(body)?).await?),
958 "two_factor_start" => reply(&identity.two_factor_start(args(body)?).await?),
959 "two_factor_enable" => reply(&identity.two_factor_enable(args(body)?).await?),
960 "two_factor_disable" => reply(&identity.two_factor_disable(args(body)?).await?),
961 "two_factor_recovery_codes" => reply(&identity.two_factor_recovery_codes(args(body)?).await?),
962 "two_factor_sign_in" => reply(&identity.two_factor_sign_in(args(body)?).await?),
Topics sit in About on the files pages, under the description963 "security_log" => reply(&identity.security_log(args(body)?).await?),
964 "email_owners" => reply(&identity.email_owners(args(body)?).await?),
965 "commit_identity" => reply(&identity.commit_identity(args(body)?).await?),
966 "push_email_guard" => reply(&identity.push_email_guard(args(body)?).await?),
967 "admin_user" => reply(&identity.admin_user(args(body)?).await?),
968 "admin_remove_email" => reply(&identity.admin_remove_email(args(body)?).await?),
969 "sign_out" => reply(&identity.sign_out(args(body)?).await?),
970 "user_for_session" => reply(&identity.user_for_session(args(body)?).await?),
971 "user_for_git_credentials" => reply(&identity.user_for_git_credentials(args(body)?).await?),
972 "user_for_access_token" => {
973 let a: TokenArgs = args(body)?;
974 reply(&identity.user_for_access_token(&a.token).await?)
975 }
976 "user_for_ssh_key" => reply(&identity.user_for_ssh_key(args(body)?).await?),
977 "user_by_username" => reply(&identity.user_by_username(args(body)?).await?),
978 "usernames" => reply(&identity.usernames(args(body)?).await?),
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar979 // The API: each username's chosen case, shown beside it.
980 "display_usernames" => reply(&identity.display_usernames(args(body)?).await?),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97981 "accounts" => reply(&identity.accounts(args(body)?).await?),
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)982 "users_for_audience" => reply(&identity.users_for_audience(args(body)?).await?),
Inbox: threads, reasons, subscriptions and watching983 "notify_by_email" => reply(&identity.notify_by_email(args(body)?).await?),
Topics sit in About on the files pages, under the description984 "profile" => reply(&identity.profile(args(body)?).await?),
985 "update_profile" => {
986 let outcome = identity.update_profile(args(body)?).await?;
987 if let Outcome::Ok(profile) = &outcome {
988 identity.announce_user(&profile.username, None).await;
989 }
990 reply(&outcome)
991 }
992 "directory" => reply(&identity.directory(args(body)?).await?),
993 "profile_workspaces" => reply(&identity.profile_workspaces(args(body)?).await?),
The apps you pin to your dock are kept with your account, per workspace and in your order, so the dock is the same on every device: identity keeps them in dock_pins and answers dock_pins and set_dock_pins, the dock reads them with the rest of the page, pins kept only on this device carry over with your first change, this device's copy still draws the dock when identity can't be reached, a pin that can't be saved says so, and they go when you or the workspace do; the workspaces guide says how.994 // Each person's dock pins, per workspace; see dock.rs.
995 "dock_pins" => reply(&identity.dock_pins(args(body)?).await?),
996 "set_dock_pins" => reply(&identity.set_dock_pins(args(body)?).await?),
Chat has sounds. A short warm cue plays for a message in a conversation you have open but aren't looking at, a direct message, a mention of you and an agent finishing something for you, with a soft tick for sending that is off until you turn it on; never for your own messages, a muted conversation, a conversation you are looking at in a window that has the front, or while you have paused notifications, and never twice for one message or more than once in a second and a half. The cues are made by the browser itself in two sets, Soft and Bright, so nothing is downloaded; the Sounds and notifications section of your notification settings turns them on and off, picks the set and the volume, plays each one, and offers desktop notifications for when the window is behind; Do not disturb is the one pause everyone sees, set from the chat sidebar's bell, the settings, or your avatar menu, now with a two-hour choice, and the top bar's bell shows a dot while it's on. Your choices are kept with your account. The sound layer sits behind one small interface so the desktop app can play them natively later. The chat and notifications guides say the rules.997 // Each person's chat sounds and desktop notifications; see sounds.rs.
998 "sound_settings" => reply(&identity.sound_settings(args(body)?).await?),
999 "set_sound_settings" => reply(&identity.set_sound_settings(args(body)?).await?),
Topics sit in About on the files pages, under the description1000 "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1001 // Services only: who registered each key, for verifying commit
1002 // signatures (repos' signatures.rs).
1003 "ssh_key_owners" => reply(&identity.ssh_key_owners(args(body)?).await?),
Topics sit in About on the files pages, under the description1004 "add_ssh_key" => reply(&identity.add_ssh_key(args(body)?).await?),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1005 "remove_ssh_key" => reply(&identity.remove_ssh_key(args(body)?).await?),
1006 // A repository's deploy keys, and who an SSH key signs in as; see
1007 // deploy_keys.rs.
1008 "list_deploy_keys" => reply(&identity.list_deploy_keys(args(body)?).await?),
1009 "get_deploy_key" => reply(&identity.get_deploy_key(args(body)?).await?),
1010 "add_deploy_key" => reply(&identity.add_deploy_key(args(body)?).await?),
1011 "remove_deploy_key" => reply(&identity.remove_deploy_key(args(body)?).await?),
1012 "principal_for_ssh_key" => reply(&identity.principal_for_ssh_key(args(body)?).await?),
Topics sit in About on the files pages, under the description1013 "list_access_tokens" => reply(&identity.list_access_tokens(args(body)?).await?),
1014 "create_access_token" => reply(&identity.create_access_token(args(body)?).await?),
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1015 // Making and changing a person's or a workspace's token, and
1016 // workspaces' rules for tokens; see token_reach.rs.
1017 "create_token" => reply(&identity.create_token(args(body)?).await?),
1018 "update_token" => reply(&identity.update_token(args(body)?).await?),
An access token can be regenerated: a new secret, its name, permissions, repositories and reach kept, and the old secret refused at once. Its page (yours under Settings, a workspace's under its settings for owners) has Regenerate with a new expiry, within the workspace's rules on lifetime, and shows the secret once; identity's regenerate_token logs it to the security log or the workspace's audit log, and refuses a revoked token. The token form's quick permissions have All, every resource at its highest level. A project's Observability settings no longer list the token g1t gives its apps on g1t.page: it is g1t's, and the page says apps there need none.1019 "regenerate_token" => reply(&identity.regenerate_token(args(body)?).await?),
Fine-grained personal tokens, workspace token rules and approvals in identity1020 "get_token_policy" => reply(&identity.get_token_policy(args(body)?).await?),
1021 "set_token_policy" => reply(&identity.set_token_policy(args(body)?).await?),
1022 "list_member_tokens" => reply(&identity.list_member_tokens(args(body)?).await?),
1023 "review_token_request" => reply(&identity.review_token_request(args(body)?).await?),
1024 "revoke_member_token" => reply(&identity.revoke_member_token(args(body)?).await?),
Topics sit in About on the files pages, under the description1025 "create_run_credential" => reply(&identity.create_run_credential(args(body)?).await?),
1026 "bind_run_credentials" => reply(&identity.bind_run_credentials(args(body)?).await?),
1027 "revoke_run_credentials" => reply(&identity.revoke_run_credentials(args(body)?).await?),
Merge branch 'worktree-agent-a3abfcce648e87dca'1028 "create_job_token" => reply(&identity.create_job_token(args(body)?).await?),
1029 "revoke_job_tokens" => reply(&identity.revoke_job_tokens(args(body)?).await?),
Merge main (membership, two-factor, GitHub repo roles) into tokens1030 "remove_access_token" => reply(&identity.remove_access_token(args(body)?).await?),
Topics sit in About on the files pages, under the description1031 // Invites and the waitlist; see invites.rs.
1032 "registration" => reply(&identity.registration_mode()),
1033 "list_invites" => reply(&identity.list_invites(args(body)?).await?),
1034 "create_invite" => reply(&identity.create_invite(args(body)?).await?),
1035 "revoke_invite" => reply(&identity.revoke_invite(args(body)?).await?),
1036 "check_invite" => reply(&identity.check_invite(args(body)?).await?),
1037 "accept_invite" => reply(&identity.accept_invite(args(body)?).await?),
1038 "invite_member" => reply(&identity.invite_member(args(body)?).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)1039 "list_invitations" => reply(&identity.list_invitations(args(body)?).await?),
1040 "accept_invitation" => reply(&identity.accept_invitation(args(body)?).await?),
1041 "decline_invitation" => reply(&identity.decline_invitation(args(body)?).await?),
1042 "find_people" => reply(&identity.find_people(args(body)?).await?),
Topics sit in About on the files pages, under the description1043 "workspace_invites" => reply(&identity.workspace_invites(args(body)?).await?),
1044 "revoke_workspace_invite" => reply(&identity.revoke_workspace_invite(args(body)?).await?),
Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now.1045 "resend_workspace_invite" => reply(&identity.resend_workspace_invite(args(body)?).await?),
Topics sit in About on the files pages, under the description1046 "request_access" => reply(&identity.request_access(args(body)?).await?),
1047 // Who has access to a repository; see access.rs.
1048 "repo_access" => reply(&identity.repo_access(args(body)?).await?),
1049 "add_collaborator" => reply(&identity.add_collaborator(args(body)?).await?),
1050 "set_collaborator_role" => reply(&identity.set_collaborator_role(args(body)?).await?),
1051 "remove_collaborator" => reply(&identity.remove_collaborator(args(body)?).await?),
1052 "collaborator_permission" => reply(&identity.collaborator_permission(args(body)?).await?),
1053 "my_repo_invitations" => reply(&identity.my_repo_invitations(args(body)?).await?),
1054 "respond_repo_invitation" => reply(&identity.respond_repo_invitation(args(body)?).await?),
1055 "revoke_repo_invitation" => reply(&identity.revoke_repo_invitation(args(body)?).await?),
1056 "set_base_permission" => reply(&identity.set_base_permission(args(body)?).await?),
Merge branch 'worktree-agent-a2013627e5ea4ab13'1057 // Where a workspace keeps its repositories' git data (EU residency).
1058 "workspace_residency" => reply(&identity.workspace_residency(args(body)?).await?),
1059 "set_workspace_residency" => reply(&identity.set_workspace_residency(args(body)?).await?),
Topics sit in About on the files pages, under the description1060 "outside_collaborators" => reply(&identity.outside_collaborators(args(body)?).await?),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1061 "forget_repo_access" => {
1062 let a: g1t_contracts::access::ForgetRepoAccessArgs = args(body)?;
1063 // A purged repository's deploy keys go with its access.
1064 identity.forget_deploy_keys(&a.repo_id).await?;
1065 reply(&identity.forget_repo_access(a).await?)
1066 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1067 // Teams (teams.rs).
1068 "list_teams" => reply(&identity.list_teams(args(body)?).await?),
1069 "get_team" => reply(&identity.get_team(args(body)?).await?),
1070 "create_team" => reply(&identity.create_team(args(body)?).await?),
Merge branch 'worktree-agent-ad7c6d88d93adc817'1071 "set_team_creation" => reply(&identity.set_team_creation(args(body)?).await?),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1072 "update_team" => reply(&identity.update_team(args(body)?).await?),
1073 "delete_team" => reply(&identity.delete_team(args(body)?).await?),
1074 "team_members" => reply(&identity.team_members(args(body)?).await?),
1075 "set_team_member" => reply(&identity.set_team_member(args(body)?).await?),
1076 "remove_team_member" => reply(&identity.remove_team_member(args(body)?).await?),
1077 "child_teams" => reply(&identity.child_teams(args(body)?).await?),
1078 "team_repos" => reply(&identity.team_repos(args(body)?).await?),
1079 "set_team_repo" => reply(&identity.set_team_repo(args(body)?).await?),
1080 "remove_team_repo" => reply(&identity.remove_team_repo(args(body)?).await?),
1081 "user_teams" => reply(&identity.user_teams(args(body)?).await?),
1082 "team_memberships" => reply(&identity.team_memberships(args(body)?).await?),
1083 "resolve_teams" => reply(&identity.resolve_teams(args(body)?).await?),
Billing, integrations, security and identity no longer serve g1t's own agents. Billing stops metering agent runs, model tokens and AI Gateway requests, drops its model catalogue and price-keeper model prices, and stops selling AI credit, which only ever paid for those; credit bought earlier still pays toward what is owed. A usage report, statement or invoice that holds an old agent or model meter shows it as "Other usage" rather than failing, and the get_ai_credit and buy_ai_credit API operations and MCP billing actions go. Integrations no longer connects model providers or routes models, and no longer assigns imported issues or alerts to g1t; a model provider already connected can still be disconnected. Security drops "Fix with g1t". Identity no longer creates agent tokens or puts agents on teams, a team's lead is a person, and run credentials are issued only for checks, the merge queue, merge checks, deployments and bumps; tokens and credentials already issued keep working until they expire. The reserved names g1t and g1t-agent stay reserved. No tables are dropped.1084 // The people directory (people.rs).
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.1085 "people_directory" => reply(&identity.people_directory(args(body)?).await?),
1086 "set_member_profile" => reply(&identity.set_member_profile(args(body)?).await?),
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1087 "resolve_owners" => reply(&identity.resolve_owners(args(body)?).await?),
Topics sit in About on the files pages, under the description1088 // Staff only: sudo.g1t.sh, over its service binding. See admin.rs.
1089 "notify_owners" => reply(&identity.notify_owners(args(body)?).await?),
1090 "admin_workspaces" => reply(&identity.admin_workspaces(args(body)?).await?),
1091 "admin_workspace" => reply(&identity.admin_workspace(args(body)?).await?),
1092 "admin_waitlist" => reply(&identity.admin_waitlist(args(body)?).await?),
1093 "admin_decide_waitlist" => reply(&identity.admin_decide_waitlist(args(body)?).await?),
1094 "admin_waitlist_pending" => reply(&identity.admin_waitlist_pending().await?),
1095 "admin_invites" => reply(&identity.admin_invites(args(body)?).await?),
1096 "admin_revoke_invite" => reply(&identity.admin_revoke_invite(args(body)?).await?),
1097 "admin_mint_invite" => reply(&identity.admin_mint_invite(args(body)?).await?),
1098 "admin_grant_invites" => reply(&identity.admin_grant_invites(args(body)?).await?),
1099 "admin_invite_tree" => reply(&identity.admin_invite_tree(args(body)?).await?),
1100 "admin_workspace_invites" => reply(&identity.admin_workspace_invites(args(body)?).await?),
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)1101 // Shared invite links for a group; see shared_invites.rs.
1102 "admin_shared_invites" => reply(&identity.admin_shared_invites().await?),
1103 "admin_create_shared_invite" => reply(&identity.admin_create_shared_invite(args(body)?).await?),
1104 "admin_revoke_shared_invite" => reply(&identity.admin_revoke_shared_invite(args(body)?).await?),
Topics sit in About on the files pages, under the description1105 // Deleted workspaces, restored or purged by staff; see deletion.rs.
1106 "admin_deleted_workspaces" => reply(&identity.admin_deleted_workspaces().await?),
1107 "admin_restore_workspace" => reply(&identity.admin_restore_workspace(args(body)?).await?),
1108 "admin_purge_workspace" => reply(&identity.admin_purge_workspace(args(body)?).await?),
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)1109 // Deleting accounts (account_deletion.rs): the person from the
1110 // site, staff from sudo. There is no API route for it.
1111 "check_account_deletion" => reply(&identity.check_account_deletion(args(body)?).await?),
1112 "delete_account" => reply(&identity.delete_account(args(body)?).await?),
1113 "admin_delete_account" => reply(&identity.admin_delete_account(args(body)?).await?),
1114 "admin_deleted_accounts" => reply(&identity.admin_deleted_accounts().await?),
1115 "admin_restore_account" => reply(&identity.admin_restore_account(args(body)?).await?),
1116 "admin_purge_account" => reply(&identity.admin_purge_account(args(body)?).await?),
Merge branch 'worktree-agent-a8385d293d42c913a'1117 // Workspace aliases, set by staff only; see aliases.rs.
1118 "admin_aliases" => reply(&identity.admin_aliases().await?),
1119 "admin_set_alias" => reply(&identity.admin_set_alias(args(body)?).await?),
1120 "admin_remove_alias" => reply(&identity.admin_remove_alias(args(body)?).await?),
Topics sit in About on the files pages, under the description1121 _ => Response::error("Unknown method", 404),
1122 };
1123 served.finish(answered)
1124}
1125
1126#[cfg(test)]
1127mod register_tests {
1128 use super::*;
1129
1130 #[test]
1131 fn nobody_registers_as_g1t() {
1132 // What register checks the username with, whatever its case.
1133 for username in ["g1t", "G1T", "g1t-agent", "G1t-Agent"] {
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1134 assert!(claimable_username(username).is_none(), "{username}");
Topics sit in About on the files pages, under the description1135 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1136 assert_eq!(claimable_username("ana").map(|name| name.canonical).as_deref(), Some("ana"));
1137 }
1138
1139 #[test]
1140 fn a_username_keeps_the_case_it_was_chosen_in() {
1141 let name = claimable_username("Ana-Lopez").unwrap();
1142 assert_eq!(name.canonical, "ana-lopez");
1143 assert_eq!(name.display_if_cased(), Some("Ana-Lopez"));
1144 assert!(USERNAME_RULES.contains("either case"));
Topics sit in About on the files pages, under the description1145 }
1146}

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