flagon-io/g1t

public

Git for AI scale: a forge for thousands of agents working on the same code at once.

g1t/services/identity/src/rename.rs

443 lines17,207 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.

Agents and memory, checks and conflicts, profiles, slug renames, custom domains1//! Renaming a workspace: changing its slug, the first segment of its URLs,
2//! the way GitHub renames an organization.
3//!
4//! The workspace keeps its id, members, tokens and display name. Its old
5//! slug is recorded in `workspace_redirects`, pointing at the workspace's
6//! id, so that old addresses resolve to whatever the slug is now: renaming
7//! twice chains, because every old slug points at the same id. An old slug
8//! stays reserved for the workspace that had it for [`SLUG_HOLD_DAYS`], so
9//! nobody else can take it while links to it still redirect; the workspace
10//! itself can rename back to it. Renames are limited to one per
11//! [`RENAME_COOLDOWN_HOURS`] to stop churn.
12//!
13//! The rename publishes `workspace.renamed`; every other service moves the
14//! rows it keeps under the slug when it hears it.
15
16use g1t_contracts::events::{NewEvent, Publish, WorkspaceRenamed};
17use g1t_contracts::identity::*;
18use g1t_contracts::time::rfc3339;
19use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, is_valid_namespace};
20use g1t_kit::now_ms;
21use serde::Deserialize;
22use worker::Result;
23
24use crate::Identity;
25
26const HOUR_MS: u64 = 60 * 60 * 1000;
27const DAY_MS: u64 = 24 * HOUR_MS;
28const SOURCE: &str = "identity";
29const TAKEN: &str = "That workspace name is taken.";
30/// How many times publishing the event is tried before giving up.
31const PUBLISH_ATTEMPTS: u32 = 3;
32
33/// The earliest `created_at` of a redirect that still holds its slug.
34pub fn hold_cutoff(now_ms: u64) -> String {
35 rfc3339(now_ms.saturating_sub(SLUG_HOLD_DAYS * DAY_MS))
36}
37
38/// Everything about a wanted slug that decides whether a workspace may
39/// take it, as read from the database.
40#[derive(Debug, Default)]
41pub struct Facts<'a> {
42 /// The workspace's id and its slug now.
43 pub workspace_id: &'a str,
44 pub current: &'a str,
45 /// The slug asked for, lowercased and trimmed.
46 pub wanted: &'a str,
47 /// Another person's username is `wanted`. The actor's own is theirs
48 /// to use, as when creating a workspace.
49 pub someone_elses_username: bool,
50 /// Another workspace's slug is `wanted`.
51 pub another_workspace: bool,
52 /// A redirect holds `wanted`: the workspace it points at, and when it
53 /// was made.
54 pub redirect: Option<(&'a str, &'a str)>,
55 /// When the workspace was last renamed, if ever.
56 pub last_renamed_at: Option<&'a str>,
57 pub now_ms: u64,
58}
59
60/// Whether the rename `facts` describe is allowed: `Ok`, or why not, in
61/// words for the owner.
62pub fn check(facts: &Facts) -> std::result::Result<(), (FailureCode, String)> {
63 let refuse = |code, message: &str| Err((code, message.to_owned()));
64 if !is_valid_namespace(facts.wanted) {
65 return refuse(
66 FailureCode::Invalid,
67 "Workspace names use lowercase letters, digits and single hyphens, up to 39 characters, and cannot be a reserved word.",
68 );
69 }
70 if facts.wanted == facts.current {
71 return refuse(FailureCode::Invalid, "That is already this workspace's name.");
72 }
73 if let Some(last) = facts.last_renamed_at {
74 let cooldown_from = rfc3339(facts.now_ms.saturating_sub(RENAME_COOLDOWN_HOURS * HOUR_MS));
75 if last > cooldown_from.as_str() {
76 return refuse(
77 FailureCode::Conflict,
78 "A workspace can be renamed once a day. Try again tomorrow.",
79 );
80 }
81 }
82 if facts.someone_elses_username || facts.another_workspace {
83 return refuse(FailureCode::Conflict, TAKEN);
84 }
85 if let Some((holder, created_at)) = facts.redirect
86 && holder != facts.workspace_id
87 && created_at >= hold_cutoff(facts.now_ms).as_str()
88 {
89 return refuse(FailureCode::Conflict, TAKEN);
90 }
91 Ok(())
92}
93
94/// A redirect as read with the slug its workspace has now.
95#[derive(Debug, Deserialize)]
96pub struct RedirectRow {
97 pub workspace_id: String,
98 /// The workspace's slug now.
99 pub slug: String,
100 pub created_at: String,
101}
102
103/// Where an old slug leads: the workspace's current slug, while the
104/// redirect still holds.
105pub fn resolve(row: Option<RedirectRow>, now_ms: u64) -> Option<String> {
106 row.filter(|row| row.created_at >= hold_cutoff(now_ms))
107 .map(|row| row.slug)
108}
109
110#[derive(Deserialize)]
111struct Target {
112 id: String,
113}
114
115impl Identity {
116 /// The redirect holding `slug`, if any, with its workspace's slug now.
117 async fn redirect(&self, slug: &str) -> Result<Option<RedirectRow>> {
118 self.db
119 .prepare(
120 "SELECT workspace_redirects.workspace_id, workspaces.slug,
121 workspace_redirects.created_at
122 FROM workspace_redirects
123 JOIN workspaces ON workspaces.id = workspace_redirects.workspace_id
124 WHERE workspace_redirects.old_slug = ?",
125 )
126 .bind(&[slug.into()])?
127 .first::<RedirectRow>(None)
128 .await
129 }
130
131 /// Whether `slug` is an old slug still reserved for the workspace that
132 /// had it, so nobody else may register or create it.
133 pub async fn slug_held(&self, slug: &str) -> Result<bool> {
134 Ok(resolve(self.redirect(slug).await?, now_ms()).is_some())
135 }
136
137 /// `resolve_slug`: the current slug for an old one still redirecting.
138 pub async fn resolve_slug(&self, a: SlugArgs) -> Result<Option<String>> {
139 let slug = a.slug.trim().to_lowercase();
140 if self.get_workspace(SlugArgs { slug: slug.clone() }).await?.is_some() {
141 return Ok(None);
142 }
143 Ok(resolve(self.redirect(&slug).await?, now_ms()))
144 }
145
146 /// Checks a rename, returning the workspace's id when it is allowed.
147 async fn rename_allowed(&self, a: &RenameWorkspaceArgs) -> Result<Outcome<(String, String)>> {
148 let current = a.slug.trim().to_lowercase();
149 let wanted = a.new_slug.trim().to_lowercase();
150 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&current) != Some(Role::Owner) {
151 return Ok(Outcome::fail(
152 FailureCode::Forbidden,
153 "Only an owner can rename a workspace.",
154 ));
155 }
156 if !a.actor.verified {
157 return Ok(Outcome::fail(
158 FailureCode::Forbidden,
159 "Confirm your email address before renaming a workspace.",
160 ));
161 }
162 let Some(workspace) = self
163 .db
164 .prepare("SELECT id FROM workspaces WHERE slug = ?")
165 .bind(&[current.as_str().into()])?
166 .first::<Target>(None)
167 .await?
168 else {
169 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
170 };
171 let someone_elses_username = self
172 .db
173 .prepare("SELECT id FROM users WHERE username = ? AND id != ?")
174 .bind(&[wanted.as_str().into(), a.actor.id.as_str().into()])?
175 .first::<serde_json::Value>(None)
176 .await?
177 .is_some();
178 let another_workspace = self
179 .db
180 .prepare("SELECT id FROM workspaces WHERE slug = ? AND id != ?")
181 .bind(&[wanted.as_str().into(), workspace.id.as_str().into()])?
182 .first::<serde_json::Value>(None)
183 .await?
184 .is_some();
185 let redirect = self.redirect(&wanted).await?;
186 let last_renamed_at = self
187 .db
188 .prepare("SELECT max(created_at) AS at FROM workspace_redirects WHERE workspace_id = ?")
189 .bind(&[workspace.id.as_str().into()])?
190 .first::<Option<String>>(Some("at"))
191 .await?
192 .flatten();
193 let facts = Facts {
194 workspace_id: &workspace.id,
195 current: &current,
196 wanted: &wanted,
197 someone_elses_username,
198 another_workspace,
199 redirect: redirect
200 .as_ref()
201 .map(|row| (row.workspace_id.as_str(), row.created_at.as_str())),
202 last_renamed_at: last_renamed_at.as_deref(),
203 now_ms: now_ms(),
204 };
205 Ok(match check(&facts) {
206 Ok(()) => Outcome::Ok((workspace.id, wanted)),
207 Err((code, message)) => Outcome::fail(code, message),
208 })
209 }
210
211 pub async fn check_workspace_rename(&self, a: RenameWorkspaceArgs) -> Result<Outcome<bool>> {
212 Ok(match self.rename_allowed(&a).await? {
213 Outcome::Ok(_) => Outcome::Ok(true),
214 Outcome::Fail(failure) => Outcome::Fail(failure),
215 })
216 }
217
218 pub async fn rename_workspace(&self, a: RenameWorkspaceArgs) -> Result<Outcome<Workspace>> {
219 let (workspace_id, wanted) = match self.rename_allowed(&a).await? {
220 Outcome::Ok(allowed) => allowed,
221 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
222 };
223 let from = a.slug.trim().to_lowercase();
224 let now = rfc3339(now_ms());
225 self.db
226 .batch(vec![
227 // A redirect the workspace is renaming back to, or one whose
228 // hold has ended, gives way to the slug in use.
229 self.db
230 .prepare("DELETE FROM workspace_redirects WHERE old_slug = ?")
231 .bind(&[wanted.as_str().into()])?,
232 self.db
233 .prepare("UPDATE workspaces SET slug = ? WHERE id = ? AND slug = ?")
234 .bind(&[
235 wanted.as_str().into(),
236 workspace_id.as_str().into(),
237 from.as_str().into(),
238 ])?,
239 self.db
240 .prepare(
241 "INSERT OR REPLACE INTO workspace_redirects (old_slug, workspace_id, created_at)
242 VALUES (?, ?, ?)",
243 )
244 .bind(&[
245 from.as_str().into(),
246 workspace_id.as_str().into(),
247 now.as_str().into(),
248 ])?,
249 // Agents at work keep their scope: it names the repository
250 // by its path.
251 self.db
252 .prepare(
253 "UPDATE access_tokens SET agent_scope = json_set(agent_scope, '$.repo.namespace', ?)
254 WHERE agent_scope IS NOT NULL
255 AND json_extract(agent_scope, '$.repo.namespace') = ?",
256 )
257 .bind(&[wanted.as_str().into(), from.as_str().into()])?,
258 ])
259 .await?;
260 self.publish_renamed(WorkspaceRenamed {
261 workspace_id,
262 from,
263 to: wanted.clone(),
264 }, &a.actor.id)
265 .await;
266 Ok(match self.get_workspace(SlugArgs { slug: wanted }).await? {
267 Some(workspace) => Outcome::Ok(workspace),
268 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
269 })
270 }
271
272 /// Tells every other service. The rename has happened by now, so a
273 /// failure is logged rather than undoing it.
274 async fn publish_renamed(&self, renamed: WorkspaceRenamed, actor: &str) {
275 let events = match self.env.service("EVENTS") {
276 Ok(events) => events,
277 Err(error) => {
278 worker::console_error!("workspace.renamed not published: {error}");
279 return;
280 }
281 };
282 let publish = Publish {
283 events: vec![NewEvent {
284 kind: "workspace.renamed",
285 source: SOURCE,
286 repo_id: None,
287 actor: Some(actor.to_owned()),
288 data: renamed,
289 }],
290 };
291 for attempt in 1..=PUBLISH_ATTEMPTS {
292 match g1t_kit::call::<_, serde_json::Value>(&events, "publish", &publish).await {
293 Ok(_) => return,
294 Err(error) => worker::console_error!(
295 "workspace.renamed publish attempt {attempt} failed: {error}"
296 ),
297 }
298 }
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use std::collections::HashMap;
306
307 const NOW: u64 = 1_790_918_179_123;
308
309 fn facts<'a>(current: &'a str, wanted: &'a str) -> Facts<'a> {
310 Facts {
311 workspace_id: "wsp_a",
312 current,
313 wanted,
314 now_ms: NOW,
315 ..Facts::default()
316 }
317 }
318
319 fn refused(facts: &Facts) -> FailureCode {
320 check(facts).unwrap_err().0
321 }
322
323 #[test]
324 fn validates_like_creation() {
325 assert!(check(&facts("acme", "acme-inc")).is_ok());
326 for bad in ["", "-acme", "acme-", "ac--me", "Acme", "acme_inc", "api", "settings", "pulls"] {
327 assert_eq!(refused(&facts("acme", bad)), FailureCode::Invalid, "{bad}");
328 }
329 assert_eq!(refused(&facts("acme", &"a".repeat(40))), FailureCode::Invalid);
330 assert_eq!(refused(&facts("acme", "acme")), FailureCode::Invalid);
331 }
332
333 #[test]
334 fn refuses_names_in_use() {
335 let taken = Facts {
336 someone_elses_username: true,
337 ..facts("acme", "bob")
338 };
339 assert_eq!(refused(&taken), FailureCode::Conflict);
340 let taken = Facts {
341 another_workspace: true,
342 ..facts("acme", "globex")
343 };
344 assert_eq!(refused(&taken), FailureCode::Conflict);
345 }
346
347 #[test]
348 fn an_old_slug_is_held_for_its_workspace_until_the_hold_ends() {
349 let recently = rfc3339(NOW - 10 * DAY_MS);
350 let long_ago = rfc3339(NOW - (SLUG_HOLD_DAYS + 1) * DAY_MS);
351 let held_by_other = Facts {
352 redirect: Some(("wsp_b", recently.as_str())),
353 ..facts("acme", "globex")
354 };
355 assert_eq!(refused(&held_by_other), FailureCode::Conflict);
356 let held_by_self = Facts {
357 redirect: Some(("wsp_a", recently.as_str())),
358 last_renamed_at: Some(recently.as_str()),
359 ..facts("acme-inc", "acme")
360 };
361 assert!(check(&held_by_self).is_ok(), "a workspace can rename back");
362 let expired = Facts {
363 redirect: Some(("wsp_b", long_ago.as_str())),
364 ..facts("acme", "globex")
365 };
366 assert!(check(&expired).is_ok(), "after the hold anyone can take it");
367 }
368
369 #[test]
370 fn renames_are_limited_to_one_a_day() {
371 let an_hour_ago = rfc3339(NOW - HOUR_MS);
372 let two_days_ago = rfc3339(NOW - 2 * DAY_MS);
373 let soon = Facts {
374 last_renamed_at: Some(an_hour_ago.as_str()),
375 ..facts("acme", "acme-inc")
376 };
377 assert_eq!(refused(&soon), FailureCode::Conflict);
378 let later = Facts {
379 last_renamed_at: Some(two_days_ago.as_str()),
380 ..facts("acme", "acme-inc")
381 };
382 assert!(check(&later).is_ok());
383 }
384
385 #[test]
386 fn hold_ends_after_the_hold_period() {
387 assert_eq!(hold_cutoff(NOW), rfc3339(NOW - SLUG_HOLD_DAYS * DAY_MS));
388 }
389
390 /// The tables, as `rename_workspace` changes them: slug by workspace
391 /// id, and old slug → (workspace id, when).
392 #[derive(Default)]
393 struct Tables {
394 workspaces: HashMap<&'static str, String>,
395 redirects: HashMap<String, (&'static str, String)>,
396 }
397
398 impl Tables {
399 /// The same steps, in the same order, as the batch.
400 fn rename(&mut self, id: &'static str, to: &str, at: u64) {
401 self.redirects.remove(to);
402 let from = self.workspaces.insert(id, to.to_owned()).unwrap();
403 self.redirects.insert(from, (id, rfc3339(at)));
404 }
405
406 /// As `redirect` + `resolve`.
407 fn resolve(&self, slug: &str, now: u64) -> Option<String> {
408 let row = self.redirects.get(slug).map(|(id, created_at)| RedirectRow {
409 workspace_id: (*id).to_owned(),
410 slug: self.workspaces[id].clone(),
411 created_at: created_at.clone(),
412 });
413 resolve(row, now)
414 }
415 }
416
417 #[test]
418 fn renames_chain_to_the_current_slug() {
419 let mut tables = Tables::default();
420 tables.workspaces.insert("wsp_a", "acme".into());
421 tables.rename("wsp_a", "acme-inc", NOW);
422 tables.rename("wsp_a", "acme-corp", NOW + 2 * DAY_MS);
423 let later = NOW + 3 * DAY_MS;
424 assert_eq!(tables.resolve("acme", later).as_deref(), Some("acme-corp"));
425 assert_eq!(tables.resolve("acme-inc", later).as_deref(), Some("acme-corp"));
426 assert_eq!(tables.resolve("unknown", later), None);
427 // Past the hold, the first old slug stops redirecting.
428 let much_later = NOW + (SLUG_HOLD_DAYS + 1) * DAY_MS;
429 assert_eq!(tables.resolve("acme", much_later), None);
430 assert_eq!(tables.resolve("acme-inc", much_later).as_deref(), Some("acme-corp"));
431 }
432
433 #[test]
434 fn renaming_back_drops_the_redirect_for_the_slug_in_use() {
435 let mut tables = Tables::default();
436 tables.workspaces.insert("wsp_a", "acme".into());
437 tables.rename("wsp_a", "acme-inc", NOW);
438 tables.rename("wsp_a", "acme", NOW + 2 * DAY_MS);
439 assert!(!tables.redirects.contains_key("acme"));
440 let later = NOW + 3 * DAY_MS;
441 assert_eq!(tables.resolve("acme-inc", later).as_deref(), Some("acme"));
442 }
443}