g1t/services/identity/src/lib.rs

846 lines37,735 bytesCodeBlame

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.

API and MCP server, Rust identity service, registration, site redesign1//! 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;
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace7mod admin;
Workspace names and icons, and a component kit for every control8mod avatars;
API and MCP server, Rust identity service, registration, site redesign9mod crypto;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look10mod deletion;
Device sign-in replaces registering and minting tokens over the API11mod device;
Search across all of g1t, Explore, and a command palette12mod directory;
Email verification, password reset, and Git for AI scale positioning13mod email;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look14mod emails;
15mod github;
16mod invites;
OAuth 2.1 sign-in for MCP clients and other applications17mod oauth;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains18mod profiles;
19mod rename;
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API20mod run_credentials;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look21mod security;
22mod throttle;
Agents as a team: lifecycle, merge queue, billing and a new shell23mod tokens;
Workspaces own repositories24mod workspaces;
API and MCP server, Rust identity service, registration, site redesign25
26use g1t_contracts::identity::*;
RFC 3339 timestamps in identity and repos27use g1t_contracts::time::{SQL_NOW, rfc3339, sql_after};
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent28use g1t_contracts::{FailureCode, Outcome, User, Viewer, claimable_namespace, new_id};
API and MCP server, Rust identity service, registration, site redesign29use g1t_kit::{args, now_ms, reply, rpc_method};
30use serde::Deserialize;
Agents as a team: lifecycle, merge queue, billing and a new shell31use tokens::TOKEN_PREFIX;
API and MCP server, Rust identity service, registration, site redesign32use worker::wasm_bindgen::JsValue;
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas33use worker::{Context, D1Database, Env, Request, Response, Result, ScheduleContext, ScheduledEvent, event};
API and MCP server, Rust identity service, registration, site redesign34
RFC 3339 timestamps in identity and repos35const SESSION_TTL_SECONDS: u64 = 30 * 24 * 60 * 60;
36const VERIFY_TTL_SECONDS: u64 = 24 * 60 * 60;
37const RESET_TTL_SECONDS: u64 = 60 * 60;
API and MCP server, Rust identity service, registration, site redesign38const MIN_PASSWORD_LENGTH: usize = 10;
Email verification, password reset, and Git for AI scale positioning39const PASSWORD_TOO_SHORT: &str = "Use a password of at least 10 characters.";
40
41/// A user as selected from the database; `verified` arrives as 0 or 1.
42#[derive(Deserialize)]
43struct Account {
44 id: String,
45 username: String,
46 verified: u8,
Workspace names and icons, and a component kit for every control47 /// Selected only where the person is being shown to themselves.
48 #[serde(default)]
49 avatar: Option<String>,
Email verification, password reset, and Git for AI scale positioning50}
51
52impl From<Account> for User {
53 fn from(row: Account) -> Self {
54 User {
55 id: row.id,
56 username: row.username,
57 verified: row.verified != 0,
Workspace names and icons, and a component kit for every control58 avatar: row.avatar,
Agents as a team: lifecycle, merge queue, billing and a new shell59 ..User::default()
Email verification, password reset, and Git for AI scale positioning60 }
61 }
62}
API and MCP server, Rust identity service, registration, site redesign63
64#[derive(Deserialize)]
65struct UserRow {
66 id: String,
67 username: String,
68 password_hash: String,
Email verification, password reset, and Git for AI scale positioning69 verified: u8,
API and MCP server, Rust identity service, registration, site redesign70}
71
Email verification, password reset, and Git for AI scale positioning72/// The owner of an emailed token.
API and MCP server, Rust identity service, registration, site redesign73#[derive(Deserialize)]
Email verification, password reset, and Git for AI scale positioning74struct TokenOwner {
75 id: String,
76 username: String,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look77 /// The address a link was sent to; null on links from before accounts
78 /// had several, which are for the primary.
79 #[serde(default)]
80 email_id: Option<String>,
Email verification, password reset, and Git for AI scale positioning81}
82
83#[derive(Deserialize)]
API and MCP server, Rust identity service, registration, site redesign84struct KeyRow {
85 id: String,
86 title: String,
87 fingerprint: String,
RFC 3339 timestamps in identity and repos88 created_at: String,
API and MCP server, Rust identity service, registration, site redesign89}
90
91impl From<KeyRow> for SshKey {
92 fn from(row: KeyRow) -> Self {
93 SshKey {
94 id: row.id,
95 title: row.title,
96 fingerprint: row.fingerprint,
RFC 3339 timestamps in identity and repos97 created_at: row.created_at,
API and MCP server, Rust identity service, registration, site redesign98 }
99 }
100}
101
102struct Identity {
103 db: D1Database,
Email verification, password reset, and Git for AI scale positioning104 env: Env,
API and MCP server, Rust identity service, registration, site redesign105}
106
107impl Identity {
Workspaces own repositories108 /// Runs a query that returns at most one user, for showing to others:
109 /// without their workspaces.
110 async fn find_public_user(&self, sql: &str, param: &str) -> Result<Viewer> {
Email verification, password reset, and Git for AI scale positioning111 Ok(self
112 .db
API and MCP server, Rust identity service, registration, site redesign113 .prepare(sql)
114 .bind(&[JsValue::from(param)])?
Email verification, password reset, and Git for AI scale positioning115 .first::<Account>(None)
116 .await?
117 .map(User::from))
118 }
119
Workspaces own repositories120 /// Attaches the workspaces a user belongs to, so that any service can
121 /// authorize them without asking again.
122 async fn with_workspaces(&self, user: Viewer) -> Result<Viewer> {
123 let Some(mut user) = user else {
124 return Ok(None);
125 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look126 let memberships = self.memberships(&user.id).await?;
127 // Access to a workspace is used only within its policy; see security.rs.
128 user.workspaces = self.within_policy(&user.id, memberships).await?;
129 // Roles on single repositories, under the same policy (access.rs).
130 let grants = self.grants_of(&user.id).await?;
131 user.grants = self.grants_within_policy(&user.id, grants).await?;
Workspaces own repositories132 Ok(Some(user))
133 }
134
135 /// Runs a query that resolves credentials to at most one user.
136 async fn find_user(&self, sql: &str, param: &str) -> Result<Viewer> {
137 let user = self.find_public_user(sql, param).await?;
138 self.with_workspaces(user).await
139 }
140
Email verification, password reset, and Git for AI scale positioning141 /// Stores a one-time token of `kind` for the user and returns it.
RFC 3339 timestamps in identity and repos142 async fn issue_email_token(&self, user_id: &str, kind: &str, ttl: u64) -> Result<String> {
Email verification, password reset, and Git for AI scale positioning143 let token = crypto::random_hex(32);
144 self.db
RFC 3339 timestamps in identity and repos145 .prepare(format!(
Email verification, password reset, and Git for AI scale positioning146 "INSERT INTO email_tokens (id, user_id, kind, expires_at)
RFC 3339 timestamps in identity and repos147 VALUES (?, ?, ?, {})",
148 sql_after(ttl)
149 ))
Email verification, password reset, and Git for AI scale positioning150 .bind(&[
151 crypto::sha256_hex(&token).into(),
152 user_id.into(),
153 kind.into(),
154 ])?
155 .run()
156 .await?;
157 Ok(token)
API and MCP server, Rust identity service, registration, site redesign158 }
159
Email verification, password reset, and Git for AI scale positioning160 /// Consumes a token of `kind`, returning its owner if it was valid.
161 async fn redeem_email_token(&self, token: &str, kind: &str) -> Result<Option<TokenOwner>> {
162 let id = crypto::sha256_hex(token);
163 let owner = self
164 .db
RFC 3339 timestamps in identity and repos165 .prepare(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look166 "SELECT users.id, users.username, email_tokens.email_id FROM email_tokens
Email verification, password reset, and Git for AI scale positioning167 JOIN users ON users.id = email_tokens.user_id
168 WHERE email_tokens.id = ? AND email_tokens.kind = ?
RFC 3339 timestamps in identity and repos169 AND email_tokens.expires_at > {SQL_NOW}"
170 ))
Email verification, password reset, and Git for AI scale positioning171 .bind(&[id.as_str().into(), kind.into()])?
172 .first::<TokenOwner>(None)
173 .await?;
174 if let Some(owner) = &owner {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look175 // Every outstanding token of this kind dies with the one used:
176 // every reset link, and every confirmation link for the same
177 // address (another address's links still work).
Email verification, password reset, and Git for AI scale positioning178 self.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look179 .prepare(
180 "DELETE FROM email_tokens WHERE user_id = ?1 AND kind = ?2
181 AND (?2 = 'reset' OR email_id IS ?3)",
182 )
183 .bind(&[
184 owner.id.as_str().into(),
185 kind.into(),
186 owner.email_id.as_deref().map_or(JsValue::NULL, Into::into),
187 ])?
Email verification, password reset, and Git for AI scale positioning188 .run()
189 .await?;
190 }
191 Ok(owner)
192 }
193
194 async fn send_verification(&self, user: &User, email: &str) -> Result<()> {
195 let token = self
196 .issue_email_token(&user.id, "verify", VERIFY_TTL_SECONDS)
197 .await?;
198 email::send_verification(&self.env, email, &user.username, &token).await
199 }
200
201 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 look202 if !self.allow(throttle::CONFIRM_ACCOUNT, &a.user.id).await? {
203 return Ok(Outcome::fail(FailureCode::Conflict, "Too many confirmation emails this hour. Check your inbox, or try again later."));
204 }
205 self.resend_primary(&a.user).await
Email verification, password reset, and Git for AI scale positioning206 }
207
208 async fn verify_email(&self, a: EmailTokenArgs) -> Result<Outcome<User>> {
209 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 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look215 if let Outcome::Fail(failure) = self.confirm_address(&owner.id, owner.email_id.as_deref()).await? {
216 return Ok(Outcome::Fail(failure));
217 }
218 // Whether the account is confirmed: whether its primary is.
219 let verified = self
220 .find_public_user(
221 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ?",
222 &owner.id,
223 )
224 .await?
225 .is_some_and(|user| user.verified);
Email verification, password reset, and Git for AI scale positioning226 Ok(Outcome::Ok(User {
227 id: owner.id,
228 username: owner.username,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look229 verified,
Workspaces own repositories230 ..User::default()
Email verification, password reset, and Git for AI scale positioning231 }))
232 }
233
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look234 /// Any confirmed address of an account can ask for a reset; so can the
235 /// unconfirmed address a new account signed up with. See emails.rs.
Email verification, password reset, and Git for AI scale positioning236 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 look237 let allowed = self.allow(throttle::RESET_EMAIL, &a.email).await?
238 && match a.client.as_deref() {
239 Some(client) => self.allow(throttle::RESET_CLIENT, client).await?,
240 None => true,
241 };
242 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 exists243 // A failure from here on happens only for a real account, so it
244 // is logged, never answered: the reply below stays the same.
245 if let Err(error) = self.send_reset(&target).await {
246 worker::console_error!("password reset for a known address failed: {error}");
247 }
248 }
249 // The same answer either way, so addresses cannot be probed.
250 Ok(true)
251 }
252
253 /// Saves a reset link for `target` and mails it, telling the account's
254 /// other addresses.
255 async fn send_reset(&self, target: &emails::ResetTarget) -> Result<()> {
256 {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look257 let token = crypto::random_hex(32);
258 self.db
259 .prepare(format!(
260 "INSERT INTO email_tokens (id, user_id, kind, expires_at, email_id)
261 VALUES (?, ?, 'reset', {}, ?)",
262 sql_after(RESET_TTL_SECONDS)
263 ))
264 .bind(&[
265 crypto::sha256_hex(&token).into(),
266 target.user_id.as_str().into(),
267 target.email_id.as_str().into(),
268 ])?
269 .run()
Email verification, password reset, and Git for AI scale positioning270 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look271 email::send_password_reset(&self.env, &target.display, &target.username, &token).await?;
272 // The primary and the backup hear of it when it went elsewhere.
273 let elsewhere = self.notice_recipients(&target.user_id, false).await?;
274 for address in elsewhere.iter().filter(|address| !address.eq_ignore_ascii_case(&target.display)) {
275 let change = format!("A password reset was asked for through {}", target.display);
276 if let Err(error) = email::send_security_notice(&self.env, address, &target.username, &change).await {
277 worker::console_error!("security notice failed: {error}");
278 }
279 }
Email verification, password reset, and Git for AI scale positioning280 }
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 exists281 Ok(())
Email verification, password reset, and Git for AI scale positioning282 }
283
284 async fn reset_password(&self, a: ResetPasswordArgs) -> Result<Outcome<User>> {
285 if a.password.chars().count() < MIN_PASSWORD_LENGTH {
286 return Ok(Outcome::fail(FailureCode::Invalid, PASSWORD_TOO_SHORT));
287 }
288 let Some(owner) = self.redeem_email_token(&a.token, "reset").await? else {
289 return Ok(Outcome::fail(
290 FailureCode::Invalid,
291 "This reset link is not valid or has expired.",
292 ));
293 };
294 self.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look295 .prepare("UPDATE users SET password_hash = ? WHERE id = ?")
Email verification, password reset, and Git for AI scale positioning296 .bind(&[
297 crypto::hash_password(&a.password).into(),
298 owner.id.as_str().into(),
299 ])?
300 .run()
301 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look302 // Following an emailed link also proves the address it went to
303 // (unless another account confirmed it first).
304 let _ = self.confirm_address(&owner.id, owner.email_id.as_deref()).await?;
305 // Anyone signed in with the old password is signed out, and nobody
306 // stays locked out by the wrong guesses before it.
Email verification, password reset, and Git for AI scale positioning307 self.db
308 .prepare("DELETE FROM sessions WHERE user_id = ?")
309 .bind(&[owner.id.as_str().into()])?
310 .run()
311 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look312 self.clear(&throttle::key(throttle::PASSWORD_ACCOUNT, &owner.id)).await?;
313 self.log_security(&owner.id, "password_changed", None, None).await;
314 self.tell_primary_and_backup(&owner.id, &owner.username, "Your password was changed").await;
315 let verified = self
316 .find_public_user(
317 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE id = ?",
318 &owner.id,
319 )
320 .await?
321 .is_some_and(|user| user.verified);
Email verification, password reset, and Git for AI scale positioning322 Ok(Outcome::Ok(User {
323 id: owner.id,
324 username: owner.username,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look325 verified,
Workspaces own repositories326 ..User::default()
Email verification, password reset, and Git for AI scale positioning327 }))
328 }
329
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look330 /// The account a login names: a username, or any confirmed address.
331 async fn password_row(&self, login: &str) -> Result<Option<UserRow>> {
332 let login = login.trim().to_lowercase();
333 let (column, value) = if login.contains('@') {
334 match self.user_with_verified_email(&login).await? {
335 Some(id) => ("id", id),
336 None => return Ok(None),
337 }
338 } else {
339 ("username", login)
340 };
341 self.db
342 .prepare(format!(
343 "SELECT id, username, password_hash, email_verified_at IS NOT NULL AS verified FROM users WHERE {column} = ?"
344 ))
345 .bind(&[JsValue::from(value)])?
API and MCP server, Rust identity service, registration, site redesign346 .first::<UserRow>(None)
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look347 .await
348 }
349
350 /// Checks a password for a login, throttled (see throttle.rs). The
351 /// refusal is one of two messages, the same for every account.
352 async fn checked_password(
353 &self,
354 login: &str,
355 password: &str,
356 client: Option<&str>,
357 ) -> Result<std::result::Result<User, &'static str>> {
358 let row = self.password_row(login).await?;
359 let subject = row.as_ref().map_or_else(|| login.trim().to_lowercase(), |row| row.id.clone());
360 let (account_key, client_key) = Identity::password_keys(&subject, client);
361 if self.password_locked(&account_key, client_key.as_deref()).await? {
362 return Ok(Err(throttle::THROTTLED));
363 }
364 let owner = row.as_ref().map(|row| (row.id.clone(), row.username.clone()));
365 match row.filter(|row| !row.password_hash.is_empty() && crypto::verify_password(password, &row.password_hash)) {
366 Some(row) => {
367 self.clear(&account_key).await?;
368 Ok(Ok(User {
369 id: row.id,
370 username: row.username,
371 verified: row.verified != 0,
372 ..User::default()
373 }))
374 }
375 None => {
376 let owner = owner.as_ref().map(|(id, name)| (id.as_str(), name.as_str()));
377 self.password_failed(&account_key, client_key.as_deref(), owner).await?;
378 Ok(Err("Incorrect username or password."))
379 }
380 }
381 }
382
383 async fn user_for_password(&self, login: &str, password: &str) -> Result<Viewer> {
384 let user = self.checked_password(login, password, None).await?.ok();
Workspaces own repositories385 self.with_workspaces(user).await
API and MCP server, Rust identity service, registration, site redesign386 }
387
388 async fn register(&self, a: RegisterArgs) -> Result<Outcome<SignedIn>> {
389 let username = a.username.trim().to_lowercase();
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent390 let claimable = claimable_namespace(&username).is_some();
API and MCP server, Rust identity service, registration, site redesign391 let email = a.email.trim().to_lowercase();
392 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 look393 let invite_code = a.invite_code.as_deref().map(str::trim).filter(|code| !code.is_empty());
394 // The invite first: without one, nothing else on the form matters.
395 if self.invites_required() && invite_code.is_none() {
396 return Ok(Outcome::fail(FailureCode::Forbidden, invites::MISSING));
397 }
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent398 if !claimable {
API and MCP server, Rust identity service, registration, site redesign399 return invalid(
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent400 "Usernames use lowercase letters, digits and single hyphens, up to 39 characters, and cannot be a reserved word.",
API and MCP server, Rust identity service, registration, site redesign401 );
402 }
403 let well_formed_email = email
404 .split_once('@')
405 .is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.'))
406 && !email.contains(char::is_whitespace);
407 if !well_formed_email {
408 return invalid("Enter a valid email address.");
409 }
410 if a.password.chars().count() < MIN_PASSWORD_LENGTH {
Email verification, password reset, and Git for AI scale positioning411 return invalid(PASSWORD_TOO_SHORT);
API and MCP server, Rust identity service, registration, site redesign412 }
413 let taken = self
414 .db
Agents as a team: lifecycle, merge queue, billing and a new shell415 // Usernames and workspaces share one namespace, so that a name
416 // means the same thing wherever it appears.
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look417 // An address is taken once an account has confirmed it; an
418 // unconfirmed one goes to whoever confirms it first (emails.rs).
Agents as a team: lifecycle, merge queue, billing and a new shell419 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look420 "SELECT username FROM users WHERE username = ?
421 UNION ALL SELECT email FROM user_emails WHERE email = ? AND verified_at IS NOT NULL
Agents as a team: lifecycle, merge queue, billing and a new shell422 UNION ALL SELECT slug FROM workspaces WHERE slug = ?",
423 )
424 .bind(&[
425 username.as_str().into(),
426 email.as_str().into(),
427 username.as_str().into(),
428 ])?
API and MCP server, Rust identity service, registration, site redesign429 .first::<serde_json::Value>(None)
430 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look431 // A renamed workspace's old slug stays reserved for it a while, and
432 // a deleted workspace's for good.
433 if taken.is_some() || self.slug_held(&username).await? || self.slug_deleted(&username).await? {
API and MCP server, Rust identity service, registration, site redesign434 return Ok(Outcome::fail(
435 FailureCode::Conflict,
436 "That username or email is already registered.",
437 ));
438 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look439 let password_hash = crypto::hash_password(&a.password);
440 let user = match self
441 .create_account(invites::NewAccount {
442 username: &username,
443 email: &email,
444 password_hash: &password_hash,
445 verified: false,
446 invite_code,
447 client: a.client.as_deref(),
448 })
449 .await?
450 {
451 Outcome::Ok(user) => user,
452 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
API and MCP server, Rust identity service, registration, site redesign453 };
Email verification, password reset, and Git for AI scale positioning454 // The account exists either way; the email can be sent again later.
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas455 // An invite sent to this address confirmed it already (invites.rs).
456 if !user.verified
457 && let Err(error) = self.send_verification(&user, &email).await
458 {
Email verification, password reset, and Git for AI scale positioning459 worker::console_error!("verification email failed: {error}");
460 }
API and MCP server, Rust identity service, registration, site redesign461 self.start_session(user).await
462 }
463
464 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 look465 let user = match self.checked_password(&a.username, &a.password, a.client.as_deref()).await? {
466 Ok(user) => user,
467 Err(message) => return Ok(Outcome::fail(FailureCode::Unauthenticated, message)),
API and MCP server, Rust identity service, registration, site redesign468 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look469 let user = self.with_workspaces(Some(user)).await?.unwrap_or_default();
API and MCP server, Rust identity service, registration, site redesign470 self.start_session(user).await
471 }
472
473 async fn start_session(&self, user: User) -> Result<Outcome<SignedIn>> {
474 let session_token = crypto::random_hex(32);
475 self.db
RFC 3339 timestamps in identity and repos476 .prepare(format!(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look477 // Signing in is proof it is the person: see security.rs.
478 "INSERT INTO sessions (id, user_id, expires_at, authenticated_at) VALUES (?, ?, {}, {SQL_NOW})",
RFC 3339 timestamps in identity and repos479 sql_after(SESSION_TTL_SECONDS)
480 ))
API and MCP server, Rust identity service, registration, site redesign481 .bind(&[
482 crypto::sha256_hex(&session_token).into(),
483 user.id.as_str().into(),
484 ])?
485 .run()
486 .await?;
487 Ok(Outcome::Ok(SignedIn {
488 user,
489 session_token,
490 }))
491 }
492
493 async fn sign_out(&self, a: SessionArgs) -> Result<()> {
494 self.db
495 .prepare("DELETE FROM sessions WHERE id = ?")
496 .bind(&[crypto::sha256_hex(&a.session_token).into()])?
497 .run()
498 .await?;
499 Ok(())
500 }
501
502 async fn user_for_session(&self, a: SessionArgs) -> Result<Viewer> {
503 self.find_user(
RFC 3339 timestamps in identity and repos504 &format!(
Workspace names and icons, and a component kit for every control505 "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified,
506 users.avatar
RFC 3339 timestamps in identity and repos507 FROM sessions JOIN users ON users.id = sessions.user_id
508 WHERE sessions.id = ? AND sessions.expires_at > {SQL_NOW}"
509 ),
API and MCP server, Rust identity service, registration, site redesign510 &crypto::sha256_hex(&a.session_token),
511 )
512 .await
513 }
514
515 async fn user_for_git_credentials(&self, a: GitCredentialsArgs) -> Result<Viewer> {
516 // Like GitHub, a token alone identifies its user.
517 if a.secret.starts_with(TOKEN_PREFIX) {
518 self.user_for_access_token(&a.secret).await
519 } else {
520 self.user_for_password(&a.username, &a.secret).await
521 }
522 }
523
524 async fn user_for_ssh_key(&self, a: FingerprintArgs) -> Result<Viewer> {
525 self.find_user(
Email verification, password reset, and Git for AI scale positioning526 "SELECT users.id, users.username, users.email_verified_at IS NOT NULL AS verified FROM ssh_keys
API and MCP server, Rust identity service, registration, site redesign527 JOIN users ON users.id = ssh_keys.user_id
528 WHERE fingerprint = ?",
529 &a.fingerprint,
530 )
531 .await
532 }
533
534 async fn user_by_username(&self, a: UsernameArgs) -> Result<Viewer> {
Workspaces own repositories535 self.find_public_user(
Email verification, password reset, and Git for AI scale positioning536 "SELECT id, username, email_verified_at IS NOT NULL AS verified FROM users WHERE username = ?",
API and MCP server, Rust identity service, registration, site redesign537 &a.username.to_lowercase(),
538 )
539 .await
540 }
541
What happened across an outcome, as a feed beside its graph542 async fn usernames(&self, a: UsernamesArgs) -> Result<std::collections::HashMap<String, String>> {
543 #[derive(serde::Deserialize)]
544 struct Named {
545 id: String,
546 name: String,
547 }
548 let ids: Vec<String> = a.ids.into_iter().take(200).collect();
549 let mut names = std::collections::HashMap::new();
550 if ids.is_empty() {
551 return Ok(names);
552 }
553 let marks = vec!["?"; ids.len()].join(", ");
554 let bind: Vec<worker::wasm_bindgen::JsValue> = ids.iter().map(|id| id.as_str().into()).collect();
555 for sql in [
556 format!("SELECT id, username AS name FROM users WHERE id IN ({marks})"),
557 format!("SELECT id, slug AS name FROM workspaces WHERE id IN ({marks})"),
558 ] {
559 for row in self.db.prepare(sql).bind(&bind)?.all().await?.results::<Named>()? {
560 names.insert(row.id, row.name);
561 }
562 }
563 Ok(names)
564 }
565
API and MCP server, Rust identity service, registration, site redesign566 async fn list_ssh_keys(&self, a: UserArgs) -> Result<Vec<SshKey>> {
567 let rows = self
568 .db
569 .prepare("SELECT id, title, fingerprint, created_at FROM ssh_keys WHERE user_id = ? ORDER BY id")
570 .bind(&[a.user.id.into()])?
571 .all()
572 .await?
573 .results::<KeyRow>()?;
574 Ok(rows.into_iter().map(SshKey::from).collect())
575 }
576
577 async fn add_ssh_key(&self, a: AddSshKeyArgs) -> Result<Outcome<SshKey>> {
578 let Some(key) = crypto::parse_ssh_key(&a.public_key) else {
579 return Ok(Outcome::fail(
580 FailureCode::Invalid,
581 "That is not a valid OpenSSH public key.",
582 ));
583 };
584 let taken = self
585 .db
586 .prepare("SELECT id FROM ssh_keys WHERE fingerprint = ?")
587 .bind(&[key.fingerprint.as_str().into()])?
588 .first::<serde_json::Value>(None)
589 .await?;
590 if taken.is_some() {
591 return Ok(Outcome::fail(
592 FailureCode::Conflict,
593 "That key is already registered.",
594 ));
595 }
596 let now = now_ms();
597 let title = [a.title.trim(), key.comment.as_str(), "SSH key"]
598 .into_iter()
599 .find(|candidate| !candidate.is_empty())
600 .unwrap_or_default()
601 .to_owned();
602 let row = KeyRow {
603 id: new_id("key", now),
604 title,
605 fingerprint: key.fingerprint,
RFC 3339 timestamps in identity and repos606 created_at: rfc3339(now),
API and MCP server, Rust identity service, registration, site redesign607 };
608 self.db
609 .prepare(
610 "INSERT INTO ssh_keys (id, user_id, title, public_key, fingerprint, created_at)
611 VALUES (?, ?, ?, ?, ?, ?)",
612 )
613 .bind(&[
614 row.id.as_str().into(),
615 a.user.id.into(),
616 row.title.as_str().into(),
617 key.public_key.into(),
618 row.fingerprint.as_str().into(),
RFC 3339 timestamps in identity and repos619 row.created_at.as_str().into(),
API and MCP server, Rust identity service, registration, site redesign620 ])?
621 .run()
622 .await?;
623 Ok(Outcome::Ok(row.into()))
624 }
625
626 /// Deletes a row the user owns from `table`.
627 async fn remove(&self, table: &str, a: RemoveArgs) -> Result<()> {
628 self.db
629 .prepare(format!("DELETE FROM {table} WHERE id = ? AND user_id = ?"))
630 .bind(&[a.id.into(), a.user.id.into()])?
631 .run()
632 .await?;
633 Ok(())
634 }
635}
636
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas637/// 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 member638/// the last summary's window was still open, so none waits on a later one;
639/// and deleted workspaces past their restore window are purged
640/// (deletion.rs).
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas641#[event(scheduled)]
642async fn scheduled(_event: ScheduledEvent, env: Env, _ctx: ScheduleContext) {
643 let Ok(db) = env.d1("DB") else { return };
644 let identity = Identity { db, env };
645 if let Err(error) = identity.notify_staff_of_requests().await {
646 worker::console_error!("waitlist summary: {error}");
647 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member648 if let Err(error) = identity.purge_due_workspaces().await {
649 worker::console_error!("workspace purge: {error}");
650 }
status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas651}
652
API and MCP server, Rust identity service, registration, site redesign653#[event(fetch)]
654async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
655 let Some(method) = rpc_method(&request) else {
656 return Response::error("Not found", 404);
657 };
Fast pages, required checks on the branch, self-hosted runners, honest incidents658 // A replica near the caller when it asks for one (crates/kit/src/d1.rs).
659 let (db, served) = g1t_kit::d1::open(&env, "DB", &request)?;
API and MCP server, Rust identity service, registration, site redesign660 let body: serde_json::Value = request.json().await?;
Fast pages, required checks on the branch, self-hosted runners, honest incidents661 let identity = Identity { db, env };
API and MCP server, Rust identity service, registration, site redesign662
Fast pages, required checks on the branch, self-hosted runners, honest incidents663 let answered = match method.as_str() {
Search across all of g1t, Explore, and a command palette664 "register" => {
665 let outcome = identity.register(args(body)?).await?;
666 if let Outcome::Ok(signed_in) = &outcome {
667 identity.announce_user(&signed_in.user.username, Some(&signed_in.user.id)).await;
668 }
669 reply(&outcome)
670 }
API and MCP server, Rust identity service, registration, site redesign671 "sign_in" => reply(&identity.sign_in(args(body)?).await?),
Search across all of g1t, Explore, and a command palette672 "create_workspace" => {
673 let outcome = identity.create_workspace(args(body)?).await?;
674 if let Outcome::Ok(workspace) = &outcome {
675 identity.announce_workspace(&workspace.id, &workspace.slug, None).await;
676 }
677 reply(&outcome)
678 }
Workspaces own repositories679 "get_workspace" => reply(&identity.get_workspace(args(body)?).await?),
680 "list_members" => reply(&identity.list_members(args(body)?).await?),
681 "add_member" => reply(&identity.add_member(args(body)?).await?),
682 "remove_member" => reply(&identity.remove_member(args(body)?).await?),
Search across all of g1t, Explore, and a command palette683 "update_workspace" => {
684 let outcome = identity.update_workspace(args(body)?).await?;
685 if let Outcome::Ok(workspace) = &outcome {
686 identity.announce_workspace(&workspace.id, &workspace.slug, None).await;
687 }
688 reply(&outcome)
689 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains690 "rename_workspace" => reply(&identity.rename_workspace(args(body)?).await?),
691 "check_workspace_rename" => reply(&identity.check_workspace_rename(args(body)?).await?),
692 "resolve_slug" => reply(&identity.resolve_slug(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look693 "check_workspace_deletion" => reply(&identity.check_workspace_deletion(args(body)?).await?),
694 "delete_workspace" => reply(&identity.delete_workspace(args(body)?).await?),
695 "transfer_repo_scopes" => reply(&identity.transfer_repo_scopes(args(body)?).await?),
Search across all of g1t, Explore, and a command palette696 "set_workspace_avatar" => {
697 let outcome = identity.set_workspace_avatar(args(body)?).await?;
698 if let Outcome::Ok(workspace) = &outcome {
699 identity.announce_workspace(&workspace.id, &workspace.slug, None).await;
700 }
701 reply(&outcome)
702 }
703 "set_user_avatar" => {
704 let a: SetUserAvatarArgs = args(body)?;
705 let (username, id) = (a.user.username.clone(), a.user.id.clone());
706 let outcome = identity.set_user_avatar(a).await?;
707 if matches!(outcome, Outcome::Ok(_)) {
708 identity.announce_user(&username, Some(&id)).await;
709 }
710 reply(&outcome)
711 }
Agents as a team: lifecycle, merge queue, billing and a new shell712 "list_workspace_tokens" => reply(&identity.list_workspace_tokens(args(body)?).await?),
713 "create_workspace_token" => reply(&identity.create_workspace_token(args(body)?).await?),
714 "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 look715 // Signing in with GitHub; see github.rs.
716 "github_enabled" => reply(&identity.github_enabled()),
717 "github_start" => reply(&identity.github_start(args(body)?).await?),
718 "github_finish" => reply(&identity.github_finish(args(body)?).await?),
719 "github_pending" => reply(&identity.github_pending(args(body)?).await?),
720 "github_sign_up" => reply(&identity.github_sign_up(args(body)?).await?),
721 "github_claim" => reply(&identity.github_claim(args(body)?).await?),
722 "github_account" => reply(&identity.github_account(args(body)?).await?),
723 "github_unlink" => reply(&identity.github_unlink(args(body)?).await?),
724 "github_user_token" => reply(&identity.github_user_token(args(body)?).await?),
725 "github_revoked" => reply(&identity.github_revoked(args(body)?).await?),
726 "github_usernames" => reply(&identity.github_usernames(args(body)?).await?),
OAuth 2.1 sign-in for MCP clients and other applications727 "oauth_authorize" => reply(&identity.oauth_authorize(args(body)?).await?),
728 "oauth_exchange" => reply(&identity.oauth_exchange(args(body)?).await?),
729 "oauth_refresh" => reply(&identity.oauth_refresh(args(body)?).await?),
730 "list_oauth_grants" => reply(&identity.list_oauth_grants(args(body)?).await?),
731 "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 step732 "update_oauth_grant" => reply(&identity.update_oauth_grant(args(body)?).await?),
Device sign-in replaces registering and minting tokens over the API733 "device_start" => reply(&identity.device_start(args(body)?).await?),
734 "device_lookup" => reply(&identity.device_lookup(args(body)?).await?),
735 "device_resolve" => reply(&identity.device_resolve(args(body)?).await?),
736 "device_claim" => reply(&identity.device_claim(args(body)?).await?),
Email verification, password reset, and Git for AI scale positioning737 "resend_verification" => reply(&identity.resend_verification(args(body)?).await?),
738 "verify_email" => reply(&identity.verify_email(args(body)?).await?),
739 "request_password_reset" => reply(&identity.request_password_reset(args(body)?).await?),
740 "reset_password" => reply(&identity.reset_password(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look741 // A person's email addresses; see emails.rs and security.rs.
742 "list_emails" => reply(&identity.list_emails(args(body)?).await?),
743 "add_email" => reply(&identity.add_email(args(body)?).await?),
744 "remove_email" => reply(&identity.remove_email(args(body)?).await?),
745 "resend_email_verification" => reply(&identity.resend_email_verification(args(body)?).await?),
746 "update_email_settings" => reply(&identity.update_email_settings(args(body)?).await?),
747 "reauthenticate" => reply(&identity.reauthenticate(args(body)?).await?),
748 "security_log" => reply(&identity.security_log(args(body)?).await?),
749 "email_owners" => reply(&identity.email_owners(args(body)?).await?),
750 "commit_identity" => reply(&identity.commit_identity(args(body)?).await?),
751 "push_email_guard" => reply(&identity.push_email_guard(args(body)?).await?),
752 "admin_user" => reply(&identity.admin_user(args(body)?).await?),
753 "admin_remove_email" => reply(&identity.admin_remove_email(args(body)?).await?),
API and MCP server, Rust identity service, registration, site redesign754 "sign_out" => reply(&identity.sign_out(args(body)?).await?),
755 "user_for_session" => reply(&identity.user_for_session(args(body)?).await?),
756 "user_for_git_credentials" => reply(&identity.user_for_git_credentials(args(body)?).await?),
757 "user_for_access_token" => {
758 let a: TokenArgs = args(body)?;
759 reply(&identity.user_for_access_token(&a.token).await?)
760 }
761 "user_for_ssh_key" => reply(&identity.user_for_ssh_key(args(body)?).await?),
762 "user_by_username" => reply(&identity.user_by_username(args(body)?).await?),
What happened across an outcome, as a feed beside its graph763 "usernames" => reply(&identity.usernames(args(body)?).await?),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains764 "profile" => reply(&identity.profile(args(body)?).await?),
Search across all of g1t, Explore, and a command palette765 "update_profile" => {
766 let outcome = identity.update_profile(args(body)?).await?;
767 if let Outcome::Ok(profile) = &outcome {
768 identity.announce_user(&profile.username, None).await;
769 }
770 reply(&outcome)
771 }
772 "directory" => reply(&identity.directory(args(body)?).await?),
Agents and memory, checks and conflicts, profiles, slug renames, custom domains773 "profile_workspaces" => reply(&identity.profile_workspaces(args(body)?).await?),
API and MCP server, Rust identity service, registration, site redesign774 "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?),
775 "add_ssh_key" => reply(&identity.add_ssh_key(args(body)?).await?),
776 "remove_ssh_key" => reply(&identity.remove("ssh_keys", args(body)?).await?),
777 "list_access_tokens" => reply(&identity.list_access_tokens(args(body)?).await?),
778 "create_access_token" => reply(&identity.create_access_token(args(body)?).await?),
Thirteen MCP tools and classic token scopes; agents rate their confidence and can be put on an issue in one step779 "update_access_token" => reply(&identity.update_access_token(args(body)?).await?),
Agents as a team: lifecycle, merge queue, billing and a new shell780 "create_agent_token" => reply(&identity.create_agent_token(args(body)?).await?),
781 "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 API782 "create_run_credential" => reply(&identity.create_run_credential(args(body)?).await?),
783 "bind_run_credentials" => reply(&identity.bind_run_credentials(args(body)?).await?),
784 "revoke_run_credentials" => reply(&identity.revoke_run_credentials(args(body)?).await?),
API and MCP server, Rust identity service, registration, site redesign785 "remove_access_token" => reply(&identity.remove("access_tokens", args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look786 // Invites and the waitlist; see invites.rs.
787 "registration" => reply(&identity.registration_mode()),
788 "list_invites" => reply(&identity.list_invites(args(body)?).await?),
789 "create_invite" => reply(&identity.create_invite(args(body)?).await?),
790 "revoke_invite" => reply(&identity.revoke_invite(args(body)?).await?),
791 "check_invite" => reply(&identity.check_invite(args(body)?).await?),
792 "accept_invite" => reply(&identity.accept_invite(args(body)?).await?),
793 "invite_member" => reply(&identity.invite_member(args(body)?).await?),
794 "workspace_invites" => reply(&identity.workspace_invites(args(body)?).await?),
795 "revoke_workspace_invite" => reply(&identity.revoke_workspace_invite(args(body)?).await?),
796 "request_access" => reply(&identity.request_access(args(body)?).await?),
797 // Who has access to a repository; see access.rs.
798 "repo_access" => reply(&identity.repo_access(args(body)?).await?),
799 "add_collaborator" => reply(&identity.add_collaborator(args(body)?).await?),
800 "set_collaborator_role" => reply(&identity.set_collaborator_role(args(body)?).await?),
801 "remove_collaborator" => reply(&identity.remove_collaborator(args(body)?).await?),
802 "collaborator_permission" => reply(&identity.collaborator_permission(args(body)?).await?),
803 "my_repo_invitations" => reply(&identity.my_repo_invitations(args(body)?).await?),
804 "respond_repo_invitation" => reply(&identity.respond_repo_invitation(args(body)?).await?),
805 "revoke_repo_invitation" => reply(&identity.revoke_repo_invitation(args(body)?).await?),
806 "set_base_permission" => reply(&identity.set_base_permission(args(body)?).await?),
Merge branch 'worktree-agent-a2013627e5ea4ab13'807 // Where a workspace keeps its repositories' git data (EU residency).
808 "workspace_residency" => reply(&identity.workspace_residency(args(body)?).await?),
809 "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 look810 "outside_collaborators" => reply(&identity.outside_collaborators(args(body)?).await?),
811 "forget_repo_access" => reply(&identity.forget_repo_access(args(body)?).await?),
Billing on Stripe's pages, month-end charges, warnings; sudo by workspace812 // Staff only: sudo.g1t.sh, over its service binding. See admin.rs.
813 "notify_owners" => reply(&identity.notify_owners(args(body)?).await?),
814 "admin_workspaces" => reply(&identity.admin_workspaces(args(body)?).await?),
815 "admin_workspace" => reply(&identity.admin_workspace(args(body)?).await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look816 "admin_waitlist" => reply(&identity.admin_waitlist(args(body)?).await?),
817 "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 quotas818 "admin_waitlist_pending" => reply(&identity.admin_waitlist_pending().await?),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look819 "admin_invites" => reply(&identity.admin_invites(args(body)?).await?),
820 "admin_revoke_invite" => reply(&identity.admin_revoke_invite(args(body)?).await?),
821 "admin_mint_invite" => reply(&identity.admin_mint_invite(args(body)?).await?),
822 "admin_grant_invites" => reply(&identity.admin_grant_invites(args(body)?).await?),
823 "admin_invite_tree" => reply(&identity.admin_invite_tree(args(body)?).await?),
824 "admin_workspace_invites" => reply(&identity.admin_workspace_invites(args(body)?).await?),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member825 // Deleted workspaces, restored or purged by staff; see deletion.rs.
826 "admin_deleted_workspaces" => reply(&identity.admin_deleted_workspaces().await?),
827 "admin_restore_workspace" => reply(&identity.admin_restore_workspace(args(body)?).await?),
828 "admin_purge_workspace" => reply(&identity.admin_purge_workspace(args(body)?).await?),
API and MCP server, Rust identity service, registration, site redesign829 _ => Response::error("Unknown method", 404),
Fast pages, required checks on the branch, self-hosted runners, honest incidents830 };
831 served.finish(answered)
API and MCP server, Rust identity service, registration, site redesign832}
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent833
834#[cfg(test)]
835mod register_tests {
836 use super::*;
837
838 #[test]
839 fn nobody_registers_as_g1t() {
840 // What register checks the username with, whatever its case.
841 for username in ["g1t", "G1T", "g1t-agent", "G1t-Agent"] {
842 assert_eq!(claimable_namespace(username), None, "{username}");
843 }
844 assert_eq!(claimable_namespace("ana").as_deref(), Some("ana"));
845 }
846}

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