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

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

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

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