diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index 898312e3..1fd7fa8c 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -62,6 +62,27 @@ pub struct RepositorySource { pub uri: String, } +/// Browser/user intent for registering one Repository in a Workspace. +/// +/// The Server parses and canonicalizes `source`; callers cannot assert a +/// transport classification or supply credential material through this DTO. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct CreateWorkspaceRepositoryRequest { + pub repository_id: String, + pub display_name: String, + pub source: String, + #[serde(default)] + pub default_ref: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CreateWorkspaceRepositoryResponse { + pub workspace_id: String, + pub repository_id: String, + pub replayed: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RepositoryObservedStatus { diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 3c17be14..6e268cef 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -117,6 +117,8 @@ pub enum Error { WorkspacePermissionDenied(String), #[error("Workspace config update conflict: {0}")] WorkspaceConfigConflict(String), + #[error("Repository conflict: {0}")] + RepositoryConflict(String), #[error("Registry inconsistency: {0}")] RegistryInconsistency(String), #[error("Worker source identity is invalid: {0}")] diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index bf390660..ba25e8a8 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -61,6 +61,7 @@ use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; use workspace_api::{ CreateRemoteRuntimeRequest, CreateRepositorySshCredentialRequest, + CreateWorkspaceRepositoryRequest, CreateWorkspaceRepositoryResponse, DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest, ObjectiveCreateRequest, ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, PutRepositorySshHostTrustRequest, RepositoryAccessProjection, @@ -121,14 +122,15 @@ use crate::repository_access::{ RepositoryAccessConfigSchemaProvider, RepositorySecretService, project_repository_access_candidate, project_repository_access_state, }; +use crate::repository_source::{parse_repository_source, repository_source_fingerprint}; use crate::resource_broker::BackendResourceBroker; use crate::runtime_settings::RuntimeConfigSchemaProvider; use crate::runtime_subscription::RuntimeSubscriptionBroker; use crate::skills; use crate::store::{ AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore, - DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryRecord, - TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord, + DeviceLoginFlowRecord, FlowSourceRecord, PasskeyCredentialRecord, RepositoryInsertOutcome, + RepositoryRecord, TicketAssignmentPrincipal, TicketAssignmentRole, TicketCoderAssignmentRecord, TicketRoleAssignmentRecord, UserRecord, WorkdirCreateOperationRecord, WorkdirRegistryRecord, WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, WorkspaceResourceKind, @@ -1898,6 +1900,12 @@ impl WorkspaceApi { RepositoryRegistryReader::new(self.config.repositories.clone()) } + fn live_repository_reader(&self) -> Result { + Ok(RepositoryRegistryReader::new( + load_configured_repositories_from_store(self.config_store.as_ref(), &self.config)?, + )) + } + fn require_workspace_repository(&self, repository_id: &str) -> ApiResult { self.store .get_repository(&self.config.workspace_id, repository_id)? @@ -2397,7 +2405,7 @@ fn build_inner_router(api: WorkspaceApi) -> Router { .route("/api/repositories", get(list_repositories)) .route( "/api/w/{workspace_id}/repositories", - get(scoped_list_repositories), + get(scoped_list_repositories).post(scoped_create_repository), ) .route("/api/repositories/{repository_id}", get(repository_detail)) .route( @@ -3480,10 +3488,11 @@ async fn scoped_update_workspace_memory_settings( })) } -async fn require_manage_repository_secrets( +async fn require_workspace_owner( api: &WorkspaceApi, workspace_id: &str, actor: &RequestActor, + permission: &str, ) -> ApiResult<()> { validate_workspace_scope(api, workspace_id)?; let workspace = api @@ -3492,14 +3501,22 @@ async fn require_manage_repository_secrets( .await? .ok_or(Error::WorkspaceIdMismatch)?; if workspace.owner_account_id.as_deref() != Some(actor.account_id.as_str()) { - return Err(Error::WorkspacePermissionDenied( - "ManageSecrets requires the Workspace owner account".to_string(), - ) + return Err(Error::WorkspacePermissionDenied(format!( + "{permission} requires the Workspace owner account" + )) .into()); } Ok(()) } +async fn require_manage_repository_secrets( + api: &WorkspaceApi, + workspace_id: &str, + actor: &RequestActor, +) -> ApiResult<()> { + require_workspace_owner(api, workspace_id, actor, "ManageSecrets").await +} + fn active_repository_access_projection( api: &WorkspaceApi, workspace_id: &str, @@ -7888,6 +7905,108 @@ async fn scoped_list_repositories( list_repositories(State(api)).await } +async fn scoped_create_repository( + State(api): State, + AxumPath(path): AxumPath, + Extension(actor): Extension, + Json(request): Json, +) -> ApiResult<(StatusCode, Json)> { + require_workspace_owner(&api, &path.workspace_id, &actor, "ManageRepositories").await?; + + let repository_id = normalize_repository_identifier(&request.repository_id)?; + let display_name = normalize_repository_text(&request.display_name, "display_name", 256)?; + let default_ref = request + .default_ref + .as_deref() + .map(|value| normalize_repository_text(value, "default_ref", 512)) + .transpose()?; + let source = parse_repository_source(&request.source) + .map_err(|error| Error::InvalidInput(format!("invalid repository source: {error}")))?; + let now = Utc::now().to_rfc3339(); + let record = RepositoryRecord { + workspace_id: path.workspace_id.clone(), + repository_id: repository_id.clone(), + name: display_name, + kind: "git".to_string(), + provider: Some("git".to_string()), + source_fingerprint: repository_source_fingerprint(&source), + source, + default_ref, + source_revision: 1, + observed_status: workspace_api::RepositoryObservedStatus::Unverified, + observed_at: None, + created_at: now.clone(), + updated_at: now, + }; + + let (status, replayed) = match api.config_store.insert_repository(&record)? { + RepositoryInsertOutcome::Created => (StatusCode::CREATED, false), + RepositoryInsertOutcome::Existing(existing) + if repository_create_intent_matches(&existing, &record) => + { + (StatusCode::OK, true) + } + RepositoryInsertOutcome::Existing(_) => { + return Err(Error::RepositoryConflict(format!( + "repository_id {repository_id} already exists with different registration intent" + )) + .into()); + } + }; + + Ok(( + status, + Json(CreateWorkspaceRepositoryResponse { + workspace_id: path.workspace_id, + repository_id, + replayed, + }), + )) +} + +fn normalize_repository_identifier(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() || value.len() > 128 { + return Err(Error::InvalidInput( + "repository_id must contain 1..=128 characters".to_string(), + )); + } + if !value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + { + return Err(Error::InvalidInput( + "repository_id must contain only ASCII letters, digits, '-', '_', or '.'".to_string(), + )); + } + Ok(value.to_string()) +} + +fn normalize_repository_text(value: &str, field: &str, max_len: usize) -> Result { + let value = value.trim(); + if value.is_empty() || value.len() > max_len || value.chars().any(char::is_control) { + return Err(Error::InvalidInput(format!( + "{field} must contain 1..={max_len} non-control characters" + ))); + } + Ok(value.to_string()) +} + +fn repository_create_intent_matches( + existing: &RepositoryRecord, + requested: &RepositoryRecord, +) -> bool { + existing.workspace_id == requested.workspace_id + && existing.repository_id == requested.repository_id + && existing.name == requested.name + && existing.kind == requested.kind + && existing.provider == requested.provider + && existing.source == requested.source + && existing.default_ref == requested.default_ref + && existing.source_revision == requested.source_revision + && existing.source_fingerprint == requested.source_fingerprint +} + async fn scoped_repository_detail( State(api): State, AxumPath(path): AxumPath, @@ -11189,7 +11308,7 @@ async fn get_objective( async fn list_repositories( State(api): State, ) -> ApiResult> { - let RepositoryListProjection { items, diagnostics } = api.repository_reader().list(); + let RepositoryListProjection { items, diagnostics } = api.live_repository_reader()?.list(); Ok(Json(RepositoryListResponse { workspace_id: api.config.workspace_id, items, @@ -11202,7 +11321,7 @@ async fn repository_detail( State(api): State, AxumPath(repository_id): AxumPath, ) -> ApiResult> { - let item = repository_lookup(api.repository_reader().summary(&repository_id))?; + let item = repository_lookup(api.live_repository_reader()?.summary(&repository_id))?; Ok(Json(RepositoryDetailResponse { workspace_id: api.config.workspace_id.clone(), item, @@ -11222,7 +11341,7 @@ async fn repository_log( commits, diagnostics, } = repository_lookup( - api.repository_reader() + api.live_repository_reader()? .recent_log(&repository_id, query.limit), )?; Ok(Json(RepositoryLogResponse { @@ -15247,7 +15366,8 @@ impl IntoResponse for ApiError { } Error::TicketAssignmentConflict(_) | Error::WorkdirAttachmentConflict(_) - | Error::WorkspaceConfigConflict(_) => StatusCode::CONFLICT, + | Error::WorkspaceConfigConflict(_) + | Error::RepositoryConflict(_) => StatusCode::CONFLICT, Error::WorkerSourceIdentity(_) | Error::InvalidInput(_) => StatusCode::BAD_REQUEST, Error::InvalidRuntimeIdentifier { .. } | Error::ReservedWorkerName(_) => { StatusCode::BAD_REQUEST @@ -17913,6 +18033,96 @@ mod tests { ); } + let repositories_uri = format!("/api/w/{}/repositories", workspace.workspace.workspace_id); + let repository_request = serde_json::json!({ + "repository_id": "documentation", + "display_name": "Documentation", + "source": temp.path().join("documentation").display().to_string(), + "default_ref": "main" + }); + let create_repository = |body: serde_json::Value| { + Request::builder() + .method(Method::POST) + .uri(&repositories_uri) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .header(ORIGIN, &expected_origin) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(body.to_string())) + .unwrap() + }; + let created = app + .clone() + .oneshot(create_repository(repository_request.clone())) + .await + .unwrap(); + assert_eq!(created.status(), StatusCode::CREATED); + let replayed = app + .clone() + .oneshot(create_repository(repository_request.clone())) + .await + .unwrap(); + assert_eq!(replayed.status(), StatusCode::OK); + + let conflict = app + .clone() + .oneshot(create_repository(serde_json::json!({ + "repository_id": "documentation", + "display_name": "Different registration", + "source": temp.path().join("documentation").display().to_string(), + "default_ref": "main" + }))) + .await + .unwrap(); + assert_eq!(conflict.status(), StatusCode::CONFLICT); + + let non_owner_create = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri(&repositories_uri) + .header( + axum::http::header::AUTHORIZATION, + format!("Bearer {non_owner_token}"), + ) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::json!({ + "repository_id": "foreign-owner", + "display_name": "Foreign owner", + "source": temp.path().join("foreign-owner").display().to_string() + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(non_owner_create.status(), StatusCode::FORBIDDEN); + + let listed = app + .clone() + .oneshot( + Request::builder() + .uri(&repositories_uri) + .header( + axum::http::header::COOKIE, + "yoi_workspace_session=browser-session-auth", + ) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(listed.status(), StatusCode::OK); + let listed_body = axum::body::to_bytes(listed.into_body(), usize::MAX) + .await + .unwrap(); + assert!(String::from_utf8_lossy(&listed_body).contains("documentation")); + let settings_uri = format!( "/api/w/{}/settings/workspace", workspace.workspace.workspace_id @@ -23817,6 +24027,26 @@ mod tests { }) .await .unwrap(); + let configured_repository = config.repositories[0].clone(); + sqlite_store + .upsert_repository(&RepositoryRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + repository_id: configured_repository.id, + name: configured_repository + .display_name + .unwrap_or_else(|| "Test Repository".to_string()), + kind: "git".to_string(), + provider: Some(configured_repository.provider), + source: configured_repository.source, + default_ref: configured_repository.default_selector, + source_revision: configured_repository.source_revision, + source_fingerprint: configured_repository.source_fingerprint, + observed_status: configured_repository.observed_status, + observed_at: configured_repository.observed_at, + created_at: "2026-01-01T00:00:00Z".to_string(), + updated_at: "2026-01-01T00:00:00Z".to_string(), + }) + .unwrap(); let ticket_id = write_ticket( &config.database_path, TEST_WORKSPACE_ID, diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 87717309..cc848115 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -326,6 +326,12 @@ pub struct RepositoryRecord { pub updated_at: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RepositoryInsertOutcome { + Created, + Existing(RepositoryRecord), +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkspaceBootstrapRecord { pub operation_key: String, @@ -860,6 +866,7 @@ pub trait ControlPlaneStore: Send + Sync { } fn list_workspaces(&self) -> Result>; fn upsert_repository(&self, record: &RepositoryRecord) -> Result<()>; + fn insert_repository(&self, record: &RepositoryRecord) -> Result; fn get_repository( &self, workspace_id: &str, @@ -2175,6 +2182,54 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn insert_repository(&self, record: &RepositoryRecord) -> Result { + self.with_conn_mut(|conn| { + let transaction = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let existing = transaction + .query_row( + r#"SELECT workspace_id, repository_id, name, kind, provider, + source_kind, source_uri, default_ref, source_revision, + source_fingerprint, observed_status, observed_at, created_at, updated_at + FROM repositories + WHERE workspace_id = ?1 AND repository_id = ?2"#, + params![record.workspace_id, record.repository_id], + read_repository_record, + ) + .optional()?; + if let Some(existing) = existing { + transaction.commit()?; + return Ok(RepositoryInsertOutcome::Existing(existing)); + } + + transaction.execute( + r#"INSERT INTO repositories ( + workspace_id, repository_id, name, kind, provider, uri, + source_kind, source_uri, default_ref, source_revision, + source_fingerprint, observed_status, observed_at, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#, + params![ + record.workspace_id, + record.repository_id, + record.name, + record.kind, + record.provider, + record.source.uri, + record.source.kind.as_str(), + record.source.uri, + record.default_ref, + record.source_revision, + record.source_fingerprint, + record.observed_status.as_str(), + record.observed_at, + record.created_at, + record.updated_at, + ], + )?; + transaction.commit()?; + Ok(RepositoryInsertOutcome::Created) + }) + } + fn get_repository( &self, workspace_id: &str,