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,147 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.

Fixes from the page sweep1//! 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;
Fixes from the page sweep8mod admin;
Merge branch 'worktree-agent-a8385d293d42c913a'9mod aliases;
Fixes from the page sweep10mod avatars;
11mod crypto;
12mod deletion;
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca13mod deploy_keys;
Fixes from the page sweep14mod 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;
Fixes from the page sweep17mod email;
18mod emails;
19mod github;
20mod invites;
Merge main (membership, two-factor, GitHub repo roles) into tokens21mod members;
Fixes from the page sweep22mod 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;
Fixes from the page sweep25mod profiles;
26mod rename;
Merge branch 'worktree-agent-a3abfcce648e87dca'27mod job_tokens;
Fixes from the page sweep28mod 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;
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.32mod suspension;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar33mod teams;
Fixes from the page sweep34mod throttle;
Fine-grained personal tokens, workspace token rules and approvals in identity35mod token_reach;
Fixes from the page sweep36mod tokens;
Merge main (membership, two-factor, GitHub repo roles) into tokens37mod two_factor;
Fixes from the page sweep38mod workspaces;
39
40use g1t_contracts::identity::*;
41use 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 managers42use g1t_contracts::{FailureCode, Outcome, User, Viewer, claimable_username, new_id};
Fixes from the page sweep43use g1t_kit::{args, now_ms, reply, rpc_method};
44use serde::Deserialize;
45use tokens::TOKEN_PREFIX;
46use worker::wasm_bindgen::JsValue;
47use worker::{Context, D1Database, Env, Request, Response, Result, ScheduleContext, ScheduledEvent, event};
48
49const SESSION_TTL_SECONDS: u64 = 30 * 24 * 60 * 60;
50const RESET_TTL_SECONDS: u64 = 60 * 60;
51const MIN_PASSWORD_LENGTH: usize = 10;
52const PASSWORD_TOO_SHORT: &str = "Use a password of at least 10 characters.";
53
54/// 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 managers55/// What a username may be, as registration and GitHub sign-up say when one is refused.
56pub(crate) const USERNAME_RULES: &str =
57 "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.";
58
Fixes from the page sweep59#[derive(Deserialize)]
60struct Account {
61 id: String,
62 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 managers63 /// Selected where the person is shown: their username as they wrote it.
64 #[serde(default)]
65 display_username: Option<String>,
Fixes from the page sweep66 verified: u8,
67 /// Selected only where the person is being shown to themselves.
68 #[serde(default)]
69 avatar: Option<String>,
70}
71
72impl From<Account> for User {
73 fn from(row: Account) -> Self {
74 User {
75 id: row.id,
76 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 managers77 display_username: row.display_username,
Fixes from the page sweep78 verified: row.verified != 0,
79 avatar: row.avatar,
80 ..User::default()
81 }
82 }
83}
84
85#[derive(Deserialize)]
86struct UserRow {
87 id: String,
88 username: String,
89 password_hash: String,
90 verified: u8,
91}
92
93/// The owner of an emailed token.
94#[derive(Deserialize)]
95struct TokenOwner {
96 id: String,
97 username: String,
98 /// The address a link was sent to; null on links from before accounts
99 /// had several, which are for the primary.
100 #[serde(default)]
101 email_id: Option<String>,
102}
103
104#[derive(Deserialize)]
105struct KeyRow {
106 id: String,
107 title: String,
108 fingerprint: String,
109 created_at: String,
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca110 #[serde(default)]
111 last_used_at: Option<String>,
Fixes from the page sweep112}
113
114impl From<KeyRow> for SshKey {
115 fn from(row: KeyRow) -> Self {
116 SshKey {
117 id: row.id,
118 title: row.title,
119 fingerprint: row.fingerprint,
120 created_at: row.created_at,
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca121 last_used_at: row.last_used_at,
Fixes from the page sweep122 }
123 }
124}
125
126struct Identity {
127 db: D1Database,
128 env: Env,
129}
130
131impl Identity {
132 /// Runs a query that returns at most one user, for showing to others:
133 /// without their workspaces.
134 async fn find_public_user(&self, sql: &str, param: &str) -> Result<Viewer> {
135 Ok(self
136 .db
137 .prepare(sql)
138 .bind(&[JsValue::from(param)])?
139 .first::<Account>(None)
140 .await?
141 .map(User::from))
142 }
143
144 /// Attaches the workspaces a user belongs to, so that any service can
145 /// authorize them without asking again.
146 async fn with_workspaces(&self, user: Viewer) -> Result<Viewer> {
147 let Some(mut user) = user else {
148 return Ok(None);
149 };
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.150 // A suspended account is nobody, however it is presented
151 // (suspension.rs): its sessions, tokens, keys and password.
152 if self.user_suspension(&user.id).await?.is_some() {
153 return Ok(None);
154 }
Merge main (membership, two-factor, GitHub repo roles) into tokens155 let memberships = self.memberships_and_policies(&user.id).await?;
Fixes from the page sweep156 // Roles on single repositories, under the same policy (access.rs).
157 let grants = self.grants_of(&user.id).await?;
Merge main (membership, two-factor, GitHub repo roles) into tokens158 // Access to a workspace is used only within its policy; see security.rs.
159 let within = self.within_policy(&user.id, memberships, grants).await?;
160 user.workspaces = within.memberships;
161 user.grants = within.grants;
162 user.held = within.held;
Fixes from the page sweep163 Ok(Some(user))
164 }
165
166 /// Runs a query that resolves credentials to at most one user.
167 async fn find_user(&self, sql: &str, param: &str) -> Result<Viewer> {
168 let user = self.find_public_user(sql, param).await?;
169 self.with_workspaces(user).await
170 }
171
172 /// Consumes a token of `kind`, returning its owner if it was valid.
173 async fn redeem_email_token(&self, token: &str, kind: &str) -> Result<Option<TokenOwner>> {
174 let id = crypto::sha256_hex(token);
175 let owner = self
176 .db
177 .prepare(format!(
178 "SELECT users.id, users.username, email_tokens.email_id FROM email_tokens
179 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)180 WHERE email_tokens.id = ? AND email_tokens.kind = ? AND users.deleted_at IS NULL
Fixes from the page sweep181 AND email_tokens.expires_at > {SQL_NOW}"
182 ))
183 .bind(&[id.as_str().into(), kind.into()])?
184 .first::<TokenOwner>(None)
185 .await?;
186 if let Some(owner) = &owner {
187 // Every outstanding token of this kind dies with the one used:
188 // every reset link, and every confirmation link for the same
189 // address (another address's links still work).
190 self.db
191 .prepare(
192 "DELETE FROM email_tokens WHERE user_id = ?1 AND kind = ?2
193 AND (?2 = 'reset' OR email_id IS ?3)",
194 )
195 .bind(&[
196 owner.id.as_str().into(),
197 kind.into(),
198 owner.email_id.as_deref().map_or(JsValue::NULL, Into::into),
199 ])?
200 .run()
201 .await?;
202 }
203 Ok(owner)
204 }
205
206 async fn resend_verification(&self, a: UserArgs) -> Result<Outcome<bool>> {
207 if !self.allow(throttle::CONFIRM_ACCOUNT, &a.user.id).await? {
208 return Ok(Outcome::fail(FailureCode::Conflict, "Too many confirmation emails this hour. Check your inbox, or try again later."));
209 }
210 self.resend_primary(&a.user).await
211 }
212
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)213 /// The link in a confirmation email, followed: signed in or not. It
214 /// ends the code sent with it (emails.rs).
215 async fn verify_email(&self, a: EmailTokenArgs) -> Result<Outcome<g1t_contracts::accounts::EmailConfirmed>> {
Fixes from the page sweep216 let Some(owner) = self.redeem_email_token(&a.token, "verify").await? else {
217 return Ok(Outcome::fail(
218 FailureCode::Invalid,
219 "This confirmation link is not valid or has expired.",
220 ));
221 };
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)222 self.confirm_address(&owner.id, owner.email_id.as_deref()).await
Fixes from the page sweep223 }
224
225 /// Any confirmed address of an account can ask for a reset; so can the
226 /// unconfirmed address a new account signed up with. See emails.rs.
227 async fn request_password_reset(&self, a: EmailArgs) -> Result<bool> {
228 let allowed = self.allow(throttle::RESET_EMAIL, &a.email).await?
229 && match a.client.as_deref() {
230 Some(client) => self.allow(throttle::RESET_CLIENT, client).await?,
231 None => true,
232 };
233 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 exists234 // A failure from here on happens only for a real account, so it
235 // is logged, never answered: the reply below stays the same.
236 if let Err(error) = self.send_reset(&target).await {
237 worker::console_error!("password reset for a known address failed: {error}");
238 }
239 }
240 // The same answer either way, so addresses cannot be probed.
241 Ok(true)
242 }
243
244 /// Saves a reset link for `target` and mails it, telling the account's
245 /// other addresses.
246 async fn send_reset(&self, target: &emails::ResetTarget) -> Result<()> {
247 {
Fixes from the page sweep248 let token = crypto::random_hex(32);
249 self.db
250 .prepare(format!(
251 "INSERT INTO email_tokens (id, user_id, kind, expires_at, email_id)
252 VALUES (?, ?, 'reset', {}, ?)",
253 sql_after(RESET_TTL_SECONDS)
254 ))
255 .bind(&[
256 crypto::sha256_hex(&token).into(),
257 target.user_id.as_str().into(),
258 target.email_id.as_str().into(),
259 ])?
260 .run()
261 .await?;
262 email::send_password_reset(&self.env, &target.display, &target.username, &token).await?;
263 // The primary and the backup hear of it when it went elsewhere.
264 let elsewhere = self.notice_recipients(&target.user_id, false).await?;
265 for address in elsewhere.iter().filter(|address| !address.eq_ignore_ascii_case(&target.display)) {
266 let change = format!("A password reset was asked for through {}", target.display);
267 if let Err(error) = email::send_security_notice(&self.env, address, &target.username, &change).await {
268 worker::console_error!("security notice failed: {error}");
269 }
270 }
271 }
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 exists272 Ok(())
Fixes from the page sweep273 }
274
275 async fn reset_password(&self, a: ResetPasswordArgs) -> Result<Outcome<User>> {
276 if a.password.chars().count() < MIN_PASSWORD_LENGTH {
277 return Ok(Outcome::fail(FailureCode::Invalid, PASSWORD_TOO_SHORT));
278 }
279 let Some(owner) = self.redeem_email_token(&a.token, "reset").await? else {
280 return Ok(Outcome::fail(
281 FailureCode::Invalid,
282 "This reset link is not valid or has expired.",
283 ));
284 };
285 self.db
286 .prepare("UPDATE users SET password_hash = ? WHERE id = ?")
287 .bind(&[
288 crypto::hash_password(&a.password).into(),
289 owner.id.as_str().into(),
290 ])?
291 .run()
292 .await?;
293 // Following an emailed link also proves the address it went to
294 // (unless another account confirmed it first).
295 let _ = self.confirm_address(&owner.id, owner.email_id.as_deref()).await?;
296 // Anyone signed in with the old password is signed out, and nobody
297 // stays locked out by the wrong guesses before it.
298 self.db
299 .prepare("DELETE FROM sessions WHERE user_id = ?")
300 .bind(&[owner.id.as_str().into()])?
301 .run()
302 .await?;
303 self.clear(&throttle::key(throttle::PASSWORD_ACCOUNT, &owner.id)).await?;
304 self.log_security(&owner.id, "password_changed", None, None).await;
305 self.tell_primary_and_backup(&owner.id, &owner.username, "Your password was changed").await;
306 let verified = self
307 .find_public_user(
308 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ?",
309 &owner.id,
310 )
311 .await?
312 .is_some_and(|user| user.verified);
313 Ok(Outcome::Ok(User {
314 id: owner.id,
315 username: owner.username,
316 verified,
317 ..User::default()
318 }))
319 }
320
321 /// The account a login names: a username, or any confirmed address.
322 async fn password_row(&self, login: &str) -> Result<Option<UserRow>> {
323 let login = login.trim().to_lowercase();
324 let (column, value) = if login.contains('@') {
325 match self.user_with_verified_email(&login).await? {
326 Some(id) => ("id", id),
327 None => return Ok(None),
328 }
329 } else {
330 ("username", login)
331 };
332 self.db
333 .prepare(format!(
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)334 "SELECT id, username, password_hash, email_verified_at IS NOT NULL AS verified FROM users
335 WHERE {column} = ? AND deleted_at IS NULL"
Fixes from the page sweep336 ))
337 .bind(&[JsValue::from(value)])?
338 .first::<UserRow>(None)
339 .await
340 }
341
342 /// Checks a password for a login, throttled (see throttle.rs). The
343 /// refusal is one of two messages, the same for every account.
344 async fn checked_password(
345 &self,
346 login: &str,
347 password: &str,
348 client: Option<&str>,
349 ) -> Result<std::result::Result<User, &'static str>> {
350 let row = self.password_row(login).await?;
351 let subject = row.as_ref().map_or_else(|| login.trim().to_lowercase(), |row| row.id.clone());
352 let (account_key, client_key) = Identity::password_keys(&subject, client);
353 if self.password_locked(&account_key, client_key.as_deref()).await? {
354 return Ok(Err(throttle::THROTTLED));
355 }
356 let owner = row.as_ref().map(|row| (row.id.clone(), row.username.clone()));
357 match row.filter(|row| !row.password_hash.is_empty() && crypto::verify_password(password, &row.password_hash)) {
358 Some(row) => {
359 self.clear(&account_key).await?;
360 Ok(Ok(User {
361 id: row.id,
362 username: row.username,
363 verified: row.verified != 0,
364 ..User::default()
365 }))
366 }
367 None => {
368 let owner = owner.as_ref().map(|(id, name)| (id.as_str(), name.as_str()));
369 self.password_failed(&account_key, client_key.as_deref(), owner).await?;
370 Ok(Err("Incorrect username or password."))
371 }
372 }
373 }
374
Merge main (membership, two-factor, GitHub repo roles) into tokens375 /// Git over HTTPS with the account's password. With two-factor
376 /// authentication on, a password alone is never enough: use an access
377 /// token (two_factor.rs).
Fixes from the page sweep378 async fn user_for_password(&self, login: &str, password: &str) -> Result<Viewer> {
379 let user = self.checked_password(login, password, None).await?.ok();
Merge main (membership, two-factor, GitHub repo roles) into tokens380 if let Some(user) = &user
381 && self.two_factor_enabled(&user.id).await?
382 {
383 return Ok(None);
384 }
Fixes from the page sweep385 self.with_workspaces(user).await
386 }
387
388 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 managers389 // Kept as typed for showing; found, linked and mentioned lowercased.
390 let chosen = claimable_username(&a.username);
391 let username = chosen.as_ref().map_or_else(|| a.username.trim().to_lowercase(), |name| name.canonical.clone());
392 let claimable = chosen.is_some();
Fixes from the page sweep393 let email = a.email.trim().to_lowercase();
394 let invalid = |message: &str| Ok(Outcome::fail(FailureCode::Invalid, message));
395 let invite_code = a.invite_code.as_deref().map(str::trim).filter(|code| !code.is_empty());
396 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 managers397 return invalid(USERNAME_RULES);
Fixes from the page sweep398 }
399 let well_formed_email = email
400 .split_once('@')
401 .is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.'))
402 && !email.contains(char::is_whitespace);
403 if !well_formed_email {
404 return invalid("Enter a valid email address.");
405 }
406 if a.password.chars().count() < MIN_PASSWORD_LENGTH {
407 return invalid(PASSWORD_TOO_SHORT);
408 }
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.409 // Anyone may sign up, so many accounts from one address is the
410 // abuse to stop: counted once the form is well formed, before
411 // anything is looked up or written.
412 if !self.sign_up_allowed(a.client.as_deref()).await? {
413 return Ok(Identity::too_many_sign_ups());
414 }
415 if self.email_blocked(&email).await? {
416 return Ok(Outcome::fail(FailureCode::Forbidden, suspension::BLOCKED));
417 }
Fixes from the page sweep418 let taken = self
419 .db
420 // Usernames and workspaces share one namespace, so that a name
421 // means the same thing wherever it appears.
422 // An address is taken once an account has confirmed it; an
423 // unconfirmed one goes to whoever confirms it first (emails.rs).
424 .prepare(
425 "SELECT username FROM users WHERE username = ?
426 UNION ALL SELECT email FROM user_emails WHERE email = ? AND verified_at IS NOT NULL
427 UNION ALL SELECT slug FROM workspaces WHERE slug = ?",
428 )
429 .bind(&[
430 username.as_str().into(),
431 email.as_str().into(),
432 username.as_str().into(),
433 ])?
434 .first::<serde_json::Value>(None)
435 .await?;
436 // A renamed workspace's old slug stays reserved for it a while, and
437 // a deleted workspace's for good.
438 if taken.is_some() || self.slug_held(&username).await? || self.slug_deleted(&username).await? {
439 return Ok(Outcome::fail(
440 FailureCode::Conflict,
441 "That username or email is already registered.",
442 ));
443 }
444 let password_hash = crypto::hash_password(&a.password);
445 let user = match self
446 .create_account(invites::NewAccount {
447 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 managers448 display_username: chosen.as_ref().and_then(|name| name.display_if_cased()),
Fixes from the page sweep449 email: &email,
450 password_hash: &password_hash,
451 verified: false,
452 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 confirm453 email_proof: a.email_proof.as_deref(),
Fixes from the page sweep454 })
455 .await?
456 {
457 Outcome::Ok(user) => user,
458 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
459 };
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)460 // The account exists either way; the email can be sent again from
461 // the confirmation page. It carries a code and a link (emails.rs).
Fixes from the page sweep462 if !user.verified
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)463 && let Err(error) = self.send_primary_confirmation(&user.id, &user.username).await
Fixes from the page sweep464 {
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)465 worker::console_error!("confirmation email failed: {error}");
Fixes from the page sweep466 }
467 self.start_session(user).await
468 }
469
470 async fn sign_in(&self, a: SignInArgs) -> Result<Outcome<SignedIn>> {
471 let user = match self.checked_password(&a.username, &a.password, a.client.as_deref()).await? {
472 Ok(user) => user,
473 Err(message) => return Ok(Outcome::fail(FailureCode::Unauthenticated, message)),
474 };
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.475 if self.user_suspension(&user.id).await?.is_some() {
476 return Ok(Outcome::fail(FailureCode::Forbidden, suspension::SUSPENDED));
477 }
Fixes from the page sweep478 let user = self.with_workspaces(Some(user)).await?.unwrap_or_default();
479 self.start_session(user).await
480 }
481
Merge main (membership, two-factor, GitHub repo roles) into tokens482 /// Starts a session for someone who just proved their password (or
483 /// GitHub account). With two-factor authentication on, it starts none:
484 /// it returns a challenge for `two_factor_sign_in` (two_factor.rs).
Fixes from the page sweep485 async fn start_session(&self, user: User) -> Result<Outcome<SignedIn>> {
Merge main (membership, two-factor, GitHub repo roles) into tokens486 if self.two_factor_enabled(&user.id).await? {
487 let challenge = self.issue_challenge(&user.id).await?;
488 return Ok(Outcome::Ok(SignedIn {
489 user: User { workspaces: Vec::new(), grants: Vec::new(), held: Vec::new(), ..user },
490 session_token: String::new(),
491 two_factor_challenge: Some(challenge),
492 }));
493 }
494 self.session_for(user).await
495 }
496
497 /// A new session for `user`, who has proved who they are in full.
498 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)499 // Whichever way it was proved, a deleted account starts none
500 // (account_deletion.rs).
501 if !self.account_live(&user.id).await? {
502 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Incorrect username or password."));
503 }
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.504 // Nor a suspended one, however it was proved (suspension.rs).
505 if self.user_suspension(&user.id).await?.is_some() {
506 return Ok(Outcome::fail(FailureCode::Forbidden, suspension::SUSPENDED));
507 }
Fixes from the page sweep508 let session_token = crypto::random_hex(32);
509 self.db
510 .prepare(format!(
511 // Signing in is proof it is the person: see security.rs.
512 "INSERT INTO sessions (id, user_id, expires_at, authenticated_at) VALUES (?, ?, {}, {SQL_NOW})",
513 sql_after(SESSION_TTL_SECONDS)
514 ))
515 .bind(&[
516 crypto::sha256_hex(&session_token).into(),
517 user.id.as_str().into(),
518 ])?
519 .run()
520 .await?;
521 Ok(Outcome::Ok(SignedIn {
522 user,
523 session_token,
Merge main (membership, two-factor, GitHub repo roles) into tokens524 two_factor_challenge: None,
Fixes from the page sweep525 }))
526 }
527
528 async fn sign_out(&self, a: SessionArgs) -> Result<()> {
529 self.db
530 .prepare("DELETE FROM sessions WHERE id = ?")
531 .bind(&[crypto::sha256_hex(&a.session_token).into()])?
532 .run()
533 .await?;
534 Ok(())
535 }
536
537 async fn user_for_session(&self, a: SessionArgs) -> Result<Viewer> {
538 self.find_user(
539 &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 managers540 "SELECT users.id, users.username, users.display_username, users.email_verified_at IS NOT NULL AS verified,
Fixes from the page sweep541 users.avatar
542 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)543 WHERE sessions.id = ? AND sessions.expires_at > {SQL_NOW} AND users.deleted_at IS NULL"
Fixes from the page sweep544 ),
545 &crypto::sha256_hex(&a.session_token),
546 )
547 .await
548 }
549
550 async fn user_for_git_credentials(&self, a: GitCredentialsArgs) -> Result<Viewer> {
551 // Like GitHub, a token alone identifies its user.
552 if a.secret.starts_with(TOKEN_PREFIX) {
553 self.user_for_access_token(&a.secret).await
554 } else {
555 self.user_for_password(&a.username, &a.secret).await
556 }
557 }
558
559 async fn user_for_ssh_key(&self, a: FingerprintArgs) -> Result<Viewer> {
560 self.find_user(
561 "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified FROM ssh_keys
562 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)563 WHERE fingerprint = ? AND users.deleted_at IS NULL",
Fixes from the page sweep564 &a.fingerprint,
565 )
566 .await
567 }
568
569 async fn user_by_username(&self, a: UsernameArgs) -> Result<Viewer> {
570 self.find_public_user(
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)571 // 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 managers572 "SELECT id, username, display_username, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ? AND deleted_at IS NULL",
Fixes from the page sweep573 &a.username.to_lowercase(),
574 )
575 .await
576 }
577
Inbox: threads, reasons, subscriptions and watching578 /// `notify_by_email`: an inbox item, emailed to the person it is for,
579 /// only at a confirmed address and only while they can still read the
580 /// repository it is about. Returns whether it was sent.
581 async fn notify_by_email(&self, a: g1t_contracts::inbox::NotifyByEmailArgs) -> Result<bool> {
582 #[derive(Deserialize)]
583 struct Address {
584 email: Option<String>,
585 }
586 let user = self
587 .find_user(
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)588 "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 watching589 &a.username.to_lowercase(),
590 )
591 .await?;
592 let Some(user) = user.filter(|user| user.verified) else {
593 return Ok(false);
594 };
595 let readable: Vec<g1t_contracts::repos::Repo> = g1t_kit::call(
596 &self.env.service("REPOS")?,
597 "readable",
598 &g1t_contracts::repos::ReadableArgs {
599 ids: vec![a.repo_id.clone()],
600 viewer: Some(user.clone()),
601 },
602 )
603 .await?;
604 if readable.is_empty() {
605 return Ok(false);
606 }
607 let address = self
608 .db
609 .prepare("SELECT email FROM users WHERE id = ?")
610 .bind(&[user.id.as_str().into()])?
611 .first::<Address>(None)
612 .await?
613 .and_then(|row| row.email)
614 .filter(|email| !email.trim().is_empty());
615 let Some(address) = address else {
616 return Ok(false);
617 };
618 email::send_notification(&self.env, &address, &a).await?;
619 Ok(true)
620 }
621
Fixes from the page sweep622 async fn usernames(&self, a: UsernamesArgs) -> Result<std::collections::HashMap<String, String>> {
623 #[derive(serde::Deserialize)]
624 struct Named {
625 id: String,
626 name: String,
627 }
628 let ids: Vec<String> = a.ids.into_iter().take(200).collect();
629 let mut names = std::collections::HashMap::new();
630 if ids.is_empty() {
631 return Ok(names);
632 }
633 let marks = vec!["?"; ids.len()].join(", ");
634 let bind: Vec<worker::wasm_bindgen::JsValue> = ids.iter().map(|id| id.as_str().into()).collect();
635 for sql in [
636 format!("SELECT id, username AS name FROM users WHERE id IN ({marks})"),
637 format!("SELECT id, slug AS name FROM workspaces WHERE id IN ({marks})"),
638 ] {
639 for row in self.db.prepare(sql).bind(&bind)?.all().await?.results::<Named>()? {
640 names.insert(row.id, row.name);
641 }
642 }
643 Ok(names)
644 }
645
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)646 /// `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.647 /// with their workspaces, roles, base permissions and repository
648 /// 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.649 /// have them. For services that answer only with what every person
650 /// 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.651 /// 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)652 async fn users_for_audience(&self, a: UsernamesArgs) -> Result<Vec<User>> {
653 let mut found = Vec::new();
654 for id in a.ids.iter().take(50) {
655 let user = self
656 .find_user(
657 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ? AND deleted_at IS NULL",
658 id,
659 )
660 .await?;
661 if let Some(user) = user {
662 found.push(user);
663 }
664 }
665 Ok(found)
666 }
667
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97668 /// `accounts`: the accounts behind these ids (at most 200), each with
669 /// its username and avatar, for lists that keep ids, such as who
670 /// 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 calendar671 /// `display_usernames`: each lowercased username's chosen case, for the
672 /// API, which shows it beside every username it answers with.
673 async fn display_usernames(&self, a: DisplayUsernamesArgs) -> Result<std::collections::HashMap<String, String>> {
674 #[derive(serde::Deserialize)]
675 struct Row {
676 username: String,
677 display_username: String,
678 }
679 let mut names: Vec<String> = a.usernames.iter().map(|name| name.trim().to_lowercase()).filter(|name| !name.is_empty()).collect();
680 names.sort();
681 names.dedup();
682 names.truncate(200);
683 let mut found = std::collections::HashMap::new();
684 if names.is_empty() {
685 return Ok(found);
686 }
687 let marks = vec!["?"; names.len()].join(", ");
688 let bind: Vec<worker::wasm_bindgen::JsValue> = names.iter().map(|name| name.as_str().into()).collect();
689 let rows = self
690 .db
691 .prepare(format!(
692 "SELECT username, display_username FROM users WHERE username IN ({marks}) AND display_username IS NOT NULL AND deleted_at IS NULL"
693 ))
694 .bind(&bind)?
695 .all()
696 .await?
697 .results::<Row>()?;
698 for row in rows {
699 if row.display_username.eq_ignore_ascii_case(&row.username) && row.display_username != row.username {
700 found.insert(row.username, row.display_username);
701 }
702 }
703 Ok(found)
704 }
705
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97706 async fn accounts(&self, a: UsernamesArgs) -> Result<std::collections::HashMap<String, g1t_contracts::accounts::EmailOwner>> {
707 #[derive(serde::Deserialize)]
708 struct Row {
709 id: String,
710 username: String,
711 avatar: Option<String>,
712 }
713 let ids: Vec<String> = a.ids.into_iter().take(200).collect();
714 let mut found = std::collections::HashMap::new();
715 if ids.is_empty() {
716 return Ok(found);
717 }
718 let marks = vec!["?"; ids.len()].join(", ");
719 let bind: Vec<worker::wasm_bindgen::JsValue> = ids.iter().map(|id| id.as_str().into()).collect();
720 let rows = self
721 .db
722 .prepare(format!("SELECT id, username, avatar FROM users WHERE id IN ({marks})"))
723 .bind(&bind)?
724 .all()
725 .await?
726 .results::<Row>()?;
727 for row in rows {
728 found.insert(row.id.clone(), g1t_contracts::accounts::EmailOwner { id: row.id, username: row.username, avatar: row.avatar });
729 }
730 Ok(found)
731 }
732
Fixes from the page sweep733 async fn list_ssh_keys(&self, a: UserArgs) -> Result<Vec<SshKey>> {
734 let rows = self
735 .db
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca736 .prepare("SELECT id, title, fingerprint, created_at, last_used_at FROM ssh_keys WHERE user_id = ? ORDER BY id")
Fixes from the page sweep737 .bind(&[a.user.id.into()])?
738 .all()
739 .await?
740 .results::<KeyRow>()?;
741 Ok(rows.into_iter().map(SshKey::from).collect())
742 }
743
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge744 /// The account (user id) that registered each key, by fingerprint
745 /// (`SHA256:…`). At most 100; unknown keys are left out.
746 async fn ssh_key_owners(&self, a: SshKeyOwnersArgs) -> Result<std::collections::HashMap<String, String>> {
747 #[derive(serde::Deserialize)]
748 struct Row {
749 fingerprint: String,
750 user_id: String,
751 }
752 let fingerprints: Vec<&String> = a.fingerprints.iter().take(100).collect();
753 if fingerprints.is_empty() {
754 return Ok(std::collections::HashMap::new());
755 }
756 let marks = vec!["?"; fingerprints.len()].join(", ");
757 let binds: Vec<JsValue> = fingerprints.iter().map(|fingerprint| fingerprint.as_str().into()).collect();
758 Ok(self
759 .db
760 .prepare(format!("SELECT fingerprint, user_id FROM ssh_keys WHERE fingerprint IN ({marks})"))
761 .bind(&binds)?
762 .all()
763 .await?
764 .results::<Row>()?
765 .into_iter()
766 .map(|row| (row.fingerprint, row.user_id))
767 .collect())
768 }
769
Fixes from the page sweep770 async fn add_ssh_key(&self, a: AddSshKeyArgs) -> Result<Outcome<SshKey>> {
771 let Some(key) = crypto::parse_ssh_key(&a.public_key) else {
772 return Ok(Outcome::fail(
773 FailureCode::Invalid,
774 "That is not a valid OpenSSH public key.",
775 ));
776 };
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca777 // Someone's SSH key, or a repository's deploy key (deploy_keys.rs).
778 if self.key_in_use(&key.fingerprint).await? {
779 return Ok(Outcome::fail(FailureCode::Conflict, g1t_contracts::deploy_keys::KEY_IN_USE));
Fixes from the page sweep780 }
781 let now = now_ms();
782 let title = [a.title.trim(), key.comment.as_str(), "SSH key"]
783 .into_iter()
784 .find(|candidate| !candidate.is_empty())
785 .unwrap_or_default()
786 .to_owned();
787 let row = KeyRow {
788 id: new_id("key", now),
789 title,
790 fingerprint: key.fingerprint,
791 created_at: rfc3339(now),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca792 last_used_at: None,
Fixes from the page sweep793 };
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca794 let inserted = self.db
Fixes from the page sweep795 .prepare(
796 "INSERT INTO ssh_keys (id, user_id, title, public_key, fingerprint, created_at)
797 VALUES (?, ?, ?, ?, ?, ?)",
798 )
799 .bind(&[
800 row.id.as_str().into(),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca801 a.user.id.as_str().into(),
Fixes from the page sweep802 row.title.as_str().into(),
803 key.public_key.into(),
804 row.fingerprint.as_str().into(),
805 row.created_at.as_str().into(),
806 ])?
807 .run()
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca808 .await;
809 // Added at the same moment elsewhere: the trigger or the unique
810 // index refused it.
811 if let Err(error) = inserted {
812 if self.key_in_use(&row.fingerprint).await? {
813 return Ok(Outcome::fail(FailureCode::Conflict, g1t_contracts::deploy_keys::KEY_IN_USE));
814 }
815 return Err(error);
816 }
Merge main (membership, two-factor, GitHub repo roles) into tokens817 let shown = format!("{} ({})", row.title, row.fingerprint);
818 self.log_security(&a.user.id, "ssh_key_added", Some(&shown), None).await;
819 self.audit_account(&a.user, "ssh_key.added", &format!("Added SSH key {shown}")).await;
Fixes from the page sweep820 Ok(Outcome::Ok(row.into()))
821 }
822
Merge main (membership, two-factor, GitHub repo roles) into tokens823 /// Deletes one of the person's SSH keys.
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca824 async fn remove_ssh_key(&self, a: RemoveArgs) -> Result<()> {
Merge main (membership, two-factor, GitHub repo roles) into tokens825 #[derive(Deserialize)]
826 struct Removed {
827 title: String,
828 fingerprint: String,
829 }
830 let removed = self
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca831 .db
Merge main (membership, two-factor, GitHub repo roles) into tokens832 .prepare("DELETE FROM ssh_keys WHERE id = ? AND user_id = ? RETURNING title, fingerprint")
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca833 .bind(&[a.id.as_str().into(), a.user.id.as_str().into()])?
Merge main (membership, two-factor, GitHub repo roles) into tokens834 .first::<Removed>(None)
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca835 .await?;
Merge main (membership, two-factor, GitHub repo roles) into tokens836 if let Some(removed) = removed {
837 let shown = format!("{} ({})", removed.title, removed.fingerprint);
838 self.log_security(&a.user.id, "ssh_key_removed", Some(&shown), None).await;
839 self.audit_account(&a.user, "ssh_key.removed", &format!("Removed SSH key {shown}")).await;
840 }
Fixes from the page sweep841 Ok(())
842 }
843}
844
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.845/// Every 15 minutes: deleted workspaces and accounts past their restore
846/// window are purged (deletion.rs, account_deletion.rs).
Fixes from the page sweep847#[event(scheduled)]
848async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
849 let Ok(db) = env.d1("DB") else { return };
850 let identity = Identity { db, env };
851 if let Err(error) = identity.purge_due_workspaces().await {
852 worker::console_error!("workspace purge: {error}");
853 }
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)854 // And deleted accounts past theirs (account_deletion.rs).
855 if let Err(error) = identity.purge_due_accounts().await {
856 worker::console_error!("account purge: {error}");
857 }
Merge main (membership, two-factor, GitHub repo roles) into tokens858 // Once: creators of repositories made before they got Admin (members.rs).
859 if let Err(error) = identity.backfill_creator_grants().await {
860 worker::console_error!("creator grants: {error}");
861 }
Fixes from the page sweep862}
863
864#[event(fetch)]
865async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
866 let Some(method) = rpc_method(&request) else {
867 return Response::error("Not found", 404);
868 };
869 // A replica near the caller when it asks for one (crates/kit/src/d1.rs).
870 let (db, served) = g1t_kit::d1::open(&env, "DB", &request)?;
871 let body: serde_json::Value = request.json().await?;
872 let identity = Identity { db, env };
873
874 let answered = match method.as_str() {
875 "register" => {
876 let outcome = identity.register(args(body)?).await?;
877 if let Outcome::Ok(signed_in) = &outcome {
878 identity.announce_user(&signed_in.user.username, Some(&signed_in.user.id)).await;
879 }
880 reply(&outcome)
881 }
882 "sign_in" => reply(&identity.sign_in(args(body)?).await?),
883 "create_workspace" => {
884 let outcome = identity.create_workspace(args(body)?).await?;
885 if let Outcome::Ok(workspace) = &outcome {
886 identity.announce_workspace(&workspace.id, &workspace.slug, None).await;
887 }
888 reply(&outcome)
889 }
890 "get_workspace" => reply(&identity.get_workspace(args(body)?).await?),
891 "list_members" => reply(&identity.list_members(args(body)?).await?),
892 "add_member" => reply(&identity.add_member(args(body)?).await?),
893 "remove_member" => reply(&identity.remove_member(args(body)?).await?),
Merge main (membership, two-factor, GitHub repo roles) into tokens894 // Owners, roles, leaving and member privileges; see members.rs.
895 "update_member" => reply(&identity.update_member(args(body)?).await?),
896 "transfer_ownership" => reply(&identity.transfer_ownership(args(body)?).await?),
897 "leave_workspace" => reply(&identity.leave_workspace(args(body)?).await?),
898 "set_member_privileges" => reply(&identity.set_member_privileges(args(body)?).await?),
899 "set_two_factor_requirement" => reply(&identity.set_two_factor_requirement(args(body)?).await?),
900 "grant_creator" => reply(&identity.grant_creator(args(body)?).await?),
Fixes from the page sweep901 "update_workspace" => {
902 let outcome = identity.update_workspace(args(body)?).await?;
903 if let Outcome::Ok(workspace) = &outcome {
904 identity.announce_workspace(&workspace.id, &workspace.slug, None).await;
905 }
906 reply(&outcome)
907 }
908 "rename_workspace" => reply(&identity.rename_workspace(args(body)?).await?),
909 "check_workspace_rename" => reply(&identity.check_workspace_rename(args(body)?).await?),
910 "resolve_slug" => reply(&identity.resolve_slug(args(body)?).await?),
Merge branch 'worktree-agent-a8385d293d42c913a'911 "resolve_alias" => reply(&identity.resolve_alias(args(body)?).await?),
Fixes from the page sweep912 "check_workspace_deletion" => reply(&identity.check_workspace_deletion(args(body)?).await?),
913 "delete_workspace" => reply(&identity.delete_workspace(args(body)?).await?),
914 "transfer_repo_scopes" => reply(&identity.transfer_repo_scopes(args(body)?).await?),
915 "set_workspace_avatar" => {
916 let outcome = identity.set_workspace_avatar(args(body)?).await?;
917 if let Outcome::Ok(workspace) = &outcome {
918 identity.announce_workspace(&workspace.id, &workspace.slug, None).await;
919 }
920 reply(&outcome)
921 }
922 "set_user_avatar" => {
923 let a: SetUserAvatarArgs = args(body)?;
924 let (username, id) = (a.user.username.clone(), a.user.id.clone());
925 let outcome = identity.set_user_avatar(a).await?;
926 if matches!(outcome, Outcome::Ok(_)) {
927 identity.announce_user(&username, Some(&id)).await;
928 }
929 reply(&outcome)
930 }
931 "list_workspace_tokens" => reply(&identity.list_workspace_tokens(args(body)?).await?),
932 "remove_workspace_token" => reply(&identity.remove_workspace_token(args(body)?).await?),
933 // Signing in with GitHub; see github.rs.
934 "github_enabled" => reply(&identity.github_enabled()),
935 "github_start" => reply(&identity.github_start(args(body)?).await?),
936 "github_finish" => reply(&identity.github_finish(args(body)?).await?),
937 "github_pending" => reply(&identity.github_pending(args(body)?).await?),
938 "github_sign_up" => reply(&identity.github_sign_up(args(body)?).await?),
939 "github_claim" => reply(&identity.github_claim(args(body)?).await?),
940 "github_account" => reply(&identity.github_account(args(body)?).await?),
941 "github_unlink" => reply(&identity.github_unlink(args(body)?).await?),
942 "github_user_token" => reply(&identity.github_user_token(args(body)?).await?),
943 "github_revoked" => reply(&identity.github_revoked(args(body)?).await?),
944 "github_usernames" => reply(&identity.github_usernames(args(body)?).await?),
945 "oauth_authorize" => reply(&identity.oauth_authorize(args(body)?).await?),
946 "oauth_exchange" => reply(&identity.oauth_exchange(args(body)?).await?),
947 "oauth_refresh" => reply(&identity.oauth_refresh(args(body)?).await?),
948 "list_oauth_grants" => reply(&identity.list_oauth_grants(args(body)?).await?),
949 "revoke_oauth_grant" => reply(&identity.revoke_oauth_grant(args(body)?).await?),
950 "update_oauth_grant" => reply(&identity.update_oauth_grant(args(body)?).await?),
951 "device_start" => reply(&identity.device_start(args(body)?).await?),
952 "device_lookup" => reply(&identity.device_lookup(args(body)?).await?),
953 "device_resolve" => reply(&identity.device_resolve(args(body)?).await?),
954 "device_claim" => reply(&identity.device_claim(args(body)?).await?),
955 "resend_verification" => reply(&identity.resend_verification(args(body)?).await?),
956 "verify_email" => reply(&identity.verify_email(args(body)?).await?),
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)957 "confirm_email_code" => reply(&identity.confirm_email_code(args(body)?).await?),
958 "change_pending_email" => reply(&identity.change_pending_email(args(body)?).await?),
Fixes from the page sweep959 "request_password_reset" => reply(&identity.request_password_reset(args(body)?).await?),
960 "reset_password" => reply(&identity.reset_password(args(body)?).await?),
961 // A person's email addresses; see emails.rs and security.rs.
962 "list_emails" => reply(&identity.list_emails(args(body)?).await?),
963 "add_email" => reply(&identity.add_email(args(body)?).await?),
964 "remove_email" => reply(&identity.remove_email(args(body)?).await?),
965 "resend_email_verification" => reply(&identity.resend_email_verification(args(body)?).await?),
966 "update_email_settings" => reply(&identity.update_email_settings(args(body)?).await?),
967 "reauthenticate" => reply(&identity.reauthenticate(args(body)?).await?),
Merge main (membership, two-factor, GitHub repo roles) into tokens968 // Two-factor authentication; see two_factor.rs.
969 "two_factor_status" => reply(&identity.two_factor_status(args(body)?).await?),
970 "two_factor_start" => reply(&identity.two_factor_start(args(body)?).await?),
971 "two_factor_enable" => reply(&identity.two_factor_enable(args(body)?).await?),
972 "two_factor_disable" => reply(&identity.two_factor_disable(args(body)?).await?),
973 "two_factor_recovery_codes" => reply(&identity.two_factor_recovery_codes(args(body)?).await?),
974 "two_factor_sign_in" => reply(&identity.two_factor_sign_in(args(body)?).await?),
Fixes from the page sweep975 "security_log" => reply(&identity.security_log(args(body)?).await?),
976 "email_owners" => reply(&identity.email_owners(args(body)?).await?),
977 "commit_identity" => reply(&identity.commit_identity(args(body)?).await?),
978 "push_email_guard" => reply(&identity.push_email_guard(args(body)?).await?),
979 "admin_user" => reply(&identity.admin_user(args(body)?).await?),
980 "admin_remove_email" => reply(&identity.admin_remove_email(args(body)?).await?),
981 "sign_out" => reply(&identity.sign_out(args(body)?).await?),
982 "user_for_session" => reply(&identity.user_for_session(args(body)?).await?),
983 "user_for_git_credentials" => reply(&identity.user_for_git_credentials(args(body)?).await?),
984 "user_for_access_token" => {
985 let a: TokenArgs = args(body)?;
986 reply(&identity.user_for_access_token(&a.token).await?)
987 }
988 "user_for_ssh_key" => reply(&identity.user_for_ssh_key(args(body)?).await?),
989 "user_by_username" => reply(&identity.user_by_username(args(body)?).await?),
990 "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 calendar991 // The API: each username's chosen case, shown beside it.
992 "display_usernames" => reply(&identity.display_usernames(args(body)?).await?),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97993 "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)994 "users_for_audience" => reply(&identity.users_for_audience(args(body)?).await?),
Inbox: threads, reasons, subscriptions and watching995 "notify_by_email" => reply(&identity.notify_by_email(args(body)?).await?),
Fixes from the page sweep996 "profile" => reply(&identity.profile(args(body)?).await?),
997 "update_profile" => {
998 let outcome = identity.update_profile(args(body)?).await?;
999 if let Outcome::Ok(profile) = &outcome {
1000 identity.announce_user(&profile.username, None).await;
1001 }
1002 reply(&outcome)
1003 }
1004 "directory" => reply(&identity.directory(args(body)?).await?),
1005 "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.1006 // Each person's dock pins, per workspace; see dock.rs.
1007 "dock_pins" => reply(&identity.dock_pins(args(body)?).await?),
1008 "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.1009 // Each person's chat sounds and desktop notifications; see sounds.rs.
1010 "sound_settings" => reply(&identity.sound_settings(args(body)?).await?),
1011 "set_sound_settings" => reply(&identity.set_sound_settings(args(body)?).await?),
Fixes from the page sweep1012 "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1013 // Services only: who registered each key, for verifying commit
1014 // signatures (repos' signatures.rs).
1015 "ssh_key_owners" => reply(&identity.ssh_key_owners(args(body)?).await?),
Fixes from the page sweep1016 "add_ssh_key" => reply(&identity.add_ssh_key(args(body)?).await?),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1017 "remove_ssh_key" => reply(&identity.remove_ssh_key(args(body)?).await?),
1018 // A repository's deploy keys, and who an SSH key signs in as; see
1019 // deploy_keys.rs.
1020 "list_deploy_keys" => reply(&identity.list_deploy_keys(args(body)?).await?),
1021 "get_deploy_key" => reply(&identity.get_deploy_key(args(body)?).await?),
1022 "add_deploy_key" => reply(&identity.add_deploy_key(args(body)?).await?),
1023 "remove_deploy_key" => reply(&identity.remove_deploy_key(args(body)?).await?),
1024 "principal_for_ssh_key" => reply(&identity.principal_for_ssh_key(args(body)?).await?),
Fixes from the page sweep1025 "list_access_tokens" => reply(&identity.list_access_tokens(args(body)?).await?),
1026 "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 managers1027 // Making and changing a person's or a workspace's token, and
1028 // workspaces' rules for tokens; see token_reach.rs.
1029 "create_token" => reply(&identity.create_token(args(body)?).await?),
1030 "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.1031 "regenerate_token" => reply(&identity.regenerate_token(args(body)?).await?),
Fine-grained personal tokens, workspace token rules and approvals in identity1032 "get_token_policy" => reply(&identity.get_token_policy(args(body)?).await?),
1033 "set_token_policy" => reply(&identity.set_token_policy(args(body)?).await?),
1034 "list_member_tokens" => reply(&identity.list_member_tokens(args(body)?).await?),
1035 "review_token_request" => reply(&identity.review_token_request(args(body)?).await?),
1036 "revoke_member_token" => reply(&identity.revoke_member_token(args(body)?).await?),
Fixes from the page sweep1037 "create_run_credential" => reply(&identity.create_run_credential(args(body)?).await?),
1038 "bind_run_credentials" => reply(&identity.bind_run_credentials(args(body)?).await?),
1039 "revoke_run_credentials" => reply(&identity.revoke_run_credentials(args(body)?).await?),
Merge branch 'worktree-agent-a3abfcce648e87dca'1040 "create_job_token" => reply(&identity.create_job_token(args(body)?).await?),
1041 "revoke_job_tokens" => reply(&identity.revoke_job_tokens(args(body)?).await?),
Merge main (membership, two-factor, GitHub repo roles) into tokens1042 "remove_access_token" => reply(&identity.remove_access_token(args(body)?).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.1043 // Invites; see invites.rs.
Fixes from the page sweep1044 "check_invite" => reply(&identity.check_invite(args(body)?).await?),
1045 "accept_invite" => reply(&identity.accept_invite(args(body)?).await?),
1046 "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)1047 "list_invitations" => reply(&identity.list_invitations(args(body)?).await?),
1048 "accept_invitation" => reply(&identity.accept_invitation(args(body)?).await?),
1049 "decline_invitation" => reply(&identity.decline_invitation(args(body)?).await?),
1050 "find_people" => reply(&identity.find_people(args(body)?).await?),
Fixes from the page sweep1051 "workspace_invites" => reply(&identity.workspace_invites(args(body)?).await?),
1052 "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.1053 "resend_workspace_invite" => reply(&identity.resend_workspace_invite(args(body)?).await?),
Fixes from the page sweep1054 // Who has access to a repository; see access.rs.
1055 "repo_access" => reply(&identity.repo_access(args(body)?).await?),
1056 "add_collaborator" => reply(&identity.add_collaborator(args(body)?).await?),
1057 "set_collaborator_role" => reply(&identity.set_collaborator_role(args(body)?).await?),
1058 "remove_collaborator" => reply(&identity.remove_collaborator(args(body)?).await?),
1059 "collaborator_permission" => reply(&identity.collaborator_permission(args(body)?).await?),
1060 "my_repo_invitations" => reply(&identity.my_repo_invitations(args(body)?).await?),
1061 "respond_repo_invitation" => reply(&identity.respond_repo_invitation(args(body)?).await?),
1062 "revoke_repo_invitation" => reply(&identity.revoke_repo_invitation(args(body)?).await?),
1063 "set_base_permission" => reply(&identity.set_base_permission(args(body)?).await?),
Merge branch 'worktree-agent-a2013627e5ea4ab13'1064 // Where a workspace keeps its repositories' git data (EU residency).
1065 "workspace_residency" => reply(&identity.workspace_residency(args(body)?).await?),
1066 "set_workspace_residency" => reply(&identity.set_workspace_residency(args(body)?).await?),
Fixes from the page sweep1067 "outside_collaborators" => reply(&identity.outside_collaborators(args(body)?).await?),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1068 "forget_repo_access" => {
1069 let a: g1t_contracts::access::ForgetRepoAccessArgs = args(body)?;
1070 // A purged repository's deploy keys go with its access.
1071 identity.forget_deploy_keys(&a.repo_id).await?;
1072 reply(&identity.forget_repo_access(a).await?)
1073 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1074 // Teams (teams.rs).
1075 "list_teams" => reply(&identity.list_teams(args(body)?).await?),
1076 "get_team" => reply(&identity.get_team(args(body)?).await?),
1077 "create_team" => reply(&identity.create_team(args(body)?).await?),
Merge branch 'worktree-agent-ad7c6d88d93adc817'1078 "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 bar1079 "update_team" => reply(&identity.update_team(args(body)?).await?),
1080 "delete_team" => reply(&identity.delete_team(args(body)?).await?),
1081 "team_members" => reply(&identity.team_members(args(body)?).await?),
1082 "set_team_member" => reply(&identity.set_team_member(args(body)?).await?),
1083 "remove_team_member" => reply(&identity.remove_team_member(args(body)?).await?),
1084 "child_teams" => reply(&identity.child_teams(args(body)?).await?),
1085 "team_repos" => reply(&identity.team_repos(args(body)?).await?),
1086 "set_team_repo" => reply(&identity.set_team_repo(args(body)?).await?),
1087 "remove_team_repo" => reply(&identity.remove_team_repo(args(body)?).await?),
1088 "user_teams" => reply(&identity.user_teams(args(body)?).await?),
1089 "team_memberships" => reply(&identity.team_memberships(args(body)?).await?),
1090 "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.1091 // 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.1092 "people_directory" => reply(&identity.people_directory(args(body)?).await?),
1093 "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 bar1094 "resolve_owners" => reply(&identity.resolve_owners(args(body)?).await?),
Fixes from the page sweep1095 // Staff only: sudo.g1t.sh, over its service binding. See admin.rs.
1096 "notify_owners" => reply(&identity.notify_owners(args(body)?).await?),
1097 "admin_workspaces" => reply(&identity.admin_workspaces(args(body)?).await?),
1098 "admin_workspace" => reply(&identity.admin_workspace(args(body)?).await?),
1099 "admin_invites" => reply(&identity.admin_invites(args(body)?).await?),
1100 "admin_revoke_invite" => reply(&identity.admin_revoke_invite(args(body)?).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.1101 // Suspending accounts and workspaces, blocking email domains; see suspension.rs.
1102 "admin_suspend_user" => reply(&identity.admin_suspend_user(args(body)?).await?),
1103 "admin_suspend_workspace" => reply(&identity.admin_suspend_workspace(args(body)?).await?),
1104 "admin_blocked_domains" => reply(&identity.admin_blocked_domains().await?),
1105 "admin_block_domain" => reply(&identity.admin_block_domain(args(body)?).await?),
Fixes from the page sweep1106 // Deleted workspaces, restored or purged by staff; see deletion.rs.
1107 "admin_deleted_workspaces" => reply(&identity.admin_deleted_workspaces().await?),
1108 "admin_restore_workspace" => reply(&identity.admin_restore_workspace(args(body)?).await?),
1109 "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)1110 // Deleting accounts (account_deletion.rs): the person from the
1111 // site, staff from sudo. There is no API route for it.
1112 "check_account_deletion" => reply(&identity.check_account_deletion(args(body)?).await?),
1113 "delete_account" => reply(&identity.delete_account(args(body)?).await?),
1114 "admin_delete_account" => reply(&identity.admin_delete_account(args(body)?).await?),
1115 "admin_deleted_accounts" => reply(&identity.admin_deleted_accounts().await?),
1116 "admin_restore_account" => reply(&identity.admin_restore_account(args(body)?).await?),
1117 "admin_purge_account" => reply(&identity.admin_purge_account(args(body)?).await?),
Merge branch 'worktree-agent-a8385d293d42c913a'1118 // Workspace aliases, set by staff only; see aliases.rs.
1119 "admin_aliases" => reply(&identity.admin_aliases().await?),
1120 "admin_set_alias" => reply(&identity.admin_set_alias(args(body)?).await?),
1121 "admin_remove_alias" => reply(&identity.admin_remove_alias(args(body)?).await?),
Fixes from the page sweep1122 _ => Response::error("Unknown method", 404),
1123 };
1124 served.finish(answered)
1125}
1126
1127#[cfg(test)]
1128mod register_tests {
1129 use super::*;
1130
1131 #[test]
1132 fn nobody_registers_as_g1t() {
1133 // What register checks the username with, whatever its case.
1134 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 managers1135 assert!(claimable_username(username).is_none(), "{username}");
Fixes from the page sweep1136 }
One kind of access token; presence and status; usernames keep their case; the tour is a miniature of the real app; icons for password managers1137 assert_eq!(claimable_username("ana").map(|name| name.canonical).as_deref(), Some("ana"));
1138 }
1139
1140 #[test]
1141 fn a_username_keeps_the_case_it_was_chosen_in() {
1142 let name = claimable_username("Ana-Lopez").unwrap();
1143 assert_eq!(name.canonical, "ana-lopez");
1144 assert_eq!(name.display_if_cased(), Some("Ana-Lopez"));
1145 assert!(USERNAME_RULES.contains("either case"));
Fixes from the page sweep1146 }
1147}

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