Skip to content

g1t/services/identity/src/rename.rs

481 lines18,938 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>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look57 /// A deleted workspace had `wanted`; it is never given to another.
58 pub deleted: bool,
Merge branch 'worktree-agent-a8385d293d42c913a'59 /// Staff made `wanted` an alias (aliases.rs); it stays theirs.
60 pub aliased: bool,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains61 pub now_ms: u64,
62}
63
64/// Whether the rename `facts` describe is allowed: `Ok`, or why not, in
65/// words for the owner.
66pub fn check(facts: &Facts) -> std::result::Result<(), (FailureCode, String)> {
67 let refuse = |code, message: &str| Err((code, message.to_owned()));
68 if !is_valid_namespace(facts.wanted) {
69 return refuse(
70 FailureCode::Invalid,
71 "Workspace names use lowercase letters, digits and single hyphens, up to 39 characters, and cannot be a reserved word.",
72 );
73 }
74 if facts.wanted == facts.current {
75 return refuse(FailureCode::Invalid, "That is already this workspace's name.");
76 }
77 if let Some(last) = facts.last_renamed_at {
78 let cooldown_from = rfc3339(facts.now_ms.saturating_sub(RENAME_COOLDOWN_HOURS * HOUR_MS));
79 if last > cooldown_from.as_str() {
80 return refuse(
81 FailureCode::Conflict,
82 "A workspace can be renamed once a day. Try again tomorrow.",
83 );
84 }
85 }
Merge branch 'worktree-agent-a8385d293d42c913a'86 if facts.someone_elses_username || facts.another_workspace || facts.deleted || facts.aliased {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains87 return refuse(FailureCode::Conflict, TAKEN);
88 }
89 if let Some((holder, created_at)) = facts.redirect
90 && holder != facts.workspace_id
91 && created_at >= hold_cutoff(facts.now_ms).as_str()
92 {
93 return refuse(FailureCode::Conflict, TAKEN);
94 }
95 Ok(())
96}
97
98/// A redirect as read with the slug its workspace has now.
99#[derive(Debug, Deserialize)]
100pub struct RedirectRow {
101 pub workspace_id: String,
102 /// The workspace's slug now.
103 pub slug: String,
104 pub created_at: String,
105}
106
107/// Where an old slug leads: the workspace's current slug, while the
108/// redirect still holds.
109pub fn resolve(row: Option<RedirectRow>, now_ms: u64) -> Option<String> {
110 row.filter(|row| row.created_at >= hold_cutoff(now_ms))
111 .map(|row| row.slug)
112}
113
114#[derive(Deserialize)]
115struct Target {
116 id: String,
117}
118
119impl Identity {
120 /// The redirect holding `slug`, if any, with its workspace's slug now.
121 async fn redirect(&self, slug: &str) -> Result<Option<RedirectRow>> {
122 self.db
123 .prepare(
124 "SELECT workspace_redirects.workspace_id, workspaces.slug,
125 workspace_redirects.created_at
126 FROM workspace_redirects
127 JOIN workspaces ON workspaces.id = workspace_redirects.workspace_id
128 WHERE workspace_redirects.old_slug = ?",
129 )
130 .bind(&[slug.into()])?
131 .first::<RedirectRow>(None)
132 .await
133 }
134
135 /// Whether `slug` is an old slug still reserved for the workspace that
Merge branch 'worktree-agent-a8385d293d42c913a'136 /// had it, or an alias staff set, so nobody else may register or create
137 /// it.
Agents and memory, checks and conflicts, profiles, slug renames, custom domains138 pub async fn slug_held(&self, slug: &str) -> Result<bool> {
Merge branch 'worktree-agent-a8385d293d42c913a'139 Ok(resolve(self.redirect(slug).await?, now_ms()).is_some() || self.is_alias(slug).await?)
Agents and memory, checks and conflicts, profiles, slug renames, custom domains140 }
141
Merge branch 'worktree-agent-a8385d293d42c913a'142 /// `resolve_slug`: the current slug for an old one still redirecting,
143 /// or for an alias (aliases.rs).
Agents and memory, checks and conflicts, profiles, slug renames, custom domains144 pub async fn resolve_slug(&self, a: SlugArgs) -> Result<Option<String>> {
145 let slug = a.slug.trim().to_lowercase();
Merge branch 'worktree-agent-a8385d293d42c913a'146 let in_use = self.get_workspace(SlugArgs { slug: slug.clone() }).await?.is_some();
147 if in_use {
Agents and memory, checks and conflicts, profiles, slug renames, custom domains148 return Ok(None);
149 }
Merge branch 'worktree-agent-a8385d293d42c913a'150 let alias = self.resolve_alias(SlugArgs { slug: slug.clone() }).await?;
151 let redirect = match alias {
152 Some(_) => None,
153 None => self.redirect(&slug).await?,
154 };
155 Ok(crate::aliases::resolve(in_use, alias, || resolve(redirect, now_ms())))
Agents and memory, checks and conflicts, profiles, slug renames, custom domains156 }
157
158 /// Checks a rename, returning the workspace's id when it is allowed.
159 async fn rename_allowed(&self, a: &RenameWorkspaceArgs) -> Result<Outcome<(String, String)>> {
160 let current = a.slug.trim().to_lowercase();
161 let wanted = a.new_slug.trim().to_lowercase();
162 if a.actor.kind != PrincipalKind::User || a.actor.role_in(&current) != Some(Role::Owner) {
163 return Ok(Outcome::fail(
164 FailureCode::Forbidden,
165 "Only an owner can rename a workspace.",
166 ));
167 }
168 if !a.actor.verified {
169 return Ok(Outcome::fail(
170 FailureCode::Forbidden,
171 "Confirm your email address before renaming a workspace.",
172 ));
173 }
174 let Some(workspace) = self
175 .db
176 .prepare("SELECT id FROM workspaces WHERE slug = ?")
177 .bind(&[current.as_str().into()])?
178 .first::<Target>(None)
179 .await?
180 else {
181 return Ok(Outcome::fail(FailureCode::NotFound, "Workspace not found."));
182 };
183 let someone_elses_username = self
184 .db
185 .prepare("SELECT id FROM users WHERE username = ? AND id != ?")
186 .bind(&[wanted.as_str().into(), a.actor.id.as_str().into()])?
187 .first::<serde_json::Value>(None)
188 .await?
189 .is_some();
190 let another_workspace = self
191 .db
192 .prepare("SELECT id FROM workspaces WHERE slug = ? AND id != ?")
193 .bind(&[wanted.as_str().into(), workspace.id.as_str().into()])?
194 .first::<serde_json::Value>(None)
195 .await?
196 .is_some();
197 let redirect = self.redirect(&wanted).await?;
198 let last_renamed_at = self
199 .db
200 .prepare("SELECT max(created_at) AS at FROM workspace_redirects WHERE workspace_id = ?")
201 .bind(&[workspace.id.as_str().into()])?
202 .first::<Option<String>>(Some("at"))
203 .await?
204 .flatten();
205 let facts = Facts {
206 workspace_id: &workspace.id,
207 current: &current,
208 wanted: &wanted,
209 someone_elses_username,
210 another_workspace,
211 redirect: redirect
212 .as_ref()
213 .map(|row| (row.workspace_id.as_str(), row.created_at.as_str())),
214 last_renamed_at: last_renamed_at.as_deref(),
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look215 deleted: self.slug_deleted(&wanted).await?,
Merge branch 'worktree-agent-a8385d293d42c913a'216 aliased: self.is_alias(&wanted).await?,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains217 now_ms: now_ms(),
218 };
219 Ok(match check(&facts) {
220 Ok(()) => Outcome::Ok((workspace.id, wanted)),
221 Err((code, message)) => Outcome::fail(code, message),
222 })
223 }
224
225 pub async fn check_workspace_rename(&self, a: RenameWorkspaceArgs) -> Result<Outcome<bool>> {
226 Ok(match self.rename_allowed(&a).await? {
227 Outcome::Ok(_) => Outcome::Ok(true),
228 Outcome::Fail(failure) => Outcome::Fail(failure),
229 })
230 }
231
232 pub async fn rename_workspace(&self, a: RenameWorkspaceArgs) -> Result<Outcome<Workspace>> {
233 let (workspace_id, wanted) = match self.rename_allowed(&a).await? {
234 Outcome::Ok(allowed) => allowed,
235 Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)),
236 };
237 let from = a.slug.trim().to_lowercase();
238 let now = rfc3339(now_ms());
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member239 // A workspace protected by its slug stays protected under the new
240 // one: the protection goes on its row (deletion.rs).
241 let protected = self.is_protected(&workspace_id, &from, false).await?;
Agents and memory, checks and conflicts, profiles, slug renames, custom domains242 self.db
243 .batch(vec![
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member244 self.db
245 .prepare("UPDATE workspaces SET protected = 1 WHERE id = ? AND ? = 1")
246 .bind(&[workspace_id.as_str().into(), u8::from(protected).into()])?,
Agents and memory, checks and conflicts, profiles, slug renames, custom domains247 // A redirect the workspace is renaming back to, or one whose
248 // hold has ended, gives way to the slug in use.
249 self.db
250 .prepare("DELETE FROM workspace_redirects WHERE old_slug = ?")
251 .bind(&[wanted.as_str().into()])?,
252 self.db
253 .prepare("UPDATE workspaces SET slug = ? WHERE id = ? AND slug = ?")
254 .bind(&[
255 wanted.as_str().into(),
256 workspace_id.as_str().into(),
257 from.as_str().into(),
258 ])?,
259 self.db
260 .prepare(
261 "INSERT OR REPLACE INTO workspace_redirects (old_slug, workspace_id, created_at)
262 VALUES (?, ?, ?)",
263 )
264 .bind(&[
265 from.as_str().into(),
266 workspace_id.as_str().into(),
267 now.as_str().into(),
268 ])?,
269 // Agents at work keep their scope: it names the repository
270 // by its path.
271 self.db
272 .prepare(
273 "UPDATE access_tokens SET agent_scope = json_set(agent_scope, '$.repo.namespace', ?)
274 WHERE agent_scope IS NOT NULL
275 AND json_extract(agent_scope, '$.repo.namespace') = ?",
276 )
277 .bind(&[wanted.as_str().into(), from.as_str().into()])?,
278 ])
279 .await?;
280 self.publish_renamed(WorkspaceRenamed {
281 workspace_id,
282 from,
283 to: wanted.clone(),
284 }, &a.actor.id)
285 .await;
286 Ok(match self.get_workspace(SlugArgs { slug: wanted }).await? {
287 Some(workspace) => Outcome::Ok(workspace),
288 None => Outcome::fail(FailureCode::NotFound, "Workspace not found."),
289 })
290 }
291
292 /// Tells every other service. The rename has happened by now, so a
293 /// failure is logged rather than undoing it.
294 async fn publish_renamed(&self, renamed: WorkspaceRenamed, actor: &str) {
295 let events = match self.env.service("EVENTS") {
296 Ok(events) => events,
297 Err(error) => {
298 worker::console_error!("workspace.renamed not published: {error}");
299 return;
300 }
301 };
302 let publish = Publish {
303 events: vec![NewEvent {
304 kind: "workspace.renamed",
305 source: SOURCE,
306 repo_id: None,
307 actor: Some(actor.to_owned()),
308 data: renamed,
309 }],
310 };
311 for attempt in 1..=PUBLISH_ATTEMPTS {
312 match g1t_kit::call::<_, serde_json::Value>(&events, "publish", &publish).await {
313 Ok(_) => return,
314 Err(error) => worker::console_error!(
315 "workspace.renamed publish attempt {attempt} failed: {error}"
316 ),
317 }
318 }
319 }
320}
321
322#[cfg(test)]
323mod tests {
324 use super::*;
325 use std::collections::HashMap;
326
327 const NOW: u64 = 1_790_918_179_123;
328
329 fn facts<'a>(current: &'a str, wanted: &'a str) -> Facts<'a> {
330 Facts {
331 workspace_id: "wsp_a",
332 current,
333 wanted,
334 now_ms: NOW,
335 ..Facts::default()
336 }
337 }
338
339 fn refused(facts: &Facts) -> FailureCode {
340 check(facts).unwrap_err().0
341 }
342
343 #[test]
344 fn validates_like_creation() {
345 assert!(check(&facts("acme", "acme-inc")).is_ok());
346 for bad in ["", "-acme", "acme-", "ac--me", "Acme", "acme_inc", "api", "settings", "pulls"] {
347 assert_eq!(refused(&facts("acme", bad)), FailureCode::Invalid, "{bad}");
348 }
349 assert_eq!(refused(&facts("acme", &"a".repeat(40))), FailureCode::Invalid);
350 assert_eq!(refused(&facts("acme", "acme")), FailureCode::Invalid);
351 }
352
353 #[test]
g1t is one name: its agent's work, commits and comments show as @g1t, and nobody can claim g1t or g1t-agent354 fn no_workspace_is_renamed_to_g1ts_names() {
355 for name in ["g1t", "g1t-agent", "G1T", "G1T-Agent"] {
356 assert_eq!(refused(&facts("acme", name)), FailureCode::Invalid, "{name}");
357 }
358 assert!(check(&facts("acme", "g1t-fans")).is_ok());
359 }
360
361 #[test]
Agents and memory, checks and conflicts, profiles, slug renames, custom domains362 fn refuses_names_in_use() {
363 let taken = Facts {
364 someone_elses_username: true,
365 ..facts("acme", "bob")
366 };
367 assert_eq!(refused(&taken), FailureCode::Conflict);
368 let taken = Facts {
369 another_workspace: true,
370 ..facts("acme", "globex")
371 };
372 assert_eq!(refused(&taken), FailureCode::Conflict);
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look373 let deleted = Facts {
374 deleted: true,
375 ..facts("acme", "initech")
376 };
377 assert_eq!(refused(&deleted), FailureCode::Conflict);
Merge branch 'worktree-agent-a8385d293d42c913a'378 let aliased = Facts {
379 aliased: true,
380 ..facts("acme", "flagon")
381 };
382 assert_eq!(refused(&aliased), FailureCode::Conflict);
Agents and memory, checks and conflicts, profiles, slug renames, custom domains383 }
384
385 #[test]
386 fn an_old_slug_is_held_for_its_workspace_until_the_hold_ends() {
387 let recently = rfc3339(NOW - 10 * DAY_MS);
388 let long_ago = rfc3339(NOW - (SLUG_HOLD_DAYS + 1) * DAY_MS);
389 let held_by_other = Facts {
390 redirect: Some(("wsp_b", recently.as_str())),
391 ..facts("acme", "globex")
392 };
393 assert_eq!(refused(&held_by_other), FailureCode::Conflict);
394 let held_by_self = Facts {
395 redirect: Some(("wsp_a", recently.as_str())),
396 last_renamed_at: Some(recently.as_str()),
397 ..facts("acme-inc", "acme")
398 };
399 assert!(check(&held_by_self).is_ok(), "a workspace can rename back");
400 let expired = Facts {
401 redirect: Some(("wsp_b", long_ago.as_str())),
402 ..facts("acme", "globex")
403 };
404 assert!(check(&expired).is_ok(), "after the hold anyone can take it");
405 }
406
407 #[test]
408 fn renames_are_limited_to_one_a_day() {
409 let an_hour_ago = rfc3339(NOW - HOUR_MS);
410 let two_days_ago = rfc3339(NOW - 2 * DAY_MS);
411 let soon = Facts {
412 last_renamed_at: Some(an_hour_ago.as_str()),
413 ..facts("acme", "acme-inc")
414 };
415 assert_eq!(refused(&soon), FailureCode::Conflict);
416 let later = Facts {
417 last_renamed_at: Some(two_days_ago.as_str()),
418 ..facts("acme", "acme-inc")
419 };
420 assert!(check(&later).is_ok());
421 }
422
423 #[test]
424 fn hold_ends_after_the_hold_period() {
425 assert_eq!(hold_cutoff(NOW), rfc3339(NOW - SLUG_HOLD_DAYS * DAY_MS));
426 }
427
428 /// The tables, as `rename_workspace` changes them: slug by workspace
429 /// id, and old slug → (workspace id, when).
430 #[derive(Default)]
431 struct Tables {
432 workspaces: HashMap<&'static str, String>,
433 redirects: HashMap<String, (&'static str, String)>,
434 }
435
436 impl Tables {
437 /// The same steps, in the same order, as the batch.
438 fn rename(&mut self, id: &'static str, to: &str, at: u64) {
439 self.redirects.remove(to);
440 let from = self.workspaces.insert(id, to.to_owned()).unwrap();
441 self.redirects.insert(from, (id, rfc3339(at)));
442 }
443
444 /// As `redirect` + `resolve`.
445 fn resolve(&self, slug: &str, now: u64) -> Option<String> {
446 let row = self.redirects.get(slug).map(|(id, created_at)| RedirectRow {
447 workspace_id: (*id).to_owned(),
448 slug: self.workspaces[id].clone(),
449 created_at: created_at.clone(),
450 });
451 resolve(row, now)
452 }
453 }
454
455 #[test]
456 fn renames_chain_to_the_current_slug() {
457 let mut tables = Tables::default();
458 tables.workspaces.insert("wsp_a", "acme".into());
459 tables.rename("wsp_a", "acme-inc", NOW);
460 tables.rename("wsp_a", "acme-corp", NOW + 2 * DAY_MS);
461 let later = NOW + 3 * DAY_MS;
462 assert_eq!(tables.resolve("acme", later).as_deref(), Some("acme-corp"));
463 assert_eq!(tables.resolve("acme-inc", later).as_deref(), Some("acme-corp"));
464 assert_eq!(tables.resolve("unknown", later), None);
465 // Past the hold, the first old slug stops redirecting.
466 let much_later = NOW + (SLUG_HOLD_DAYS + 1) * DAY_MS;
467 assert_eq!(tables.resolve("acme", much_later), None);
468 assert_eq!(tables.resolve("acme-inc", much_later).as_deref(), Some("acme-corp"));
469 }
470
471 #[test]
472 fn renaming_back_drops_the_redirect_for_the_slug_in_use() {
473 let mut tables = Tables::default();
474 tables.workspaces.insert("wsp_a", "acme".into());
475 tables.rename("wsp_a", "acme-inc", NOW);
476 tables.rename("wsp_a", "acme", NOW + 2 * DAY_MS);
477 assert!(!tables.redirects.contains_key("acme"));
478 let later = NOW + 3 * DAY_MS;
479 assert_eq!(tables.resolve("acme-inc", later).as_deref(), Some("acme"));
480 }
481}

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