Skip to content

g1t/services/identity/src/workspaces.rs

391 lines15,008 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.

Workspaces own repositories1//! 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;
Workspaces own repositories8use g1t_contracts::identity::*;
Merge branch 'worktree-agent-ad7c6d88d93adc817'9use g1t_contracts::teams::TeamCreation;
Workspaces own repositories10use g1t_contracts::time::rfc3339;
Agents as a team: lifecycle, merge queue, billing and a new shell11use g1t_contracts::{
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent12 FailureCode, Membership, Outcome, PrincipalKind, Role, User, claimable_namespace, new_id,
Agents as a team: lifecycle, merge queue, billing and a new shell13};
Workspaces own repositories14use g1t_kit::now_ms;
15use serde::Deserialize;
16use worker::Result;
17
18use crate::Identity;
19
20/// Enough for a person and their teams; stops one account claiming names in
21/// bulk.
22const MAX_WORKSPACES_PER_USER: usize = 10;
23
Agents as a team: lifecycle, merge queue, billing and a new shell24const MAX_NAME_LENGTH: usize = 80;
25const MAX_DESCRIPTION_LENGTH: usize = 160;
26
Workspaces own repositories27const WORKSPACE_COLUMNS: &str = "workspaces.id, workspaces.slug, workspaces.name,
Merge branch 'worktree-agent-ad7c6d88d93adc817'28 workspaces.description, workspaces.avatar, workspaces.created_at, workspaces.base_permission, workspaces.team_creation,
Workspaces own repositories29 (SELECT count(*) FROM workspace_members
30 WHERE workspace_members.workspace_id = workspaces.id) AS member_count";
31
32#[derive(Deserialize)]
33struct WorkspaceRow {
34 id: String,
35 slug: String,
36 name: String,
Agents as a team: lifecycle, merge queue, billing and a new shell37 description: Option<String>,
Workspace names and icons, and a component kit for every control38 avatar: Option<String>,
Workspaces own repositories39 created_at: String,
40 member_count: u32,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look41 #[serde(default)]
42 base_permission: Option<String>,
Merge branch 'worktree-agent-ad7c6d88d93adc817'43 #[serde(default)]
44 team_creation: Option<String>,
Workspaces own repositories45}
46
47impl From<WorkspaceRow> for Workspace {
48 fn from(row: WorkspaceRow) -> Self {
49 Workspace {
50 id: row.id,
51 slug: row.slug,
52 name: row.name,
Agents as a team: lifecycle, merge queue, billing and a new shell53 description: row.description,
Workspaces own repositories54 created_at: row.created_at,
55 member_count: row.member_count,
Workspace names and icons, and a component kit for every control56 avatar: row.avatar,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look57 base_permission: row
58 .base_permission
59 .as_deref()
60 .and_then(BasePermission::parse)
61 .unwrap_or_default(),
Merge branch 'worktree-agent-ad7c6d88d93adc817'62 team_creation: row
63 .team_creation
64 .as_deref()
65 .and_then(TeamCreation::parse)
66 .unwrap_or_default(),
Workspaces own repositories67 }
68 }
69}
70
71#[derive(Deserialize)]
72struct MemberRow {
73 username: String,
74 role: Role,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look75 #[serde(default)]
76 name: Option<String>,
77 #[serde(default)]
78 avatar: Option<String>,
Workspaces own repositories79}
80
81impl Identity {
82 /// The workspaces a user belongs to, attached to every user resolved
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look83 /// from credentials, with what the site needs to show each one and
84 /// what members get on its repositories (access.rs).
Workspaces own repositories85 pub async fn memberships(&self, user_id: &str) -> Result<Vec<Membership>> {
86 self.db
87 .prepare(
Workspace names and icons, and a component kit for every control88 "SELECT workspaces.slug, workspace_members.role, workspaces.name,
Merge branch 'worktree-agent-ad7c6d88d93adc817'89 workspaces.avatar, workspaces.base_permission, workspaces.team_creation
Workspace names and icons, and a component kit for every control90 FROM workspace_members
Workspaces own repositories91 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 member92 WHERE workspace_members.user_id = ? AND workspaces.deleted_at IS NULL
93 ORDER BY workspaces.slug",
Workspaces own repositories94 )
95 .bind(&[user_id.into()])?
96 .all()
97 .await?
98 .results::<Membership>()
99 }
100
101 pub async fn create_workspace(&self, a: CreateWorkspaceArgs) -> Result<Outcome<Workspace>> {
Agents as a team: lifecycle, merge queue, billing and a new shell102 if a.user.kind != PrincipalKind::User {
103 return Ok(Outcome::fail(
104 FailureCode::Forbidden,
105 "A workspace's access token cannot create workspaces. Sign in as a person.",
106 ));
107 }
Workspaces own repositories108 if !a.user.verified {
109 return Ok(Outcome::fail(
110 FailureCode::Forbidden,
111 "Confirm your email address before creating a workspace.",
112 ));
113 }
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent114 let Some(slug) = claimable_namespace(&a.slug) else {
Workspaces own repositories115 return Ok(Outcome::fail(
116 FailureCode::Invalid,
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent117 "Workspace names use lowercase letters, digits and single hyphens, up to 39 characters, and cannot be a reserved word.",
Workspaces own repositories118 ));
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent119 };
Workspaces own repositories120 if self.memberships(&a.user.id).await?.len() >= MAX_WORKSPACES_PER_USER {
121 return Ok(Outcome::fail(
122 FailureCode::Conflict,
123 "You belong to the maximum number of workspaces.",
124 ));
125 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person126 // One free workspace per person (paid.rs): a new one starts free.
127 if let Some(refused) = self.second_free_workspace(&a.user.id).await? {
128 return Ok(refused);
129 }
Agents as a team: lifecycle, merge queue, billing and a new shell130 // Usernames and workspaces share one namespace: a person's username
131 // is theirs to use for a workspace, and nobody else's.
132 let someone_elses_username = self
133 .db
134 .prepare("SELECT id FROM users WHERE username = ? AND id != ?")
135 .bind(&[slug.as_str().into(), a.user.id.as_str().into()])?
136 .first::<serde_json::Value>(None)
Workspaces own repositories137 .await?
Agents as a team: lifecycle, merge queue, billing and a new shell138 .is_some();
139 if someone_elses_username
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member140 // A deleted workspace still holds its slug until it is purged.
141 || self.slug_in_use(&slug).await?
Agents and memory, checks and conflicts, profiles, slug renames, custom domains142 // A renamed workspace's old slug stays reserved for it a while.
143 || self.slug_held(&slug).await?
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look144 // A deleted workspace's slug is never given to anyone else; the
145 // person whose username it is may use it again.
146 || (self.slug_deleted(&slug).await?
147 && !crate::deletion::may_reclaim(&slug, &a.user.username))
Workspaces own repositories148 {
149 return Ok(Outcome::fail(
150 FailureCode::Conflict,
151 "That workspace name is taken.",
152 ));
153 }
154 let now = now_ms();
155 let workspace = Workspace {
156 id: new_id("wsp", now),
157 name: match a.name.trim() {
158 "" => slug.clone(),
Agents as a team: lifecycle, merge queue, billing and a new shell159 name => name.chars().take(MAX_NAME_LENGTH).collect(),
Workspaces own repositories160 },
Agents as a team: lifecycle, merge queue, billing and a new shell161 description: None,
Workspaces own repositories162 slug,
163 created_at: rfc3339(now),
164 member_count: 1,
Workspace names and icons, and a component kit for every control165 avatar: None,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look166 base_permission: BasePermission::default(),
Merge branch 'worktree-agent-ad7c6d88d93adc817'167 team_creation: TeamCreation::default(),
Workspaces own repositories168 };
169 self.db
170 .batch(vec![
171 self.db
172 .prepare(
173 "INSERT INTO workspaces (id, slug, name, created_by, created_at)
174 VALUES (?, ?, ?, ?, ?)",
175 )
176 .bind(&[
177 workspace.id.as_str().into(),
178 workspace.slug.as_str().into(),
179 workspace.name.as_str().into(),
180 a.user.id.as_str().into(),
181 workspace.created_at.as_str().into(),
182 ])?,
183 self.db
184 .prepare(
185 "INSERT INTO workspace_members (workspace_id, user_id, role, created_at)
186 VALUES (?, ?, 'owner', ?)",
187 )
188 .bind(&[
189 workspace.id.as_str().into(),
190 a.user.id.as_str().into(),
191 workspace.created_at.as_str().into(),
192 ])?,
193 ])
194 .await?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look195 self.forget_deleted(&workspace.slug).await?;
Workspaces own repositories196 Ok(Outcome::Ok(workspace))
197 }
198
199 pub async fn get_workspace(&self, a: SlugArgs) -> Result<Option<Workspace>> {
200 Ok(self
201 .db
202 .prepare(format!(
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member203 "SELECT {WORKSPACE_COLUMNS} FROM workspaces WHERE slug = ? AND deleted_at IS NULL"
Workspaces own repositories204 ))
205 .bind(&[a.slug.to_lowercase().into()])?
206 .first::<WorkspaceRow>(None)
207 .await?
208 .map(Workspace::from))
209 }
210
Agents as a team: lifecycle, merge queue, billing and a new shell211 pub async fn update_workspace(&self, a: UpdateWorkspaceArgs) -> Result<Outcome<Workspace>> {
212 let slug = a.slug.to_lowercase();
213 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
214 return Ok(Outcome::fail(
215 FailureCode::Forbidden,
216 "Only an owner can change a workspace's details.",
217 ));
218 }
219 let name: String = match a.name.trim() {
220 "" => slug.clone(),
221 name => name.chars().take(MAX_NAME_LENGTH).collect(),
222 };
223 let description: String = a
224 .description
225 .trim()
226 .chars()
227 .take(MAX_DESCRIPTION_LENGTH)
228 .collect();
229 self.db
230 .prepare("UPDATE workspaces SET name = ?, description = ? WHERE slug = ?")
231 .bind(&[
232 name.into(),
233 if description.is_empty() {
234 worker::wasm_bindgen::JsValue::NULL
235 } else {
236 description.into()
237 },
238 slug.as_str().into(),
239 ])?
240 .run()
241 .await?;
242 Ok(match self.get_workspace(SlugArgs { slug }).await? {
243 Some(workspace) => Outcome::Ok(workspace),
244 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
245 })
246 }
247
Workspaces own repositories248 pub async fn list_members(&self, a: ListMembersArgs) -> Result<Outcome<Vec<Member>>> {
249 let slug = a.slug.to_lowercase();
250 if !a.viewer.is_some_and(|viewer| viewer.is_member(&slug)) {
251 return Ok(Outcome::fail(
252 FailureCode::Forbidden,
253 "Only members can see who is in a workspace.",
254 ));
255 }
256 let rows = self
257 .db
258 .prepare(
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look259 "SELECT users.username, workspace_members.role, users.display_name AS name, users.avatar FROM workspace_members
Workspaces own repositories260 JOIN users ON users.id = workspace_members.user_id
261 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 member262 WHERE workspaces.slug = ? AND workspaces.deleted_at IS NULL
Workspaces own repositories263 ORDER BY workspace_members.role DESC, users.username",
264 )
265 .bind(&[slug.into()])?
266 .all()
267 .await?
268 .results::<MemberRow>()?;
269 Ok(Outcome::Ok(
270 rows.into_iter()
271 .map(|row| Member {
272 username: row.username,
273 role: row.role,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look274 name: row.name,
275 avatar: row.avatar,
Workspaces own repositories276 })
277 .collect(),
278 ))
279 }
280
281 /// The ids needed to change a workspace's members, if `actor` owns it
282 /// and `username` exists.
283 async fn member_target(&self, a: &MemberArgs) -> Result<Outcome<(String, User)>> {
284 let slug = a.slug.to_lowercase();
Agents as a team: lifecycle, merge queue, billing and a new shell285 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
Workspaces own repositories286 return Ok(Outcome::fail(
287 FailureCode::Forbidden,
288 "Only an owner can change a workspace's members.",
289 ));
290 }
291 let Some(workspace) = self.get_workspace(SlugArgs { slug }).await? else {
292 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
293 };
294 let Some(user) = self
295 .find_public_user(
296 "SELECT id, username, email_verified_at IS NOT NULL AS verified
297 FROM users WHERE username = ?",
298 &a.username.trim().to_lowercase(),
299 )
300 .await?
301 else {
302 return Ok(Outcome::fail(
303 FailureCode::NotFound,
304 "There is no account with that username.",
305 ));
306 };
307 Ok(Outcome::Ok((workspace.id, user)))
308 }
309
310 pub async fn add_member(&self, a: MemberArgs) -> Result<Outcome<bool>> {
311 let (workspace_id, user) = match self.member_target(&a).await? {
312 Outcome::Ok(target) => target,
313 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
314 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look315 // What the workspace asks of its members (security.rs); nothing yet.
316 if let Some(why) = self.policy_refusal(&user.id, &a.slug.to_lowercase()).await? {
317 return Ok(Outcome::fail(FailureCode::Forbidden, why));
318 }
Merge Stripe Tax, the card fee on card payments, and one free workspace per person319 // A free workspace adds no one until it starts the plan (paid.rs);
320 // g1t's agent is never someone added.
321 if !crate::paid::is_g1t(&user.username)
322 && let Some(refused) = self.free_workspace_refusal(&a.slug).await?
323 {
324 return Ok(refused);
325 }
Workspaces own repositories326 self.db
327 .prepare(
328 "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
329 VALUES (?, ?, 'member', ?)",
330 )
331 .bind(&[
332 workspace_id.into(),
333 user.id.into(),
334 rfc3339(now_ms()).into(),
335 ])?
336 .run()
337 .await?;
338 Ok(Outcome::Ok(true))
339 }
340
341 pub async fn remove_member(&self, a: MemberArgs) -> Result<Outcome<bool>> {
342 let (workspace_id, user) = match self.member_target(&a).await? {
343 Outcome::Ok(target) => target,
344 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
345 };
346 if user.id == a.actor.id {
347 return Ok(Outcome::fail(
348 FailureCode::Conflict,
349 "An owner cannot remove themselves.",
350 ));
351 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look352 // Leaving a workspace takes away every way into it: the person's
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar353 // roles on its repositories go too (access.rs), and their place in
354 // its teams (teams.rs). To keep someone on a repository, add them
355 // to it again as an outside collaborator.
Workspaces own repositories356 self.db
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look357 .batch(vec![
358 self.db
359 .prepare("DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?")
360 .bind(&[workspace_id.as_str().into(), user.id.as_str().into()])?,
361 self.db
362 .prepare(
363 "DELETE FROM repo_grants
364 WHERE workspace_id = ? AND principal_kind = 'user' AND principal_id = ?",
365 )
366 .bind(&[workspace_id.as_str().into(), user.id.as_str().into()])?,
Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar367 self.db
368 .prepare(
369 "DELETE FROM team_members
370 WHERE team_id IN (SELECT id FROM teams WHERE workspace_id = ?) AND user_id = ?",
371 )
372 .bind(&[workspace_id.as_str().into(), user.id.as_str().into()])?,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look373 ])
Workspaces own repositories374 .await?;
375 Ok(Outcome::Ok(true))
376 }
377}
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent378
379#[cfg(test)]
380mod tests {
381 use super::*;
382
383 #[test]
384 fn no_workspace_is_created_with_g1ts_names() {
385 // What create_workspace takes the slug through, whatever its case.
386 for slug in ["g1t", "G1T", " g1t-agent ", "G1T-Agent"] {
387 assert_eq!(claimable_namespace(slug), None, "{slug}");
388 }
389 assert_eq!(claimable_namespace("Acme").as_deref(), Some("acme"));
390 }
391}

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