Skip to content
566 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.

Billing accounts, terms and enterprises; g1t is no longer free1//! Workspaces and their members.
2//!
3//! A workspace owns repositories and is the first segment of their URLs.
4//! There is one kind: a person's own space and a company's differ only in
5//! how many members they have. Nothing can be created outside one.
6
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look7use g1t_contracts::access::BasePermission;
Merge main (membership, two-factor, GitHub repo roles) into tokens8use g1t_contracts::audit::Surface;
@g1t welcomes you when you join a workspace, and that is the one introduction any agent makes. Identity now says when a person becomes a member, workspace.member_joined, by invitation, by an invite code, or by creating the workspace, and webhooks can hear it; the agents service hears it too, and for anyone who joined, never the creator, the workspace's @g1t opens your direct message and says in its own voice who it is, what to ask it, how agents answer, and where things are, once per person per workspace and charged to @g1t like any reply, with a fixed welcome when no model can be used. The agents, workspaces, authentication and webhooks guides say so.9use g1t_contracts::events::{JoinedHow, MemberJoined};
Billing accounts, terms and enterprises; g1t is no longer free10use g1t_contracts::identity::*;
Merge main (membership, two-factor, GitHub repo roles) into tokens11use g1t_contracts::members::{LeaveWorkspaceArgs, last_owner_refusal};
Merge branch 'worktree-agent-ad7c6d88d93adc817'12use g1t_contracts::teams::TeamCreation;
Billing accounts, terms and enterprises; g1t is no longer free13use g1t_contracts::time::rfc3339;
14use g1t_contracts::{
Merge main (membership, two-factor, GitHub repo roles) into tokens15 FailureCode, MemberPrivileges, Membership, OrgRole, Outcome, PrincipalKind, Role, User, claimable_namespace, new_id,
Billing accounts, terms and enterprises; g1t is no longer free16};
17use g1t_kit::now_ms;
18use serde::Deserialize;
19use worker::Result;
20
21use crate::Identity;
22
23/// Enough for a person and their teams; stops one account claiming names in
24/// bulk.
25const MAX_WORKSPACES_PER_USER: usize = 10;
26
27const MAX_NAME_LENGTH: usize = 80;
28const MAX_DESCRIPTION_LENGTH: usize = 160;
29
30const WORKSPACE_COLUMNS: &str = "workspaces.id, workspaces.slug, workspaces.name,
Merge branch 'worktree-agent-ad7c6d88d93adc817'31 workspaces.description, workspaces.avatar, workspaces.created_at, workspaces.base_permission, workspaces.team_creation,
Merge main (membership, two-factor, GitHub repo roles) into tokens32 workspaces.member_privileges, workspaces.require_two_factor,
Billing accounts, terms and enterprises; g1t is no longer free33 (SELECT count(*) FROM workspace_members
34 WHERE workspace_members.workspace_id = workspaces.id) AS member_count";
35
36#[derive(Deserialize)]
37struct WorkspaceRow {
38 id: String,
39 slug: String,
40 name: String,
41 description: Option<String>,
Workspace names and icons, and a component kit for every control42 avatar: Option<String>,
Billing accounts, terms and enterprises; g1t is no longer free43 created_at: String,
44 member_count: u32,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look45 #[serde(default)]
46 base_permission: Option<String>,
Merge branch 'worktree-agent-ad7c6d88d93adc817'47 #[serde(default)]
48 team_creation: Option<String>,
Merge main (membership, two-factor, GitHub repo roles) into tokens49 #[serde(default)]
50 member_privileges: Option<String>,
51 #[serde(default)]
52 require_two_factor: Option<u8>,
Billing accounts, terms and enterprises; g1t is no longer free53}
54
55impl From<WorkspaceRow> for Workspace {
56 fn from(row: WorkspaceRow) -> Self {
57 Workspace {
58 id: row.id,
59 slug: row.slug,
60 name: row.name,
61 description: row.description,
62 created_at: row.created_at,
63 member_count: row.member_count,
Workspace names and icons, and a component kit for every control64 avatar: row.avatar,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look65 base_permission: row
66 .base_permission
67 .as_deref()
68 .and_then(BasePermission::parse)
69 .unwrap_or_default(),
Merge branch 'worktree-agent-ad7c6d88d93adc817'70 team_creation: row
71 .team_creation
72 .as_deref()
73 .and_then(TeamCreation::parse)
74 .unwrap_or_default(),
Merge main (membership, two-factor, GitHub repo roles) into tokens75 privileges: MemberPrivileges::from_stored(row.member_privileges.as_deref()),
76 two_factor_requirement_enabled: row.require_two_factor.unwrap_or(0) != 0,
Billing accounts, terms and enterprises; g1t is no longer free77 }
78 }
79}
80
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)81/// The name a new account's own workspace takes: its username, when that
82/// can name a workspace at all (g1t's own names cannot).
83pub(crate) fn own_workspace_slug(username: &str) -> Option<String> {
84 claimable_namespace(username)
85}
86
Merge main (membership, two-factor, GitHub repo roles) into tokens87/// One of a person's memberships, as stored.
Billing accounts, terms and enterprises; g1t is no longer free88#[derive(Deserialize)]
Merge main (membership, two-factor, GitHub repo roles) into tokens89struct MembershipRow {
90 slug: String,
Billing accounts, terms and enterprises; g1t is no longer free91 role: Role,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look92 name: Option<String>,
93 avatar: Option<String>,
Merge main (membership, two-factor, GitHub repo roles) into tokens94 base_permission: Option<String>,
95 team_creation: Option<String>,
96 member_privileges: Option<String>,
97 #[serde(default)]
98 billing_manager: u8,
99 #[serde(default)]
100 security_manager: u8,
101 #[serde(default)]
102 require_two_factor: u8,
Billing accounts, terms and enterprises; g1t is no longer free103}
104
105impl Identity {
106 /// The workspaces a user belongs to, attached to every user resolved
Merge main (membership, two-factor, GitHub repo roles) into tokens107 /// from credentials, with what the site needs to show each one, what
108 /// members get on its repositories (access.rs), the roles the person
109 /// holds besides member (members.rs), and its member privileges.
Billing accounts, terms and enterprises; g1t is no longer free110 pub async fn memberships(&self, user_id: &str) -> Result<Vec<Membership>> {
Merge main (membership, two-factor, GitHub repo roles) into tokens111 Ok(self.memberships_and_policies(user_id).await?.into_iter().map(|(membership, _)| membership).collect())
112 }
113
114 /// The same, each with whether its workspace requires two-factor
115 /// authentication (security.rs).
116 pub async fn memberships_and_policies(&self, user_id: &str) -> Result<Vec<(Membership, bool)>> {
117 let rows = self
118 .db
Billing accounts, terms and enterprises; g1t is no longer free119 .prepare(
Workspace names and icons, and a component kit for every control120 "SELECT workspaces.slug, workspace_members.role, workspaces.name,
Merge main (membership, two-factor, GitHub repo roles) into tokens121 workspaces.avatar, workspaces.base_permission, workspaces.team_creation,
122 workspaces.member_privileges, workspaces.require_two_factor,
123 workspace_members.billing_manager, workspace_members.security_manager
Workspace names and icons, and a component kit for every control124 FROM workspace_members
Billing accounts, terms and enterprises; g1t is no longer free125 JOIN workspaces ON workspaces.id = workspace_members.workspace_id
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member126 WHERE workspace_members.user_id = ? AND workspaces.deleted_at IS NULL
127 ORDER BY workspaces.slug",
Billing accounts, terms and enterprises; g1t is no longer free128 )
129 .bind(&[user_id.into()])?
130 .all()
131 .await?
Merge main (membership, two-factor, GitHub repo roles) into tokens132 .results::<MembershipRow>()?;
133 Ok(rows
134 .into_iter()
135 .map(|row| {
136 let mut org_roles = Vec::new();
137 if row.billing_manager != 0 {
138 org_roles.push(OrgRole::BillingManager);
139 }
140 if row.security_manager != 0 {
141 org_roles.push(OrgRole::SecurityManager);
142 }
143 let membership = Membership {
144 slug: row.slug,
145 role: row.role,
146 name: row.name,
147 avatar: row.avatar,
148 base_permission: row.base_permission.as_deref().and_then(BasePermission::parse),
149 team_creation: row.team_creation.as_deref().and_then(TeamCreation::parse),
150 org_roles,
151 privileges: Some(MemberPrivileges::from_stored(row.member_privileges.as_deref())),
152 };
153 (membership, row.require_two_factor != 0)
154 })
155 .collect())
Billing accounts, terms and enterprises; g1t is no longer free156 }
157
158 pub async fn create_workspace(&self, a: CreateWorkspaceArgs) -> Result<Outcome<Workspace>> {
159 if a.user.kind != PrincipalKind::User {
160 return Ok(Outcome::fail(
161 FailureCode::Forbidden,
162 "A workspace's access token cannot create workspaces. Sign in as a person.",
163 ));
164 }
165 if !a.user.verified {
166 return Ok(Outcome::fail(
167 FailureCode::Forbidden,
168 "Confirm your email address before creating a workspace.",
169 ));
170 }
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent171 let Some(slug) = claimable_namespace(&a.slug) else {
Billing accounts, terms and enterprises; g1t is no longer free172 return Ok(Outcome::fail(
173 FailureCode::Invalid,
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent174 "Workspace names use lowercase letters, digits and single hyphens, up to 39 characters, and cannot be a reserved word.",
Billing accounts, terms and enterprises; g1t is no longer free175 ));
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent176 };
Billing accounts, terms and enterprises; g1t is no longer free177 if self.memberships(&a.user.id).await?.len() >= MAX_WORKSPACES_PER_USER {
178 return Ok(Outcome::fail(
179 FailureCode::Conflict,
180 "You belong to the maximum number of workspaces.",
181 ));
182 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person183 // One free workspace per person (paid.rs): a new one starts free.
184 if let Some(refused) = self.second_free_workspace(&a.user.id).await? {
185 return Ok(refused);
186 }
Billing accounts, terms and enterprises; g1t is no longer free187 // Usernames and workspaces share one namespace: a person's username
188 // is theirs to use for a workspace, and nobody else's.
189 let someone_elses_username = self
190 .db
191 .prepare("SELECT id FROM users WHERE username = ? AND id != ?")
192 .bind(&[slug.as_str().into(), a.user.id.as_str().into()])?
193 .first::<serde_json::Value>(None)
194 .await?
195 .is_some();
196 if someone_elses_username
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member197 // A deleted workspace still holds its slug until it is purged.
198 || self.slug_in_use(&slug).await?
Agents and memory, checks and conflicts, profiles, slug renames, custom domains199 // A renamed workspace's old slug stays reserved for it a while.
200 || self.slug_held(&slug).await?
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look201 // A deleted workspace's slug is never given to anyone else; the
202 // person whose username it is may use it again.
203 || (self.slug_deleted(&slug).await?
204 && !crate::deletion::may_reclaim(&slug, &a.user.username))
Billing accounts, terms and enterprises; g1t is no longer free205 {
206 return Ok(Outcome::fail(
207 FailureCode::Conflict,
208 "That workspace name is taken.",
209 ));
210 }
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)211 let name = match a.name.trim() {
212 "" => slug.clone(),
213 name => name.chars().take(MAX_NAME_LENGTH).collect(),
214 };
@g1t welcomes you when you join a workspace, and that is the one introduction any agent makes. Identity now says when a person becomes a member, workspace.member_joined, by invitation, by an invite code, or by creating the workspace, and webhooks can hear it; the agents service hears it too, and for anyone who joined, never the creator, the workspace's @g1t opens your direct message and says in its own voice who it is, what to ask it, how agents answer, and where things are, once per person per workspace and charged to @g1t like any reply, with a fixed welcome when no model can be used. The agents, workspaces, authentication and webhooks guides say so.215 Ok(Outcome::Ok(self.insert_workspace(&a.user, slug, name).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)216 }
217
@g1t welcomes you when you join a workspace, and that is the one introduction any agent makes. Identity now says when a person becomes a member, workspace.member_joined, by invitation, by an invite code, or by creating the workspace, and webhooks can hear it; the agents service hears it too, and for anyone who joined, never the creator, the workspace's @g1t opens your direct message and says in its own voice who it is, what to ask it, how agents answer, and where things are, once per person per workspace and charged to @g1t like any reply, with a fixed welcome when no model can be used. The agents, workspaces, authentication and webhooks guides say so.218 /// Tells other services a person became a member of a workspace
219 /// (`workspace.member_joined`, with `how` saying by which door): the
220 /// agents service's @g1t welcomes them, once. Best effort, after the
221 /// membership is written; a failure is logged.
222 pub(crate) async fn announce_member_joined(&self, workspace_id: &str, slug: &str, user: &User, role: Role, how: JoinedHow) {
223 self.announce("workspace.member_joined", Some(&user.id), member_joined(workspace_id, slug, user, role, how))
224 .await;
225 }
226
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)227 /// A new account's own workspace, named for its username, on the free
228 /// plan as every new workspace is: so nobody is left without one. Made
229 /// only when the username is free to use as a workspace's name (it
230 /// usually is: usernames and workspaces share one namespace). None
231 /// when it is not, and the site asks the person to make one.
232 pub(crate) async fn create_own_workspace(&self, user: &User) -> Result<Option<Workspace>> {
233 let Some(slug) = own_workspace_slug(&user.username) else {
234 return Ok(None);
235 };
236 if self.slug_in_use(&slug).await?
237 || self.slug_held(&slug).await?
238 || (self.slug_deleted(&slug).await? && !crate::deletion::may_reclaim(&slug, &user.username))
239 {
240 return Ok(None);
241 }
@g1t welcomes you when you join a workspace, and that is the one introduction any agent makes. Identity now says when a person becomes a member, workspace.member_joined, by invitation, by an invite code, or by creating the workspace, and webhooks can hear it; the agents service hears it too, and for anyone who joined, never the creator, the workspace's @g1t opens your direct message and says in its own voice who it is, what to ask it, how agents answer, and where things are, once per person per workspace and charged to @g1t like any reply, with a fixed welcome when no model can be used. The agents, workspaces, authentication and webhooks guides say so.242 let made = self.insert_workspace(user, slug.clone(), slug).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)243 match made {
244 Ok(workspace) => Ok(Some(workspace)),
245 // Taken a moment ago: the person makes one themselves.
246 Err(error) if error.to_string().contains("UNIQUE") => Ok(None),
247 Err(error) => Err(error),
248 }
249 }
250
@g1t welcomes you when you join a workspace, and that is the one introduction any agent makes. Identity now says when a person becomes a member, workspace.member_joined, by invitation, by an invite code, or by creating the workspace, and webhooks can hear it; the agents service hears it too, and for anyone who joined, never the creator, the workspace's @g1t opens your direct message and says in its own voice who it is, what to ask it, how agents answer, and where things are, once per person per workspace and charged to @g1t like any reply, with a fixed welcome when no model can be used. The agents, workspaces, authentication and webhooks guides say so.251 /// Makes a workspace `user` owns, once every check has passed, and
252 /// says so (`workspace.member_joined` as `created`: its maker gets
253 /// no welcome).
254 async fn insert_workspace(&self, user: &User, slug: String, name: String) -> Result<Workspace> {
255 let user_id = user.id.as_str();
Billing accounts, terms and enterprises; g1t is no longer free256 let now = now_ms();
257 let workspace = Workspace {
258 id: new_id("wsp", now),
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)259 name,
Billing accounts, terms and enterprises; g1t is no longer free260 description: None,
261 slug,
262 created_at: rfc3339(now),
263 member_count: 1,
Workspace names and icons, and a component kit for every control264 avatar: None,
Merge main (membership, two-factor, GitHub repo roles) into tokens265 // Read, as on GitHub: an owner widens it on People.
266 base_permission: BasePermission::FOR_NEW_WORKSPACES,
Merge branch 'worktree-agent-ad7c6d88d93adc817'267 team_creation: TeamCreation::default(),
Merge main (membership, two-factor, GitHub repo roles) into tokens268 privileges: MemberPrivileges::default(),
269 two_factor_requirement_enabled: false,
Billing accounts, terms and enterprises; g1t is no longer free270 };
271 self.db
272 .batch(vec![
273 self.db
274 .prepare(
Merge main (membership, two-factor, GitHub repo roles) into tokens275 "INSERT INTO workspaces (id, slug, name, created_by, created_at, base_permission)
276 VALUES (?, ?, ?, ?, ?, ?)",
Billing accounts, terms and enterprises; g1t is no longer free277 )
278 .bind(&[
279 workspace.id.as_str().into(),
280 workspace.slug.as_str().into(),
281 workspace.name.as_str().into(),
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)282 user_id.into(),
Billing accounts, terms and enterprises; g1t is no longer free283 workspace.created_at.as_str().into(),
Merge main (membership, two-factor, GitHub repo roles) into tokens284 workspace.base_permission.as_str().into(),
Billing accounts, terms and enterprises; g1t is no longer free285 ])?,
286 self.db
287 .prepare(
288 "INSERT INTO workspace_members (workspace_id, user_id, role, created_at)
289 VALUES (?, ?, 'owner', ?)",
290 )
291 .bind(&[
292 workspace.id.as_str().into(),
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)293 user_id.into(),
Billing accounts, terms and enterprises; g1t is no longer free294 workspace.created_at.as_str().into(),
295 ])?,
296 ])
297 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look298 self.forget_deleted(&workspace.slug).await?;
@g1t welcomes you when you join a workspace, and that is the one introduction any agent makes. Identity now says when a person becomes a member, workspace.member_joined, by invitation, by an invite code, or by creating the workspace, and webhooks can hear it; the agents service hears it too, and for anyone who joined, never the creator, the workspace's @g1t opens your direct message and says in its own voice who it is, what to ask it, how agents answer, and where things are, once per person per workspace and charged to @g1t like any reply, with a fixed welcome when no model can be used. The agents, workspaces, authentication and webhooks guides say so.299 self.announce_member_joined(&workspace.id, &workspace.slug, user, Role::Owner, JoinedHow::Created).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)300 Ok(workspace)
Billing accounts, terms and enterprises; g1t is no longer free301 }
302
303 pub async fn get_workspace(&self, a: SlugArgs) -> Result<Option<Workspace>> {
304 Ok(self
305 .db
306 .prepare(format!(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member307 "SELECT {WORKSPACE_COLUMNS} FROM workspaces WHERE slug = ? AND deleted_at IS NULL"
Billing accounts, terms and enterprises; g1t is no longer free308 ))
309 .bind(&[a.slug.to_lowercase().into()])?
310 .first::<WorkspaceRow>(None)
311 .await?
312 .map(Workspace::from))
313 }
314
315 pub async fn update_workspace(&self, a: UpdateWorkspaceArgs) -> Result<Outcome<Workspace>> {
316 let slug = a.slug.to_lowercase();
317 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
318 return Ok(Outcome::fail(
319 FailureCode::Forbidden,
320 "Only an owner can change a workspace's details.",
321 ));
322 }
323 let name: String = match a.name.trim() {
324 "" => slug.clone(),
325 name => name.chars().take(MAX_NAME_LENGTH).collect(),
326 };
327 let description: String = a
328 .description
329 .trim()
330 .chars()
331 .take(MAX_DESCRIPTION_LENGTH)
332 .collect();
333 self.db
334 .prepare("UPDATE workspaces SET name = ?, description = ? WHERE slug = ?")
335 .bind(&[
336 name.into(),
337 if description.is_empty() {
338 worker::wasm_bindgen::JsValue::NULL
339 } else {
340 description.into()
341 },
342 slug.as_str().into(),
343 ])?
344 .run()
345 .await?;
346 Ok(match self.get_workspace(SlugArgs { slug }).await? {
347 Some(workspace) => Outcome::Ok(workspace),
348 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
349 })
350 }
351
352 pub async fn list_members(&self, a: ListMembersArgs) -> Result<Outcome<Vec<Member>>> {
353 let slug = a.slug.to_lowercase();
Merge main (membership, two-factor, GitHub repo roles) into tokens354 let Some(viewer) = a.viewer.filter(|viewer| viewer.is_member(&slug)) else {
Billing accounts, terms and enterprises; g1t is no longer free355 return Ok(Outcome::fail(
356 FailureCode::Forbidden,
357 "Only members can see who is in a workspace.",
358 ));
Merge main (membership, two-factor, GitHub repo roles) into tokens359 };
360 // Owners see who has two-factor authentication on, as they need to
361 // before requiring it.
362 let owner = viewer.role_in(&slug) == Some(Role::Owner);
363 #[derive(Deserialize)]
364 struct Row {
365 #[serde(flatten)]
366 member: crate::members::MemberRow,
367 #[serde(default)]
368 two_factor: u8,
Billing accounts, terms and enterprises; g1t is no longer free369 }
370 let rows = self
371 .db
372 .prepare(
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar373 "SELECT workspace_members.user_id, users.username, users.display_username, workspace_members.role, users.display_name AS name, users.avatar,
Merge main (membership, two-factor, GitHub repo roles) into tokens374 workspace_members.billing_manager, workspace_members.security_manager,
375 EXISTS (SELECT 1 FROM two_factor WHERE two_factor.user_id = users.id AND two_factor.enabled_at IS NOT NULL) AS two_factor
376 FROM workspace_members
Billing accounts, terms and enterprises; g1t is no longer free377 JOIN users ON users.id = workspace_members.user_id
378 JOIN workspaces ON workspaces.id = workspace_members.workspace_id
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member379 WHERE workspaces.slug = ? AND workspaces.deleted_at IS NULL
Billing accounts, terms and enterprises; g1t is no longer free380 ORDER BY workspace_members.role DESC, users.username",
381 )
382 .bind(&[slug.into()])?
383 .all()
384 .await?
Merge main (membership, two-factor, GitHub repo roles) into tokens385 .results::<Row>()?;
Billing accounts, terms and enterprises; g1t is no longer free386 Ok(Outcome::Ok(
387 rows.into_iter()
Merge main (membership, two-factor, GitHub repo roles) into tokens388 .map(|row| row.member.member(owner.then_some(row.two_factor != 0)))
Billing accounts, terms and enterprises; g1t is no longer free389 .collect(),
390 ))
391 }
392
393 /// The ids needed to change a workspace's members, if `actor` owns it
394 /// and `username` exists.
395 async fn member_target(&self, a: &MemberArgs) -> Result<Outcome<(String, User)>> {
396 let slug = a.slug.to_lowercase();
397 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
398 return Ok(Outcome::fail(
399 FailureCode::Forbidden,
400 "Only an owner can change a workspace's members.",
401 ));
402 }
403 let Some(workspace) = self.get_workspace(SlugArgs { slug }).await? else {
404 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
405 };
406 let Some(user) = self
407 .find_public_user(
408 "SELECT id, username, email_verified_at IS NOT NULL AS verified
Merge account deletion: soft delete for 30 days, staff restore and purge, ghost for what remains (identity 0037)409 FROM users WHERE username = ? AND deleted_at IS NULL",
Billing accounts, terms and enterprises; g1t is no longer free410 &a.username.trim().to_lowercase(),
411 )
412 .await?
413 else {
414 return Ok(Outcome::fail(
415 FailureCode::NotFound,
416 "There is no account with that username.",
417 ));
418 };
419 Ok(Outcome::Ok((workspace.id, user)))
420 }
421
422 pub async fn add_member(&self, a: MemberArgs) -> Result<Outcome<bool>> {
423 let (workspace_id, user) = match self.member_target(&a).await? {
424 Outcome::Ok(target) => target,
425 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
426 };
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)427 // A person is never added without saying yes: they get a workspace
428 // invitation to accept or decline (invites/invitations.rs). Only
429 // g1t's own agent is added at once.
430 if !crate::paid::is_g1t(&user.username) {
431 let invited = self
432 .invite_member(InviteMemberArgs {
433 actor: a.actor,
434 slug: a.slug,
435 email: String::new(),
436 username: Some(user.username),
437 role: None,
Workspace is the workspace's settings, in one place. Its sidebar is grouped, General, Access, Money, Compute, Code, Agents, Chat, Artifacts, Security and Integrations, every page one click away with no settings inside settings, the groups folding and the owner-only pages hidden from members; what is not here yet is marked Soon with a hint. Members is a list with search and filters for role, two-factor and team, and Invite opens a dialog: who, by username, name or email, their role and a note that goes into the invitation; nothing is filled in inline any more. Invitations is its own page with All, Pending, Accepted, Declined, Expired and Revoked filters that say how many, and each row's menu can copy the link, send it again or revoke it; identity learned to send an invitation again and to carry the note. Permissions gathers every rule about who may do what, by part: Code's base permission and member privileges, who creates teams, who creates channels, and, marked Soon with what applies today, forking private repositories, adding agents to conversations, messaging agents directly, creating spaces and default sharing, creating workspace agents and raising budgets, deploying to production and publishing packages. General lost what moved. The permissions guide is new, and the workspaces, people and teams, access, chat, teams, authentication and billing guides say where things are now.438 message: None,
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)439 surface: a.surface,
440 })
441 .await?;
442 return Ok(match invited {
443 Outcome::Ok(_) => Outcome::Ok(true),
444 Outcome::Fail(failure) => Outcome::Fail(failure),
445 });
446 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look447 // What the workspace asks of its members (security.rs); nothing yet.
448 if let Some(why) = self.policy_refusal(&user.id, &a.slug.to_lowercase()).await? {
449 return Ok(Outcome::fail(FailureCode::Forbidden, why));
450 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person451 // A free workspace adds no one until it starts the plan (paid.rs);
452 // g1t's agent is never someone added.
453 if !crate::paid::is_g1t(&user.username)
454 && let Some(refused) = self.free_workspace_refusal(&a.slug).await?
455 {
456 return Ok(refused);
457 }
Merge main (membership, two-factor, GitHub repo roles) into tokens458 let added = self
459 .db
Billing accounts, terms and enterprises; g1t is no longer free460 .prepare(
461 "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
Merge main (membership, two-factor, GitHub repo roles) into tokens462 VALUES (?, ?, 'member', ?) RETURNING user_id",
Billing accounts, terms and enterprises; g1t is no longer free463 )
464 .bind(&[
465 workspace_id.into(),
Merge main (membership, two-factor, GitHub repo roles) into tokens466 user.id.as_str().into(),
Billing accounts, terms and enterprises; g1t is no longer free467 rfc3339(now_ms()).into(),
468 ])?
Merge main (membership, two-factor, GitHub repo roles) into tokens469 .first::<serde_json::Value>(None)
470 .await?
471 .is_some();
472 if added {
473 self.audit_workspace(
474 &a.actor,
475 "member.added",
476 &a.slug.to_lowercase(),
477 a.surface.unwrap_or(Surface::Web),
478 format!("Added {} as a member", user.username),
479 )
480 .await;
481 }
Billing accounts, terms and enterprises; g1t is no longer free482 Ok(Outcome::Ok(true))
483 }
484
485 pub async fn remove_member(&self, a: MemberArgs) -> Result<Outcome<bool>> {
Merge main (membership, two-factor, GitHub repo roles) into tokens486 // Removing yourself is leaving, which anyone may do.
487 if a.username.trim().trim_start_matches('@').eq_ignore_ascii_case(&a.actor.username) {
488 return self
489 .leave_workspace(LeaveWorkspaceArgs { user: a.actor, slug: a.slug, surface: a.surface })
490 .await;
491 }
Billing accounts, terms and enterprises; g1t is no longer free492 let (workspace_id, user) = match self.member_target(&a).await? {
493 Outcome::Ok(target) => target,
494 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
495 };
Merge main (membership, two-factor, GitHub repo roles) into tokens496 let slug = a.slug.to_lowercase();
497 let Some(row) = self.member_row(&workspace_id, &user.username).await? else {
498 return Ok(Outcome::Ok(true));
499 };
500 if row.role == Role::Owner
501 && let Some(why) = last_owner_refusal(self.owner_count(&workspace_id).await?, true)
502 {
503 return Ok(Outcome::fail(FailureCode::Conflict, why));
Billing accounts, terms and enterprises; g1t is no longer free504 }
Merge main (membership, two-factor, GitHub repo roles) into tokens505 self.drop_member(&workspace_id, &user.id).await?;
506 self.audit_workspace(
507 &a.actor,
508 "member.removed",
509 &slug,
510 a.surface.unwrap_or(Surface::Web),
511 format!("Removed {} from the workspace", user.username),
512 )
513 .await;
Billing accounts, terms and enterprises; g1t is no longer free514 Ok(Outcome::Ok(true))
515 }
516}
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent517
@g1t welcomes you when you join a workspace, and that is the one introduction any agent makes. Identity now says when a person becomes a member, workspace.member_joined, by invitation, by an invite code, or by creating the workspace, and webhooks can hear it; the agents service hears it too, and for anyone who joined, never the creator, the workspace's @g1t opens your direct message and says in its own voice who it is, what to ask it, how agents answer, and where things are, once per person per workspace and charged to @g1t like any reply, with a fixed welcome when no model can be used. The agents, workspaces, authentication and webhooks guides say so.518/// The `workspace.member_joined` event for `user` becoming a member of the
519/// workspace `slug` with `role`, by the door `how`.
520pub(crate) fn member_joined(workspace_id: &str, slug: &str, user: &User, role: Role, how: JoinedHow) -> MemberJoined {
521 MemberJoined {
522 workspace_id: workspace_id.to_owned(),
523 workspace: slug.to_lowercase(),
524 user_id: user.id.clone(),
525 username: user.username.clone(),
526 role,
527 how,
528 }
529}
530
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent531#[cfg(test)]
532mod tests {
533 use super::*;
534
535 #[test]
@g1t welcomes you when you join a workspace, and that is the one introduction any agent makes. Identity now says when a person becomes a member, workspace.member_joined, by invitation, by an invite code, or by creating the workspace, and webhooks can hear it; the agents service hears it too, and for anyone who joined, never the creator, the workspace's @g1t opens your direct message and says in its own voice who it is, what to ask it, how agents answer, and where things are, once per person per workspace and charged to @g1t like any reply, with a fixed welcome when no model can be used. The agents, workspaces, authentication and webhooks guides say so.536 fn a_member_joining_names_the_person_the_workspace_their_role_and_the_door() {
537 let ada = User {
538 id: "usr_ada".into(),
539 username: "ada".into(),
540 ..User::default()
541 };
542 let joined = member_joined("wsp_1", "Acme", &ada, Role::Member, JoinedHow::Invitation);
543 assert_eq!(joined.workspace_id, "wsp_1");
544 assert_eq!(joined.workspace, "acme", "the slug as every service keys it");
545 assert_eq!(joined.user_id, "usr_ada");
546 assert_eq!(joined.username, "ada");
547 assert_eq!(joined.role, Role::Member);
548 assert_eq!(joined.how, JoinedHow::Invitation);
549 // The maker of a workspace is its first owner, and marked so: no welcome for them.
550 let made = member_joined("wsp_2", "ada", &ada, Role::Owner, JoinedHow::Created);
551 assert_eq!((made.role, made.how), (Role::Owner, JoinedHow::Created));
552 let data = serde_json::to_value(&made).unwrap();
553 assert_eq!(data["how"], "created");
554 assert_eq!(data["role"], "owner");
555 assert_eq!(data["workspaceId"], "wsp_2");
556 }
557
558 #[test]
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent559 fn no_workspace_is_created_with_g1ts_names() {
560 // What create_workspace takes the slug through, whatever its case.
561 for slug in ["g1t", "G1T", " g1t-agent ", "G1T-Agent"] {
562 assert_eq!(claimable_namespace(slug), None, "{slug}");
563 }
564 assert_eq!(claimable_namespace("Acme").as_deref(), Some("acme"));
565 }
566}

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