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

Deployments: a preview for every pull request, production on g1t.page1//! 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
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look6mod access;
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)7mod account_deletion;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace8mod admin;
Merge branch 'worktree-agent-a8385d293d42c913a'9mod aliases;
Workspace names and icons, and a component kit for every control10mod avatars;
Deployments: a preview for every pull request, production on g1t.page11mod crypto;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look12mod deletion;
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca13mod deploy_keys;
Deployments: a preview for every pull request, production on g1t.page14mod device;
Search across all of g1t, Explore, and a command palette15mod 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;
Deployments: a preview for every pull request, production on g1t.page17mod email;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look18mod emails;
19mod github;
20mod invites;
Merge main (membership, two-factor, GitHub repo roles) into tokens21mod members;
Deployments: a preview for every pull request, production on g1t.page22mod 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;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains25mod profiles;
26mod rename;
Merge branch 'worktree-agent-a3abfcce648e87dca'27mod job_tokens;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API28mod run_credentials;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look29mod security;
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)30mod shared_invites;
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar31mod teams;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look32mod throttle;
Fine-grained personal tokens, workspace token rules and approvals in identity33mod token_reach;
Deployments: a preview for every pull request, production on g1t.page34mod tokens;
Merge main (membership, two-factor, GitHub repo roles) into tokens35mod two_factor;
Deployments: a preview for every pull request, production on g1t.page36mod workspaces;
37
38use g1t_contracts::identity::*;
39use 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 managers40use g1t_contracts::{FailureCode, Outcome, User, Viewer, claimable_username, new_id};
Deployments: a preview for every pull request, production on g1t.page41use g1t_kit::{args, now_ms, reply, rpc_method};
42use serde::Deserialize;
43use tokens::TOKEN_PREFIX;
44use worker::wasm_bindgen::JsValue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas45use worker::{Context, D1Database, Env, Request, Response, Result, ScheduleContext, ScheduledEvent, event};
Deployments: a preview for every pull request, production on g1t.page46
47const SESSION_TTL_SECONDS: u64 = 30 * 24 * 60 * 60;
48const RESET_TTL_SECONDS: u64 = 60 * 60;
49const MIN_PASSWORD_LENGTH: usize = 10;
50const PASSWORD_TOO_SHORT: &str = "Use a password of at least 10 characters.";
51
52/// 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 managers53/// What a username may be, as registration and GitHub sign-up say when one is refused.
54pub(crate) const USERNAME_RULES: &str =
55 "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.";
56
Deployments: a preview for every pull request, production on g1t.page57#[derive(Deserialize)]
58struct Account {
59 id: String,
60 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 managers61 /// Selected where the person is shown: their username as they wrote it.
62 #[serde(default)]
63 display_username: Option<String>,
Deployments: a preview for every pull request, production on g1t.page64 verified: u8,
Workspace names and icons, and a component kit for every control65 /// Selected only where the person is being shown to themselves.
66 #[serde(default)]
67 avatar: Option<String>,
Deployments: a preview for every pull request, production on g1t.page68}
69
70impl From<Account> for User {
71 fn from(row: Account) -> Self {
72 User {
73 id: row.id,
74 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 managers75 display_username: row.display_username,
Deployments: a preview for every pull request, production on g1t.page76 verified: row.verified != 0,
Workspace names and icons, and a component kit for every control77 avatar: row.avatar,
Deployments: a preview for every pull request, production on g1t.page78 ..User::default()
79 }
80 }
81}
82
83#[derive(Deserialize)]
84struct UserRow {
85 id: String,
86 username: String,
87 password_hash: String,
88 verified: u8,
89}
90
91/// The owner of an emailed token.
92#[derive(Deserialize)]
93struct TokenOwner {
94 id: String,
95 username: String,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look96 /// The address a link was sent to; null on links from before accounts
97 /// had several, which are for the primary.
98 #[serde(default)]
99 email_id: Option<String>,
Deployments: a preview for every pull request, production on g1t.page100}
101
102#[derive(Deserialize)]
103struct KeyRow {
104 id: String,
105 title: String,
106 fingerprint: String,
107 created_at: String,
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca108 #[serde(default)]
109 last_used_at: Option<String>,
Deployments: a preview for every pull request, production on g1t.page110}
111
112impl From<KeyRow> for SshKey {
113 fn from(row: KeyRow) -> Self {
114 SshKey {
115 id: row.id,
116 title: row.title,
117 fingerprint: row.fingerprint,
118 created_at: row.created_at,
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca119 last_used_at: row.last_used_at,
Deployments: a preview for every pull request, production on g1t.page120 }
121 }
122}
123
124struct Identity {
125 db: D1Database,
126 env: Env,
127}
128
129impl Identity {
130 /// Runs a query that returns at most one user, for showing to others:
131 /// without their workspaces.
132 async fn find_public_user(&self, sql: &str, param: &str) -> Result<Viewer> {
133 Ok(self
134 .db
135 .prepare(sql)
136 .bind(&[JsValue::from(param)])?
137 .first::<Account>(None)
138 .await?
139 .map(User::from))
140 }
141
142 /// Attaches the workspaces a user belongs to, so that any service can
143 /// authorize them without asking again.
144 async fn with_workspaces(&self, user: Viewer) -> Result<Viewer> {
145 let Some(mut user) = user else {
146 return Ok(None);
147 };
Merge main (membership, two-factor, GitHub repo roles) into tokens148 let memberships = self.memberships_and_policies(&user.id).await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look149 // Roles on single repositories, under the same policy (access.rs).
150 let grants = self.grants_of(&user.id).await?;
Merge main (membership, two-factor, GitHub repo roles) into tokens151 // Access to a workspace is used only within its policy; see security.rs.
152 let within = self.within_policy(&user.id, memberships, grants).await?;
153 user.workspaces = within.memberships;
154 user.grants = within.grants;
155 user.held = within.held;
Deployments: a preview for every pull request, production on g1t.page156 Ok(Some(user))
157 }
158
159 /// Runs a query that resolves credentials to at most one user.
160 async fn find_user(&self, sql: &str, param: &str) -> Result<Viewer> {
161 let user = self.find_public_user(sql, param).await?;
162 self.with_workspaces(user).await
163 }
164
165 /// Consumes a token of `kind`, returning its owner if it was valid.
166 async fn redeem_email_token(&self, token: &str, kind: &str) -> Result<Option<TokenOwner>> {
167 let id = crypto::sha256_hex(token);
168 let owner = self
169 .db
170 .prepare(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look171 "SELECT users.id, users.username, email_tokens.email_id FROM email_tokens
Deployments: a preview for every pull request, production on g1t.page172 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)173 WHERE email_tokens.id = ? AND email_tokens.kind = ? AND users.deleted_at IS NULL
Deployments: a preview for every pull request, production on g1t.page174 AND email_tokens.expires_at > {SQL_NOW}"
175 ))
176 .bind(&[id.as_str().into(), kind.into()])?
177 .first::<TokenOwner>(None)
178 .await?;
179 if let Some(owner) = &owner {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look180 // Every outstanding token of this kind dies with the one used:
181 // every reset link, and every confirmation link for the same
182 // address (another address's links still work).
Deployments: a preview for every pull request, production on g1t.page183 self.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look184 .prepare(
185 "DELETE FROM email_tokens WHERE user_id = ?1 AND kind = ?2
186 AND (?2 = 'reset' OR email_id IS ?3)",
187 )
188 .bind(&[
189 owner.id.as_str().into(),
190 kind.into(),
191 owner.email_id.as_deref().map_or(JsValue::NULL, Into::into),
192 ])?
Deployments: a preview for every pull request, production on g1t.page193 .run()
194 .await?;
195 }
196 Ok(owner)
197 }
198
199 async fn resend_verification(&self, a: UserArgs) -> Result<Outcome<bool>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look200 if !self.allow(throttle::CONFIRM_ACCOUNT, &a.user.id).await? {
201 return Ok(Outcome::fail(FailureCode::Conflict, "Too many confirmation emails this hour. Check your inbox, or try again later."));
202 }
203 self.resend_primary(&a.user).await
Deployments: a preview for every pull request, production on g1t.page204 }
205
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)206 /// The link in a confirmation email, followed: signed in or not. It
207 /// ends the code sent with it (emails.rs).
208 async fn verify_email(&self, a: EmailTokenArgs) -> Result<Outcome<g1t_contracts::accounts::EmailConfirmed>> {
Deployments: a preview for every pull request, production on g1t.page209 let Some(owner) = self.redeem_email_token(&a.token, "verify").await? else {
210 return Ok(Outcome::fail(
211 FailureCode::Invalid,
212 "This confirmation link is not valid or has expired.",
213 ));
214 };
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)215 self.confirm_address(&owner.id, owner.email_id.as_deref()).await
Deployments: a preview for every pull request, production on g1t.page216 }
217
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look218 /// Any confirmed address of an account can ask for a reset; so can the
219 /// unconfirmed address a new account signed up with. See emails.rs.
Deployments: a preview for every pull request, production on g1t.page220 async fn request_password_reset(&self, a: EmailArgs) -> Result<bool> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look221 let allowed = self.allow(throttle::RESET_EMAIL, &a.email).await?
222 && match a.client.as_deref() {
223 Some(client) => self.allow(throttle::RESET_CLIENT, client).await?,
224 None => true,
225 };
226 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 exists227 // A failure from here on happens only for a real account, so it
228 // is logged, never answered: the reply below stays the same.
229 if let Err(error) = self.send_reset(&target).await {
230 worker::console_error!("password reset for a known address failed: {error}");
231 }
232 }
233 // The same answer either way, so addresses cannot be probed.
234 Ok(true)
235 }
236
237 /// Saves a reset link for `target` and mails it, telling the account's
238 /// other addresses.
239 async fn send_reset(&self, target: &emails::ResetTarget) -> Result<()> {
240 {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look241 let token = crypto::random_hex(32);
242 self.db
243 .prepare(format!(
244 "INSERT INTO email_tokens (id, user_id, kind, expires_at, email_id)
245 VALUES (?, ?, 'reset', {}, ?)",
246 sql_after(RESET_TTL_SECONDS)
247 ))
248 .bind(&[
249 crypto::sha256_hex(&token).into(),
250 target.user_id.as_str().into(),
251 target.email_id.as_str().into(),
252 ])?
253 .run()
Deployments: a preview for every pull request, production on g1t.page254 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look255 email::send_password_reset(&self.env, &target.display, &target.username, &token).await?;
256 // The primary and the backup hear of it when it went elsewhere.
257 let elsewhere = self.notice_recipients(&target.user_id, false).await?;
258 for address in elsewhere.iter().filter(|address| !address.eq_ignore_ascii_case(&target.display)) {
259 let change = format!("A password reset was asked for through {}", target.display);
260 if let Err(error) = email::send_security_notice(&self.env, address, &target.username, &change).await {
261 worker::console_error!("security notice failed: {error}");
262 }
263 }
Deployments: a preview for every pull request, production on g1t.page264 }
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 exists265 Ok(())
Deployments: a preview for every pull request, production on g1t.page266 }
267
268 async fn reset_password(&self, a: ResetPasswordArgs) -> Result<Outcome<User>> {
269 if a.password.chars().count() < MIN_PASSWORD_LENGTH {
270 return Ok(Outcome::fail(FailureCode::Invalid, PASSWORD_TOO_SHORT));
271 }
272 let Some(owner) = self.redeem_email_token(&a.token, "reset").await? else {
273 return Ok(Outcome::fail(
274 FailureCode::Invalid,
275 "This reset link is not valid or has expired.",
276 ));
277 };
278 self.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look279 .prepare("UPDATE users SET password_hash = ? WHERE id = ?")
Deployments: a preview for every pull request, production on g1t.page280 .bind(&[
281 crypto::hash_password(&a.password).into(),
282 owner.id.as_str().into(),
283 ])?
284 .run()
285 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look286 // Following an emailed link also proves the address it went to
287 // (unless another account confirmed it first).
288 let _ = self.confirm_address(&owner.id, owner.email_id.as_deref()).await?;
289 // Anyone signed in with the old password is signed out, and nobody
290 // stays locked out by the wrong guesses before it.
Deployments: a preview for every pull request, production on g1t.page291 self.db
292 .prepare("DELETE FROM sessions WHERE user_id = ?")
293 .bind(&[owner.id.as_str().into()])?
294 .run()
295 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look296 self.clear(&throttle::key(throttle::PASSWORD_ACCOUNT, &owner.id)).await?;
297 self.log_security(&owner.id, "password_changed", None, None).await;
298 self.tell_primary_and_backup(&owner.id, &owner.username, "Your password was changed").await;
299 let verified = self
300 .find_public_user(
301 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ?",
302 &owner.id,
303 )
304 .await?
305 .is_some_and(|user| user.verified);
Deployments: a preview for every pull request, production on g1t.page306 Ok(Outcome::Ok(User {
307 id: owner.id,
308 username: owner.username,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look309 verified,
Deployments: a preview for every pull request, production on g1t.page310 ..User::default()
311 }))
312 }
313
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look314 /// The account a login names: a username, or any confirmed address.
315 async fn password_row(&self, login: &str) -> Result<Option<UserRow>> {
316 let login = login.trim().to_lowercase();
317 let (column, value) = if login.contains('@') {
318 match self.user_with_verified_email(&login).await? {
319 Some(id) => ("id", id),
320 None => return Ok(None),
321 }
322 } else {
323 ("username", login)
324 };
325 self.db
326 .prepare(format!(
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)327 "SELECT id, username, password_hash, email_verified_at IS NOT NULL AS verified FROM users
328 WHERE {column} = ? AND deleted_at IS NULL"
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look329 ))
330 .bind(&[JsValue::from(value)])?
Deployments: a preview for every pull request, production on g1t.page331 .first::<UserRow>(None)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look332 .await
333 }
334
335 /// Checks a password for a login, throttled (see throttle.rs). The
336 /// refusal is one of two messages, the same for every account.
337 async fn checked_password(
338 &self,
339 login: &str,
340 password: &str,
341 client: Option<&str>,
342 ) -> Result<std::result::Result<User, &'static str>> {
343 let row = self.password_row(login).await?;
344 let subject = row.as_ref().map_or_else(|| login.trim().to_lowercase(), |row| row.id.clone());
345 let (account_key, client_key) = Identity::password_keys(&subject, client);
346 if self.password_locked(&account_key, client_key.as_deref()).await? {
347 return Ok(Err(throttle::THROTTLED));
348 }
349 let owner = row.as_ref().map(|row| (row.id.clone(), row.username.clone()));
350 match row.filter(|row| !row.password_hash.is_empty() && crypto::verify_password(password, &row.password_hash)) {
351 Some(row) => {
352 self.clear(&account_key).await?;
353 Ok(Ok(User {
354 id: row.id,
355 username: row.username,
356 verified: row.verified != 0,
357 ..User::default()
358 }))
359 }
360 None => {
361 let owner = owner.as_ref().map(|(id, name)| (id.as_str(), name.as_str()));
362 self.password_failed(&account_key, client_key.as_deref(), owner).await?;
363 Ok(Err("Incorrect username or password."))
364 }
365 }
366 }
367
Merge main (membership, two-factor, GitHub repo roles) into tokens368 /// Git over HTTPS with the account's password. With two-factor
369 /// authentication on, a password alone is never enough: use an access
370 /// token (two_factor.rs).
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look371 async fn user_for_password(&self, login: &str, password: &str) -> Result<Viewer> {
372 let user = self.checked_password(login, password, None).await?.ok();
Merge main (membership, two-factor, GitHub repo roles) into tokens373 if let Some(user) = &user
374 && self.two_factor_enabled(&user.id).await?
375 {
376 return Ok(None);
377 }
Deployments: a preview for every pull request, production on g1t.page378 self.with_workspaces(user).await
379 }
380
381 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 managers382 // Kept as typed for showing; found, linked and mentioned lowercased.
383 let chosen = claimable_username(&a.username);
384 let username = chosen.as_ref().map_or_else(|| a.username.trim().to_lowercase(), |name| name.canonical.clone());
385 let claimable = chosen.is_some();
Deployments: a preview for every pull request, production on g1t.page386 let email = a.email.trim().to_lowercase();
387 let invalid = |message: &str| Ok(Outcome::fail(FailureCode::Invalid, message));
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look388 let invite_code = a.invite_code.as_deref().map(str::trim).filter(|code| !code.is_empty());
389 // The invite first: without one, nothing else on the form matters.
390 if self.invites_required() && invite_code.is_none() {
391 return Ok(Outcome::fail(FailureCode::Forbidden, invites::MISSING));
392 }
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent393 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 managers394 return invalid(USERNAME_RULES);
Deployments: a preview for every pull request, production on g1t.page395 }
396 let well_formed_email = email
397 .split_once('@')
398 .is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.'))
399 && !email.contains(char::is_whitespace);
400 if !well_formed_email {
401 return invalid("Enter a valid email address.");
402 }
403 if a.password.chars().count() < MIN_PASSWORD_LENGTH {
404 return invalid(PASSWORD_TOO_SHORT);
405 }
406 let taken = self
407 .db
408 // Usernames and workspaces share one namespace, so that a name
409 // means the same thing wherever it appears.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look410 // An address is taken once an account has confirmed it; an
411 // unconfirmed one goes to whoever confirms it first (emails.rs).
Deployments: a preview for every pull request, production on g1t.page412 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look413 "SELECT username FROM users WHERE username = ?
414 UNION ALL SELECT email FROM user_emails WHERE email = ? AND verified_at IS NOT NULL
Deployments: a preview for every pull request, production on g1t.page415 UNION ALL SELECT slug FROM workspaces WHERE slug = ?",
416 )
417 .bind(&[
418 username.as_str().into(),
419 email.as_str().into(),
420 username.as_str().into(),
421 ])?
422 .first::<serde_json::Value>(None)
423 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look424 // A renamed workspace's old slug stays reserved for it a while, and
425 // a deleted workspace's for good.
426 if taken.is_some() || self.slug_held(&username).await? || self.slug_deleted(&username).await? {
Deployments: a preview for every pull request, production on g1t.page427 return Ok(Outcome::fail(
428 FailureCode::Conflict,
429 "That username or email is already registered.",
430 ));
431 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look432 let password_hash = crypto::hash_password(&a.password);
433 let user = match self
434 .create_account(invites::NewAccount {
435 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 managers436 display_username: chosen.as_ref().and_then(|name| name.display_if_cased()),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look437 email: &email,
438 password_hash: &password_hash,
439 verified: false,
440 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 confirm441 email_proof: a.email_proof.as_deref(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look442 client: a.client.as_deref(),
443 })
444 .await?
445 {
446 Outcome::Ok(user) => user,
447 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
Deployments: a preview for every pull request, production on g1t.page448 };
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)449 // The account exists either way; the email can be sent again from
450 // the confirmation page. It carries a code and a link (emails.rs).
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas451 if !user.verified
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)452 && let Err(error) = self.send_primary_confirmation(&user.id, &user.username).await
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas453 {
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)454 worker::console_error!("confirmation email failed: {error}");
Deployments: a preview for every pull request, production on g1t.page455 }
456 self.start_session(user).await
457 }
458
459 async fn sign_in(&self, a: SignInArgs) -> Result<Outcome<SignedIn>> {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look460 let user = match self.checked_password(&a.username, &a.password, a.client.as_deref()).await? {
461 Ok(user) => user,
462 Err(message) => return Ok(Outcome::fail(FailureCode::Unauthenticated, message)),
Deployments: a preview for every pull request, production on g1t.page463 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look464 let user = self.with_workspaces(Some(user)).await?.unwrap_or_default();
Deployments: a preview for every pull request, production on g1t.page465 self.start_session(user).await
466 }
467
Merge main (membership, two-factor, GitHub repo roles) into tokens468 /// Starts a session for someone who just proved their password (or
469 /// GitHub account). With two-factor authentication on, it starts none:
470 /// it returns a challenge for `two_factor_sign_in` (two_factor.rs).
Deployments: a preview for every pull request, production on g1t.page471 async fn start_session(&self, user: User) -> Result<Outcome<SignedIn>> {
Merge main (membership, two-factor, GitHub repo roles) into tokens472 if self.two_factor_enabled(&user.id).await? {
473 let challenge = self.issue_challenge(&user.id).await?;
474 return Ok(Outcome::Ok(SignedIn {
475 user: User { workspaces: Vec::new(), grants: Vec::new(), held: Vec::new(), ..user },
476 session_token: String::new(),
477 two_factor_challenge: Some(challenge),
478 }));
479 }
480 self.session_for(user).await
481 }
482
483 /// A new session for `user`, who has proved who they are in full.
484 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)485 // Whichever way it was proved, a deleted account starts none
486 // (account_deletion.rs).
487 if !self.account_live(&user.id).await? {
488 return Ok(Outcome::fail(FailureCode::Unauthenticated, "Incorrect username or password."));
489 }
Deployments: a preview for every pull request, production on g1t.page490 let session_token = crypto::random_hex(32);
491 self.db
492 .prepare(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look493 // Signing in is proof it is the person: see security.rs.
494 "INSERT INTO sessions (id, user_id, expires_at, authenticated_at) VALUES (?, ?, {}, {SQL_NOW})",
Deployments: a preview for every pull request, production on g1t.page495 sql_after(SESSION_TTL_SECONDS)
496 ))
497 .bind(&[
498 crypto::sha256_hex(&session_token).into(),
499 user.id.as_str().into(),
500 ])?
501 .run()
502 .await?;
503 Ok(Outcome::Ok(SignedIn {
504 user,
505 session_token,
Merge main (membership, two-factor, GitHub repo roles) into tokens506 two_factor_challenge: None,
Deployments: a preview for every pull request, production on g1t.page507 }))
508 }
509
510 async fn sign_out(&self, a: SessionArgs) -> Result<()> {
511 self.db
512 .prepare("DELETE FROM sessions WHERE id = ?")
513 .bind(&[crypto::sha256_hex(&a.session_token).into()])?
514 .run()
515 .await?;
516 Ok(())
517 }
518
519 async fn user_for_session(&self, a: SessionArgs) -> Result<Viewer> {
520 self.find_user(
521 &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 managers522 "SELECT users.id, users.username, users.display_username, users.email_verified_at IS NOT NULL AS verified,
Workspace names and icons, and a component kit for every control523 users.avatar
Deployments: a preview for every pull request, production on g1t.page524 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)525 WHERE sessions.id = ? AND sessions.expires_at > {SQL_NOW} AND users.deleted_at IS NULL"
Deployments: a preview for every pull request, production on g1t.page526 ),
527 &crypto::sha256_hex(&a.session_token),
528 )
529 .await
530 }
531
532 async fn user_for_git_credentials(&self, a: GitCredentialsArgs) -> Result<Viewer> {
533 // Like GitHub, a token alone identifies its user.
534 if a.secret.starts_with(TOKEN_PREFIX) {
535 self.user_for_access_token(&a.secret).await
536 } else {
537 self.user_for_password(&a.username, &a.secret).await
538 }
539 }
540
541 async fn user_for_ssh_key(&self, a: FingerprintArgs) -> Result<Viewer> {
542 self.find_user(
543 "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified FROM ssh_keys
544 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)545 WHERE fingerprint = ? AND users.deleted_at IS NULL",
Deployments: a preview for every pull request, production on g1t.page546 &a.fingerprint,
547 )
548 .await
549 }
550
551 async fn user_by_username(&self, a: UsernameArgs) -> Result<Viewer> {
552 self.find_public_user(
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)553 // 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 managers554 "SELECT id, username, display_username, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ? AND deleted_at IS NULL",
Deployments: a preview for every pull request, production on g1t.page555 &a.username.to_lowercase(),
556 )
557 .await
558 }
559
Inbox: threads, reasons, subscriptions and watching560 /// `notify_by_email`: an inbox item, emailed to the person it is for,
561 /// only at a confirmed address and only while they can still read the
562 /// repository it is about. Returns whether it was sent.
563 async fn notify_by_email(&self, a: g1t_contracts::inbox::NotifyByEmailArgs) -> Result<bool> {
564 #[derive(Deserialize)]
565 struct Address {
566 email: Option<String>,
567 }
568 let user = self
569 .find_user(
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)570 "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 watching571 &a.username.to_lowercase(),
572 )
573 .await?;
574 let Some(user) = user.filter(|user| user.verified) else {
575 return Ok(false);
576 };
577 let readable: Vec<g1t_contracts::repos::Repo> = g1t_kit::call(
578 &self.env.service("REPOS")?,
579 "readable",
580 &g1t_contracts::repos::ReadableArgs {
581 ids: vec![a.repo_id.clone()],
582 viewer: Some(user.clone()),
583 },
584 )
585 .await?;
586 if readable.is_empty() {
587 return Ok(false);
588 }
589 let address = self
590 .db
591 .prepare("SELECT email FROM users WHERE id = ?")
592 .bind(&[user.id.as_str().into()])?
593 .first::<Address>(None)
594 .await?
595 .and_then(|row| row.email)
596 .filter(|email| !email.trim().is_empty());
597 let Some(address) = address else {
598 return Ok(false);
599 };
600 email::send_notification(&self.env, &address, &a).await?;
601 Ok(true)
602 }
603
Deployments: a preview for every pull request, production on g1t.page604 async fn usernames(&self, a: UsernamesArgs) -> Result<std::collections::HashMap<String, String>> {
605 #[derive(serde::Deserialize)]
606 struct Named {
607 id: String,
608 name: String,
609 }
610 let ids: Vec<String> = a.ids.into_iter().take(200).collect();
611 let mut names = std::collections::HashMap::new();
612 if ids.is_empty() {
613 return Ok(names);
614 }
615 let marks = vec!["?"; ids.len()].join(", ");
616 let bind: Vec<worker::wasm_bindgen::JsValue> = ids.iter().map(|id| id.as_str().into()).collect();
617 for sql in [
618 format!("SELECT id, username AS name FROM users WHERE id IN ({marks})"),
619 format!("SELECT id, slug AS name FROM workspaces WHERE id IN ({marks})"),
620 ] {
621 for row in self.db.prepare(sql).bind(&bind)?.all().await?.results::<Named>()? {
622 names.insert(row.id, row.name);
623 }
624 }
625 Ok(names)
626 }
627
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)628 /// `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.629 /// with their workspaces, roles, base permissions and repository
630 /// grants, under each workspace's policy, as a signed-in viewer would
631 /// have them. For the agents service, which answers only with what
632 /// every person who will read the answer may see
633 /// (docs.g1t.sh/guides/agent-access/, "What an agent can and can't
634 /// know"). Ids of no live account are left out, so the caller can tell
635 /// 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)636 async fn users_for_audience(&self, a: UsernamesArgs) -> Result<Vec<User>> {
637 let mut found = Vec::new();
638 for id in a.ids.iter().take(50) {
639 let user = self
640 .find_user(
641 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ? AND deleted_at IS NULL",
642 id,
643 )
644 .await?;
645 if let Some(user) = user {
646 found.push(user);
647 }
648 }
649 Ok(found)
650 }
651
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97652 /// `accounts`: the accounts behind these ids (at most 200), each with
653 /// its username and avatar, for lists that keep ids, such as who
654 /// 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 calendar655 /// `display_usernames`: each lowercased username's chosen case, for the
656 /// API, which shows it beside every username it answers with.
657 async fn display_usernames(&self, a: DisplayUsernamesArgs) -> Result<std::collections::HashMap<String, String>> {
658 #[derive(serde::Deserialize)]
659 struct Row {
660 username: String,
661 display_username: String,
662 }
663 let mut names: Vec<String> = a.usernames.iter().map(|name| name.trim().to_lowercase()).filter(|name| !name.is_empty()).collect();
664 names.sort();
665 names.dedup();
666 names.truncate(200);
667 let mut found = std::collections::HashMap::new();
668 if names.is_empty() {
669 return Ok(found);
670 }
671 let marks = vec!["?"; names.len()].join(", ");
672 let bind: Vec<worker::wasm_bindgen::JsValue> = names.iter().map(|name| name.as_str().into()).collect();
673 let rows = self
674 .db
675 .prepare(format!(
676 "SELECT username, display_username FROM users WHERE username IN ({marks}) AND display_username IS NOT NULL AND deleted_at IS NULL"
677 ))
678 .bind(&bind)?
679 .all()
680 .await?
681 .results::<Row>()?;
682 for row in rows {
683 if row.display_username.eq_ignore_ascii_case(&row.username) && row.display_username != row.username {
684 found.insert(row.username, row.display_username);
685 }
686 }
687 Ok(found)
688 }
689
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97690 async fn accounts(&self, a: UsernamesArgs) -> Result<std::collections::HashMap<String, g1t_contracts::accounts::EmailOwner>> {
691 #[derive(serde::Deserialize)]
692 struct Row {
693 id: String,
694 username: String,
695 avatar: Option<String>,
696 }
697 let ids: Vec<String> = a.ids.into_iter().take(200).collect();
698 let mut found = std::collections::HashMap::new();
699 if ids.is_empty() {
700 return Ok(found);
701 }
702 let marks = vec!["?"; ids.len()].join(", ");
703 let bind: Vec<worker::wasm_bindgen::JsValue> = ids.iter().map(|id| id.as_str().into()).collect();
704 let rows = self
705 .db
706 .prepare(format!("SELECT id, username, avatar FROM users WHERE id IN ({marks})"))
707 .bind(&bind)?
708 .all()
709 .await?
710 .results::<Row>()?;
711 for row in rows {
712 found.insert(row.id.clone(), g1t_contracts::accounts::EmailOwner { id: row.id, username: row.username, avatar: row.avatar });
713 }
714 Ok(found)
715 }
716
Deployments: a preview for every pull request, production on g1t.page717 async fn list_ssh_keys(&self, a: UserArgs) -> Result<Vec<SshKey>> {
718 let rows = self
719 .db
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca720 .prepare("SELECT id, title, fingerprint, created_at, last_used_at FROM ssh_keys WHERE user_id = ? ORDER BY id")
Deployments: a preview for every pull request, production on g1t.page721 .bind(&[a.user.id.into()])?
722 .all()
723 .await?
724 .results::<KeyRow>()?;
725 Ok(rows.into_iter().map(SshKey::from).collect())
726 }
727
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge728 /// The account (user id) that registered each key, by fingerprint
729 /// (`SHA256:…`). At most 100; unknown keys are left out.
730 async fn ssh_key_owners(&self, a: SshKeyOwnersArgs) -> Result<std::collections::HashMap<String, String>> {
731 #[derive(serde::Deserialize)]
732 struct Row {
733 fingerprint: String,
734 user_id: String,
735 }
736 let fingerprints: Vec<&String> = a.fingerprints.iter().take(100).collect();
737 if fingerprints.is_empty() {
738 return Ok(std::collections::HashMap::new());
739 }
740 let marks = vec!["?"; fingerprints.len()].join(", ");
741 let binds: Vec<JsValue> = fingerprints.iter().map(|fingerprint| fingerprint.as_str().into()).collect();
742 Ok(self
743 .db
744 .prepare(format!("SELECT fingerprint, user_id FROM ssh_keys WHERE fingerprint IN ({marks})"))
745 .bind(&binds)?
746 .all()
747 .await?
748 .results::<Row>()?
749 .into_iter()
750 .map(|row| (row.fingerprint, row.user_id))
751 .collect())
752 }
753
Deployments: a preview for every pull request, production on g1t.page754 async fn add_ssh_key(&self, a: AddSshKeyArgs) -> Result<Outcome<SshKey>> {
755 let Some(key) = crypto::parse_ssh_key(&a.public_key) else {
756 return Ok(Outcome::fail(
757 FailureCode::Invalid,
758 "That is not a valid OpenSSH public key.",
759 ));
760 };
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca761 // Someone's SSH key, or a repository's deploy key (deploy_keys.rs).
762 if self.key_in_use(&key.fingerprint).await? {
763 return Ok(Outcome::fail(FailureCode::Conflict, g1t_contracts::deploy_keys::KEY_IN_USE));
Deployments: a preview for every pull request, production on g1t.page764 }
765 let now = now_ms();
766 let title = [a.title.trim(), key.comment.as_str(), "SSH key"]
767 .into_iter()
768 .find(|candidate| !candidate.is_empty())
769 .unwrap_or_default()
770 .to_owned();
771 let row = KeyRow {
772 id: new_id("key", now),
773 title,
774 fingerprint: key.fingerprint,
775 created_at: rfc3339(now),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca776 last_used_at: None,
Deployments: a preview for every pull request, production on g1t.page777 };
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca778 let inserted = self.db
Deployments: a preview for every pull request, production on g1t.page779 .prepare(
780 "INSERT INTO ssh_keys (id, user_id, title, public_key, fingerprint, created_at)
781 VALUES (?, ?, ?, ?, ?, ?)",
782 )
783 .bind(&[
784 row.id.as_str().into(),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca785 a.user.id.as_str().into(),
Deployments: a preview for every pull request, production on g1t.page786 row.title.as_str().into(),
787 key.public_key.into(),
788 row.fingerprint.as_str().into(),
789 row.created_at.as_str().into(),
790 ])?
791 .run()
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca792 .await;
793 // Added at the same moment elsewhere: the trigger or the unique
794 // index refused it.
795 if let Err(error) = inserted {
796 if self.key_in_use(&row.fingerprint).await? {
797 return Ok(Outcome::fail(FailureCode::Conflict, g1t_contracts::deploy_keys::KEY_IN_USE));
798 }
799 return Err(error);
800 }
Merge main (membership, two-factor, GitHub repo roles) into tokens801 let shown = format!("{} ({})", row.title, row.fingerprint);
802 self.log_security(&a.user.id, "ssh_key_added", Some(&shown), None).await;
803 self.audit_account(&a.user, "ssh_key.added", &format!("Added SSH key {shown}")).await;
Deployments: a preview for every pull request, production on g1t.page804 Ok(Outcome::Ok(row.into()))
805 }
806
Merge main (membership, two-factor, GitHub repo roles) into tokens807 /// Deletes one of the person's SSH keys.
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca808 async fn remove_ssh_key(&self, a: RemoveArgs) -> Result<()> {
Merge main (membership, two-factor, GitHub repo roles) into tokens809 #[derive(Deserialize)]
810 struct Removed {
811 title: String,
812 fingerprint: String,
813 }
814 let removed = self
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca815 .db
Merge main (membership, two-factor, GitHub repo roles) into tokens816 .prepare("DELETE FROM ssh_keys WHERE id = ? AND user_id = ? RETURNING title, fingerprint")
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca817 .bind(&[a.id.as_str().into(), a.user.id.as_str().into()])?
Merge main (membership, two-factor, GitHub repo roles) into tokens818 .first::<Removed>(None)
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca819 .await?;
Merge main (membership, two-factor, GitHub repo roles) into tokens820 if let Some(removed) = removed {
821 let shown = format!("{} ({})", removed.title, removed.fingerprint);
822 self.log_security(&a.user.id, "ssh_key_removed", Some(&shown), None).await;
823 self.audit_account(&a.user, "ssh_key.removed", &format!("Removed SSH key {shown}")).await;
824 }
Deployments: a preview for every pull request, production on g1t.page825 Ok(())
826 }
827}
828
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas829/// Every 15 minutes: staff hear about waitlist requests that arrived while
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member830/// the last summary's window was still open, so none waits on a later one;
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)831/// and deleted workspaces and accounts past their restore window are purged
832/// (deletion.rs, account_deletion.rs).
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas833#[event(scheduled)]
834async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
835 let Ok(db) = env.d1("DB") else { return };
836 let identity = Identity { db, env };
837 if let Err(error) = identity.notify_staff_of_requests().await {
838 worker::console_error!("waitlist summary: {error}");
839 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member840 if let Err(error) = identity.purge_due_workspaces().await {
841 worker::console_error!("workspace purge: {error}");
842 }
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)843 // And deleted accounts past theirs (account_deletion.rs).
844 if let Err(error) = identity.purge_due_accounts().await {
845 worker::console_error!("account purge: {error}");
846 }
Merge main (membership, two-factor, GitHub repo roles) into tokens847 // Once: creators of repositories made before they got Admin (members.rs).
848 if let Err(error) = identity.backfill_creator_grants().await {
849 worker::console_error!("creator grants: {error}");
850 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas851}
852
Deployments: a preview for every pull request, production on g1t.page853#[event(fetch)]
854async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
855 let Some(method) = rpc_method(&request) else {
856 return Response::error("Not found", 404);
857 };
Fast pages, required checks on the branch, self-hosted runners, honest incidents858 // A replica near the caller when it asks for one (crates/kit/src/d1.rs).
859 let (db, served) = g1t_kit::d1::open(&env, "DB", &request)?;
Deployments: a preview for every pull request, production on g1t.page860 let body: serde_json::Value = request.json().await?;
Fast pages, required checks on the branch, self-hosted runners, honest incidents861 let identity = Identity { db, env };
Deployments: a preview for every pull request, production on g1t.page862
Fast pages, required checks on the branch, self-hosted runners, honest incidents863 let answered = match method.as_str() {
Search across all of g1t, Explore, and a command palette864 "register" => {
865 let outcome = identity.register(args(body)?).await?;
866 if let Outcome::Ok(signed_in) = &outcome {
867 identity.announce_user(&signed_in.user.username, Some(&signed_in.user.id)).await;
868 }
869 reply(&outcome)
870 }
Deployments: a preview for every pull request, production on g1t.page871 "sign_in" => reply(&identity.sign_in(args(body)?).await?),
Search across all of g1t, Explore, and a command palette872 "create_workspace" => {
873 let outcome = identity.create_workspace(args(body)?).await?;
874 if let Outcome::Ok(workspace) = &outcome {
875 identity.announce_workspace(&workspace.id, &workspace.slug, None).await;
876 }
877 reply(&outcome)
878 }
Deployments: a preview for every pull request, production on g1t.page879 "get_workspace" => reply(&identity.get_workspace(args(body)?).await?),
880 "list_members" => reply(&identity.list_members(args(body)?).await?),
881 "add_member" => reply(&identity.add_member(args(body)?).await?),
882 "remove_member" => reply(&identity.remove_member(args(body)?).await?),
Merge main (membership, two-factor, GitHub repo roles) into tokens883 // Owners, roles, leaving and member privileges; see members.rs.
884 "update_member" => reply(&identity.update_member(args(body)?).await?),
885 "transfer_ownership" => reply(&identity.transfer_ownership(args(body)?).await?),
886 "leave_workspace" => reply(&identity.leave_workspace(args(body)?).await?),
887 "set_member_privileges" => reply(&identity.set_member_privileges(args(body)?).await?),
888 "set_two_factor_requirement" => reply(&identity.set_two_factor_requirement(args(body)?).await?),
889 "grant_creator" => reply(&identity.grant_creator(args(body)?).await?),
Search across all of g1t, Explore, and a command palette890 "update_workspace" => {
891 let outcome = identity.update_workspace(args(body)?).await?;
892 if let Outcome::Ok(workspace) = &outcome {
893 identity.announce_workspace(&workspace.id, &workspace.slug, None).await;
894 }
895 reply(&outcome)
896 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains897 "rename_workspace" => reply(&identity.rename_workspace(args(body)?).await?),
898 "check_workspace_rename" => reply(&identity.check_workspace_rename(args(body)?).await?),
899 "resolve_slug" => reply(&identity.resolve_slug(args(body)?).await?),
Merge branch 'worktree-agent-a8385d293d42c913a'900 "resolve_alias" => reply(&identity.resolve_alias(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look901 "check_workspace_deletion" => reply(&identity.check_workspace_deletion(args(body)?).await?),
902 "delete_workspace" => reply(&identity.delete_workspace(args(body)?).await?),
903 "transfer_repo_scopes" => reply(&identity.transfer_repo_scopes(args(body)?).await?),
Search across all of g1t, Explore, and a command palette904 "set_workspace_avatar" => {
905 let outcome = identity.set_workspace_avatar(args(body)?).await?;
906 if let Outcome::Ok(workspace) = &outcome {
907 identity.announce_workspace(&workspace.id, &workspace.slug, None).await;
908 }
909 reply(&outcome)
910 }
911 "set_user_avatar" => {
912 let a: SetUserAvatarArgs = args(body)?;
913 let (username, id) = (a.user.username.clone(), a.user.id.clone());
914 let outcome = identity.set_user_avatar(a).await?;
915 if matches!(outcome, Outcome::Ok(_)) {
916 identity.announce_user(&username, Some(&id)).await;
917 }
918 reply(&outcome)
919 }
Deployments: a preview for every pull request, production on g1t.page920 "list_workspace_tokens" => reply(&identity.list_workspace_tokens(args(body)?).await?),
921 "remove_workspace_token" => reply(&identity.remove_workspace_token(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look922 // Signing in with GitHub; see github.rs.
923 "github_enabled" => reply(&identity.github_enabled()),
924 "github_start" => reply(&identity.github_start(args(body)?).await?),
925 "github_finish" => reply(&identity.github_finish(args(body)?).await?),
926 "github_pending" => reply(&identity.github_pending(args(body)?).await?),
927 "github_sign_up" => reply(&identity.github_sign_up(args(body)?).await?),
928 "github_claim" => reply(&identity.github_claim(args(body)?).await?),
929 "github_account" => reply(&identity.github_account(args(body)?).await?),
930 "github_unlink" => reply(&identity.github_unlink(args(body)?).await?),
931 "github_user_token" => reply(&identity.github_user_token(args(body)?).await?),
932 "github_revoked" => reply(&identity.github_revoked(args(body)?).await?),
933 "github_usernames" => reply(&identity.github_usernames(args(body)?).await?),
Deployments: a preview for every pull request, production on g1t.page934 "oauth_authorize" => reply(&identity.oauth_authorize(args(body)?).await?),
935 "oauth_exchange" => reply(&identity.oauth_exchange(args(body)?).await?),
936 "oauth_refresh" => reply(&identity.oauth_refresh(args(body)?).await?),
937 "list_oauth_grants" => reply(&identity.list_oauth_grants(args(body)?).await?),
938 "revoke_oauth_grant" => reply(&identity.revoke_oauth_grant(args(body)?).await?),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step939 "update_oauth_grant" => reply(&identity.update_oauth_grant(args(body)?).await?),
Deployments: a preview for every pull request, production on g1t.page940 "device_start" => reply(&identity.device_start(args(body)?).await?),
941 "device_lookup" => reply(&identity.device_lookup(args(body)?).await?),
942 "device_resolve" => reply(&identity.device_resolve(args(body)?).await?),
943 "device_claim" => reply(&identity.device_claim(args(body)?).await?),
944 "resend_verification" => reply(&identity.resend_verification(args(body)?).await?),
945 "verify_email" => reply(&identity.verify_email(args(body)?).await?),
Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036)946 "confirm_email_code" => reply(&identity.confirm_email_code(args(body)?).await?),
947 "change_pending_email" => reply(&identity.change_pending_email(args(body)?).await?),
Deployments: a preview for every pull request, production on g1t.page948 "request_password_reset" => reply(&identity.request_password_reset(args(body)?).await?),
949 "reset_password" => reply(&identity.reset_password(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look950 // A person's email addresses; see emails.rs and security.rs.
951 "list_emails" => reply(&identity.list_emails(args(body)?).await?),
952 "add_email" => reply(&identity.add_email(args(body)?).await?),
953 "remove_email" => reply(&identity.remove_email(args(body)?).await?),
954 "resend_email_verification" => reply(&identity.resend_email_verification(args(body)?).await?),
955 "update_email_settings" => reply(&identity.update_email_settings(args(body)?).await?),
956 "reauthenticate" => reply(&identity.reauthenticate(args(body)?).await?),
Merge main (membership, two-factor, GitHub repo roles) into tokens957 // Two-factor authentication; see two_factor.rs.
958 "two_factor_status" => reply(&identity.two_factor_status(args(body)?).await?),
959 "two_factor_start" => reply(&identity.two_factor_start(args(body)?).await?),
960 "two_factor_enable" => reply(&identity.two_factor_enable(args(body)?).await?),
961 "two_factor_disable" => reply(&identity.two_factor_disable(args(body)?).await?),
962 "two_factor_recovery_codes" => reply(&identity.two_factor_recovery_codes(args(body)?).await?),
963 "two_factor_sign_in" => reply(&identity.two_factor_sign_in(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look964 "security_log" => reply(&identity.security_log(args(body)?).await?),
965 "email_owners" => reply(&identity.email_owners(args(body)?).await?),
966 "commit_identity" => reply(&identity.commit_identity(args(body)?).await?),
967 "push_email_guard" => reply(&identity.push_email_guard(args(body)?).await?),
968 "admin_user" => reply(&identity.admin_user(args(body)?).await?),
969 "admin_remove_email" => reply(&identity.admin_remove_email(args(body)?).await?),
Deployments: a preview for every pull request, production on g1t.page970 "sign_out" => reply(&identity.sign_out(args(body)?).await?),
971 "user_for_session" => reply(&identity.user_for_session(args(body)?).await?),
972 "user_for_git_credentials" => reply(&identity.user_for_git_credentials(args(body)?).await?),
973 "user_for_access_token" => {
974 let a: TokenArgs = args(body)?;
975 reply(&identity.user_for_access_token(&a.token).await?)
976 }
977 "user_for_ssh_key" => reply(&identity.user_for_ssh_key(args(body)?).await?),
978 "user_by_username" => reply(&identity.user_by_username(args(body)?).await?),
979 "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 calendar980 // The API: each username's chosen case, shown beside it.
981 "display_usernames" => reply(&identity.display_usernames(args(body)?).await?),
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97982 "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)983 "users_for_audience" => reply(&identity.users_for_audience(args(body)?).await?),
Inbox: threads, reasons, subscriptions and watching984 "notify_by_email" => reply(&identity.notify_by_email(args(body)?).await?),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains985 "profile" => reply(&identity.profile(args(body)?).await?),
Search across all of g1t, Explore, and a command palette986 "update_profile" => {
987 let outcome = identity.update_profile(args(body)?).await?;
988 if let Outcome::Ok(profile) = &outcome {
989 identity.announce_user(&profile.username, None).await;
990 }
991 reply(&outcome)
992 }
993 "directory" => reply(&identity.directory(args(body)?).await?),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains994 "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.995 // Each person's dock pins, per workspace; see dock.rs.
996 "dock_pins" => reply(&identity.dock_pins(args(body)?).await?),
997 "set_dock_pins" => reply(&identity.set_dock_pins(args(body)?).await?),
Deployments: a preview for every pull request, production on g1t.page998 "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge999 // Services only: who registered each key, for verifying commit
1000 // signatures (repos' signatures.rs).
1001 "ssh_key_owners" => reply(&identity.ssh_key_owners(args(body)?).await?),
Deployments: a preview for every pull request, production on g1t.page1002 "add_ssh_key" => reply(&identity.add_ssh_key(args(body)?).await?),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1003 "remove_ssh_key" => reply(&identity.remove_ssh_key(args(body)?).await?),
1004 // A repository's deploy keys, and who an SSH key signs in as; see
1005 // deploy_keys.rs.
1006 "list_deploy_keys" => reply(&identity.list_deploy_keys(args(body)?).await?),
1007 "get_deploy_key" => reply(&identity.get_deploy_key(args(body)?).await?),
1008 "add_deploy_key" => reply(&identity.add_deploy_key(args(body)?).await?),
1009 "remove_deploy_key" => reply(&identity.remove_deploy_key(args(body)?).await?),
1010 "principal_for_ssh_key" => reply(&identity.principal_for_ssh_key(args(body)?).await?),
Deployments: a preview for every pull request, production on g1t.page1011 "list_access_tokens" => reply(&identity.list_access_tokens(args(body)?).await?),
1012 "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 managers1013 // Making and changing a person's or a workspace's token, and
1014 // workspaces' rules for tokens; see token_reach.rs.
1015 "create_token" => reply(&identity.create_token(args(body)?).await?),
1016 "update_token" => reply(&identity.update_token(args(body)?).await?),
Fine-grained personal tokens, workspace token rules and approvals in identity1017 "get_token_policy" => reply(&identity.get_token_policy(args(body)?).await?),
1018 "set_token_policy" => reply(&identity.set_token_policy(args(body)?).await?),
1019 "list_member_tokens" => reply(&identity.list_member_tokens(args(body)?).await?),
1020 "review_token_request" => reply(&identity.review_token_request(args(body)?).await?),
1021 "revoke_member_token" => reply(&identity.revoke_member_token(args(body)?).await?),
Deployments: a preview for every pull request, production on g1t.page1022 "create_agent_token" => reply(&identity.create_agent_token(args(body)?).await?),
1023 "agent_scope" => reply(&identity.agent_scope(args(body)?).await?),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1024 "create_run_credential" => reply(&identity.create_run_credential(args(body)?).await?),
1025 "bind_run_credentials" => reply(&identity.bind_run_credentials(args(body)?).await?),
1026 "revoke_run_credentials" => reply(&identity.revoke_run_credentials(args(body)?).await?),
Merge branch 'worktree-agent-a3abfcce648e87dca'1027 "create_job_token" => reply(&identity.create_job_token(args(body)?).await?),
1028 "revoke_job_tokens" => reply(&identity.revoke_job_tokens(args(body)?).await?),
Merge main (membership, two-factor, GitHub repo roles) into tokens1029 "remove_access_token" => reply(&identity.remove_access_token(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1030 // Invites and the waitlist; see invites.rs.
1031 "registration" => reply(&identity.registration_mode()),
1032 "list_invites" => reply(&identity.list_invites(args(body)?).await?),
1033 "create_invite" => reply(&identity.create_invite(args(body)?).await?),
1034 "revoke_invite" => reply(&identity.revoke_invite(args(body)?).await?),
1035 "check_invite" => reply(&identity.check_invite(args(body)?).await?),
1036 "accept_invite" => reply(&identity.accept_invite(args(body)?).await?),
1037 "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)1038 "list_invitations" => reply(&identity.list_invitations(args(body)?).await?),
1039 "accept_invitation" => reply(&identity.accept_invitation(args(body)?).await?),
1040 "decline_invitation" => reply(&identity.decline_invitation(args(body)?).await?),
1041 "find_people" => reply(&identity.find_people(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1042 "workspace_invites" => reply(&identity.workspace_invites(args(body)?).await?),
1043 "revoke_workspace_invite" => reply(&identity.revoke_workspace_invite(args(body)?).await?),
1044 "request_access" => reply(&identity.request_access(args(body)?).await?),
1045 // Who has access to a repository; see access.rs.
1046 "repo_access" => reply(&identity.repo_access(args(body)?).await?),
1047 "add_collaborator" => reply(&identity.add_collaborator(args(body)?).await?),
1048 "set_collaborator_role" => reply(&identity.set_collaborator_role(args(body)?).await?),
1049 "remove_collaborator" => reply(&identity.remove_collaborator(args(body)?).await?),
1050 "collaborator_permission" => reply(&identity.collaborator_permission(args(body)?).await?),
1051 "my_repo_invitations" => reply(&identity.my_repo_invitations(args(body)?).await?),
1052 "respond_repo_invitation" => reply(&identity.respond_repo_invitation(args(body)?).await?),
1053 "revoke_repo_invitation" => reply(&identity.revoke_repo_invitation(args(body)?).await?),
1054 "set_base_permission" => reply(&identity.set_base_permission(args(body)?).await?),
Merge branch 'worktree-agent-a2013627e5ea4ab13'1055 // Where a workspace keeps its repositories' git data (EU residency).
1056 "workspace_residency" => reply(&identity.workspace_residency(args(body)?).await?),
1057 "set_workspace_residency" => reply(&identity.set_workspace_residency(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1058 "outside_collaborators" => reply(&identity.outside_collaborators(args(body)?).await?),
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca1059 "forget_repo_access" => {
1060 let a: g1t_contracts::access::ForgetRepoAccessArgs = args(body)?;
1061 // A purged repository's deploy keys go with its access.
1062 identity.forget_deploy_keys(&a.repo_id).await?;
1063 reply(&identity.forget_repo_access(a).await?)
1064 }
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar1065 // Teams (teams.rs).
1066 "list_teams" => reply(&identity.list_teams(args(body)?).await?),
1067 "get_team" => reply(&identity.get_team(args(body)?).await?),
1068 "create_team" => reply(&identity.create_team(args(body)?).await?),
Merge branch 'worktree-agent-ad7c6d88d93adc817'1069 "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 bar1070 "update_team" => reply(&identity.update_team(args(body)?).await?),
1071 "delete_team" => reply(&identity.delete_team(args(body)?).await?),
1072 "team_members" => reply(&identity.team_members(args(body)?).await?),
1073 "set_team_member" => reply(&identity.set_team_member(args(body)?).await?),
1074 "remove_team_member" => reply(&identity.remove_team_member(args(body)?).await?),
1075 "child_teams" => reply(&identity.child_teams(args(body)?).await?),
1076 "team_repos" => reply(&identity.team_repos(args(body)?).await?),
1077 "set_team_repo" => reply(&identity.set_team_repo(args(body)?).await?),
1078 "remove_team_repo" => reply(&identity.remove_team_repo(args(body)?).await?),
1079 "user_teams" => reply(&identity.user_teams(args(body)?).await?),
1080 "team_memberships" => reply(&identity.team_memberships(args(body)?).await?),
1081 "resolve_teams" => reply(&identity.resolve_teams(args(body)?).await?),
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.1082 // Agents on teams (teams.rs), and the people directory (people.rs).
1083 "team_agents" => reply(&identity.team_agents(args(body)?).await?),
1084 "set_team_agent" => reply(&identity.set_team_agent(args(body)?).await?),
1085 "remove_team_agent" => reply(&identity.remove_team_agent(args(body)?).await?),
1086 "agent_teams" => reply(&identity.agent_teams(args(body)?).await?),
An agent is on teams the way a person is: through team membership, with no team or department of its own. Add it to teams when you create it, from its profile or from the team's page; the Agents sidebar groups agents by the teams they're on, with those on none last; what an agent is told about its teammates, team budgets, skills reach, Spend's By team and the People pages all read memberships. Agents with an old team name matching a team are moved onto it once; the rest are left off a team. Templates name a role only. The agents, teams and people guides say how.1087 "team_agent_index" => reply(&identity.team_agent_index(args(body)?).await?),
1088 "adopt_agent_teams" => reply(&identity.adopt_agent_teams(args(body)?).await?),
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.1089 "people_directory" => reply(&identity.people_directory(args(body)?).await?),
1090 "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 bar1091 "resolve_owners" => reply(&identity.resolve_owners(args(body)?).await?),
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1092 // Staff only: sudo.g1t.sh, over its service binding. See admin.rs.
1093 "notify_owners" => reply(&identity.notify_owners(args(body)?).await?),
1094 "admin_workspaces" => reply(&identity.admin_workspaces(args(body)?).await?),
1095 "admin_workspace" => reply(&identity.admin_workspace(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1096 "admin_waitlist" => reply(&identity.admin_waitlist(args(body)?).await?),
1097 "admin_decide_waitlist" => reply(&identity.admin_decide_waitlist(args(body)?).await?),
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas1098 "admin_waitlist_pending" => reply(&identity.admin_waitlist_pending().await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1099 "admin_invites" => reply(&identity.admin_invites(args(body)?).await?),
1100 "admin_revoke_invite" => reply(&identity.admin_revoke_invite(args(body)?).await?),
1101 "admin_mint_invite" => reply(&identity.admin_mint_invite(args(body)?).await?),
1102 "admin_grant_invites" => reply(&identity.admin_grant_invites(args(body)?).await?),
1103 "admin_invite_tree" => reply(&identity.admin_invite_tree(args(body)?).await?),
1104 "admin_workspace_invites" => reply(&identity.admin_workspace_invites(args(body)?).await?),
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)1105 // Shared invite links for a group; see shared_invites.rs.
1106 "admin_shared_invites" => reply(&identity.admin_shared_invites().await?),
1107 "admin_create_shared_invite" => reply(&identity.admin_create_shared_invite(args(body)?).await?),
1108 "admin_revoke_shared_invite" => reply(&identity.admin_revoke_shared_invite(args(body)?).await?),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1109 // Deleted workspaces, restored or purged by staff; see deletion.rs.
1110 "admin_deleted_workspaces" => reply(&identity.admin_deleted_workspaces().await?),
1111 "admin_restore_workspace" => reply(&identity.admin_restore_workspace(args(body)?).await?),
1112 "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)1113 // Deleting accounts (account_deletion.rs): the person from the
1114 // site, staff from sudo. There is no API route for it.
1115 "check_account_deletion" => reply(&identity.check_account_deletion(args(body)?).await?),
1116 "delete_account" => reply(&identity.delete_account(args(body)?).await?),
1117 "admin_delete_account" => reply(&identity.admin_delete_account(args(body)?).await?),
1118 "admin_deleted_accounts" => reply(&identity.admin_deleted_accounts().await?),
1119 "admin_restore_account" => reply(&identity.admin_restore_account(args(body)?).await?),
1120 "admin_purge_account" => reply(&identity.admin_purge_account(args(body)?).await?),
Merge branch 'worktree-agent-a8385d293d42c913a'1121 // Workspace aliases, set by staff only; see aliases.rs.
1122 "admin_aliases" => reply(&identity.admin_aliases().await?),
1123 "admin_set_alias" => reply(&identity.admin_set_alias(args(body)?).await?),
1124 "admin_remove_alias" => reply(&identity.admin_remove_alias(args(body)?).await?),
Deployments: a preview for every pull request, production on g1t.page1125 _ => Response::error("Unknown method", 404),
Fast pages, required checks on the branch, self-hosted runners, honest incidents1126 };
1127 served.finish(answered)
Deployments: a preview for every pull request, production on g1t.page1128}
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1129
1130#[cfg(test)]
1131mod register_tests {
1132 use super::*;
1133
1134 #[test]
1135 fn nobody_registers_as_g1t() {
1136 // What register checks the username with, whatever its case.
1137 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 managers1138 assert!(claimable_username(username).is_none(), "{username}");
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1139 }
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_eq!(claimable_username("ana").map(|name| name.canonical).as_deref(), Some("ana"));
1141 }
1142
1143 #[test]
1144 fn a_username_keeps_the_case_it_was_chosen_in() {
1145 let name = claimable_username("Ana-Lopez").unwrap();
1146 assert_eq!(name.canonical, "ana-lopez");
1147 assert_eq!(name.display_if_cased(), Some("Ana-Lopez"));
1148 assert!(USERNAME_RULES.contains("either case"));
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1149 }
1150}

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