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

Docs: the quickstart as steps, with both ways to put an agent on an issue1//! 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;
Docs: the quickstart as steps, with both ways to put an agent on an issue11mod 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;
Docs: the quickstart as steps, with both ways to put an agent on an issue14mod 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;
Docs: the quickstart as steps, with both ways to put an agent on an issue17mod 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;
Docs: the quickstart as steps, with both ways to put an agent on an issue22mod 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;
Docs: the quickstart as steps, with both ways to put an agent on an issue34mod tokens;
Merge main (membership, two-factor, GitHub repo roles) into tokens35mod two_factor;
Docs: the quickstart as steps, with both ways to put an agent on an issue36mod 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};
Docs: the quickstart as steps, with both ways to put an agent on an issue41use 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};
Docs: the quickstart as steps, with both ways to put an agent on an issue46
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
Docs: the quickstart as steps, with both ways to put an agent on an issue57#[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>,
Docs: the quickstart as steps, with both ways to put an agent on an issue64 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>,
Docs: the quickstart as steps, with both ways to put an agent on an issue68}
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,
Docs: the quickstart as steps, with both ways to put an agent on an issue76 verified: row.verified != 0,
Workspace names and icons, and a component kit for every control77 avatar: row.avatar,
Docs: the quickstart as steps, with both ways to put an agent on an issue78 ..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>,
Docs: the quickstart as steps, with both ways to put an agent on an issue100}
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>,
Docs: the quickstart as steps, with both ways to put an agent on an issue110}
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,
Docs: the quickstart as steps, with both ways to put an agent on an issue120 }
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;
Docs: the quickstart as steps, with both ways to put an agent on an issue156 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
Docs: the quickstart as steps, with both ways to put an agent on an issue172 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
Docs: the quickstart as steps, with both ways to put an agent on an issue174 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).
Docs: the quickstart as steps, with both ways to put an agent on an issue183 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 ])?
Docs: the quickstart as steps, with both ways to put an agent on an issue193 .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
Docs: the quickstart as steps, with both ways to put an agent on an issue204 }
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>> {
Docs: the quickstart as steps, with both ways to put an agent on an issue209 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
Docs: the quickstart as steps, with both ways to put an agent on an issue216 }
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.
Docs: the quickstart as steps, with both ways to put an agent on an issue220 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()
Docs: the quickstart as steps, with both ways to put an agent on an issue254 .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 }
Docs: the quickstart as steps, with both ways to put an agent on an issue264 }
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(())
Docs: the quickstart as steps, with both ways to put an agent on an issue266 }
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 = ?")
Docs: the quickstart as steps, with both ways to put an agent on an issue280 .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.
Docs: the quickstart as steps, with both ways to put an agent on an issue291 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);
Docs: the quickstart as steps, with both ways to put an agent on an issue306 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,
Docs: the quickstart as steps, with both ways to put an agent on an issue310 ..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)])?
Docs: the quickstart as steps, with both ways to put an agent on an issue331 .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 }
Docs: the quickstart as steps, with both ways to put an agent on an issue378 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();
Docs: the quickstart as steps, with both ways to put an agent on an issue386 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);
Docs: the quickstart as steps, with both ways to put an agent on an issue395 }
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).
Docs: the quickstart as steps, with both ways to put an agent on an issue412 .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
Docs: the quickstart as steps, with both ways to put an agent on an issue415 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? {
Docs: the quickstart as steps, with both ways to put an agent on an issue427 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)),
Docs: the quickstart as steps, with both ways to put an agent on an issue448 };
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}");
Docs: the quickstart as steps, with both ways to put an agent on an issue455 }
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)),
Docs: the quickstart as steps, with both ways to put an agent on an issue463 };
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();
Docs: the quickstart as steps, with both ways to put an agent on an issue465 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).
Docs: the quickstart as steps, with both ways to put an agent on an issue471 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 }
Docs: the quickstart as steps, with both ways to put an agent on an issue490 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})",
Docs: the quickstart as steps, with both ways to put an agent on an issue495 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,
Docs: the quickstart as steps, with both ways to put an agent on an issue507 }))
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
Docs: the quickstart as steps, with both ways to put an agent on an issue524 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"
Docs: the quickstart as steps, with both ways to put an agent on an issue526 ),
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",
Docs: the quickstart as steps, with both ways to put an agent on an issue546 &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",
Docs: the quickstart as steps, with both ways to put an agent on an issue555 &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
Docs: the quickstart as steps, with both ways to put an agent on an issue604 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
Docs: the quickstart as steps, with both ways to put an agent on an issue717 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")
Docs: the quickstart as steps, with both ways to put an agent on an issue721 .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
Docs: the quickstart as steps, with both ways to put an agent on an issue754 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));
Docs: the quickstart as steps, with both ways to put an agent on an issue764 }
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,
Docs: the quickstart as steps, with both ways to put an agent on an issue777 };
Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca778 let inserted = self.db
Docs: the quickstart as steps, with both ways to put an agent on an issue779 .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(),
Docs: the quickstart as steps, with both ways to put an agent on an issue786 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;
Docs: the quickstart as steps, with both ways to put an agent on an issue804 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 }
Docs: the quickstart as steps, with both ways to put an agent on an issue825 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
Docs: the quickstart as steps, with both ways to put an agent on an issue853#[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)?;
Docs: the quickstart as steps, with both ways to put an agent on an issue860 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 };
Docs: the quickstart as steps, with both ways to put an agent on an issue862
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 }
Docs: the quickstart as steps, with both ways to put an agent on an issue871 "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 }
Docs: the quickstart as steps, with both ways to put an agent on an issue879 "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 }
Docs: the quickstart as steps, with both ways to put an agent on an issue920 "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?),
Docs: the quickstart as steps, with both ways to put an agent on an issue934 "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?),
Docs: the quickstart as steps, with both ways to put an agent on an issue940 "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?),
Docs: the quickstart as steps, with both ways to put an agent on an issue948 "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?),
Docs: the quickstart as steps, with both ways to put an agent on an issue970 "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?),
Docs: the quickstart as steps, with both ways to put an agent on an issue998 "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?),
Docs: the quickstart as steps, with both ways to put an agent on an issue1002 "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?),
Docs: the quickstart as steps, with both ways to put an agent on an issue1011 "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?),
Docs: the quickstart as steps, with both ways to put an agent on an issue1022 "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?),
1087 "people_directory" => reply(&identity.people_directory(args(body)?).await?),
1088 "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 bar1089 "resolve_owners" => reply(&identity.resolve_owners(args(body)?).await?),
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace1090 // Staff only: sudo.g1t.sh, over its service binding. See admin.rs.
1091 "notify_owners" => reply(&identity.notify_owners(args(body)?).await?),
1092 "admin_workspaces" => reply(&identity.admin_workspaces(args(body)?).await?),
1093 "admin_workspace" => reply(&identity.admin_workspace(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1094 "admin_waitlist" => reply(&identity.admin_waitlist(args(body)?).await?),
1095 "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 quotas1096 "admin_waitlist_pending" => reply(&identity.admin_waitlist_pending().await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1097 "admin_invites" => reply(&identity.admin_invites(args(body)?).await?),
1098 "admin_revoke_invite" => reply(&identity.admin_revoke_invite(args(body)?).await?),
1099 "admin_mint_invite" => reply(&identity.admin_mint_invite(args(body)?).await?),
1100 "admin_grant_invites" => reply(&identity.admin_grant_invites(args(body)?).await?),
1101 "admin_invite_tree" => reply(&identity.admin_invite_tree(args(body)?).await?),
1102 "admin_workspace_invites" => reply(&identity.admin_workspace_invites(args(body)?).await?),
Merge shared invite links: label, uses, expiry, domains; joined through recorded (identity 0038)1103 // Shared invite links for a group; see shared_invites.rs.
1104 "admin_shared_invites" => reply(&identity.admin_shared_invites().await?),
1105 "admin_create_shared_invite" => reply(&identity.admin_create_shared_invite(args(body)?).await?),
1106 "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 member1107 // Deleted workspaces, restored or purged by staff; see deletion.rs.
1108 "admin_deleted_workspaces" => reply(&identity.admin_deleted_workspaces().await?),
1109 "admin_restore_workspace" => reply(&identity.admin_restore_workspace(args(body)?).await?),
1110 "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)1111 // Deleting accounts (account_deletion.rs): the person from the
1112 // site, staff from sudo. There is no API route for it.
1113 "check_account_deletion" => reply(&identity.check_account_deletion(args(body)?).await?),
1114 "delete_account" => reply(&identity.delete_account(args(body)?).await?),
1115 "admin_delete_account" => reply(&identity.admin_delete_account(args(body)?).await?),
1116 "admin_deleted_accounts" => reply(&identity.admin_deleted_accounts().await?),
1117 "admin_restore_account" => reply(&identity.admin_restore_account(args(body)?).await?),
1118 "admin_purge_account" => reply(&identity.admin_purge_account(args(body)?).await?),
Merge branch 'worktree-agent-a8385d293d42c913a'1119 // Workspace aliases, set by staff only; see aliases.rs.
1120 "admin_aliases" => reply(&identity.admin_aliases().await?),
1121 "admin_set_alias" => reply(&identity.admin_set_alias(args(body)?).await?),
1122 "admin_remove_alias" => reply(&identity.admin_remove_alias(args(body)?).await?),
Docs: the quickstart as steps, with both ways to put an agent on an issue1123 _ => Response::error("Unknown method", 404),
Fast pages, required checks on the branch, self-hosted runners, honest incidents1124 };
1125 served.finish(answered)
Docs: the quickstart as steps, with both ways to put an agent on an issue1126}
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent1127
1128#[cfg(test)]
1129mod register_tests {
1130 use super::*;
1131
1132 #[test]
1133 fn nobody_registers_as_g1t() {
1134 // What register checks the username with, whatever its case.
1135 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 managers1136 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-agent1137 }
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_eq!(claimable_username("ana").map(|name| name.canonical).as_deref(), Some("ana"));
1139 }
1140
1141 #[test]
1142 fn a_username_keeps_the_case_it_was_chosen_in() {
1143 let name = claimable_username("Ana-Lopez").unwrap();
1144 assert_eq!(name.canonical, "ana-lopez");
1145 assert_eq!(name.display_if_cased(), Some("Ana-Lopez"));
1146 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-agent1147 }
1148}

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