flagon-io/g1t

public

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t/services/identity/src/workspaces.rs

314 lines11,290 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
7use g1t_contracts::identity::*;
8use g1t_contracts::time::rfc3339;
Agents as a team: lifecycle, merge queue, billing and a new shell9use g1t_contracts::{
10 FailureCode, Membership, Outcome, PrincipalKind, Role, User, is_valid_namespace, new_id,
11};
Workspaces own repositories12use g1t_kit::now_ms;
13use serde::Deserialize;
14use worker::Result;
15
16use crate::Identity;
17
18/// Enough for a person and their teams; stops one account claiming names in
19/// bulk.
20const MAX_WORKSPACES_PER_USER: usize = 10;
21
Agents as a team: lifecycle, merge queue, billing and a new shell22const MAX_NAME_LENGTH: usize = 80;
23const MAX_DESCRIPTION_LENGTH: usize = 160;
24
Workspaces own repositories25const WORKSPACE_COLUMNS: &str = "workspaces.id, workspaces.slug, workspaces.name,
Workspace names and icons, and a component kit for every control26 workspaces.description, workspaces.avatar, workspaces.created_at,
Workspaces own repositories27 (SELECT count(*) FROM workspace_members
28 WHERE workspace_members.workspace_id = workspaces.id) AS member_count";
29
30#[derive(Deserialize)]
31struct WorkspaceRow {
32 id: String,
33 slug: String,
34 name: String,
Agents as a team: lifecycle, merge queue, billing and a new shell35 description: Option<String>,
Workspace names and icons, and a component kit for every control36 avatar: Option<String>,
Workspaces own repositories37 created_at: String,
38 member_count: u32,
39}
40
41impl From<WorkspaceRow> for Workspace {
42 fn from(row: WorkspaceRow) -> Self {
43 Workspace {
44 id: row.id,
45 slug: row.slug,
46 name: row.name,
Agents as a team: lifecycle, merge queue, billing and a new shell47 description: row.description,
Workspaces own repositories48 created_at: row.created_at,
49 member_count: row.member_count,
Workspace names and icons, and a component kit for every control50 avatar: row.avatar,
Workspaces own repositories51 }
52 }
53}
54
55#[derive(Deserialize)]
56struct MemberRow {
57 username: String,
58 role: Role,
59}
60
61impl Identity {
62 /// The workspaces a user belongs to, attached to every user resolved
Workspace names and icons, and a component kit for every control63 /// from credentials, with what the site needs to show each one.
Workspaces own repositories64 pub async fn memberships(&self, user_id: &str) -> Result<Vec<Membership>> {
65 self.db
66 .prepare(
Workspace names and icons, and a component kit for every control67 "SELECT workspaces.slug, workspace_members.role, workspaces.name,
68 workspaces.avatar
69 FROM workspace_members
Workspaces own repositories70 JOIN workspaces ON workspaces.id = workspace_members.workspace_id
71 WHERE workspace_members.user_id = ? ORDER BY workspaces.slug",
72 )
73 .bind(&[user_id.into()])?
74 .all()
75 .await?
76 .results::<Membership>()
77 }
78
79 pub async fn create_workspace(&self, a: CreateWorkspaceArgs) -> Result<Outcome<Workspace>> {
Agents as a team: lifecycle, merge queue, billing and a new shell80 if a.user.kind != PrincipalKind::User {
81 return Ok(Outcome::fail(
82 FailureCode::Forbidden,
83 "A workspace's access token cannot create workspaces. Sign in as a person.",
84 ));
85 }
Workspaces own repositories86 if !a.user.verified {
87 return Ok(Outcome::fail(
88 FailureCode::Forbidden,
89 "Confirm your email address before creating a workspace.",
90 ));
91 }
92 let slug = a.slug.trim().to_lowercase();
93 if !is_valid_namespace(&slug) {
94 return Ok(Outcome::fail(
95 FailureCode::Invalid,
96 "Workspace names use lowercase letters, digits and single hyphens, up to 39 characters.",
97 ));
98 }
99 if self.memberships(&a.user.id).await?.len() >= MAX_WORKSPACES_PER_USER {
100 return Ok(Outcome::fail(
101 FailureCode::Conflict,
102 "You belong to the maximum number of workspaces.",
103 ));
104 }
Agents as a team: lifecycle, merge queue, billing and a new shell105 // Usernames and workspaces share one namespace: a person's username
106 // is theirs to use for a workspace, and nobody else's.
107 let someone_elses_username = self
108 .db
109 .prepare("SELECT id FROM users WHERE username = ? AND id != ?")
110 .bind(&[slug.as_str().into(), a.user.id.as_str().into()])?
111 .first::<serde_json::Value>(None)
Workspaces own repositories112 .await?
Agents as a team: lifecycle, merge queue, billing and a new shell113 .is_some();
114 if someone_elses_username
115 || self
116 .get_workspace(SlugArgs { slug: slug.clone() })
117 .await?
118 .is_some()
Workspaces own repositories119 {
120 return Ok(Outcome::fail(
121 FailureCode::Conflict,
122 "That workspace name is taken.",
123 ));
124 }
125 let now = now_ms();
126 let workspace = Workspace {
127 id: new_id("wsp", now),
128 name: match a.name.trim() {
129 "" => slug.clone(),
Agents as a team: lifecycle, merge queue, billing and a new shell130 name => name.chars().take(MAX_NAME_LENGTH).collect(),
Workspaces own repositories131 },
Agents as a team: lifecycle, merge queue, billing and a new shell132 description: None,
Workspaces own repositories133 slug,
134 created_at: rfc3339(now),
135 member_count: 1,
Workspace names and icons, and a component kit for every control136 avatar: None,
Workspaces own repositories137 };
138 self.db
139 .batch(vec![
140 self.db
141 .prepare(
142 "INSERT INTO workspaces (id, slug, name, created_by, created_at)
143 VALUES (?, ?, ?, ?, ?)",
144 )
145 .bind(&[
146 workspace.id.as_str().into(),
147 workspace.slug.as_str().into(),
148 workspace.name.as_str().into(),
149 a.user.id.as_str().into(),
150 workspace.created_at.as_str().into(),
151 ])?,
152 self.db
153 .prepare(
154 "INSERT INTO workspace_members (workspace_id, user_id, role, created_at)
155 VALUES (?, ?, 'owner', ?)",
156 )
157 .bind(&[
158 workspace.id.as_str().into(),
159 a.user.id.as_str().into(),
160 workspace.created_at.as_str().into(),
161 ])?,
162 ])
163 .await?;
164 Ok(Outcome::Ok(workspace))
165 }
166
167 pub async fn get_workspace(&self, a: SlugArgs) -> Result<Option<Workspace>> {
168 Ok(self
169 .db
170 .prepare(format!(
171 "SELECT {WORKSPACE_COLUMNS} FROM workspaces WHERE slug = ?"
172 ))
173 .bind(&[a.slug.to_lowercase().into()])?
174 .first::<WorkspaceRow>(None)
175 .await?
176 .map(Workspace::from))
177 }
178
Agents as a team: lifecycle, merge queue, billing and a new shell179 pub async fn update_workspace(&self, a: UpdateWorkspaceArgs) -> Result<Outcome<Workspace>> {
180 let slug = a.slug.to_lowercase();
181 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
182 return Ok(Outcome::fail(
183 FailureCode::Forbidden,
184 "Only an owner can change a workspace's details.",
185 ));
186 }
187 let name: String = match a.name.trim() {
188 "" => slug.clone(),
189 name => name.chars().take(MAX_NAME_LENGTH).collect(),
190 };
191 let description: String = a
192 .description
193 .trim()
194 .chars()
195 .take(MAX_DESCRIPTION_LENGTH)
196 .collect();
197 self.db
198 .prepare("UPDATE workspaces SET name = ?, description = ? WHERE slug = ?")
199 .bind(&[
200 name.into(),
201 if description.is_empty() {
202 worker::wasm_bindgen::JsValue::NULL
203 } else {
204 description.into()
205 },
206 slug.as_str().into(),
207 ])?
208 .run()
209 .await?;
210 Ok(match self.get_workspace(SlugArgs { slug }).await? {
211 Some(workspace) => Outcome::Ok(workspace),
212 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
213 })
214 }
215
Workspaces own repositories216 pub async fn list_members(&self, a: ListMembersArgs) -> Result<Outcome<Vec<Member>>> {
217 let slug = a.slug.to_lowercase();
218 if !a.viewer.is_some_and(|viewer| viewer.is_member(&slug)) {
219 return Ok(Outcome::fail(
220 FailureCode::Forbidden,
221 "Only members can see who is in a workspace.",
222 ));
223 }
224 let rows = self
225 .db
226 .prepare(
227 "SELECT users.username, workspace_members.role FROM workspace_members
228 JOIN users ON users.id = workspace_members.user_id
229 JOIN workspaces ON workspaces.id = workspace_members.workspace_id
230 WHERE workspaces.slug = ?
231 ORDER BY workspace_members.role DESC, users.username",
232 )
233 .bind(&[slug.into()])?
234 .all()
235 .await?
236 .results::<MemberRow>()?;
237 Ok(Outcome::Ok(
238 rows.into_iter()
239 .map(|row| Member {
240 username: row.username,
241 role: row.role,
242 })
243 .collect(),
244 ))
245 }
246
247 /// The ids needed to change a workspace's members, if `actor` owns it
248 /// and `username` exists.
249 async fn member_target(&self, a: &MemberArgs) -> Result<Outcome<(String, User)>> {
250 let slug = a.slug.to_lowercase();
Agents as a team: lifecycle, merge queue, billing and a new shell251 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&slug) != Some(Role::Owner) {
Workspaces own repositories252 return Ok(Outcome::fail(
253 FailureCode::Forbidden,
254 "Only an owner can change a workspace's members.",
255 ));
256 }
257 let Some(workspace) = self.get_workspace(SlugArgs { slug }).await? else {
258 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
259 };
260 let Some(user) = self
261 .find_public_user(
262 "SELECT id, username, email_verified_at IS NOT NULL AS verified
263 FROM users WHERE username = ?",
264 &a.username.trim().to_lowercase(),
265 )
266 .await?
267 else {
268 return Ok(Outcome::fail(
269 FailureCode::NotFound,
270 "There is no account with that username.",
271 ));
272 };
273 Ok(Outcome::Ok((workspace.id, user)))
274 }
275
276 pub async fn add_member(&self, a: MemberArgs) -> Result<Outcome<bool>> {
277 let (workspace_id, user) = match self.member_target(&a).await? {
278 Outcome::Ok(target) => target,
279 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
280 };
281 self.db
282 .prepare(
283 "INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
284 VALUES (?, ?, 'member', ?)",
285 )
286 .bind(&[
287 workspace_id.into(),
288 user.id.into(),
289 rfc3339(now_ms()).into(),
290 ])?
291 .run()
292 .await?;
293 Ok(Outcome::Ok(true))
294 }
295
296 pub async fn remove_member(&self, a: MemberArgs) -> Result<Outcome<bool>> {
297 let (workspace_id, user) = match self.member_target(&a).await? {
298 Outcome::Ok(target) => target,
299 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
300 };
301 if user.id == a.actor.id {
302 return Ok(Outcome::fail(
303 FailureCode::Conflict,
304 "An owner cannot remove themselves.",
305 ));
306 }
307 self.db
308 .prepare("DELETE FROM workspace_members WHERE workspace_id = ? AND user_id = ?")
309 .bind(&[workspace_id.into(), user.id.into()])?
310 .run()
311 .await?;
312 Ok(Outcome::Ok(true))
313 }
314}