diff --git a/Cargo.lock b/Cargo.lock index 95c611f38..176ff2908 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6715,6 +6715,7 @@ name = "yoi-workspace-server" version = "0.1.0" dependencies = [ "agen", + "api-macros", "async-trait", "axum", "chrono", diff --git a/crates/server-api/Cargo.toml b/crates/server-api/Cargo.toml index 718005b08..982cf4075 100644 --- a/crates/server-api/Cargo.toml +++ b/crates/server-api/Cargo.toml @@ -55,3 +55,7 @@ required-features = ["typescript"] [[example]] name = "generate_repository_access_types" required-features = ["typescript"] + +[[example]] +name = "generate_drive_api_types" +required-features = ["typescript"] diff --git a/crates/server-api/examples/generate_drive_api_types.rs b/crates/server-api/examples/generate_drive_api_types.rs new file mode 100644 index 000000000..0acd1a2b0 --- /dev/null +++ b/crates/server-api/examples/generate_drive_api_types.rs @@ -0,0 +1,6 @@ +fn main() { + print!( + "{}", + server_api::normalized_typescript(server_api::drive_api_typescript()) + ); +} diff --git a/crates/server-api/src/drive.rs b/crates/server-api/src/drive.rs new file mode 100644 index 000000000..4c0d2f05a --- /dev/null +++ b/crates/server-api/src/drive.rs @@ -0,0 +1,902 @@ +//! Workspace Drive HTTP contracts. Entries are logical, workspace-bound resources, +//! never host paths. Worker identity and grants are resolved by the service from +//! trusted request context, not supplied by ordinary operation DTOs. +use schemars::JsonSchema; +use serde::{Deserialize, Deserializer, Serialize}; + +pub const DRIVE_TEXT_MAX_BYTES: usize = 64 * 1024; +pub const DRIVE_CHUNK_MAX_BYTES: u32 = 64 * 1024; +pub const DRIVE_PAGE_MAX_LIMIT: u32 = 200; +pub const DRIVE_SEARCH_MAX_LIMIT: u32 = 128; +pub const DRIVE_FILE_MAX_BYTES: u32 = 16 * 1024 * 1024; + +fn decimal<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + if !canonical_positive_decimal(&value) { + return Err(serde::de::Error::custom( + "expected a canonical positive decimal string", + )); + } + Ok(value) +} +fn optional_decimal<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + let value = Option::::deserialize(deserializer)?; + if value + .as_deref() + .is_some_and(|value| !canonical_positive_decimal(value)) + { + return Err(serde::de::Error::custom( + "expected a canonical positive decimal string", + )); + } + Ok(value) +} +fn canonical_positive_decimal(value: &str) -> bool { + value + .as_bytes() + .first() + .is_some_and(|first| matches!(first, b'1'..=b'9')) + && value.bytes().all(|byte| byte.is_ascii_digit()) + && value.parse::().is_ok_and(|value| value > 0) +} +fn bounded_text<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let value = String::deserialize(deserializer)?; + if value.len() > DRIVE_TEXT_MAX_BYTES { + return Err(serde::de::Error::custom("Drive text exceeds 64 KiB")); + } + Ok(value) +} +fn bounded_chunk<'de, D: Deserializer<'de>>(deserializer: D) -> Result { + let value = u32::deserialize(deserializer)?; + if value == 0 || value > DRIVE_CHUNK_MAX_BYTES { + return Err(serde::de::Error::custom( + "Drive read size must be between 1 and 65536 bytes", + )); + } + Ok(value) +} +fn optional_limit<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + let value = Option::::deserialize(deserializer)?; + if value.is_some_and(|value| value == 0 || value > DRIVE_PAGE_MAX_LIMIT) { + return Err(serde::de::Error::custom( + "Drive page limit must be between 1 and 200", + )); + } + Ok(value) +} +const fn default_read_size() -> u32 { + DRIVE_CHUNK_MAX_BYTES +} +fn optional_search_limit<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + let value = Option::::deserialize(deserializer)?; + if value.is_some_and(|value| value == 0 || value > DRIVE_SEARCH_MAX_LIMIT) { + return Err(serde::de::Error::custom( + "Drive search limit must be between 1 and 128", + )); + } + Ok(value) +} + +/// Numeric node IDs alone are not public references: the referenced Workspace +/// must match the route Workspace before the service resolves the node. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveEntryRef { + pub workspace_id: String, + #[serde(deserialize_with = "decimal")] + #[schemars(regex(pattern = "^[1-9][0-9]*$"))] + pub node_id: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum DriveEntryKind { + Folder, + File, +} + +/// Metadata only: content is returned through bounded text or binary routes. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveEntry { + pub entry: DriveEntryRef, + pub parent: Option, + pub name: String, + pub kind: DriveEntryKind, + #[serde(deserialize_with = "decimal")] + #[schemars(regex(pattern = "^[1-9][0-9]*$"))] + pub revision: String, + /// File length in bytes; absent for folders. + #[schemars(range(min = 0, max = 16_777_216))] + pub size: Option, + pub content_type: Option, + pub updated_by: String, + /// Timestamp projected from storage's updated_at_ms; not a creation time. + pub updated_at: String, + /// Stable relative authenticated URL in this same Workspace. No bearer token + /// or cross-Workspace link is embedded in this metadata. + pub latest_url: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveEntryQuery { + pub entry_workspace_id: String, + #[serde(deserialize_with = "decimal")] + #[schemars(regex(pattern = "^[1-9][0-9]*$"))] + pub id: String, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveListQuery { + pub entry_workspace_id: String, + /// Folder to list. + #[serde(deserialize_with = "decimal")] + #[schemars(regex(pattern = "^[1-9][0-9]*$"))] + pub id: String, + #[serde(default, deserialize_with = "optional_limit")] + #[schemars(range(min = 1, max = 200))] + pub limit: Option, + /// Opaque service-issued page cursor, not a bare node ID. + #[serde(default)] + pub after: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveSearchQuery { + pub query: String, + /// Search file text as well as names only when explicitly requested. + #[serde(default)] + pub include_text: bool, + #[serde(default, deserialize_with = "optional_search_limit")] + #[schemars(range(min = 1, max = 128))] + pub limit: Option, + #[serde(default)] + pub after: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveListResponse { + pub entries: Vec, + pub next_after: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveReadTextQuery { + pub entry_workspace_id: String, + #[serde(deserialize_with = "decimal")] + #[schemars(regex(pattern = "^[1-9][0-9]*$"))] + pub id: String, + #[serde(default = "default_read_size", deserialize_with = "bounded_chunk")] + #[schemars(range(min = 1, max = 65536))] + pub max_bytes: u32, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveReadTextResponse { + pub entry: DriveEntry, + #[serde(deserialize_with = "bounded_text")] + pub text: String, + pub truncated: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveReadChunkQuery { + pub entry_workspace_id: String, + #[serde(deserialize_with = "decimal")] + #[schemars(regex(pattern = "^[1-9][0-9]*$"))] + pub id: String, + /// Bind a multi-chunk read to the same file revision. + #[serde(deserialize_with = "decimal")] + #[schemars(regex(pattern = "^[1-9][0-9]*$"))] + pub expected_revision: String, + #[schemars(range(min = 0, max = 16_777_216))] + pub offset: u32, + #[serde(default = "default_read_size", deserialize_with = "bounded_chunk")] + #[schemars(range(min = 1, max = 65536))] + pub length: u32, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveDownloadQuery { + pub entry_workspace_id: String, + #[serde(deserialize_with = "decimal")] + #[schemars(regex(pattern = "^[1-9][0-9]*$"))] + pub id: String, + #[serde(default, deserialize_with = "optional_decimal")] + pub expected_revision: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveMutationRequest { + pub request_id: String, + pub mutation: DriveMutation, +} +/// JSON can mutate bounded UTF-8 TEXT only. Arbitrary file bytes use drive_upload. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] +pub enum DriveMutation { + CreateFolder { + parent: DriveEntryRef, + name: String, + }, + CreateText { + parent: DriveEntryRef, + name: String, + #[serde(deserialize_with = "bounded_text")] + text: String, + content_type: String, + }, + UpdateText { + id: DriveEntryRef, + #[serde(deserialize_with = "decimal")] + #[schemars(regex(pattern = "^[1-9][0-9]*$"))] + expected_revision: String, + #[serde(deserialize_with = "bounded_text")] + text: String, + content_type: String, + }, + Relocate { + id: DriveEntryRef, + #[serde(deserialize_with = "decimal")] + #[schemars(regex(pattern = "^[1-9][0-9]*$"))] + expected_revision: String, + parent: DriveEntryRef, + name: String, + }, + Delete { + id: DriveEntryRef, + #[serde(deserialize_with = "decimal")] + #[schemars(regex(pattern = "^[1-9][0-9]*$"))] + expected_revision: String, + }, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveMutationResponse { + pub request_id: String, + /// A deleted entry has no live metadata. + pub entry: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum DriveUploadOperation { + Create, + Update, +} +/// Flat query metadata accompanies the binary body. The service rejects invalid +/// combinations: create requires parent_id/name and forbids id/expected_revision; +/// update requires id/expected_revision and forbids parent_id/name. Both targets +/// are bound by entry_workspace_id; declared size and SHA256 must match the body. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveUploadQuery { + pub operation: DriveUploadOperation, + pub request_id: String, + pub entry_workspace_id: String, + #[serde(default, deserialize_with = "optional_decimal")] + pub parent_id: Option, + #[serde(default)] + pub name: Option, + #[serde(default, deserialize_with = "optional_decimal")] + pub id: Option, + #[serde(default, deserialize_with = "optional_decimal")] + pub expected_revision: Option, + pub content_type: String, + /// Values above the file limit still decode as u32 so the service returns + /// typed Limit rather than a generic request-decoding error. + #[schemars(range(min = 0, max = 16_777_216))] + pub size: u32, + pub sha256: String, +} +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum DriveRequestState { + /// No commit record exists at the observation snapshot. An in-flight request + /// can still commit afterward; this is not proof of a final failed outcome. + Uncommitted, + Committed, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveRequestStatusResponse { + pub request_id: String, + pub state: DriveRequestState, + pub response: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum DriveAccess { + ReadOnly, + ReadWrite, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveGrantCreateRequest { + pub runtime_id: String, + pub worker_id: String, + pub access: DriveAccess, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveGrantResponse { + #[serde(deserialize_with = "decimal")] + #[schemars(regex(pattern = "^[1-9][0-9]*$"))] + pub grant_id: String, + pub workspace_id: String, + pub runtime_id: String, + pub worker_id: String, + pub access: DriveAccess, + pub revoked: bool, + pub created_by: String, + pub created_at: String, + pub revoked_by: Option, + pub revoked_at: Option, +} +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveGrantListQuery { + #[serde(default, deserialize_with = "optional_limit")] + #[schemars(range(min = 1, max = 200))] + pub limit: Option, + /// Canonical grant ID cursor within this Workspace. + #[serde(default, deserialize_with = "optional_decimal")] + pub after: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveGrantListResponse { + pub grants: Vec, + pub next_after: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum DriveApiErrorCode { + Denied, + NotFound, + Conflict, + Invalid, + Limit, + StorageUnavailable, + OutcomeUnknown, +} +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum DriveFailureClassification { + NotCommitted, + Unknown, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DriveApiError { + pub code: DriveApiErrorCode, + pub classification: DriveFailureClassification, + pub message: String, +} +impl DriveApiError { + /// Never include provider errors, paths, SQL, or caller-supplied messages. + pub fn new(code: DriveApiErrorCode) -> Self { + use DriveApiErrorCode::*; + Self { + code, + classification: if code == OutcomeUnknown { + DriveFailureClassification::Unknown + } else { + DriveFailureClassification::NotCommitted + }, + message: match code { + Denied => "Drive access denied", + NotFound => "Drive resource not found", + Conflict => "Drive revision or name conflict", + Invalid => "Invalid Drive request", + Limit => "Drive request exceeds a limit", + StorageUnavailable => "Drive storage unavailable", + OutcomeUnknown => "Drive mutation outcome unknown", + } + .into(), + } + } +} +impl api_macros::HttpError for DriveApiError { + fn status_code(&self) -> u16 { + use DriveApiErrorCode::*; + match self.code { + Denied => 403, + NotFound => 404, + Conflict => 409, + Invalid => 400, + Limit => 413, + StorageUnavailable | OutcomeUnknown => 503, + } + } +} +impl api_macros::HttpRequestError for DriveApiError { + fn from_request_rejection(status: u16, _message: String) -> Self { + Self::new(if status == 413 { + DriveApiErrorCode::Limit + } else { + DriveApiErrorCode::Invalid + }) + } +} + +impl api_macros::openapi::OpenApiSchema for DriveApiError {} +impl api_macros::openapi::OpenApiSchema for DriveEntry {} +impl api_macros::openapi::OpenApiSchema for DriveEntryQuery {} +impl api_macros::openapi::OpenApiSchema for DriveListQuery {} +impl api_macros::openapi::OpenApiSchema for DriveSearchQuery {} +impl api_macros::openapi::OpenApiSchema for DriveListResponse {} +impl api_macros::openapi::OpenApiSchema for DriveReadTextQuery {} +impl api_macros::openapi::OpenApiSchema for DriveReadTextResponse {} +impl api_macros::openapi::OpenApiSchema for DriveReadChunkQuery {} +impl api_macros::openapi::OpenApiSchema for DriveDownloadQuery {} +impl api_macros::openapi::OpenApiSchema for DriveMutationRequest {} +impl api_macros::openapi::OpenApiSchema for DriveMutationResponse {} +impl api_macros::openapi::OpenApiSchema for DriveUploadQuery {} +impl api_macros::openapi::OpenApiSchema for DriveRequestStatusResponse {} +impl api_macros::openapi::OpenApiSchema for DriveGrantCreateRequest {} +impl api_macros::openapi::OpenApiSchema for DriveGrantResponse {} +impl api_macros::openapi::OpenApiSchema for DriveGrantListQuery {} +impl api_macros::openapi::OpenApiSchema for DriveGrantListResponse {} + +/// Browser wire declarations are derived from the transport DTOs, not copied +/// from storage models or maintained independently in the Web client. +#[cfg(feature = "typescript")] +pub fn drive_api_typescript() -> String { + use ts_rs::TS; + let config = ts_rs::Config::default(); + let declarations = [ + DriveEntryRef::decl(&config), + DriveEntryKind::decl(&config), + DriveEntry::decl(&config), + DriveEntryQuery::decl(&config), + DriveListQuery::decl(&config), + DriveSearchQuery::decl(&config), + DriveListResponse::decl(&config), + DriveReadTextQuery::decl(&config), + DriveReadTextResponse::decl(&config), + DriveReadChunkQuery::decl(&config), + DriveDownloadQuery::decl(&config), + DriveMutation::decl(&config), + DriveMutationRequest::decl(&config), + DriveMutationResponse::decl(&config), + DriveUploadOperation::decl(&config), + DriveUploadQuery::decl(&config), + DriveRequestState::decl(&config), + DriveRequestStatusResponse::decl(&config), + DriveAccess::decl(&config), + DriveGrantCreateRequest::decl(&config), + DriveGrantResponse::decl(&config), + DriveGrantListQuery::decl(&config), + DriveGrantListResponse::decl(&config), + DriveApiErrorCode::decl(&config), + DriveFailureClassification::decl(&config), + DriveApiError::decl(&config), + ]; + format!( + "// Generated from server-api. Do not edit by hand.\n// Regenerate: cargo run -q -p server-api --features typescript --example generate_drive_api_types > web/workspace/src/lib/generated/drive-api.ts\n\n{}\n", + declarations + .into_iter() + .map(|declaration| format!("export {declaration}")) + .collect::>() + .join("\n\n") + ) +} + +#[cfg(all(test, feature = "typescript"))] +mod typescript_tests { + #[test] + fn generated_drive_api_contract_is_current() { + let expected = super::drive_api_typescript(); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../web/workspace/src/lib/generated/drive-api.ts"); + let actual = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + assert_eq!( + normalize(&actual), + normalize(&expected), + "regenerate Drive API TypeScript types with `cargo run -q -p server-api --features typescript --example generate_drive_api_types > web/workspace/src/lib/generated/drive-api.ts` and format the generated file" + ); + } + fn normalize(value: &str) -> String { + value + .chars() + .filter_map(|character| match character { + character if character.is_whitespace() => None, + ',' => Some(';'), + character => Some(character), + }) + .collect::() + .replace("=|", "=") + .replace(";}", "}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ApiContract, HttpMethod, ServerApiMetadata, canonical_openapi_document}; + use serde_json::json; + + #[test] + fn drive_entry_references_reject_unbound_or_noncanonical_numeric_ids() { + let valid = json!({"workspace_id":"workspace-a", "node_id":"9007199254740993"}); + let entry: DriveEntryRef = serde_json::from_value(valid.clone()).unwrap(); + assert_eq!(serde_json::to_value(entry).unwrap(), valid); + for invalid in [ + json!("7"), + json!(7), + json!({"node_id":"7"}), + json!({"workspace_id":"a", "node_id":7}), + ] { + assert!(serde_json::from_value::(invalid).is_err()); + } + for id in [ + "", + "0", + "01", + "-1", + "+1", + "1.0", + "1e3", + " 1", + "١", + "9223372036854775808", + "18446744073709551616", + ] { + assert!( + serde_json::from_value::(json!({"workspace_id":"a", "node_id":id})) + .is_err(), + "accepted {id:?}" + ); + assert!( + serde_json::from_value::( + json!({"entry_workspace_id":"a", "id":id}) + ) + .is_err() + ); + } + assert!( + serde_json::from_value::( + json!({"workspace_id":"a","node_id":"9223372036854775807"}) + ) + .is_ok() + ); + assert!(serde_json::from_value::(json!({"id":"7"})).is_err()); + assert!(serde_json::from_value::(json!({"id":"7"})).is_err()); + assert!(serde_json::from_value::(json!({"id":"7"})).is_err()); + assert!(serde_json::from_value::(json!({"id":"7"})).is_err()); + assert!( + serde_json::from_value::( + json!({"entry_workspace_id":"a", "id":"7", "worker_id":"spoofed"}) + ) + .is_err() + ); + } + + #[test] + fn drive_metadata_exposes_only_stored_update_provenance_and_same_workspace_url() { + let value = json!({ + "entry":{"workspace_id":"a","node_id":"7"}, + "parent":{"workspace_id":"a","node_id":"1"}, + "name":"file.txt","kind":"file","revision":"2","size":5, + "content_type":"text/plain","updated_by":"user-a","updated_at":"2026-10-08T00:00:00Z", + "latest_url":"/api/w/a/drive/download?entry_workspace_id=a&id=7" + }); + let entry: DriveEntry = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(entry).unwrap(), value); + for field in ["sha256", "created_at"] { + let mut fabricated = value.clone(); + fabricated[field] = json!("not stored"); + assert!(serde_json::from_value::(fabricated).is_err()); + } + } + + #[test] + fn drive_search_text_is_opt_in_and_work_budget_is_bounded() { + let query: DriveSearchQuery = serde_json::from_value(json!({"query":"name"})).unwrap(); + assert!(!query.include_text); + let query: DriveSearchQuery = + serde_json::from_value(json!({"query":"text","include_text":true,"limit":128})) + .unwrap(); + assert!(query.include_text); + for limit in [0, 129, u32::MAX] { + assert!( + serde_json::from_value::(json!({"query":"name","limit":limit})) + .is_err() + ); + } + } + + #[test] + fn drive_request_status_is_a_commit_snapshot_not_a_fictitious_lifecycle() { + for state in ["uncommitted", "committed"] { + let value = json!({"request_id":"r","state":state,"response":null}); + let response: DriveRequestStatusResponse = + serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(response).unwrap(), value); + } + for state in ["pending", "unknown", "not_committed"] { + assert!( + serde_json::from_value::( + json!({"request_id":"r","state":state,"response":null}) + ) + .is_err() + ); + } + } + + #[test] + fn drive_json_mutations_accept_only_bounded_text_and_workspace_bound_cas() { + let valid = json!({"request_id":"request-a", "mutation":{ + "operation":"update_text", "id":{"workspace_id":"a","node_id":"7"}, + "expected_revision":"9007199254740993", "text":"hello", "content_type":"text/plain" + }}); + let request: DriveMutationRequest = serde_json::from_value(valid.clone()).unwrap(); + assert_eq!(serde_json::to_value(request).unwrap(), valid); + let mut invalid = valid.clone(); + invalid["mutation"]["bytes"] = json!([0, 255]); + assert!(serde_json::from_value::(invalid).is_err()); + let mut invalid = valid.clone(); + invalid["mutation"]["id"] = json!("7"); + assert!(serde_json::from_value::(invalid).is_err()); + let mut invalid = valid.clone(); + invalid["mutation"]["expected_revision"] = json!("01"); + assert!(serde_json::from_value::(invalid).is_err()); + // UTF-8 byte count, not character count, determines the text bound. + let mut boundary = valid; + boundary["mutation"]["text"] = json!("é".repeat(DRIVE_TEXT_MAX_BYTES / 2)); + assert!(serde_json::from_value::(boundary.clone()).is_ok()); + boundary["mutation"]["text"] = json!("é".repeat(DRIVE_TEXT_MAX_BYTES / 2 + 1)); + assert!(serde_json::from_value::(boundary).is_err()); + } + + #[test] + fn drive_reads_reject_zero_or_over_64kib_requests() { + for size in [0, 65537, u32::MAX] { + assert!( + serde_json::from_value::( + json!({"entry_workspace_id":"a","id":"7","max_bytes":size}) + ) + .is_err() + ); + assert!(serde_json::from_value::(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":0,"length":size})).is_err()); + } + let default: DriveReadTextQuery = + serde_json::from_value(json!({"entry_workspace_id":"a","id":"7"})).unwrap(); + assert_eq!(default.max_bytes, 65536); + let chunk: DriveReadChunkQuery = serde_json::from_value(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":0,"length":65536})).unwrap(); + assert_eq!(chunk.length, 65536); + } + + #[test] + fn drive_upload_requires_binding_declared_integrity_and_request_identity() { + let valid = json!({"operation":"update","request_id":"request-a","entry_workspace_id":"a","id":"7","expected_revision":"2","content_type":"application/octet-stream","size":3,"sha256":"a".repeat(64)}); + assert!(serde_json::from_value::(valid.clone()).is_ok()); + for field in ["entry_workspace_id", "size", "sha256", "request_id"] { + let mut missing = valid.clone(); + missing.as_object_mut().unwrap().remove(field); + assert!( + serde_json::from_value::(missing).is_err(), + "accepted missing {field}" + ); + } + for size in [DRIVE_FILE_MAX_BYTES, DRIVE_FILE_MAX_BYTES + 1, u32::MAX] { + let mut oversized = valid.clone(); + oversized["size"] = json!(size); + let query: DriveUploadQuery = serde_json::from_value(oversized).unwrap(); + assert_eq!( + query.size, size, + "service must receive oversized declared sizes for typed Limit classification" + ); + } + assert!(serde_json::from_value::(json!({"entry_workspace_id":"a","id":"7","expected_revision":"1","offset":DRIVE_FILE_MAX_BYTES,"length":1})).is_ok()); + let mut invalid = valid; + invalid["bytes"] = json!([0, 255]); + assert!(serde_json::from_value::(invalid).is_err()); + } + + #[test] + fn drive_grants_reject_command_access_and_unbounded_pages() { + let grant = json!({"runtime_id":"r","worker_id":"w","access":"read_write"}); + assert_eq!( + serde_json::to_value( + serde_json::from_value::(grant.clone()).unwrap() + ) + .unwrap(), + grant + ); + assert!( + serde_json::from_value::( + json!({"runtime_id":"r","worker_id":"w","access":"read_write_command"}) + ) + .is_err() + ); + assert!(serde_json::from_value::(json!({"runtime_id":"r","worker_id":"w","access":"read_only","workspace_id":"spoofed"})).is_err()); + for limit in [0, 201, u32::MAX] { + assert!(serde_json::from_value::(json!({"limit":limit})).is_err()); + } + assert!( + serde_json::from_value::(json!({"limit":200,"after":"7"})).is_ok() + ); + assert!(serde_json::from_value::(json!({"after":"01"})).is_err()); + } + + #[test] + fn drive_errors_have_fixed_safe_messages_and_typed_outcome_classification() { + use DriveApiErrorCode::*; + for (code, wire, status, message) in [ + (Denied, "denied", 403, "Drive access denied"), + (NotFound, "not_found", 404, "Drive resource not found"), + (Conflict, "conflict", 409, "Drive revision or name conflict"), + (Invalid, "invalid", 400, "Invalid Drive request"), + (Limit, "limit", 413, "Drive request exceeds a limit"), + ( + StorageUnavailable, + "storage_unavailable", + 503, + "Drive storage unavailable", + ), + ( + OutcomeUnknown, + "outcome_unknown", + 503, + "Drive mutation outcome unknown", + ), + ] { + let error = DriveApiError::new(code); + assert_eq!(api_macros::HttpError::status_code(&error), status); + assert_eq!( + serde_json::to_value(&error).unwrap(), + json!({"code":wire,"classification":if code == OutcomeUnknown {"unknown"} else {"not_committed"},"message":message}) + ); + } + for status in [400, 413, 415, 422] { + let error = ::from_request_rejection( + status, + "/private/path: SQL failed".into(), + ); + assert_eq!( + error, + DriveApiError::new(if status == 413 { Limit } else { Invalid }) + ); + } + assert!(serde_json::from_value::(json!({"code":"invalid","classification":"not_committed","message":"Invalid Drive request","provider_error":"secret"})).is_err()); + } + + #[test] + fn drive_routes_are_workspace_scoped_authenticated_and_binary_is_not_json() { + let document = canonical_openapi_document().unwrap(); + let document: serde_json::Value = + serde_json::from_str(&document.to_json().unwrap()).unwrap(); + let schemas = &document["components"]["schemas"]; + assert_eq!( + schemas["DriveEntry"]["properties"]["size"]["maximum"], + DRIVE_FILE_MAX_BYTES + ); + assert_eq!( + schemas["DriveReadChunkQuery"]["properties"]["offset"]["maximum"], + DRIVE_FILE_MAX_BYTES + ); + assert_eq!( + schemas["DriveUploadQuery"]["properties"]["size"]["maximum"], + DRIVE_FILE_MAX_BYTES + ); + assert_eq!( + schemas["DriveSearchQuery"]["properties"]["limit"]["maximum"], + DRIVE_SEARCH_MAX_LIMIT + ); + for (name, method, suffix, wire_method) in [ + ("drive_root", HttpMethod::Get, "/root", "get"), + ("drive_metadata", HttpMethod::Get, "/metadata", "get"), + ("drive_list", HttpMethod::Get, "/list", "get"), + ("drive_search", HttpMethod::Get, "/search", "get"), + ("drive_read_text", HttpMethod::Get, "/read-text", "get"), + ("drive_read_chunk", HttpMethod::Get, "/read-chunk", "get"), + ("drive_download", HttpMethod::Get, "/download", "get"), + ("drive_mutate", HttpMethod::Post, "/mutate", "post"), + ("drive_upload", HttpMethod::Put, "/upload", "put"), + ( + "drive_request_status", + HttpMethod::Get, + "/requests/{request_id}", + "get", + ), + ("drive_grant_create", HttpMethod::Post, "/grants", "post"), + ( + "drive_grant_revoke", + HttpMethod::Delete, + "/grants/{grant_id}", + "delete", + ), + ("drive_grant_list", HttpMethod::Get, "/grants", "get"), + ] { + let operation = ServerApiMetadata::OPERATIONS + .iter() + .find(|operation| operation.operation_id == name) + .unwrap(); + let path = format!("/api/w/{{workspace_id}}/drive{suffix}"); + assert_eq!(operation.method, method); + assert_eq!(operation.path, path); + let documented = &document["paths"][&path][wire_method]; + assert_eq!(documented["operationId"], name); + assert_eq!( + documented["security"], + json!([{"bearerAuth":[]},{"browserSession":[]}]) + ); + // Server extensions must not become caller-controlled parameters. + assert!( + documented["parameters"] + .as_array() + .unwrap() + .iter() + .all(|parameter| parameter["name"] != "context") + ); + if matches!(name, "drive_read_chunk" | "drive_download") { + let content = &documented["responses"]["200"]["content"]; + assert!(content["application/octet-stream"].is_object()); + assert!(content["application/json"].is_null()); + } + if name == "drive_download" { + let headers = documented["responses"]["200"]["headers"] + .as_object() + .unwrap(); + assert_eq!(headers.len(), 5); + for header in [ + "content-disposition", + "cache-control", + "etag", + "x-content-type-options", + "content-security-policy", + ] { + assert_eq!( + headers[header]["schema"]["$ref"], + "#/components/schemas/string" + ); + } + assert!(!headers.contains_key("content-type")); + assert!(!headers.contains_key("content-length")); + } + if name == "drive_upload" { + let content = &documented["requestBody"]["content"]; + assert!(content["application/octet-stream"].is_object()); + assert!(content["application/json"].is_null()); + } + } + } +} diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index a1f4fefcc..4cb3bced9 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -10,7 +10,9 @@ use api_macros::api; pub use api_macros::axum as server_support; pub use api_macros::reqwest as client_support; pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation}; +mod drive; pub mod repository_openapi_typescript; +pub use drive::*; mod workspace_config; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -2603,6 +2605,116 @@ pub trait ServerApi { #[path] working_directory_id: String, #[body] request: WorkingDirectoryRemovalRequest, ) -> Result; + // Ordinary operations accept member browser sessions or trusted Worker request + // context. The service resolves Worker grants; no operation accepts an actor or + // grant ID from the caller. Grant management is Workspace-owner-only. + #[get("/api/w/{workspace_id}/drive/root", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)] + async fn drive_root( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + ) -> Result; + #[get("/api/w/{workspace_id}/drive/metadata", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)] + async fn drive_metadata( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[query] query: DriveEntryQuery, + ) -> Result; + #[get("/api/w/{workspace_id}/drive/list", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)] + async fn drive_list( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[query] query: DriveListQuery, + ) -> Result; + #[get("/api/w/{workspace_id}/drive/search", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)] + async fn drive_search( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[query] query: DriveSearchQuery, + ) -> Result; + #[get("/api/w/{workspace_id}/drive/read-text", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)] + async fn drive_read_text( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[query] query: DriveReadTextQuery, + ) -> Result; + #[get("/api/w/{workspace_id}/drive/read-chunk", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)] + async fn drive_read_chunk( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[query] query: DriveReadChunkQuery, + ) -> Result; + // Download safety/cache headers are service-produced typed response metadata; + // Content-Type/Content-Length remain controlled by the binary transport. + #[get( + "/api/w/{workspace_id}/drive/download", + responses = [(status = 200, body = BinaryBody, headers = [ + ("content-disposition", String), + ("cache-control", String), + ("etag", String), + ("x-content-type-options", String), + ("content-security-policy", String) + ])], + error_status = 400, + additional_error_statuses = [401,403,404,409,413,503], + bearer_auth = true, + browser_auth = true + )] + async fn drive_download( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[query] query: DriveDownloadQuery, + ) -> Result; + #[post("/api/w/{workspace_id}/drive/mutate", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)] + async fn drive_mutate( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: DriveMutationRequest, + ) -> Result; + #[put("/api/w/{workspace_id}/drive/upload", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)] + async fn drive_upload( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[query] query: DriveUploadQuery, + #[binary] body: BinaryBody, + ) -> Result; + #[get("/api/w/{workspace_id}/drive/requests/{request_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)] + async fn drive_request_status( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] request_id: String, + ) -> Result; + #[post("/api/w/{workspace_id}/drive/grants", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true, normalize_body_errors = true)] + async fn drive_grant_create( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: DriveGrantCreateRequest, + ) -> Result; + #[delete("/api/w/{workspace_id}/drive/grants/{grant_id}", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)] + async fn drive_grant_revoke( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] grant_id: String, + ) -> Result; + #[get("/api/w/{workspace_id}/drive/grants", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,503], bearer_auth = true, browser_auth = true)] + async fn drive_grant_list( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[query] query: DriveGrantListQuery, + ) -> Result; + #[get("/api/w/{workspace_id}/workers/self/workspace-config", status = 200, error_status = 400, additional_error_statuses = [401,403,404,409,413,500,503], openapi = false)] async fn current_worker_workspace_config_get( &self, diff --git a/crates/workspace-drive/src/lib.rs b/crates/workspace-drive/src/lib.rs index 221f7f6ad..861d2e4b9 100644 --- a/crates/workspace-drive/src/lib.rs +++ b/crates/workspace-drive/src/lib.rs @@ -29,6 +29,8 @@ pub enum Error { NotFound, #[error("Drive revision, name, or request identity conflict")] Conflict, + #[error("Drive access denied")] + Denied, #[error("Workspace Drive is fenced for deletion")] Fenced, #[error("Drive storage failure: {0}")] @@ -174,8 +176,11 @@ pub struct CollectionPage { pub next_after: Option, } +type Authorizer = dyn Fn(&Connection, bool) -> Result<()> + Send + Sync; + #[derive(Clone)] pub struct Drive { + authorizer: Option>, database: FeatureDatabase, blobs: Arc, workspace_id: String, @@ -232,6 +237,7 @@ impl Drive { Ok(()) })?; Ok(Self { + authorizer: None, database, blobs, workspace_id, @@ -278,8 +284,36 @@ impl Drive { })?; Ok(drive) } + /// Bind immutable caller authority to this handle and its clones. The callback + /// runs within every client operation's IMMEDIATE transaction, never at bind + /// time; attached authority stays locked through blob I/O and publication. + /// Host maintenance must use an unbound handle. + pub fn with_authorizer(&self, authorizer: F) -> Self + where + F: Fn(&Connection, bool) -> Result<()> + Send + Sync + 'static, + { + Self { + authorizer: Some(Arc::new(authorizer)), + ..self.clone() + } + } + fn authorize(&self, connection: &Connection, write: bool) -> Result<()> { + match &self.authorizer { + Some(authorizer) => authorizer(connection, write), + None => Ok(()), + } + } + /// Early admission check, not a lease: every subsequent operation still + /// invokes its authorizer in that operation's own transaction. + pub fn check_authorized(&self, write: bool) -> Result<()> { + self.database.try_transaction(|tx| { + self.authorize(tx, write)?; + accepting(tx) + }) + } pub fn root(&self) -> Result { - self.database.try_with_connection(|c| { + self.database.try_transaction(|c| { + self.authorize(c, false)?; let id = c.query_row( "SELECT id FROM drive_nodes WHERE parent_id IS NULL", [], @@ -289,7 +323,10 @@ impl Drive { }) } pub fn metadata(&self, id: NodeId) -> Result { - self.database.try_with_connection(|c| node(c, id)) + self.database.try_transaction(|c| { + self.authorize(c, false)?; + node(c, id) + }) } /// Keyset pages are ordered by stable ID, not name. Concurrent mutations may @@ -297,6 +334,7 @@ impl Drive { pub fn list(&self, parent: NodeId, after: Option, limit: usize) -> Result { validate_limit(limit, MAX_PAGE)?; self.database.try_transaction(|tx| { + self.authorize(tx, false)?; directory(tx, parent)?; let mut stmt = tx.prepare( "SELECT id FROM drive_nodes WHERE parent_id=?1 AND id>?2 ORDER BY id LIMIT ?3", @@ -320,7 +358,8 @@ impl Drive { pub fn request_status(&self, request_id: &str) -> Result { validate_identity(request_id, "request_id", 128)?; - self.database.try_with_connection(|c| { + self.database.try_transaction(|c| { + self.authorize(c, false)?; let result = c .query_row( "SELECT result_json FROM drive_receipts WHERE request_id=?1", @@ -354,6 +393,7 @@ impl Drive { .map(|b| format!("{b:02x}")) .collect(); self.database.try_transaction(|tx| { + self.authorize(tx, true)?; if let Some((old_fingerprint,json)) = tx.query_row("SELECT fingerprint,result_json FROM drive_receipts WHERE request_id=?1", [request_id], |r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?))).optional()? { if old_fingerprint != fingerprint { return Err(Error::Conflict); } @@ -419,6 +459,7 @@ impl Drive { ) -> Result { validate_limit(length, MAX_READ_BYTES)?; self.database.try_transaction(|tx| { + self.authorize(tx, false)?; accepting(tx)?; let selected = node(tx, id)?; if selected.revision != revision { @@ -488,6 +529,7 @@ impl Drive { validate_identity(query, "query", 256)?; validate_limit(budget, MAX_SEARCH_NODES)?; self.database.try_transaction(|tx| { + self.authorize(tx, false)?; accepting(tx)?; let mut stmt = tx.prepare("SELECT id FROM drive_nodes WHERE parent_id IS NOT NULL AND id>?1 ORDER BY id LIMIT ?2")?; let ids = stmt.query_map(params![after.map_or(0,|v|v.0),budget+1],|r|r.get::<_,i64>(0))?.collect::,_>>()?; diff --git a/crates/workspace-drive/src/tests.rs b/crates/workspace-drive/src/tests.rs index 00172b08c..703e4c51b 100644 --- a/crates/workspace-drive/src/tests.rs +++ b/crates/workspace-drive/src/tests.rs @@ -299,3 +299,183 @@ fn interrupted_full_permission_and_sync_failed_uploads_do_not_publish_metadata_o ); } } + +fn grant_fixture(t: &tempfile::TempDir) -> (Drive, Connection) { + let authority = t.path().join("server.db"); + let server = Connection::open(&authority).unwrap(); + feature_storage::configure_connection(&server).unwrap(); + server + .execute_batch( + "CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY,state TEXT NOT NULL); + INSERT INTO workspaces VALUES ('ws','active'); + CREATE TABLE grants(write_access INTEGER NOT NULL,revoked INTEGER NOT NULL); + INSERT INTO grants VALUES (1,0);", + ) + .unwrap(); + let storage = feature_storage::FeatureStorage::new(t.path().join("metadata")) + .workspace("ws") + .unwrap(); + let registration = Drive::register(&storage).unwrap(); + ( + Drive::open_with_workspace_authority( + &storage, + ®istration, + &t.path().join("blobs"), + &authority, + ) + .unwrap(), + server, + ) +} +fn grant_authorizer(conn: &Connection, write: bool) -> Result<()> { + assert!( + !conn.is_autocommit(), + "authority must be checked in the operation transaction" + ); + let allowed: bool = conn.query_row( + "SELECT revoked=0 AND (?1=0 OR write_access=1) FROM yoi_workspace_authority.grants", + [write], + |r| r.get(0), + )?; + if allowed { Ok(()) } else { Err(Error::Denied) } +} + +#[test] +fn bound_authorizer_checks_every_read_write_and_receipt_replay_without_caching() { + let t = tempfile::tempdir().unwrap(); + let (raw, server) = grant_fixture(&t); + let root = raw.root().unwrap(); + let bound = raw.with_authorizer(grant_authorizer); + let mutation = create(root.id, "file"); + let result = bound.mutate("request", "worker", mutation.clone()).unwrap(); + bound.check_authorized(true).unwrap(); + server + .execute("UPDATE grants SET write_access=0", []) + .unwrap(); + assert!(bound.check_authorized(false).is_ok()); + assert!(matches!(bound.check_authorized(true), Err(Error::Denied))); + assert!(bound.root().is_ok()); + assert!(bound.metadata(result.node.id).is_ok()); + assert!(bound.list(root.id, None, 10).is_ok()); + assert!(bound.search("file", true, None, 10).is_ok()); + assert!( + bound + .read(result.node.id, result.node.revision, 0, 100) + .is_ok() + ); + assert!( + bound + .read_text(result.node.id, result.node.revision, 0, 100) + .is_ok() + ); + assert!(bound.request_status("request").is_ok()); + assert!( + matches!( + bound.mutate("request", "worker", mutation.clone()), + Err(Error::Denied) + ), + "read-only receipt replay is a write request" + ); + let clone = bound.clone(); + server.execute("UPDATE grants SET revoked=1", []).unwrap(); + assert!(matches!(clone.check_authorized(false), Err(Error::Denied))); + assert!(matches!(clone.check_authorized(true), Err(Error::Denied))); + assert!(matches!(clone.root(), Err(Error::Denied))); + assert!(matches!(clone.metadata(result.node.id), Err(Error::Denied))); + assert!(matches!(clone.list(root.id, None, 10), Err(Error::Denied))); + assert!(matches!( + clone.search("file", true, None, 10), + Err(Error::Denied) + )); + assert!(matches!( + clone.read(result.node.id, result.node.revision, 0, 100), + Err(Error::Denied) + )); + assert!(matches!( + clone.read_text(result.node.id, result.node.revision, 0, 100), + Err(Error::Denied) + )); + assert!(matches!( + clone.request_status("request"), + Err(Error::Denied) + )); + assert!(matches!( + clone.mutate("request", "worker", mutation), + Err(Error::Denied) + )); + assert!(matches!( + clone.mutate("denied", "worker", create(root.id, "denied")), + Err(Error::Denied) + )); + assert_eq!( + raw.blobs.list(None, 10).unwrap().len(), + 1, + "denied write must not publish a blob" + ); + assert_eq!( + raw.request_status("denied").unwrap(), + RequestStatus::Uncommitted + ); + assert_eq!(raw.list(root.id, None, 10).unwrap().nodes.len(), 1); + raw.collect(None, 10).unwrap(); +} + +#[test] +fn revoke_serializes_with_publication_and_cached_handle_denies_after_revoke_commit() { + use std::sync::{Mutex, mpsc}; + use std::time::Duration; + let t = tempfile::tempdir().unwrap(); + let (raw, mut server) = grant_fixture(&t); + let root = raw.root().unwrap(); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let release_rx = Mutex::new(release_rx); + let publishing = raw.with_authorizer(move |conn, write| { + grant_authorizer(conn, write)?; + entered_tx.send(()).unwrap(); + release_rx + .lock() + .unwrap() + .recv_timeout(Duration::from_secs(5)) + .unwrap(); + Ok(()) + }); + let cached = raw.with_authorizer(grant_authorizer); + let writer = std::thread::spawn(move || { + publishing.mutate("publishing", "worker", create(root.id, "published")) + }); + entered_rx.recv_timeout(Duration::from_secs(5)).unwrap(); + server.busy_timeout(Duration::ZERO).unwrap(); + assert!( + matches!(server.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate), + Err(rusqlite::Error::SqliteFailure(e,_)) if e.code==rusqlite::ErrorCode::DatabaseBusy), + "revoke cannot acquire authority while mutation publishes" + ); + release_tx.send(()).unwrap(); + let published = writer.join().unwrap().unwrap(); + let revoke = server + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .unwrap(); + revoke.execute("UPDATE grants SET revoked=1", []).unwrap(); + revoke.commit().unwrap(); + assert_eq!(raw.metadata(published.node.id).unwrap(), published.node); + assert_eq!( + raw.request_status("publishing").unwrap(), + RequestStatus::Committed { + result: published.clone() + } + ); + assert!(matches!( + cached.request_status("publishing"), + Err(Error::Denied) + )); + assert!(matches!( + cached.mutate("publishing", "worker", create(root.id, "published")), + Err(Error::Denied) + )); + assert!(matches!( + cached.mutate("after-revoke", "worker", create(root.id, "denied")), + Err(Error::Denied) + )); + assert_eq!(raw.blobs.list(None, 10).unwrap().len(), 1); +} diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 3e3e3444b..1f5f4f66a 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -11,6 +11,7 @@ name = "yoi-server" path = "src/main.rs" [dependencies] +api-macros = { workspace = true, features = ["axum"] } async-trait.workspace = true axum = { workspace = true, features = ["ws"] } chrono = { version = "0.4", default-features = false, features = ["clock"] } diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index 87fbac7cc..886ec21ef 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -1629,3 +1629,28 @@ CREATE TABLE workspace_config_grants ( ); CREATE UNIQUE INDEX workspace_config_grants_active_worker ON workspace_config_grants(workspace_id, runtime_id, worker_id) WHERE revoked = 0; + +CREATE TABLE workspace_drive_grants ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + grant_id INTEGER PRIMARY KEY AUTOINCREMENT, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + access TEXT NOT NULL CHECK(access IN ('read_only', 'read_write')), + revoked INTEGER NOT NULL DEFAULT 0 CHECK(revoked IN (0, 1)), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + revoked_by TEXT, + revoked_at TEXT, + CHECK((revoked=0 AND revoked_by IS NULL AND revoked_at IS NULL) + OR (revoked=1 AND revoked_by IS NOT NULL AND revoked_at IS NOT NULL)) +); +CREATE UNIQUE INDEX workspace_drive_grants_active_worker + ON workspace_drive_grants(workspace_id, runtime_id, worker_id) WHERE revoked=0; +-- Retain grant audit across Worker deletion, but never carry its authority into +-- a later lifetime reusing the same Runtime/Worker identity. +CREATE TRIGGER workspace_drive_grants_worker_deleted BEFORE DELETE ON worker_registry BEGIN + UPDATE workspace_drive_grants SET revoked=1, revoked_by='server:worker-removal', + revoked_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE workspace_id=OLD.workspace_id AND runtime_id=OLD.runtime_id + AND worker_id=OLD.worker_id AND revoked=0; +END; diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index bca30ba76..e3a574509 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -6,6 +6,10 @@ pub(crate) use worker_operations::{ WorkerOperationContext, WorkspaceWorker, WorkspaceWorkerMethodSender, }; +#[path = "server_drive.rs"] +mod drive; +#[path = "server_drive_grants.rs"] +mod drive_grants; #[path = "server_workspace_config.rs"] mod workspace_config; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -2601,6 +2605,9 @@ fn signed_request_target(uri: &Uri) -> &str { } fn repository_api_rejection(path: &str, status: StatusCode, message: &str) -> Response { + if path.starts_with("/api/w/") && path.contains("/drive/") { + return (status, Json(drive::failure(status.as_u16()))).into_response(); + } if path == "/api/repositories" || path.starts_with("/api/repositories/") || (path.starts_with("/api/w/") && path.contains("/repositories")) @@ -6303,6 +6310,7 @@ fn generated_workspace_catalog_contract_router(service: ServerApiContractService fn generated_workspace_contract_router(service: ServerApiContractService) -> Router { let service = Arc::new(service); Router::new() + .merge(drive::router(service.clone())) .merge(server_api::server_api_axum::workspace_current( service.clone(), )) @@ -11112,6 +11120,128 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn drive_root( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ) -> std::result::Result { + let api = self.workspace_api().map_err(|_| drive::failure(404))?; + drive::root(api, &context, &workspace_id).await + } + async fn drive_metadata( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + query: server_api::DriveEntryQuery, + ) -> std::result::Result { + let api = self.workspace_api().map_err(|_| drive::failure(404))?; + drive::metadata(api, &context, &workspace_id, query).await + } + async fn drive_list( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + query: server_api::DriveListQuery, + ) -> std::result::Result { + let api = self.workspace_api().map_err(|_| drive::failure(404))?; + drive::list(api, &context, &workspace_id, query).await + } + async fn drive_search( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + query: server_api::DriveSearchQuery, + ) -> std::result::Result { + let api = self.workspace_api().map_err(|_| drive::failure(404))?; + drive::search(api, &context, &workspace_id, query).await + } + async fn drive_read_text( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + query: server_api::DriveReadTextQuery, + ) -> std::result::Result { + let api = self.workspace_api().map_err(|_| drive::failure(404))?; + drive::read_text(api, &context, &workspace_id, query).await + } + async fn drive_read_chunk( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + query: server_api::DriveReadChunkQuery, + ) -> std::result::Result { + let api = self.workspace_api().map_err(|_| drive::failure(404))?; + drive::read_chunk(api, &context, &workspace_id, query).await + } + async fn drive_download( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + query: server_api::DriveDownloadQuery, + ) -> std::result::Result< + server_api::server_api_responses::DriveDownload, + server_api::DriveApiError, + > { + let api = self.workspace_api().map_err(|_| drive::failure(404))?; + drive::download(api, &context, &workspace_id, query).await + } + async fn drive_mutate( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::DriveMutationRequest, + ) -> std::result::Result { + let api = self.workspace_api().map_err(|_| drive::failure(404))?; + drive::mutate(api, &context, &workspace_id, request).await + } + async fn drive_upload( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + query: server_api::DriveUploadQuery, + body: server_api::BinaryBody, + ) -> std::result::Result { + let api = self.workspace_api().map_err(|_| drive::failure(404))?; + drive::upload(api, &context, &workspace_id, query, body).await + } + async fn drive_request_status( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request_id: String, + ) -> std::result::Result + { + let api = self.workspace_api().map_err(|_| drive::failure(404))?; + drive::request_status(api, &context, &workspace_id, request_id).await + } + async fn drive_grant_create( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::DriveGrantCreateRequest, + ) -> std::result::Result { + let api = self.workspace_api().map_err(|_| drive::failure(404))?; + drive_grants::create_grant(api, &context, &workspace_id, request).await + } + async fn drive_grant_revoke( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + grant_id: String, + ) -> std::result::Result { + let api = self.workspace_api().map_err(|_| drive::failure(404))?; + drive_grants::revoke_grant(api, &context, &workspace_id, &grant_id).await + } + async fn drive_grant_list( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + query: server_api::DriveGrantListQuery, + ) -> std::result::Result { + let api = self.workspace_api().map_err(|_| drive::failure(404))?; + drive_grants::list_grants(api, &context, &workspace_id, query).await + } + async fn current_worker_workspace_config_get( &self, context: server_api::ServerRequestContext, @@ -32591,6 +32721,7 @@ impl IntoResponse for ApiError { #[cfg(test)] mod tests { mod auth_logging_tests; + mod drive_api_tests; mod drive_tests; mod subject_spawn_tests; mod subjektiv_jobs_tests; diff --git a/crates/workspace-server/src/server/tests/drive_api_tests.rs b/crates/workspace-server/src/server/tests/drive_api_tests.rs new file mode 100644 index 000000000..b6ec7ac69 --- /dev/null +++ b/crates/workspace-server/src/server/tests/drive_api_tests.rs @@ -0,0 +1,1006 @@ +//! Real isolated Server DB + FeatureStorage + LocalFileSystem HTTP boundaries. +use super::*; +use server_api::*; +use sha2::{Digest, Sha256}; + +fn base() -> String { + format!("/api/w/{TEST_WORKSPACE_ID}/drive") +} +fn reference(id: &str) -> Value { + json!({"workspace_id":TEST_WORKSPACE_ID,"node_id":id}) +} +fn digest(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} +fn upload_url(request: &str, root: &str, name: &str, media: &str, bytes: &[u8]) -> String { + let query = url::form_urlencoded::Serializer::new(String::new()) + .append_pair("operation", "create") + .append_pair("request_id", request) + .append_pair("entry_workspace_id", TEST_WORKSPACE_ID) + .append_pair("parent_id", root) + .append_pair("name", name) + .append_pair("content_type", media) + .append_pair("size", &bytes.len().to_string()) + .append_pair("sha256", &digest(bytes)) + .finish(); + format!("{}/upload?{query}", base()) +} +async fn response_json(response: Response, expected: StatusCode) -> Value { + let status = response.status(); + let bytes = to_bytes(response.into_body(), 4 * 1024 * 1024) + .await + .unwrap(); + let body: Value = + serde_json::from_slice(&bytes).unwrap_or_else(|_| json!(String::from_utf8_lossy(&bytes))); + assert_eq!(status, expected, "{body}"); + body +} +async fn user_call( + api: &WorkspaceApi, + token: &str, + method: &str, + path: &str, + body: Option, + expected: StatusCode, +) -> Value { + let mut builder = Request::builder() + .method(method) + .uri(path) + .header("authorization", format!("Bearer {token}")); + let bytes = if let Some(body) = body { + builder = builder.header(CONTENT_TYPE, "application/json"); + serde_json::to_vec(&body).unwrap() + } else { + vec![] + }; + response_json( + build_router(api.clone()) + .oneshot(builder.body(Body::from(bytes)).unwrap()) + .await + .unwrap(), + expected, + ) + .await +} +async fn root_id(api: &WorkspaceApi, token: &str) -> String { + user_call( + api, + token, + "GET", + &format!("{}/root", base()), + None, + StatusCode::OK, + ) + .await["entry"]["node_id"] + .as_str() + .unwrap() + .to_string() +} +async fn mutate( + api: &WorkspaceApi, + token: &str, + request: &str, + mutation: Value, + expected: StatusCode, +) -> Value { + user_call( + api, + token, + "POST", + &format!("{}/mutate", base()), + Some(json!({"request_id":request,"mutation":mutation})), + expected, + ) + .await +} + +#[tokio::test] +async fn drive_http_receipts_cas_stable_url_and_workspace_refs_follow_storage_authority() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api(temp.path()).await; + let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + let root = root_id(&api, &token).await; + let create = json!({"operation":"create_text","parent":reference(&root),"name":"日本語.md","text":"# Shared\n","content_type":"text/markdown"}); + let first = mutate(&api, &token, "create", create.clone(), StatusCode::OK).await; + let id = first["entry"]["entry"]["node_id"].as_str().unwrap(); + let rev = first["entry"]["revision"].as_str().unwrap(); + assert_eq!( + first, + mutate(&api, &token, "create", create.clone(), StatusCode::OK).await, + "same request must replay" + ); + mutate(&api, &token, "same-name", create, StatusCode::CONFLICT).await; + let status = user_call( + &api, + &token, + "GET", + &format!("{}/requests/create", base()), + None, + StatusCode::OK, + ) + .await; + assert_eq!(status["state"], "committed"); + assert_eq!(status["response"], first); + let text = user_call( + &api, + &token, + "GET", + &format!( + "{}/read-text?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}&max_bytes=65536", + base() + ), + None, + StatusCode::OK, + ) + .await; + assert_eq!(text["text"], "# Shared\n"); + let wrong=mutate(&api,&token,"foreign",json!({"operation":"delete","id":{"workspace_id":"foreign","node_id":id},"expected_revision":rev}),StatusCode::FORBIDDEN).await; + assert_eq!(wrong["code"], "denied"); + user_call( + &api, + &token, + "GET", + &format!("{}/metadata?entry_workspace_id=foreign&id={id}", base()), + None, + StatusCode::FORBIDDEN, + ) + .await; + mutate( + &api, + &token, + "traversal", + json!({"operation":"create_folder","parent":reference(&root),"name":"../escape"}), + StatusCode::BAD_REQUEST, + ) + .await; + let updated=mutate(&api,&token,"update",json!({"operation":"update_text","id":reference(id),"expected_revision":rev,"text":"new","content_type":"text/plain"}),StatusCode::OK).await; + for operation in [ + json!({"operation":"update_text","id":reference(id),"expected_revision":rev,"text":"lost","content_type":"text/plain"}), + json!({"operation":"delete","id":reference(id),"expected_revision":rev}), + json!({"operation":"relocate","id":reference(id),"expected_revision":rev,"parent":reference(&root),"name":"lost"}), + ] { + mutate(&api, &token, "stale", operation, StatusCode::CONFLICT).await; + } + user_call(&api,&token,"GET",&format!("{}/read-chunk?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}&expected_revision={rev}&offset=0&length=1",base()),None,StatusCode::CONFLICT).await; + let renamed=mutate(&api,&token,"rename",json!({"operation":"relocate","id":reference(id),"expected_revision":updated["entry"]["revision"],"parent":reference(&root),"name":"renamed.md"}),StatusCode::OK).await; + // URL is independent of name/parent; metadata lookup still resolves the same ID. + assert_eq!(renamed["entry"]["entry"], first["entry"]["entry"]); + let deleted=mutate(&api,&token,"delete",json!({"operation":"delete","id":reference(id),"expected_revision":renamed["entry"]["revision"]}),StatusCode::OK).await; + assert!(deleted["entry"].is_null()); + user_call( + &api, + &token, + "GET", + &format!( + "{}/metadata?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}", + base() + ), + None, + StatusCode::NOT_FOUND, + ) + .await; + let recreated=mutate(&api,&token,"recreate",json!({"operation":"create_text","parent":reference(&root),"name":"renamed.md","text":"","content_type":"text/plain"}),StatusCode::OK).await; + assert_ne!(recreated["entry"]["entry"]["node_id"], id); +} + +#[tokio::test] +async fn drive_binary_upload_download_enforce_digest_size_and_safe_active_media_headers() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api(temp.path()).await; + let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + let root = root_id(&api, &token).await; + for (i, bytes, media) in [ + (0, b"".as_slice(), "text/html"), + (1, b"\x89PNG\r\n\xff".as_slice(), "image/png"), + (2, b"".as_slice(), "application/octet-stream"), + ] { + let path = upload_url( + &format!("upload-{i}"), + &root, + &format!("file-{i}"), + media, + bytes, + ); + let response = build_router(api.clone()) + .oneshot( + Request::builder() + .method("PUT") + .uri(path) + .header("authorization", format!("Bearer {token}")) + .header(CONTENT_TYPE, "application/octet-stream") + .body(Body::from(bytes)) + .unwrap(), + ) + .await + .unwrap(); + let saved = response_json(response, StatusCode::OK).await; + let id = saved["entry"]["entry"]["node_id"].as_str().unwrap(); + let path = format!( + "{}/download?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}", + base() + ); + let response = build_router(api.clone()) + .oneshot( + Request::builder() + .uri(&path) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CONTENT_TYPE], media); + assert_eq!(response.headers()["x-content-type-options"], "nosniff"); + assert!( + response.headers()["content-disposition"] + .to_str() + .unwrap() + .starts_with("attachment;") + ); + assert_eq!(response.headers()[CACHE_CONTROL], "private, no-store"); + assert!( + response.headers()["content-security-policy"] + .to_str() + .unwrap() + .contains("sandbox") + ); + assert!(response.headers()["etag"].to_str().unwrap().contains(id)); + assert_eq!( + to_bytes(response.into_body(), workspace_drive::MAX_FILE_BYTES) + .await + .unwrap() + .as_ref(), + bytes + ); + let response = build_router(api.clone()) + .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + response_json(response, StatusCode::UNAUTHORIZED).await; + } + let path = upload_url("bad-digest", &root, "bad", "text/plain", b"aaa"); + let response = build_router(api.clone()) + .oneshot( + Request::builder() + .method("PUT") + .uri(&path) + .header("authorization", format!("Bearer {token}")) + .body(Body::from("bbb")) + .unwrap(), + ) + .await + .unwrap(); + response_json(response, StatusCode::BAD_REQUEST).await; + let response = build_router(api.clone()) + .oneshot( + Request::builder() + .method("PUT") + .uri(path) + .header("authorization", format!("Bearer {token}")) + .body(Body::from("a")) + .unwrap(), + ) + .await + .unwrap(); + response_json(response, StatusCode::BAD_REQUEST).await; + let listing = user_call( + &api, + &token, + "GET", + &format!( + "{}/list?entry_workspace_id={TEST_WORKSPACE_ID}&id={root}", + base() + ), + None, + StatusCode::OK, + ) + .await; + assert_eq!( + listing["entries"].as_array().unwrap().len(), + 3, + "mismatch cannot publish" + ); +} + +async fn workers(api: &WorkspaceApi) -> (WorkdirlessFixtureRuntime, Vec) { + register_test_runtime(api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; + let runtime = WorkdirlessFixtureRuntime::default(); + api.runtime.register_or_replace(runtime.clone()); + let mut workers = vec![]; + for name in ["Drive reader", "Drive writer"] { + let Json(created) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.into(), + display_name: name.into(), + singleton_key: None, + profile: Some("builtin:coder".into()), + ticket_assignment: None, + initial_submit: vec![], + workdir_attachments: vec![], + feature_connections: Default::default(), + control_operation_id: None, + }), + ) + .await + .unwrap(); + workers.push(RuntimeWorkerRef::new(created.runtime_id, created.worker_id)); + } + (runtime, workers) +} +async fn signed( + api: &WorkspaceApi, + identity: &RuntimeIdentityMaterial, + worker: Option<&str>, + method: &str, + path: &str, + body: Option, + expected: StatusCode, +) -> Value { + let bytes = body + .map(|v| serde_json::to_vec(&v).unwrap()) + .unwrap_or_default(); + response_json( + build_router(api.clone()) + .oneshot(runtime_source_request( + identity, worker, method, path, bytes, + )) + .await + .unwrap(), + expected, + ) + .await +} +#[tokio::test] +async fn drive_two_workers_and_non_owner_use_grants_without_workdir_or_profile_authority() { + let temp = tempfile::tempdir().unwrap(); + let mut api = test_api(temp.path()).await; + let owner = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + let member = seed_test_api_token(api.store.as_ref(), "member"); + let (_runtime, workers) = workers(&api).await; + let identity = RuntimeIdentityMaterial::generate(&workers[0].runtime_id).unwrap(); + configure_runtime_request_auth(&mut api, &identity, &workers[0].runtime_id); + let root = root_id(&api, &member).await; + let get = format!("{}/root", base()); + signed( + &api, + &identity, + Some(&workers[0].worker_id), + "GET", + &get, + None, + StatusCode::FORBIDDEN, + ) + .await; + user_call(&api,&member,"POST",&format!("{}/grants",base()),Some(json!({"runtime_id":workers[0].runtime_id,"worker_id":workers[0].worker_id,"access":"read_write"})),StatusCode::FORBIDDEN).await; + let mut grants = vec![]; + for (worker, access) in workers.iter().zip(["read_only", "read_write"]) { + grants.push(user_call(&api,&owner,"POST",&format!("{}/grants",base()),Some(json!({"runtime_id":worker.runtime_id,"worker_id":worker.worker_id,"access":access})),StatusCode::OK).await); + assert!( + api.store + .list_worker_workdir_links(TEST_WORKSPACE_ID, worker) + .unwrap() + .is_empty() + ); + signed( + &api, + &identity, + Some(&worker.worker_id), + "GET", + &get, + None, + StatusCode::OK, + ) + .await; + } + let mutation = json!({"request_id":"worker-write","mutation":{"operation":"create_text","parent":reference(&root),"name":"shared.md","text":"shared","content_type":"text/markdown"}}); + signed( + &api, + &identity, + Some(&workers[0].worker_id), + "POST", + &format!("{}/mutate", base()), + Some(mutation.clone()), + StatusCode::FORBIDDEN, + ) + .await; + let saved = signed( + &api, + &identity, + Some(&workers[1].worker_id), + "POST", + &format!("{}/mutate", base()), + Some(mutation.clone()), + StatusCode::OK, + ) + .await; + let id = saved["entry"]["entry"]["node_id"].as_str().unwrap(); + let text_path = format!( + "{}/read-text?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}", + base() + ); + assert_eq!( + signed( + &api, + &identity, + Some(&workers[0].worker_id), + "GET", + &text_path, + None, + StatusCode::OK + ) + .await["text"], + "shared" + ); + assert_eq!( + user_call(&api, &member, "GET", &text_path, None, StatusCode::OK).await["text"], + "shared" + ); + signed( + &api, + &identity, + None, + "GET", + &get, + None, + StatusCode::FORBIDDEN, + ) + .await; + signed( + &api, + &identity, + Some("not-registered"), + "GET", + &get, + None, + StatusCode::UNAUTHORIZED, + ) + .await; + user_call( + &api, + &owner, + "DELETE", + &format!( + "{}/grants/{}", + base(), + grants[1]["grant_id"].as_str().unwrap() + ), + None, + StatusCode::OK, + ) + .await; + signed( + &api, + &identity, + Some(&workers[1].worker_id), + "POST", + &format!("{}/mutate", base()), + Some(mutation), + StatusCode::FORBIDDEN, + ) + .await; + signed( + &api, + &identity, + Some(&workers[1].worker_id), + "GET", + &format!("{}/requests/worker-write", base()), + None, + StatusCode::FORBIDDEN, + ) + .await; +} + +struct AbortServer(tokio::task::JoinHandle<()>); +impl Drop for AbortServer { + fn drop(&mut self) { + self.0.abort(); + } +} +#[tokio::test] +async fn drive_generated_client_roundtrips_binary_and_declared_headers_over_real_http() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api(temp.path()).await; + seed_test_api_token(api.store.as_ref(), "drive-client"); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let app = build_router(api); + let _server = AbortServer(tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + })); + let client = ServerApiClient::builder(&url) + .unwrap() + .authorizer(TestBearerAuthorizer("api-token-drive-client")) + .response_body_limit(workspace_drive::MAX_FILE_BYTES) + .build() + .unwrap(); + let workspace = TEST_WORKSPACE_ID.to_string(); + let root = client.drive_root(workspace.clone()).await.unwrap(); + for (request, bytes, media) in [ + ("markdown", b"# Client\n".as_slice(), "text/markdown"), + ("image", b"\x89PNG\xff".as_slice(), "image/png"), + ("empty", b"".as_slice(), "application/octet-stream"), + ] { + let saved = client + .drive_upload( + workspace.clone(), + DriveUploadQuery { + operation: DriveUploadOperation::Create, + request_id: request.into(), + entry_workspace_id: workspace.clone(), + parent_id: Some(root.entry.node_id.clone()), + name: Some(request.into()), + id: None, + expected_revision: None, + content_type: media.into(), + size: bytes.len() as u32, + sha256: digest(bytes), + }, + bytes.to_vec().into(), + ) + .await + .unwrap(); + let saved = saved.entry.unwrap(); + let response = client + .drive_download( + workspace.clone(), + DriveDownloadQuery { + entry_workspace_id: workspace.clone(), + id: saved.entry.node_id.clone(), + expected_revision: Some(saved.revision.clone()), + }, + ) + .await + .unwrap(); + let server_api_responses::DriveDownload::Status200 { + body, + header_content_disposition, + header_cache_control, + header_x_content_type_options, + .. + } = response; + assert_eq!(body.as_ref(), bytes); + assert!(header_content_disposition.starts_with("attachment;")); + assert_eq!(header_cache_control, "private, no-store"); + assert_eq!(header_x_content_type_options, "nosniff"); + let chunk = client + .drive_read_chunk( + workspace.clone(), + DriveReadChunkQuery { + entry_workspace_id: workspace.clone(), + id: saved.entry.node_id, + expected_revision: saved.revision, + offset: 0, + length: 65536, + }, + ) + .await + .unwrap(); + assert_eq!(chunk.as_ref(), bytes); + } +} + +#[tokio::test] +async fn drive_stream_abort_invalid_query_and_storage_loss_never_publish_or_leak_paths() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api(temp.path()).await; + let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + let root = root_id(&api, &token).await; + let chunks = futures::stream::iter([ + Ok(axum::body::Bytes::from_static(b"a")), + Err(std::io::Error::other("private host path")), + ]); + let path = upload_url("interrupted", &root, "broken", "text/plain", b"aaa"); + let response = build_router(api.clone()) + .oneshot( + Request::builder() + .method("PUT") + .uri(path) + .header("authorization", format!("Bearer {token}")) + .body(Body::from_stream(chunks)) + .unwrap(), + ) + .await + .unwrap(); + let rejected = response_json(response, StatusCode::BAD_REQUEST).await; + assert!(!rejected.to_string().contains("private host")); + let status = user_call( + &api, + &token, + "GET", + &format!("{}/requests/interrupted", base()), + None, + StatusCode::OK, + ) + .await; + assert_eq!(status["state"], "uncommitted"); + let over = + upload_url("oversized", &root, "big", "text/plain", b"").replace("size=0", "size=16777217"); + let response = build_router(api.clone()) + .oneshot( + Request::builder() + .method("PUT") + .uri(over) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response_json(response, StatusCode::PAYLOAD_TOO_LARGE).await["code"], + "limit" + ); + let malformed = user_call( + &api, + &token, + "GET", + &format!( + "{}/metadata?entry_workspace_id={TEST_WORKSPACE_ID}&id=01", + base() + ), + None, + StatusCode::BAD_REQUEST, + ) + .await; + assert_eq!(malformed["code"], "invalid"); + mutate(&api,&token,"fake-actor",json!({"operation":"create_folder","parent":reference(&root),"name":"fake","actor":"owner"}),StatusCode::BAD_REQUEST).await; + let saved=mutate(&api,&token,"real",json!({"operation":"create_text","parent":reference(&root),"name":"real","text":"persisted","content_type":"text/plain"}),StatusCode::OK).await; + let blob_root = api.config.drive_blob_root().unwrap(); + // Delete only the fixture's immutable blobs to simulate a Host storage failure. + for file in fs::read_dir(blob_root).unwrap() { + fs::remove_file(file.unwrap().path()).unwrap(); + } + let id = saved["entry"]["entry"]["node_id"].as_str().unwrap(); + let error = user_call( + &api, + &token, + "GET", + &format!( + "{}/read-text?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}", + base() + ), + None, + StatusCode::SERVICE_UNAVAILABLE, + ) + .await; + assert_eq!(error["code"], "storage_unavailable"); + assert!(!error.to_string().contains(temp.path().to_str().unwrap())); + user_call( + &api, + &token, + "GET", + &format!( + "{}/metadata?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}", + base() + ), + None, + StatusCode::OK, + ) + .await; +} + +#[tokio::test] +async fn drive_verified_upload_revoked_during_reception_cannot_commit_or_replay() { + let temp = tempfile::tempdir().unwrap(); + let mut api = test_api(temp.path()).await; + let owner = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + let root = root_id(&api, &owner).await; + let (_runtime, workers) = workers(&api).await; + let identity = RuntimeIdentityMaterial::generate(&workers[0].runtime_id).unwrap(); + configure_runtime_request_auth(&mut api, &identity, &workers[0].runtime_id); + let grant=user_call(&api,&owner,"POST",&format!("{}/grants",base()),Some(json!({"runtime_id":workers[0].runtime_id,"worker_id":workers[0].worker_id,"access":"read_write"})),StatusCode::OK).await; + let store = api.store.clone(); + let grant_id = grant["grant_id"].as_str().unwrap().to_owned(); + let account = format!("account-{TEST_WORKSPACE_ID}"); + let bytes = b"publish only while granted"; + let path = upload_url( + "revoked-in-flight", + &root, + "not-published", + "text/plain", + bytes, + ); + let body = Body::from_stream(futures::stream::once(async move { + // This frame is polled after the HTTP adapter's early authorization. + store + .revoke_workspace_drive_grant(TEST_WORKSPACE_ID, &grant_id, &account) + .unwrap(); + Ok::<_, std::io::Error>(axum::body::Bytes::from_static(bytes)) + })); + // Exercise the authorized HTTP boundary after the separately tested signed + // ingress has populated its trusted source; this is not a client header bypass. + let app = generated_workspace_contract_router(ServerApiContractService::Workspace(api.clone())) + .layer(Extension( + crate::worker_source::VerifiedRuntimeRequestSource { + runtime_id: workers[0].runtime_id.clone(), + worker_id: Some(workers[0].worker_id.clone()), + }, + )); + let response = app + .oneshot( + Request::builder() + .method("PUT") + .uri(path) + .body(body) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response_json(response, StatusCode::FORBIDDEN).await["code"], + "denied" + ); + let status = user_call( + &api, + &owner, + "GET", + &format!("{}/requests/revoked-in-flight", base()), + None, + StatusCode::OK, + ) + .await; + assert_eq!(status["state"], "uncommitted"); + let page = user_call( + &api, + &owner, + "GET", + &format!( + "{}/list?entry_workspace_id={TEST_WORKSPACE_ID}&id={root}", + base() + ), + None, + StatusCode::OK, + ) + .await; + assert!(page["entries"].as_array().unwrap().is_empty()); +} + +#[tokio::test] +async fn drive_read_entrypoints_use_current_grants_and_cannot_fallback_to_browser_actor() { + let temp = tempfile::tempdir().unwrap(); + let mut api = test_api(temp.path()).await; + let owner = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + let root = root_id(&api, &owner).await; + let saved=mutate(&api,&owner,"matrix-file",json!({"operation":"create_text","parent":reference(&root),"name":"matrix","text":"bytes","content_type":"text/plain"}),StatusCode::OK).await; + let id = saved["entry"]["entry"]["node_id"].as_str().unwrap(); + let rev = saved["entry"]["revision"].as_str().unwrap(); + let (_runtime, workers) = workers(&api).await; + let identity = RuntimeIdentityMaterial::generate(&workers[0].runtime_id).unwrap(); + configure_runtime_request_auth(&mut api, &identity, &workers[0].runtime_id); + let routes = [ + format!("{}/root", base()), + format!( + "{}/metadata?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}", + base() + ), + format!( + "{}/list?entry_workspace_id={TEST_WORKSPACE_ID}&id={root}", + base() + ), + format!("{}/search?query=matrix", base()), + format!( + "{}/read-text?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}", + base() + ), + format!( + "{}/read-chunk?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}&expected_revision={rev}&offset=0&length=4", + base() + ), + format!( + "{}/download?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}", + base() + ), + format!("{}/requests/matrix-file", base()), + ]; + for path in &routes { + signed( + &api, + &identity, + Some(&workers[0].worker_id), + "GET", + path, + None, + StatusCode::FORBIDDEN, + ) + .await; + } + let grant=user_call(&api,&owner,"POST",&format!("{}/grants",base()),Some(json!({"runtime_id":workers[0].runtime_id,"worker_id":workers[0].worker_id,"access":"read_only"})),StatusCode::OK).await; + let response = build_router(api.clone()) + .oneshot(runtime_source_request( + &identity, + Some(&workers[0].worker_id), + "PUT", + &upload_url("ro-upload", &root, "denied", "text/plain", b"x"), + b"x".to_vec(), + )) + .await + .unwrap(); + assert_eq!( + response_json(response, StatusCode::FORBIDDEN).await["code"], + "denied" + ); + user_call( + &api, + &owner, + "DELETE", + &format!("{}/grants/{}", base(), grant["grant_id"].as_str().unwrap()), + None, + StatusCode::OK, + ) + .await; + for path in &routes { + signed( + &api, + &identity, + Some(&workers[0].worker_id), + "GET", + path, + None, + StatusCode::FORBIDDEN, + ) + .await; + } + let mut context = ServerRequestContext { + actor: Some(test_owner_actor()), + worker_source: Some(ServerWorkerSource { + runtime_id: workers[0].runtime_id.clone(), + worker_id: workers[0].worker_id.clone(), + }), + runtime_source: None, + origin: None, + transport_headers: vec![], + }; + assert_eq!( + drive::root(&api, &context, TEST_WORKSPACE_ID) + .await + .unwrap_err() + .code, + DriveApiErrorCode::Denied + ); + context.runtime_source = Some(ServerRuntimeSource { + runtime_id: workers[0].runtime_id.clone(), + worker_id: Some(workers[1].worker_id.clone()), + }); + assert_eq!( + drive::root(&api, &context, TEST_WORKSPACE_ID) + .await + .unwrap_err() + .code, + DriveApiErrorCode::Denied + ); +} + +#[tokio::test] +async fn drive_concurrent_http_cas_has_one_winner_and_name_search_pages_are_scoped() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api(temp.path()).await; + let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + let root = root_id(&api, &token).await; + let saved=mutate(&api,&token,"concurrent-file",json!({"operation":"create_text","parent":reference(&root),"name":"candidate","text":"find me","content_type":"text/plain"}),StatusCode::OK).await; + let id = &saved["entry"]["entry"]["node_id"]; + let rev = &saved["entry"]["revision"]; + let request = |identity: &str, text: &str| { + Request::builder().method("POST").uri(format!("{}/mutate",base())).header("authorization",format!("Bearer {token}")).header(CONTENT_TYPE,"application/json").body(Body::from(json!({"request_id":identity,"mutation":{"operation":"update_text","id":reference(id.as_str().unwrap()),"expected_revision":rev,"text":text,"content_type":"text/plain"}}).to_string())).unwrap() + }; + let app = build_router(api.clone()); + let (a, b) = tokio::join!( + app.clone().oneshot(request("a", "find a")), + app.oneshot(request("b", "find b")) + ); + let mut statuses = [a.unwrap().status().as_u16(), b.unwrap().status().as_u16()]; + statuses.sort(); + assert_eq!(statuses, [200, 409]); + mutate( + &api, + &token, + "folder", + json!({"operation":"create_folder","parent":reference(&root),"name":"other"}), + StatusCode::OK, + ) + .await; + let page = user_call( + &api, + &token, + "GET", + &format!( + "{}/list?entry_workspace_id={TEST_WORKSPACE_ID}&id={root}&limit=1", + base() + ), + None, + StatusCode::OK, + ) + .await; + assert_eq!(page["entries"].as_array().unwrap().len(), 1); + let cursor = page["next_after"].as_str().unwrap(); + user_call( + &api, + &token, + "GET", + &format!( + "{}/list?entry_workspace_id={TEST_WORKSPACE_ID}&id={root}&after={}", + base(), + cursor.replace(TEST_WORKSPACE_ID, "foreign") + ), + None, + StatusCode::FORBIDDEN, + ) + .await; + let names = user_call( + &api, + &token, + "GET", + &format!("{}/search?query=find", base()), + None, + StatusCode::OK, + ) + .await; + assert!(names["entries"].as_array().unwrap().is_empty()); + let text = user_call( + &api, + &token, + "GET", + &format!("{}/search?query=find&include_text=true", base()), + None, + StatusCode::OK, + ) + .await; + assert_eq!(text["entries"].as_array().unwrap().len(), 1); +} + +#[tokio::test] +async fn drive_download_stream_expires_instead_of_mixing_revisions() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api(temp.path()).await; + let token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + let root = root_id(&api, &token).await; + let bytes = vec![b'a'; 128 * 1024]; + let path = upload_url( + "stream-generation", + &root, + "large", + "application/octet-stream", + &bytes, + ); + let response = build_router(api.clone()) + .oneshot( + Request::builder() + .method("PUT") + .uri(path) + .header("authorization", format!("Bearer {token}")) + .body(Body::from(bytes)) + .unwrap(), + ) + .await + .unwrap(); + let saved = response_json(response, StatusCode::OK).await; + let id = saved["entry"]["entry"]["node_id"].as_str().unwrap(); + let rev = saved["entry"]["revision"].as_str().unwrap(); + let response = build_router(api.clone()) + .oneshot( + Request::builder() + .uri(format!( + "{}/download?entry_workspace_id={TEST_WORKSPACE_ID}&id={id}", + base() + )) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + mutate(&api,&token,"stream-update",json!({"operation":"update_text","id":reference(id),"expected_revision":rev,"text":"generation b","content_type":"text/plain"}),StatusCode::OK).await; + let mut stream = response.into_body().into_data_stream(); + let first = stream.next().await.unwrap().unwrap(); + assert_eq!(first.as_ref(), vec![b'a'; 64 * 1024]); + assert!( + stream.next().await.unwrap().is_err(), + "next chunk must expire, not deliver generation b" + ); +} diff --git a/crates/workspace-server/src/server_drive.rs b/crates/workspace-server/src/server_drive.rs new file mode 100644 index 000000000..0f8dc7c85 --- /dev/null +++ b/crates/workspace-server/src/server_drive.rs @@ -0,0 +1,736 @@ +//! Authorized Workspace document API. Storage, CAS and receipts stay in workspace-drive. +//! HTTP upload consumes frames with a hard bound; DB publication occurs only after validation. +use super::*; +use futures::StreamExt; +use server_api::*; +use sha2::{Digest, Sha256}; +use workspace_drive::{Drive, Mutation, NodeId, Revision}; + +fn mutation_actor(context: &ServerRequestContext) -> DriveResult { + if let Some(source) = &context.runtime_source { + let worker = source.worker_id.as_ref().ok_or_else(|| failure(403))?; + Ok(format!( + "worker:{}", + serde_json::to_string(&(&source.runtime_id, worker)).map_err(|_| failure(400))? + )) + } else { + Ok(format!( + "user:{}", + context.actor.as_ref().ok_or_else(|| failure(403))?.user_id + )) + } +} + +type DriveResult = std::result::Result; +const TRANSFER_CHUNK: usize = 64 * 1024; + +pub(super) fn failure(status: u16) -> DriveApiError { + let code = match status { + 401 | 403 => DriveApiErrorCode::Denied, + 404 => DriveApiErrorCode::NotFound, + 409 => DriveApiErrorCode::Conflict, + 413 => DriveApiErrorCode::Limit, + 500..=599 => DriveApiErrorCode::StorageUnavailable, + _ => DriveApiErrorCode::Invalid, + }; + DriveApiError::new(code) +} +fn domain_error(error: workspace_drive::Error, mutation: bool) -> DriveApiError { + use workspace_drive::Error; + match error { + Error::Denied => failure(403), + Error::NotFound => failure(404), + Error::Conflict | Error::Fenced => failure(409), + Error::Invalid(_) => failure(400), + Error::Blob(_) => failure(503), + _ if mutation => DriveApiError::new(DriveApiErrorCode::OutcomeUnknown), + _ => failure(503), + } +} +async fn blocking( + mutation: bool, + action: impl FnOnce() -> workspace_drive::Result + Send + 'static, +) -> DriveResult { + tokio::task::spawn_blocking(action) + .await + .map_err(|_| { + if mutation { + DriveApiError::new(DriveApiErrorCode::OutcomeUnknown) + } else { + failure(503) + } + })? + .map_err(|error| domain_error(error, mutation)) +} +fn node_id(value: &str) -> DriveResult { + value.to_owned().try_into().map_err(|_| failure(400)) +} +fn revision(value: &str) -> DriveResult { + value.to_owned().try_into().map_err(|_| failure(400)) +} +fn entry(workspace: &str, reference: &DriveEntryRef) -> DriveResult { + if reference.workspace_id != workspace { + return Err(failure(403)); + } + node_id(&reference.node_id) +} +fn scoped_id(workspace: &str, entry_workspace: &str, id: &str) -> DriveResult { + if workspace != entry_workspace { + return Err(failure(403)); + } + node_id(id) +} +fn safe_content_type(value: &str) -> DriveResult { + // Deliberately accept a bare MIME type only, not attacker-controlled header parameters. + if value.len() > 128 + || !value.contains('/') + || value.matches('/').count() != 1 + || value.split('/').any(str::is_empty) + || !value + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"!#$&^_.+-/".contains(&b)) + { + return Err(failure(400)); + } + Ok(value.to_ascii_lowercase()) +} +fn disposition(name: &str) -> String { + // ASCII fallback plus RFC 5987 UTF-8 filename. Never place raw names in headers. + let encoded: String = name + .as_bytes() + .iter() + .map(|b| { + if b.is_ascii_alphanumeric() || b"-._".contains(b) { + (*b as char).to_string() + } else { + format!("%{b:02X}") + } + }) + .collect(); + format!("attachment; filename=\"download\"; filename*=UTF-8''{encoded}") +} +fn error_response(error: DriveApiError) -> Response { + let status = match error.code { + DriveApiErrorCode::Denied => 403, + DriveApiErrorCode::NotFound => 404, + DriveApiErrorCode::Conflict => 409, + DriveApiErrorCode::Limit => 413, + DriveApiErrorCode::Invalid => 400, + DriveApiErrorCode::StorageUnavailable | DriveApiErrorCode::OutcomeUnknown => 503, + }; + (StatusCode::from_u16(status).unwrap(), Json(error)).into_response() +} +// No raw storage diagnostic is ever placed in a public error or stream failure. +fn stream_error() -> std::io::Error { + std::io::Error::other("Drive transfer unavailable") +} + +/// Stream HTTP frames while enforcing both announced and observed limits. Storage currently +/// accepts a bounded whole-file Vec; this is binary buffering, never JSON/base64 expansion. +async fn receive(body: axum::body::Body, declared: u64, digest: &str) -> DriveResult> { + if declared > workspace_drive::MAX_FILE_BYTES as u64 { + return Err(failure(413)); + } + if digest.len() != 64 + || !digest + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { + return Err(failure(400)); + } + let mut stream = body.into_data_stream(); + let mut bytes = Vec::new(); + let mut hasher = Sha256::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| failure(400))?; + let size = bytes + .len() + .checked_add(chunk.len()) + .ok_or_else(|| failure(413))?; + if size > workspace_drive::MAX_FILE_BYTES { + return Err(failure(413)); + } + if size > declared as usize { + return Err(failure(400)); + } + hasher.update(&chunk); + bytes.extend_from_slice(&chunk); + } + if bytes.len() != declared as usize + || hasher + .finalize() + .iter() + .map(|b| format!("{b:02x}")) + .collect::() + != digest + { + return Err(failure(400)); + } + Ok(bytes) +} + +fn public_node(node: workspace_drive::Node) -> DriveEntry { + let workspace_id = node.workspace_id.clone(); + let time = chrono::DateTime::from_timestamp_millis(node.updated_at_ms) + .map(|t| t.to_rfc3339()) + .unwrap_or_default(); + DriveEntry { + entry: DriveEntryRef { + workspace_id: workspace_id.clone(), + node_id: node.id.get().to_string(), + }, + parent: node.parent_id.map(|id| DriveEntryRef { + workspace_id, + node_id: id.get().to_string(), + }), + name: node.name, + kind: match node.kind { + workspace_drive::Kind::File => DriveEntryKind::File, + workspace_drive::Kind::Directory => DriveEntryKind::Folder, + }, + revision: node.revision.get().to_string(), + size: (node.kind == workspace_drive::Kind::File).then_some(node.size as u32), + content_type: node.content_type, + updated_by: node.updated_by, + updated_at: time, + latest_url: format!( + "/api/w/{}/drive/{}?entry_workspace_id={}&id={}", + node.workspace_id, + if node.kind == workspace_drive::Kind::File { + "download" + } else { + "metadata" + }, + node.workspace_id, + node.id.get() + ), + } +} +fn cursor(workspace: &str, id: Option) -> Option { + id.map(|id| format!("{workspace}:{}", id.get())) +} +fn after(workspace: &str, value: Option) -> DriveResult> { + value + .map(|value| { + let (scope, id) = value.rsplit_once(':').ok_or_else(|| failure(400))?; + scoped_id(workspace, scope, id) + }) + .transpose() +} +fn limit(value: Option, maximum: usize) -> DriveResult { + let limit = value.unwrap_or(maximum as u32) as usize; + if limit == 0 || limit > maximum { + return Err(failure(413)); + } + Ok(limit) +} +pub(super) async fn root( + api: &WorkspaceApi, + context: &ServerRequestContext, + workspace: &str, +) -> DriveResult { + let drive = drive_grants::authorized_drive(api, context, workspace, false).await?; + blocking(false, move || drive.root()).await.map(public_node) +} +pub(super) async fn metadata( + api: &WorkspaceApi, + context: &ServerRequestContext, + workspace: &str, + query: DriveEntryQuery, +) -> DriveResult { + let drive = drive_grants::authorized_drive(api, context, workspace, false).await?; + let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?; + blocking(false, move || drive.metadata(id)) + .await + .map(public_node) +} +pub(super) async fn list( + api: &WorkspaceApi, + context: &ServerRequestContext, + workspace: &str, + query: DriveListQuery, +) -> DriveResult { + let drive = drive_grants::authorized_drive(api, context, workspace, false).await?; + let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?; + let after = after(workspace, query.after)?; + let limit = limit(query.limit, workspace_drive::MAX_PAGE)?; + let page = blocking(false, move || drive.list(id, after, limit)).await?; + Ok(DriveListResponse { + entries: page.nodes.into_iter().map(public_node).collect(), + next_after: cursor(workspace, page.next_after), + }) +} +pub(super) async fn search( + api: &WorkspaceApi, + context: &ServerRequestContext, + workspace: &str, + query: DriveSearchQuery, +) -> DriveResult { + let drive = drive_grants::authorized_drive(api, context, workspace, false).await?; + let after = after(workspace, query.after)?; + let limit = limit(query.limit, workspace_drive::MAX_SEARCH_NODES)?; + let page = blocking(false, move || { + drive.search(&query.query, query.include_text, after, limit) + }) + .await?; + Ok(DriveListResponse { + entries: page.nodes.into_iter().map(public_node).collect(), + next_after: cursor(workspace, page.next_after), + }) +} +pub(super) async fn read_text( + api: &WorkspaceApi, + context: &ServerRequestContext, + workspace: &str, + query: DriveReadTextQuery, +) -> DriveResult { + let drive = drive_grants::authorized_drive(api, context, workspace, false).await?; + let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?; + let max = query.max_bytes as usize; + if max == 0 || max > DRIVE_TEXT_MAX_BYTES { + return Err(failure(413)); + } + let (node, chunk) = blocking(false, move || { + let node = drive.metadata(id)?; + let chunk = drive.read(id, node.revision, 0, max)?; + Ok((node, chunk)) + }) + .await?; + let text = match std::str::from_utf8(&chunk.bytes) { + Ok(text) => text, + Err(error) if !chunk.eof && error.error_len().is_none() => { + std::str::from_utf8(&chunk.bytes[..error.valid_up_to()]).map_err(|_| failure(400))? + } + Err(_) => return Err(failure(400)), + }; + Ok(DriveReadTextResponse { + entry: public_node(node), + text: text.into(), + truncated: !chunk.eof, + }) +} +pub(super) async fn read_chunk( + api: &WorkspaceApi, + context: &ServerRequestContext, + workspace: &str, + query: DriveReadChunkQuery, +) -> DriveResult { + let drive = drive_grants::authorized_drive(api, context, workspace, false).await?; + let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?; + let revision = revision(&query.expected_revision)?; + if query.length == 0 || query.length > DRIVE_CHUNK_MAX_BYTES { + return Err(failure(413)); + } + let chunk = blocking(false, move || { + drive.read(id, revision, query.offset.into(), query.length as usize) + }) + .await?; + Ok(chunk.bytes.into()) +} +fn text_bytes(text: String) -> DriveResult> { + if text.len() > DRIVE_TEXT_MAX_BYTES { + return Err(failure(413)); + } + Ok(text.into_bytes()) +} +fn mutation(workspace: &str, mutation: DriveMutation) -> DriveResult { + Ok(match mutation { + DriveMutation::CreateFolder { parent, name } => Mutation::CreateFolder { + parent: entry(workspace, &parent)?, + name, + }, + DriveMutation::CreateText { + parent, + name, + text, + content_type, + } => Mutation::CreateFile { + parent: entry(workspace, &parent)?, + name, + content_type: safe_content_type(&content_type)?, + bytes: text_bytes(text)?, + }, + DriveMutation::UpdateText { + id, + expected_revision, + text, + content_type, + } => Mutation::Update { + id: entry(workspace, &id)?, + expected_revision: revision(&expected_revision)?, + content_type: safe_content_type(&content_type)?, + bytes: text_bytes(text)?, + }, + DriveMutation::Relocate { + id, + expected_revision, + parent, + name, + } => Mutation::Relocate { + id: entry(workspace, &id)?, + expected_revision: revision(&expected_revision)?, + parent: entry(workspace, &parent)?, + name, + }, + DriveMutation::Delete { + id, + expected_revision, + } => Mutation::Delete { + id: entry(workspace, &id)?, + expected_revision: revision(&expected_revision)?, + }, + }) +} +fn result(request_id: String, result: workspace_drive::MutationResult) -> DriveMutationResponse { + DriveMutationResponse { + request_id, + entry: (!result.deleted).then(|| public_node(result.node)), + } +} +pub(super) async fn mutate( + api: &WorkspaceApi, + context: &ServerRequestContext, + workspace: &str, + request: DriveMutationRequest, +) -> DriveResult { + let drive = drive_grants::authorized_drive(api, context, workspace, true).await?; + let actor = mutation_actor(context)?; + let operation = mutation(workspace, request.mutation)?; + let request_id = request.request_id.clone(); + let mutated = blocking(true, move || { + drive.mutate(&request.request_id, &actor, operation) + }) + .await?; + Ok(result(request_id, mutated)) +} +fn upload_mutation( + workspace: &str, + query: &DriveUploadQuery, + bytes: Vec, +) -> DriveResult { + if query.entry_workspace_id != workspace { + return Err(failure(403)); + } + let content_type = safe_content_type(&query.content_type)?; + match query.operation { + DriveUploadOperation::Create if query.id.is_none() && query.expected_revision.is_none() => { + Ok(Mutation::CreateFile { + parent: node_id(query.parent_id.as_deref().ok_or_else(|| failure(400))?)?, + name: query.name.clone().ok_or_else(|| failure(400))?, + content_type, + bytes, + }) + } + DriveUploadOperation::Update if query.parent_id.is_none() && query.name.is_none() => { + Ok(Mutation::Update { + id: node_id(query.id.as_deref().ok_or_else(|| failure(400))?)?, + expected_revision: revision( + query + .expected_revision + .as_deref() + .ok_or_else(|| failure(400))?, + )?, + content_type, + bytes, + }) + } + _ => Err(failure(400)), + } +} +pub(super) async fn upload( + api: &WorkspaceApi, + context: &ServerRequestContext, + workspace: &str, + query: DriveUploadQuery, + body: BinaryBody, +) -> DriveResult { + let drive = drive_grants::authorized_drive(api, context, workspace, true).await?; + let actor = mutation_actor(context)?; + let bytes = receive( + axum::body::Body::from(body.into_bytes()), + query.size.into(), + &query.sha256, + ) + .await?; + publish_upload(drive, actor, workspace, query, bytes).await +} +async fn publish_upload( + drive: Drive, + actor: String, + workspace: &str, + query: DriveUploadQuery, + bytes: Vec, +) -> DriveResult { + let mutation = upload_mutation(workspace, &query, bytes)?; + let request_id = query.request_id.clone(); + let mutated = blocking(true, move || { + drive.mutate(&query.request_id, &actor, mutation) + }) + .await?; + Ok(result(request_id, mutated)) +} +pub(super) async fn request_status( + api: &WorkspaceApi, + context: &ServerRequestContext, + workspace: &str, + request_id: String, +) -> DriveResult { + let drive = drive_grants::authorized_drive(api, context, workspace, false).await?; + let id = request_id.clone(); + let status = blocking(false, move || drive.request_status(&id)).await?; + Ok(match status { + workspace_drive::RequestStatus::Uncommitted => DriveRequestStatusResponse { + request_id, + state: DriveRequestState::Uncommitted, + response: None, + }, + workspace_drive::RequestStatus::Committed { result: mutation } => { + DriveRequestStatusResponse { + request_id: request_id.clone(), + state: DriveRequestState::Committed, + response: Some(result(request_id, mutation)), + } + } + }) +} + +async fn select_download( + api: &WorkspaceApi, + context: &ServerRequestContext, + workspace: &str, + query: DriveDownloadQuery, +) -> DriveResult<(Drive, workspace_drive::Node)> { + let drive = drive_grants::authorized_drive(api, context, workspace, false).await?; + let id = scoped_id(workspace, &query.entry_workspace_id, &query.id)?; + let for_node = drive.clone(); + let node = blocking(false, move || for_node.metadata(id)).await?; + if node.kind != workspace_drive::Kind::File { + return Err(failure(400)); + } + if query + .expected_revision + .as_deref() + .is_some_and(|v| v != node.revision.get().to_string()) + { + return Err(failure(409)); + } + safe_content_type(node.content_type.as_deref().unwrap_or("")).map_err(|_| failure(503))?; + Ok((drive, node)) +} +pub(super) async fn download( + api: &WorkspaceApi, + context: &ServerRequestContext, + workspace: &str, + query: DriveDownloadQuery, +) -> DriveResult { + let (drive, node) = select_download(api, context, workspace, query).await?; + let mut bytes = Vec::new(); + let mut offset = 0; + loop { + let copy = drive.clone(); + let chunk = blocking(false, move || { + copy.read(node.id, node.revision, offset, TRANSFER_CHUNK) + }) + .await?; + offset += chunk.bytes.len() as u64; + bytes.extend_from_slice(&chunk.bytes); + if chunk.eof { + break; + } + } + Ok(server_api::server_api_responses::DriveDownload::Status200 { + body: bytes.into(), + header_content_disposition: disposition(&node.name), + header_cache_control: "private, no-store".into(), + header_etag: validator(&node), + header_x_content_type_options: "nosniff".into(), + header_content_security_policy: "sandbox; default-src 'none'".into(), + }) +} +fn validator(node: &workspace_drive::Node) -> String { + format!( + "\"drive-{}-{}-{}\"", + node.workspace_id, + node.id.get(), + node.revision.get() + ) +} + +pub(super) fn router(service: Arc) -> Router { + Router::new() + .merge(streaming_router(service.clone())) + .merge(server_api::server_api_axum::drive_root(service.clone())) + .merge(server_api::server_api_axum::drive_metadata(service.clone())) + .merge(server_api::server_api_axum::drive_list(service.clone())) + .merge(server_api::server_api_axum::drive_search(service.clone())) + .merge(server_api::server_api_axum::drive_read_text( + service.clone(), + )) + .merge(server_api::server_api_axum::drive_read_chunk( + service.clone(), + )) + .merge(server_api::server_api_axum::drive_mutate(service.clone())) + .merge(server_api::server_api_axum::drive_request_status( + service.clone(), + )) + .merge(server_api::server_api_axum::drive_grant_create( + service.clone(), + )) + .merge(server_api::server_api_axum::drive_grant_revoke( + service.clone(), + )) + .merge(server_api::server_api_axum::drive_grant_list( + service.clone(), + )) + .layer(DefaultBodyLimit::max(512 * 1024)) + .layer(middleware::from_fn(normalize_rejections)) +} +async fn normalize_rejections(request: Request, next: Next) -> Response { + let response = next.run(request).await; + if matches!(response.status().as_u16(), 415 | 422) { + error_response(failure(400)) + } else if matches!(response.status().as_u16(), 400 | 413) + && response + .headers() + .get(CONTENT_TYPE) + .is_none_or(|value| value != "application/json") + { + error_response(failure(response.status().as_u16())) + } else { + response + } +} + +fn streaming_router(service: Arc) -> Router { + // The operation inventory is the route authority, not duplicated string literals. + use api_macros::Operation; + use axum::routing::put; + use server_api::server_api_operations::{DriveDownload, DriveUpload}; + Router::new() + .route(DriveUpload::METADATA.path, put(streaming_upload)) + .route(DriveDownload::METADATA.path, get(streaming_download)) + .with_state(service) +} +async fn streaming_upload( + State(service): State>, + AxumPath(workspace): AxumPath, + Extension(context): Extension, + query: std::result::Result, axum::extract::rejection::QueryRejection>, + request: Request, +) -> Response { + let result = async { + let api = service.workspace_api().map_err(|_| failure(404))?; + let drive = drive_grants::authorized_drive(api, &context, &workspace, true).await?; + let actor = mutation_actor(&context)?; + let Query(query) = query.map_err(|_| failure(400))?; + // Reject malformed metadata, foreign refs and over-limit declarations before reading. + upload_mutation(&workspace, &query, Vec::new())?; + if query.size as usize > workspace_drive::MAX_FILE_BYTES { + return Err(failure(413)); + } + if let Some(length) = request.headers().get(axum::http::header::CONTENT_LENGTH) { + let length: u64 = length + .to_str() + .ok() + .and_then(|s| s.parse().ok()) + .ok_or_else(|| failure(400))?; + if length > workspace_drive::MAX_FILE_BYTES as u64 { + return Err(failure(413)); + } + if length != u64::from(query.size) { + return Err(failure(400)); + } + } + let bytes = receive(request.into_body(), query.size.into(), &query.sha256).await?; + // The bound Drive authorizer runs again under attached-authority IMMEDIATE, + // so a grant revoked during reception cannot publish or replay a receipt. + publish_upload(drive, actor, &workspace, query, bytes).await + } + .await; + match result { + Ok(result) => Json(result).into_response(), + Err(error) => error_response(error), + } +} +async fn streaming_download( + State(service): State>, + AxumPath(workspace): AxumPath, + Extension(context): Extension, + query: std::result::Result, axum::extract::rejection::QueryRejection>, +) -> Response { + let result = async { + let api = service.workspace_api().map_err(|_| failure(404))?; + let Query(query) = query.map_err(|_| failure(400))?; + let (drive, node) = select_download(api, &context, &workspace, query).await?; + // Validate the referenced blob before HTTP success, including a zero-byte file. + let first_drive = drive.clone(); + let first = blocking(false, move || { + first_drive.read(node.id, node.revision, 0, TRANSFER_CHUNK) + }) + .await?; + let id = node.id; + let revision = node.revision; + let state = (drive, id, revision, Some(first), 0_u64, false); + let stream = futures::stream::try_unfold( + state, + |(drive, id, revision, first, offset, done)| async move { + if done { + return Ok(None); + } + let chunk = match first { + Some(chunk) => chunk, + None => { + let copy = drive.clone(); + blocking(false, move || { + copy.read(id, revision, offset, TRANSFER_CHUNK) + }) + .await + .map_err(|_| stream_error())? + } + }; + let next = offset + chunk.bytes.len() as u64; + let eof = chunk.eof; + Ok::<_, std::io::Error>(Some(( + axum::body::Bytes::from(chunk.bytes), + (drive, id, revision, None, next, eof), + ))) + }, + ); + let mut response = axum::body::Body::from_stream(stream).into_response(); + let headers = response.headers_mut(); + headers.insert( + CONTENT_TYPE, + HeaderValue::from_str(&safe_content_type( + node.content_type.as_deref().unwrap_or(""), + )?) + .map_err(|_| failure(503))?, + ); + headers.insert( + axum::http::header::CONTENT_LENGTH, + HeaderValue::from_str(&node.size.to_string()).map_err(|_| failure(503))?, + ); + headers.insert( + axum::http::header::CONTENT_DISPOSITION, + HeaderValue::from_str(&disposition(&node.name)).map_err(|_| failure(503))?, + ); + headers.insert( + axum::http::header::ETAG, + HeaderValue::from_str(&validator(&node)).map_err(|_| failure(503))?, + ); + headers.insert(CACHE_CONTROL, HeaderValue::from_static("private, no-store")); + headers.insert( + "x-content-type-options", + HeaderValue::from_static("nosniff"), + ); + headers.insert( + "content-security-policy", + HeaderValue::from_static("sandbox; default-src 'none'"), + ); + Ok::<_, DriveApiError>(response) + } + .await; + match result { + Ok(response) => response, + Err(error) => error_response(error), + } +} diff --git a/crates/workspace-server/src/server_drive_grants.rs b/crates/workspace-server/src/server_drive_grants.rs new file mode 100644 index 000000000..9d85eaf67 --- /dev/null +++ b/crates/workspace-server/src/server_drive_grants.rs @@ -0,0 +1,229 @@ +//! Drive authority is rechecked inside the metadata transaction against the +//! attached Server DB. A bound handle is not an authorization cache or lease. +use super::*; +use rusqlite::params; +use server_api::{ + DriveApiError, DriveApiErrorCode, DriveGrantCreateRequest, DriveGrantListQuery, + DriveGrantListResponse, DriveGrantResponse, ServerRequestContext, +}; + +type DriveResult = std::result::Result; +fn fail(code: DriveApiErrorCode) -> DriveApiError { + DriveApiError::new(code) +} + +pub(super) fn pre_error(error: impl Into) -> DriveApiError { + let error = error.into(); + fail(match api_error_status(&error.error).as_u16() { + 401 | 403 => DriveApiErrorCode::Denied, + 404 => DriveApiErrorCode::NotFound, + 409 => DriveApiErrorCode::Conflict, + 413 => DriveApiErrorCode::Limit, + 500..=599 => DriveApiErrorCode::StorageUnavailable, + _ => DriveApiErrorCode::Invalid, + }) +} +fn mutation_error(error: Error) -> DriveApiError { + if matches!(error, Error::Sqlite(_) | Error::Store(_)) { + fail(DriveApiErrorCode::OutcomeUnknown) + } else { + pre_error(error) + } +} +fn worker_request(context: &ServerRequestContext) -> bool { + context.runtime_source.is_some() + || context.worker_source.is_some() + || context.transport_headers.iter().any(|(name, _)| { + name.eq_ignore_ascii_case("x-yoi-runtime-id") + || name.eq_ignore_ascii_case("x-yoi-worker-id") + }) +} +fn browser_actor(context: &ServerRequestContext) -> DriveResult<&RequestActor> { + if worker_request(context) { + return Err(fail(DriveApiErrorCode::Denied)); + } + context + .actor + .as_ref() + .ok_or_else(|| fail(DriveApiErrorCode::Denied)) +} + +pub(super) async fn authorized_drive( + api: &WorkspaceApi, + context: &ServerRequestContext, + workspace_id: &str, + write: bool, +) -> DriveResult { + validate_workspace_scope(api, workspace_id).map_err(pre_error)?; + let workspace = workspace_id.to_owned(); + let drive = if worker_request(context) { + // Never fall back to an actor when a Worker/Runtime source is present, + // incomplete, or inconsistent with its transport identity. + let headers = current_worker_contract_headers(context) + .map_err(|_| fail(DriveApiErrorCode::Denied))?; + let worker = current_worker_identity(api, workspace_id, &headers) + .map_err(|_| fail(DriveApiErrorCode::Denied))?; + if context.worker_source.as_ref().is_some_and(|source| { + source.runtime_id != worker.runtime_id || source.worker_id != worker.worker_id + }) { + return Err(fail(DriveApiErrorCode::Denied)); + } + let drive = api.drive().await.map_err(pre_error)?; + drive.with_authorizer(move |conn, write| { + if !crate::store::drive_worker_is_live(conn, true, &workspace, &worker)? { + return Err(workspace_drive::Error::Denied); + } + let granted: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM yoi_workspace_authority.workspace_drive_grants + WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND revoked=0 AND (?4=0 OR access='read_write'))", + params![workspace,worker.runtime_id,worker.worker_id,write],|row|row.get(0))?; + if !granted { return Err(workspace_drive::Error::Denied); } + Ok(()) + }) + } else { + let actor = browser_actor(context)?; + let account = actor.account_id.clone(); + let user = actor.user_id.clone(); + let drive = api.drive().await.map_err(pre_error)?; + // Ordinary Workspace use is member-facing: the current Server policy + // admits persisted authenticated users, not only the Workspace owner. + drive.with_authorizer(move |conn, _write| { + let member: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM yoi_workspace_authority.workspaces ws + CROSS JOIN yoi_workspace_authority.users u + WHERE ws.workspace_id=?1 AND ws.state='active' AND u.account_id=?2 AND u.user_id=?3)", + params![workspace,account,user],|row|row.get(0))?; + if !member { return Err(workspace_drive::Error::Denied); } + Ok(()) + }) + }; + // Reject missing/read-only grants before upload body reception. This check + // is admission only: every later operation rechecks under its own lock. + tokio::task::spawn_blocking(move || { + drive.check_authorized(write)?; + Ok::<_, workspace_drive::Error>(drive) + }) + .await + .map_err(|_| fail(DriveApiErrorCode::StorageUnavailable))? + .map_err(|error| { + fail(match error { + workspace_drive::Error::Denied => DriveApiErrorCode::Denied, + workspace_drive::Error::Fenced | workspace_drive::Error::Conflict => { + DriveApiErrorCode::Conflict + } + workspace_drive::Error::NotFound => DriveApiErrorCode::NotFound, + workspace_drive::Error::Invalid(_) => DriveApiErrorCode::Invalid, + _ => DriveApiErrorCode::StorageUnavailable, + }) + }) +} + +pub(super) async fn create_grant( + api: &WorkspaceApi, + context: &ServerRequestContext, + workspace_id: &str, + request: DriveGrantCreateRequest, +) -> DriveResult { + let actor = browser_actor(context)?; + require_workspace_owner(api, workspace_id, actor, "Drive grants") + .await + .map_err(pre_error)?; + for identity in [&request.runtime_id, &request.worker_id] { + if identity.is_empty() || identity.len() > 256 || identity.chars().any(char::is_control) { + return Err(fail(DriveApiErrorCode::Invalid)); + } + } + let worker = RuntimeWorkerRef::new(&request.runtime_id, &request.worker_id); + let summary = api + .runtime + .worker(&worker) + .map_err(|_| fail(DriveApiErrorCode::NotFound))?; + if summary.workspace.workspace_id.as_deref() != Some(workspace_id) { + return Err(fail(DriveApiErrorCode::Denied)); + } + let grant = DriveGrantResponse { + grant_id: String::new(), + workspace_id: workspace_id.into(), + runtime_id: request.runtime_id, + worker_id: request.worker_id, + access: request.access, + revoked: false, + created_by: actor.account_id.clone(), + created_at: String::new(), + revoked_by: None, + revoked_at: None, + }; + let store = api.store.clone(); + tokio::task::spawn_blocking(move || store.create_workspace_drive_grant(&grant)) + .await + .map_err(|_| fail(DriveApiErrorCode::OutcomeUnknown))? + .map_err(mutation_error) +} + +pub(super) async fn revoke_grant( + api: &WorkspaceApi, + context: &ServerRequestContext, + workspace_id: &str, + grant_id: &str, +) -> DriveResult { + let actor = browser_actor(context)?; + require_workspace_owner(api, workspace_id, actor, "Drive grants") + .await + .map_err(pre_error)?; + validate_grant_id(grant_id)?; + let (workspace, grant, actor) = ( + workspace_id.to_owned(), + grant_id.to_owned(), + actor.account_id.clone(), + ); + let store = api.store.clone(); + tokio::task::spawn_blocking(move || { + store.revoke_workspace_drive_grant(&workspace, &grant, &actor) + }) + .await + .map_err(|_| fail(DriveApiErrorCode::OutcomeUnknown))? + .map_err(mutation_error)? + .ok_or_else(|| fail(DriveApiErrorCode::NotFound)) +} + +fn validate_grant_id(id: &str) -> DriveResult<()> { + if !id + .parse::() + .is_ok_and(|n| n > 0 && n.to_string() == id) + { + return Err(fail(DriveApiErrorCode::Invalid)); + } + Ok(()) +} +pub(super) async fn list_grants( + api: &WorkspaceApi, + context: &ServerRequestContext, + workspace_id: &str, + query: DriveGrantListQuery, +) -> DriveResult { + let actor = browser_actor(context)?; + require_workspace_owner(api, workspace_id, actor, "Drive grants") + .await + .map_err(pre_error)?; + let limit = query.limit.unwrap_or(100) as usize; + if limit == 0 || limit > 200 { + return Err(fail(DriveApiErrorCode::Invalid)); + } + if let Some(after) = &query.after { + validate_grant_id(after)?; + } + let workspace = workspace_id.to_owned(); + let store = api.store.clone(); + let mut grants = tokio::task::spawn_blocking(move || { + store.list_workspace_drive_grants(&workspace, limit + 1, query.after.as_deref()) + }) + .await + .map_err(|_| fail(DriveApiErrorCode::StorageUnavailable))? + .map_err(pre_error)?; + let more = grants.len() > limit; + grants.truncate(limit); + let next_after = if more { + grants.last().map(|g| g.grant_id.clone()) + } else { + None + }; + Ok(DriveGrantListResponse { grants, next_after }) +} diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 5aa116eee..9d78665a7 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -28,7 +28,8 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 83; +const LATEST_SCHEMA_VERSION: i64 = 84; +const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants"; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; @@ -293,6 +294,11 @@ const MIGRATIONS: &[Migration] = &[ name: "Generic Ticket Worker roles and durable claims", apply: migrate_ticket_worker_v82_to_v83, }, + Migration { + version: 84, + name: WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME, + apply: migrate_workspace_drive_grants_v83_to_v84, + }, ]; #[derive(Clone, Copy)] @@ -2005,6 +2011,28 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { ) -> Result; fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result; + fn create_workspace_drive_grant( + &self, + grant: &server_api::DriveGrantResponse, + ) -> Result; + fn get_workspace_drive_grant( + &self, + workspace_id: &str, + grant_id: &str, + ) -> Result>; + fn list_workspace_drive_grants( + &self, + workspace_id: &str, + limit: usize, + after: Option<&str>, + ) -> Result>; + fn revoke_workspace_drive_grant( + &self, + workspace_id: &str, + grant_id: &str, + actor: &str, + ) -> Result>; + fn create_workspace_config_grant( &self, grant: &server_api::WorkspaceConfigGrantResponse, @@ -9505,6 +9533,81 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn create_workspace_drive_grant( + &self, + grant: &server_api::DriveGrantResponse, + ) -> Result { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + require_drive_grant_owner(&tx, &grant.workspace_id, &grant.created_by)?; + let worker = RuntimeWorkerRef::new(&grant.runtime_id, &grant.worker_id); + if !drive_worker_is_live(&tx, false, &grant.workspace_id, &worker)? { + return Err(Error::WorkspacePermissionDenied("Worker is not live in this Workspace".into())); + } + let existing = tx.query_row(&format!("{DRIVE_GRANT_SELECT} WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND revoked=0"), + params![grant.workspace_id, grant.runtime_id, grant.worker_id], read_drive_grant).optional()?; + if let Some(existing) = existing { + if existing.access != grant.access { + return Err(Error::WorkspaceConfigConflict("Drive grant exists; revoke it first".into())); + } + return Ok(existing); + } + let access = match grant.access { server_api::DriveAccess::ReadOnly => "read_only", server_api::DriveAccess::ReadWrite => "read_write" }; + tx.execute("INSERT INTO workspace_drive_grants(workspace_id,runtime_id,worker_id,access,revoked,created_by,created_at) VALUES(?1,?2,?3,?4,0,?5,?6)", + params![grant.workspace_id,grant.runtime_id,grant.worker_id,access,grant.created_by,chrono::Utc::now().to_rfc3339()])?; + let stored = tx.query_row(&format!("{DRIVE_GRANT_SELECT} WHERE workspace_id=?1 AND grant_id=?2"),params![grant.workspace_id,tx.last_insert_rowid()],read_drive_grant)?; + tx.commit()?; + Ok(stored) + }) + } + fn get_workspace_drive_grant( + &self, + workspace_id: &str, + grant_id: &str, + ) -> Result> { + self.with_conn(|conn| { + Ok(conn + .query_row( + &format!("{DRIVE_GRANT_SELECT} WHERE workspace_id=?1 AND grant_id=?2"), + params![workspace_id, grant_id], + read_drive_grant, + ) + .optional()?) + }) + } + fn list_workspace_drive_grants( + &self, + workspace_id: &str, + limit: usize, + after: Option<&str>, + ) -> Result> { + if limit == 0 || limit > 201 { + return Err(Error::WorkspaceConfigConflict( + "Invalid Drive grant page limit".into(), + )); + } + self.with_conn(|conn| { + let mut stmt = conn.prepare(&format!("{DRIVE_GRANT_SELECT} WHERE workspace_id=?1 AND grant_id>?2 ORDER BY grant_id LIMIT ?3"))?; + Ok(stmt.query_map(params![workspace_id,after.unwrap_or("0").parse::().map_err(|_| Error::WorkspaceConfigConflict("Invalid Drive grant cursor".into()))?,limit],read_drive_grant)?.collect::>>()?) + }) + } + fn revoke_workspace_drive_grant( + &self, + workspace_id: &str, + grant_id: &str, + actor: &str, + ) -> Result> { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + require_drive_grant_owner(&tx, workspace_id, actor)?; + tx.execute("UPDATE workspace_drive_grants SET revoked=1,revoked_by=?3,revoked_at=?4 WHERE workspace_id=?1 AND grant_id=?2 AND revoked=0", + params![workspace_id,grant_id,actor,chrono::Utc::now().to_rfc3339()])?; + let stored = tx.query_row(&format!("{DRIVE_GRANT_SELECT} WHERE workspace_id=?1 AND grant_id=?2"),params![workspace_id,grant_id],read_drive_grant).optional()?; + tx.commit()?; + Ok(stored) + }) + } + fn create_workspace_config_grant( &self, grant: &server_api::WorkspaceConfigGrantResponse, @@ -11906,6 +12009,12 @@ pub(crate) fn commit_worker_catalog_removal( workspace_id: &str, worker: &RuntimeWorkerRef, ) -> Result { + conn.execute( + "UPDATE workspace_drive_grants SET revoked=1, revoked_by='server:worker-removal', + revoked_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND revoked=0", + params![workspace_id, worker.runtime_id, worker.worker_id], + )?; let existing = conn .query_row( "SELECT 1 FROM worker_registry_projection_removals WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", @@ -11925,6 +12034,56 @@ pub(crate) fn commit_worker_catalog_removal( }) } +const DRIVE_GRANT_SELECT: &str = "SELECT grant_id,workspace_id,runtime_id,worker_id,access,revoked,created_by,created_at,revoked_by,revoked_at FROM workspace_drive_grants"; +fn read_drive_grant(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(server_api::DriveGrantResponse { + grant_id: row.get::<_, i64>(0)?.to_string(), + workspace_id: row.get(1)?, + runtime_id: row.get(2)?, + worker_id: row.get(3)?, + access: match row.get::<_, String>(4)?.as_str() { + "read_only" => server_api::DriveAccess::ReadOnly, + "read_write" => server_api::DriveAccess::ReadWrite, + _ => return Err(rusqlite::Error::InvalidQuery), + }, + revoked: row.get(5)?, + created_by: row.get(6)?, + created_at: row.get(7)?, + revoked_by: row.get(8)?, + revoked_at: row.get(9)?, + }) +} +fn require_drive_grant_owner(conn: &Connection, workspace: &str, actor: &str) -> Result<()> { + let allowed: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id=?1 AND owner_account_id=?2 AND state='active')",params![workspace,actor],|r|r.get(0))?; + if !allowed { + return Err(Error::WorkspacePermissionDenied( + "Drive grants require the active Workspace owner".into(), + )); + } + Ok(()) +} +/// The prefix is selected only by trusted Host code, never request data. Uses +/// the durable Runtime/Worker identity and removal authority, not observations. +pub(crate) fn drive_worker_is_live( + conn: &Connection, + attached: bool, + workspace: &str, + worker: &RuntimeWorkerRef, +) -> rusqlite::Result { + let a = if attached { + "yoi_workspace_authority." + } else { + "" + }; + conn.query_row(&format!("SELECT EXISTS(SELECT 1 FROM {a}worker_registry wr JOIN {a}workspaces ws ON ws.workspace_id=wr.workspace_id + WHERE wr.workspace_id=?1 AND wr.runtime_id=?2 AND wr.worker_id=?3 AND ws.state='active' + AND (wr.runtime_id=?4 OR EXISTS(SELECT 1 FROM {a}workspace_runtime_bindings rb WHERE rb.workspace_id=?1 AND rb.runtime_id=?2 AND rb.state='verified' AND rb.revoked_at IS NULL)) + AND NOT EXISTS(SELECT 1 FROM {a}worker_registry_projection_removals rm WHERE rm.workspace_id=?1 AND rm.runtime_id=?2 AND rm.worker_id=?3) + AND NOT EXISTS(SELECT 1 FROM {a}worker_tombstones wt WHERE wt.workspace_id=?1 AND wt.runtime_id=?2 AND wt.worker_id=?3) + AND NOT EXISTS(SELECT 1 FROM {a}worker_removal_operations ro WHERE ro.workspace_id=?1 AND ro.runtime_id=?2 AND ro.worker_id=?3 AND ro.state IN ('executing','failed','succeeded')))") , + params![workspace,worker.runtime_id,worker.worker_id,crate::hosts::EMBEDDED_RUNTIME_ID],|r|r.get(0)) +} + fn read_worker_control_grant_record( row: &rusqlite::Row<'_>, ) -> rusqlite::Result { @@ -12595,6 +12754,22 @@ fn read_workdir_registry_record( }) } +fn migrate_workspace_drive_grants_v83_to_v84(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 83 { + return Err(Error::Store( + "Drive grants migration requires schema 83".into(), + )); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch(include_str!("workspace_drive_grants.sql"))?; + tx.execute( + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(?1,?2)", + params![84_i64, WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + fn migrate_worker_restore_intents_v79_to_v80(conn: &Connection) -> Result<()> { let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; tx.execute_batch("CREATE TABLE worker_restore_intents ( @@ -16816,6 +16991,193 @@ fn migrate_workdir_credential_candidate_snapshots_v58_to_v59(conn: &Connection) #[cfg(test)] mod tests { include!("store_workspace_config_tests.rs"); + + fn drive_grant_fixture(store: &SqliteWorkspaceStore) -> server_api::DriveGrantResponse { + store.with_conn(|c| { + c.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('drive-owner','user','drive-owner','Owner','1','1'); + INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('drive-ws','drive-owner','Workspace','active','1','1'),('drive-other','drive-owner','Other','active','1','1');")?; + for workspace in ["drive-ws", "drive-other"] { + c.execute("INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,private_material_ref,revision,state,created_at,updated_at) VALUES(?1,?1,'ed25519',?1,1,'pending_provisioning','1','1')", [workspace])?; + c.execute("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES(?1,?2,'worker','Worker','normal','1','1')", params![workspace, crate::hosts::EMBEDDED_RUNTIME_ID])?; + } + Ok(()) + }).unwrap(); + server_api::DriveGrantResponse { + grant_id: String::new(), + workspace_id: "drive-ws".into(), + runtime_id: crate::hosts::EMBEDDED_RUNTIME_ID.into(), + worker_id: "worker".into(), + access: server_api::DriveAccess::ReadOnly, + revoked: false, + created_by: "drive-owner".into(), + created_at: String::new(), + revoked_by: None, + revoked_at: None, + } + } + #[test] + fn drive_grants_migrate_and_preserve_audit_through_revoke_worker_delete_and_restart() { + let t = tempfile::tempdir().unwrap(); + let path = t.path().join("server.db"); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + let mut proposed = drive_grant_fixture(&store); + store.with_conn(|c| { + c.execute_batch("DROP TRIGGER workspace_drive_grants_worker_deleted; DROP TABLE workspace_drive_grants; UPDATE __yoi_schema_migrations SET version=83 WHERE version=84;")?; + assert_eq!(current_schema_version(c)?, 83); + Ok(()) + }).unwrap(); + drop(store); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + store + .with_conn(|c| { + assert_eq!(current_schema_version(c)?, 84); + Ok(()) + }) + .unwrap(); + let first = store.create_workspace_drive_grant(&proposed).unwrap(); + assert_eq!(first.grant_id, "1"); + assert_eq!(first.created_by, "drive-owner"); + assert!(!first.created_at.is_empty()); + assert_eq!( + store.create_workspace_drive_grant(&proposed).unwrap(), + first + ); + proposed.access = server_api::DriveAccess::ReadWrite; + assert!(store.create_workspace_drive_grant(&proposed).is_err()); + proposed.workspace_id = "drive-other".into(); + let other = store.create_workspace_drive_grant(&proposed).unwrap(); + assert!( + store + .get_workspace_drive_grant("drive-other", &first.grant_id) + .unwrap() + .is_none() + ); + assert!( + store + .revoke_workspace_drive_grant("drive-other", &first.grant_id, "drive-owner") + .unwrap() + .is_none() + ); + assert!( + store + .revoke_workspace_drive_grant("drive-ws", &first.grant_id, "stranger") + .is_err() + ); + let revoked = store + .revoke_workspace_drive_grant("drive-ws", &first.grant_id, "drive-owner") + .unwrap() + .unwrap(); + assert!(revoked.revoked); + assert_eq!(revoked.revoked_by.as_deref(), Some("drive-owner")); + assert!(revoked.revoked_at.is_some()); + assert_eq!( + store + .revoke_workspace_drive_grant("drive-ws", &first.grant_id, "drive-owner") + .unwrap() + .unwrap(), + revoked + ); + proposed.workspace_id = "drive-ws".into(); + let second = store.create_workspace_drive_grant(&proposed).unwrap(); + assert_ne!(second.grant_id, first.grant_id); + assert_eq!( + store + .list_workspace_drive_grants("drive-ws", 1, None) + .unwrap(), + vec![revoked.clone()] + ); + assert_eq!( + store + .list_workspace_drive_grants("drive-ws", 1, Some(&first.grant_id)) + .unwrap(), + vec![second.clone()] + ); + store + .delete_worker_registry( + "drive-ws", + &RuntimeWorkerRef::new(crate::hosts::EMBEDDED_RUNTIME_ID, "worker"), + ) + .unwrap(); + let retired = store + .get_workspace_drive_grant("drive-ws", &second.grant_id) + .unwrap() + .unwrap(); + assert!(retired.revoked); + assert_eq!(retired.revoked_by.as_deref(), Some("server:worker-removal")); + assert_eq!(retired.created_at, second.created_at); + assert!(store.create_workspace_drive_grant(&proposed).is_err()); + assert_eq!( + store + .get_workspace_drive_grant("drive-other", &other.grant_id) + .unwrap(), + Some(other) + ); + drop(store); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + assert_eq!( + store + .get_workspace_drive_grant("drive-ws", &first.grant_id) + .unwrap(), + Some(revoked) + ); + assert_eq!( + store + .get_workspace_drive_grant("drive-ws", &second.grant_id) + .unwrap(), + Some(retired) + ); + store + .with_conn(|c| { + c.execute("DELETE FROM workspaces WHERE workspace_id='drive-ws'", [])?; + Ok(()) + }) + .unwrap(); + assert!( + store + .list_workspace_drive_grants("drive-ws", 10, None) + .unwrap() + .is_empty() + ); + assert_eq!( + store + .list_workspace_drive_grants("drive-other", 10, None) + .unwrap() + .len(), + 1 + ); + } + #[test] + fn drive_grants_baseline_constraints_and_worker_lifetime_fail_closed() { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + let proposed = drive_grant_fixture(&store); + let first = store.create_workspace_drive_grant(&proposed).unwrap(); + store.with_conn(|c| { + assert_eq!(current_schema_version(c)?, 84); + assert!(c.execute("INSERT INTO workspace_drive_grants(workspace_id,runtime_id,worker_id,access,created_by,created_at) VALUES('drive-ws',?1,'worker','read_write','drive-owner','now')", [crate::hosts::EMBEDDED_RUNTIME_ID]).is_err()); + assert!(c.execute("INSERT INTO workspace_drive_grants(workspace_id,runtime_id,worker_id,access,created_by,created_at) VALUES('drive-ws','r','w','command','drive-owner','now')", []).is_err()); + assert!(c.execute("UPDATE workspace_drive_grants SET revoked=1 WHERE grant_id=?1", [&first.grant_id]).is_err()); + c.execute("INSERT INTO worker_registry_projection_removals(workspace_id,runtime_id,worker_id) VALUES('drive-ws',?1,'worker')", [crate::hosts::EMBEDDED_RUNTIME_ID])?; + assert!(!drive_worker_is_live(c, false, "drive-ws", &RuntimeWorkerRef::new(crate::hosts::EMBEDDED_RUNTIME_ID, "worker"))?); + Ok(()) + }).unwrap(); + assert!(store.create_workspace_drive_grant(&proposed).is_err()); + store + .with_conn(|c| { + c.execute( + "DELETE FROM worker_registry WHERE workspace_id='drive-ws'", + [], + )?; + Ok(()) + }) + .unwrap(); + assert!( + store + .get_workspace_drive_grant("drive-ws", &first.grant_id) + .unwrap() + .unwrap() + .revoked + ); + } use super::*; #[test] @@ -18340,6 +18702,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx } fn downgrade_ticket_worker_schema(conn: &Connection, restore_worker_fk: bool) { + // Older-generation fixtures must not retain the new Drive authority. + conn.execute_batch("DROP TRIGGER IF EXISTS workspace_drive_grants_worker_deleted; DROP TABLE IF EXISTS workspace_drive_grants;").unwrap(); // Restore the actual old role and FK contracts, not just the old // unique index, so migration tests exercise real coder rows. for table in [ @@ -18428,7 +18792,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .iter() .map(|s| s.version) .collect::>(), - vec![83] + vec![83, 84] ); let store = SqliteWorkspaceStore::open(&path).unwrap(); let assignment = store @@ -18454,7 +18818,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ); } store.with_conn(|conn| { - assert_eq!(current_schema_version(conn)?,83); + assert_eq!(current_schema_version(conn)?,LATEST_SCHEMA_VERSION); assert_eq!(conn.query_row("SELECT role FROM ticket_worker_assignments WHERE assignment_id='assignment'", [], |r| r.get::<_,String>(0))?,"worker"); assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r.get::<_,i64>(0))?,0); Ok(()) @@ -18739,6 +19103,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx version: 83, name: "Generic Ticket Worker roles and durable claims".to_string() }, + WorkspaceSchemaMigrationStep { + version: 84, + name: WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME.to_string() + }, ] ); @@ -18832,6 +19200,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (81, "Backend Job immutable resource serialization".to_string()), (82, "Ticket responsibility and unfinished work separation".to_string()), (83, "Generic Ticket Worker roles and durable claims".to_string()), + (84, WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME.to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -19183,7 +19552,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .collect::>(), vec![ 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, - 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83 + 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84 ] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); @@ -19192,7 +19561,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 34); + assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 35); assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap()); assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap()); assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap()); diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index 360b239d2..78da114fe 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -87,6 +87,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "worker_workdir_links", "workspace_config_entries", "workspace_config_grants", + "workspace_drive_grants", "workspace_config_tree_revisions", "workspace_config_trees", "workspace_create_operations", diff --git a/crates/workspace-server/src/workspace_drive_grants.sql b/crates/workspace-server/src/workspace_drive_grants.sql new file mode 100644 index 000000000..2f68349cc --- /dev/null +++ b/crates/workspace-server/src/workspace_drive_grants.sql @@ -0,0 +1,24 @@ +CREATE TABLE workspace_drive_grants ( + workspace_id TEXT NOT NULL REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + grant_id INTEGER PRIMARY KEY AUTOINCREMENT, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + access TEXT NOT NULL CHECK(access IN ('read_only', 'read_write')), + revoked INTEGER NOT NULL DEFAULT 0 CHECK(revoked IN (0, 1)), + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + revoked_by TEXT, + revoked_at TEXT, + CHECK((revoked=0 AND revoked_by IS NULL AND revoked_at IS NULL) + OR (revoked=1 AND revoked_by IS NOT NULL AND revoked_at IS NOT NULL)) +); +CREATE UNIQUE INDEX workspace_drive_grants_active_worker + ON workspace_drive_grants(workspace_id, runtime_id, worker_id) WHERE revoked=0; +-- Retain grant audit across Worker deletion, but never carry its authority into +-- a later lifetime reusing the same Runtime/Worker identity. +CREATE TRIGGER workspace_drive_grants_worker_deleted BEFORE DELETE ON worker_registry BEGIN + UPDATE workspace_drive_grants SET revoked=1, revoked_by='server:worker-removal', + revoked_at=strftime('%Y-%m-%dT%H:%M:%fZ', 'now') + WHERE workspace_id=OLD.workspace_id AND runtime_id=OLD.runtime_id + AND worker_id=OLD.worker_id AND revoked=0; +END; diff --git a/docs/design/workspace-drive.md b/docs/design/workspace-drive.md index b9c6e0e40..50d498afb 100644 --- a/docs/design/workspace-drive.md +++ b/docs/design/workspace-drive.md @@ -5,8 +5,9 @@ Drive is a Workspace-owned **latest-version document tree**, not a mounted or POSIX-compatible filesystem. `workspace-drive` owns storage, hierarchy, revision conflicts, mutation receipts, bounded reads/search, and garbage collection. -`WorkspaceApi::drive` is the trusted Server adapter. Authorization and HTTP DTOs -belong to T-722; Worker/Web clients and end-to-end routing belong to T-723–725. +`WorkspaceApi::drive` is the trusted Server adapter. The authorized HTTP API and +Backend-managed grants are documented below. Worker Tools/WIP, Web UI, and full +end-to-end routing belong to T-723–725. Session attachments remain Session-owned and are not Drive authority. Worker, Session, and Workdir cleanup do not delete Drive. @@ -262,3 +263,106 @@ The hermetic backup test stops writers, snapshots actual FeatureStorage SQLite, copies actual LocalFileSystem blobs, restores into absent roots, and checks stable IDs/latest content/receipts and non-reused ID allocation. It does not validate a production backup scheduler, volume snapshot, or physical power cut. + +## Authorized typed HTTP API + +The Rust `server-api` contract owns `/api/w/{workspace_id}/drive/` operations: + +- `GET root`, `metadata`, `list`, `search`, `read-text`, `read-chunk`, `download`; +- `POST mutate` (create folder/bounded UTF-8 text, replace text, relocate, delete); +- `PUT upload` (binary body with flat query metadata); +- `GET requests/{request_id}` for the committed receipt snapshot; +- owner-controlled `POST/GET grants` and `DELETE grants/{grant_id}`. + +Ordinary usage follows the existing member-facing authenticated Server-user +policy. Non-ownership alone does not deny use. Grant management follows the +existing Workspace-owner account boundary. No new general membership ACL is +introduced. Browser/API-token identity is supplied by authentication middleware; +user/account existence and active Workspace state are checked again in the +operation transaction. Cookie mutations require the configured application +Origin through the existing middleware. No ordinary DTO accepts an actor. + +Runtime-forwarded requests require the existing signed source proof and live +Runtime/Worker catalog membership. Workers receive an explicit whole-Drive +`read_only` or `read_write` grant, independently of Profile, Subject, Ticket, +Workdir or commands. Incomplete/mismatched Runtime or Worker identity cannot fall +back to a browser actor. Grant creation rechecks owner, active Workspace, and +existing live Worker in the Server DB transaction; active access changes require +revoking the previous grant first (no silent downgrade). Migration 84 stores +creation/revocation actor and time. Removal fences and catalog deletion revoke +access and retain audit; grant rows cascade with Workspace deletion. No alternate +Worker registry or Drive Workdir attachment is created. + +A clone-bound Drive authorizer queries the attached Server authority under every +client-operation `BEGIN IMMEDIATE`, including receipt replay/result queries. +The lock spans validation, blob persistence and DB publication. Grant revocation +and Worker removal use the same Server DB write authority: whichever transaction +wins first is ordered first. A mutation committing before revocation is valid; +a revoked cached handle cannot commit later. Upload admission also checks access +before consuming the body, but that admission is not a lease: publication always +rechecks current access. Download checks authorization separately for every +chunk, and stops on revocation, deletion, revision expiry or storage failure. + +References contain **both** Workspace ID and stable decimal node ID. JSON uses +`DriveEntryRef`; flat GET/binary queries require `entry_workspace_id` matching the +route Workspace before resolving `id`/`parent_id`. IDs/revisions are canonical +positive decimal strings within signed 64-bit range; file sizes/offsets are +bounded `u32`. List/search cursors carry Workspace identity and the last node ID; +they are continuation hints, not capabilities or snapshots. Returned `latest_url` +is a relative authenticated URL bound to Workspace and ID, independent of logical +name/parent. It confers neither public sharing nor approval. File URLs download; +folder URLs return metadata. Deletion/recreation never reuses the old reference. + +Web upload consumes binary HTTP frames with an observed **16 MiB** ceiling and +checks declared size and lowercase SHA-256 before publication. Oversized announced +or received data, mismatches, aborted bodies and I/O errors never return success. +The storage boundary currently requires a whole-file Vec, so uploads retain at +most one bounded binary file in memory before calling T-721; they are not +constant-memory object-store multipart uploads and are never expanded to +JSON/base64. Runtime source-proof verification separately buffers a bounded body +before granting trusted ingress, as in other signed Server operations. Web +downloads stream **64 KiB** revision-fixed chunks; they do not materialize the +whole file. Generated Rust clients retain their explicit bounded-response policy +and return exact `BinaryBody` bytes. Worker JSON-facing adapters can request +bounded text or binary chunks through this same contract, but Tools/WIP and +model-context/image presentation belong to T-723; this API does not emit large +base64 JSON blobs. + +Downloads validate bare MIME types and always use `Content-Disposition: +attachment` with percent-encoded UTF-8 names, `nosniff`, a sandbox/default-none +CSP, `private, no-store`, and a Workspace/node/revision ETag. Arbitrary HTML/SVG +bytes are storable, not safe app-origin inline previews. Content length is exact; +if a later chunk loses authority or expires, the response stream fails rather +than returning another generation or a successful truncated file. Even an empty +file validates its referenced blob before HTTP success. Conditional 304/range +HTTP semantics are not added; revision-selected chunk reads are explicit. + +Errors use fixed, path-free typed codes: `denied`, `not_found`, `conflict`, +`invalid`, `limit`, `storage_unavailable`, `outcome_unknown`. SQL/commit/task +uncertainty at a mutation boundary is `outcome_unknown`/`unknown`; known rejection +or blob persistence failure is `not_committed`. A successful DB commit alone +returns a mutation response. After a lost response, query the same request ID; +`uncommitted` only means no committed receipt in that snapshot and must not be +interpreted as proof that an in-flight request can never commit. Exact replay uses +the same identity/payload/transport-bound actor and current permissions. Never +blindly create a fresh request ID to resolve an unknown outcome. Physical paths, +blob keys and raw provider errors are not public. + +Contract regeneration/freshness: + +```sh +cargo run -q -p server-api --example export_openapi -- openapi/server-api.json +cargo run -q -p server-api --example export_openapi -- --check openapi/server-api.json +cargo run -q -p server-api --features typescript --example generate_drive_api_types > web/workspace/src/lib/generated/drive-api.ts +deno fmt web/workspace/src/lib/generated/drive-api.ts +cargo test -p server-api --features typescript generated_drive_api_contract_is_current +# Existing OpenAPI-derived projections embed the whole-contract digest: +for example in generate_repository_openapi_types generate_runtime_api_types generate_ticket_api_types generate_companion_api_types generate_worker_launch_api_types; do + cargo run -q -p server-api --example "$example" + cargo run -q -p server-api --example "$example" -- --check +done +``` + +The existing BinaryBody contract also supports binary **success responses**, +including declared response headers, without changing binary request bytes or +JSON/error behavior. It exports inline OpenAPI binary bodies, not JSON byte arrays. diff --git a/docs/report/t-722-subworker-cwd.md b/docs/report/t-722-subworker-cwd.md new file mode 100644 index 000000000..62692260a --- /dev/null +++ b/docs/report/t-722-subworker-cwd.md @@ -0,0 +1,18 @@ +# T-722: SubWorker cwd and logical path friction + +During delegation from the assigned repository Workdir, a child started with +`cwd: crates/server-api` and readable policy paths under repository-root +`docs/development`. Its Read calls using repository-relative logical paths could +not read the required testing strategy; absolute paths were rejected as invalid +logical paths too. The child correctly stopped without edits. The parent +inspected the committed child session, stopped that idle child, and recreated it +with repository-root cwd and explicit read-only repository scope plus write scope +limited to server-api. Policy reads and implementation then succeeded. + +The current observable behavior appears to root child filesystem tools at the +selected cwd, rather than making cwd only the command default as the spawn-tool +description states. This makes repository-root logical paths surprising. Consider +keeping the attachment root invariant and applying cwd only to command execution, +or documenting/returning a distinct filesystem base explicitly. Do not encourage +absolute paths, parent traversal, or broader write grants as a workaround. This +report records a tool usability issue, not additional Ticket requirements. diff --git a/openapi/server-api.json b/openapi/server-api.json index 56b5011ad..6d9e3fcac 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -1968,6 +1968,763 @@ ], "type": "string" }, + "DriveAccess": { + "enum": [ + "read_only", + "read_write" + ], + "type": "string" + }, + "DriveApiError": { + "additionalProperties": false, + "properties": { + "classification": { + "$ref": "#/components/schemas/DriveFailureClassification" + }, + "code": { + "$ref": "#/components/schemas/DriveApiErrorCode" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "classification", + "message" + ], + "type": "object" + }, + "DriveApiErrorCode": { + "enum": [ + "denied", + "not_found", + "conflict", + "invalid", + "limit", + "storage_unavailable", + "outcome_unknown" + ], + "type": "string" + }, + "DriveDownloadQuery": { + "additionalProperties": false, + "properties": { + "entry_workspace_id": { + "type": "string" + }, + "expected_revision": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "id": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + } + }, + "required": [ + "entry_workspace_id", + "id" + ], + "type": "object" + }, + "DriveEntry": { + "additionalProperties": false, + "description": "Metadata only: content is returned through bounded text or binary routes.", + "properties": { + "content_type": { + "type": [ + "string", + "null" + ] + }, + "entry": { + "$ref": "#/components/schemas/DriveEntryRef" + }, + "kind": { + "$ref": "#/components/schemas/DriveEntryKind" + }, + "latest_url": { + "description": "Stable relative authenticated URL in this same Workspace. No bearer token\nor cross-Workspace link is embedded in this metadata.", + "type": "string" + }, + "name": { + "type": "string" + }, + "parent": { + "anyOf": [ + { + "$ref": "#/components/schemas/DriveEntryRef" + }, + { + "type": "null" + } + ] + }, + "revision": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + }, + "size": { + "description": "File length in bytes; absent for folders.", + "format": "uint32", + "maximum": 16777216, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "updated_at": { + "description": "Timestamp projected from storage's updated_at_ms; not a creation time.", + "type": "string" + }, + "updated_by": { + "type": "string" + } + }, + "required": [ + "entry", + "name", + "kind", + "revision", + "updated_by", + "updated_at", + "latest_url" + ], + "type": "object" + }, + "DriveEntryKind": { + "enum": [ + "folder", + "file" + ], + "type": "string" + }, + "DriveEntryQuery": { + "additionalProperties": false, + "properties": { + "entry_workspace_id": { + "type": "string" + }, + "id": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + } + }, + "required": [ + "entry_workspace_id", + "id" + ], + "type": "object" + }, + "DriveEntryRef": { + "additionalProperties": false, + "description": "Numeric node IDs alone are not public references: the referenced Workspace\nmust match the route Workspace before the service resolves the node.", + "properties": { + "node_id": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "node_id" + ], + "type": "object" + }, + "DriveFailureClassification": { + "enum": [ + "not_committed", + "unknown" + ], + "type": "string" + }, + "DriveGrantCreateRequest": { + "additionalProperties": false, + "properties": { + "access": { + "$ref": "#/components/schemas/DriveAccess" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "runtime_id", + "worker_id", + "access" + ], + "type": "object" + }, + "DriveGrantListQuery": { + "additionalProperties": false, + "properties": { + "after": { + "default": null, + "description": "Canonical grant ID cursor within this Workspace.", + "type": [ + "string", + "null" + ] + }, + "limit": { + "default": null, + "format": "uint32", + "maximum": 200, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "DriveGrantListResponse": { + "additionalProperties": false, + "properties": { + "grants": { + "items": { + "$ref": "#/components/schemas/DriveGrantResponse" + }, + "type": "array" + }, + "next_after": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "grants" + ], + "type": "object" + }, + "DriveGrantResponse": { + "additionalProperties": false, + "properties": { + "access": { + "$ref": "#/components/schemas/DriveAccess" + }, + "created_at": { + "type": "string" + }, + "created_by": { + "type": "string" + }, + "grant_id": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + }, + "revoked": { + "type": "boolean" + }, + "revoked_at": { + "type": [ + "string", + "null" + ] + }, + "revoked_by": { + "type": [ + "string", + "null" + ] + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "grant_id", + "workspace_id", + "runtime_id", + "worker_id", + "access", + "revoked", + "created_by", + "created_at" + ], + "type": "object" + }, + "DriveListQuery": { + "additionalProperties": false, + "properties": { + "after": { + "default": null, + "description": "Opaque service-issued page cursor, not a bare node ID.", + "type": [ + "string", + "null" + ] + }, + "entry_workspace_id": { + "type": "string" + }, + "id": { + "description": "Folder to list.", + "pattern": "^[1-9][0-9]*$", + "type": "string" + }, + "limit": { + "default": null, + "format": "uint32", + "maximum": 200, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "entry_workspace_id", + "id" + ], + "type": "object" + }, + "DriveListResponse": { + "additionalProperties": false, + "properties": { + "entries": { + "items": { + "$ref": "#/components/schemas/DriveEntry" + }, + "type": "array" + }, + "next_after": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "entries" + ], + "type": "object" + }, + "DriveMutation": { + "description": "JSON can mutate bounded UTF-8 TEXT only. Arbitrary file bytes use drive_upload.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "operation": { + "const": "create_folder", + "type": "string" + }, + "parent": { + "$ref": "#/components/schemas/DriveEntryRef" + } + }, + "required": [ + "operation", + "parent", + "name" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "content_type": { + "type": "string" + }, + "name": { + "type": "string" + }, + "operation": { + "const": "create_text", + "type": "string" + }, + "parent": { + "$ref": "#/components/schemas/DriveEntryRef" + }, + "text": { + "type": "string" + } + }, + "required": [ + "operation", + "parent", + "name", + "text", + "content_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "content_type": { + "type": "string" + }, + "expected_revision": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/DriveEntryRef" + }, + "operation": { + "const": "update_text", + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "operation", + "id", + "expected_revision", + "text", + "content_type" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "expected_revision": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/DriveEntryRef" + }, + "name": { + "type": "string" + }, + "operation": { + "const": "relocate", + "type": "string" + }, + "parent": { + "$ref": "#/components/schemas/DriveEntryRef" + } + }, + "required": [ + "operation", + "id", + "expected_revision", + "parent", + "name" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "expected_revision": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/DriveEntryRef" + }, + "operation": { + "const": "delete", + "type": "string" + } + }, + "required": [ + "operation", + "id", + "expected_revision" + ], + "type": "object" + } + ] + }, + "DriveMutationRequest": { + "additionalProperties": false, + "properties": { + "mutation": { + "$ref": "#/components/schemas/DriveMutation" + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "request_id", + "mutation" + ], + "type": "object" + }, + "DriveMutationResponse": { + "additionalProperties": false, + "properties": { + "entry": { + "anyOf": [ + { + "$ref": "#/components/schemas/DriveEntry" + }, + { + "type": "null" + } + ], + "description": "A deleted entry has no live metadata." + }, + "request_id": { + "type": "string" + } + }, + "required": [ + "request_id" + ], + "type": "object" + }, + "DriveReadChunkQuery": { + "additionalProperties": false, + "properties": { + "entry_workspace_id": { + "type": "string" + }, + "expected_revision": { + "description": "Bind a multi-chunk read to the same file revision.", + "pattern": "^[1-9][0-9]*$", + "type": "string" + }, + "id": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + }, + "length": { + "default": 65536, + "format": "uint32", + "maximum": 65536, + "minimum": 1, + "type": "integer" + }, + "offset": { + "format": "uint32", + "maximum": 16777216, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "entry_workspace_id", + "id", + "expected_revision", + "offset" + ], + "type": "object" + }, + "DriveReadTextQuery": { + "additionalProperties": false, + "properties": { + "entry_workspace_id": { + "type": "string" + }, + "id": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + }, + "max_bytes": { + "default": 65536, + "format": "uint32", + "maximum": 65536, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "entry_workspace_id", + "id" + ], + "type": "object" + }, + "DriveReadTextResponse": { + "additionalProperties": false, + "properties": { + "entry": { + "$ref": "#/components/schemas/DriveEntry" + }, + "text": { + "type": "string" + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "entry", + "text", + "truncated" + ], + "type": "object" + }, + "DriveRequestState": { + "oneOf": [ + { + "enum": [ + "committed" + ], + "type": "string" + }, + { + "const": "uncommitted", + "description": "No commit record exists at the observation snapshot. An in-flight request\ncan still commit afterward; this is not proof of a final failed outcome.", + "type": "string" + } + ] + }, + "DriveRequestStatusResponse": { + "additionalProperties": false, + "properties": { + "request_id": { + "type": "string" + }, + "response": { + "anyOf": [ + { + "$ref": "#/components/schemas/DriveMutationResponse" + }, + { + "type": "null" + } + ] + }, + "state": { + "$ref": "#/components/schemas/DriveRequestState" + } + }, + "required": [ + "request_id", + "state" + ], + "type": "object" + }, + "DriveSearchQuery": { + "additionalProperties": false, + "properties": { + "after": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "include_text": { + "default": false, + "description": "Search file text as well as names only when explicitly requested.", + "type": "boolean" + }, + "limit": { + "default": null, + "format": "uint32", + "maximum": 128, + "minimum": 1, + "type": [ + "integer", + "null" + ] + }, + "query": { + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "DriveUploadOperation": { + "enum": [ + "create", + "update" + ], + "type": "string" + }, + "DriveUploadQuery": { + "additionalProperties": false, + "description": "Flat query metadata accompanies the binary body. The service rejects invalid\ncombinations: create requires parent_id/name and forbids id/expected_revision;\nupdate requires id/expected_revision and forbids parent_id/name. Both targets\nare bound by entry_workspace_id; declared size and SHA256 must match the body.", + "properties": { + "content_type": { + "type": "string" + }, + "entry_workspace_id": { + "type": "string" + }, + "expected_revision": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "id": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "name": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "operation": { + "$ref": "#/components/schemas/DriveUploadOperation" + }, + "parent_id": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "request_id": { + "type": "string" + }, + "sha256": { + "type": "string" + }, + "size": { + "description": "Values above the file limit still decode as u32 so the service returns\ntyped Limit rather than a generic request-decoding error.", + "format": "uint32", + "maximum": 16777216, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "operation", + "request_id", + "entry_workspace_id", + "content_type", + "size", + "sha256" + ], + "type": "object" + }, "EffectivePromptCatalog": { "additionalProperties": false, "properties": { @@ -15671,7 +16428,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933" + "x-yoi-source-digest": "sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da" }, "openapi": "3.1.0", "paths": { @@ -19490,6 +20247,1555 @@ ] } }, + "/api/w/{workspace_id}/drive/download": { + "get": { + "operationId": "drive_download", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/DriveDownloadQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Successful response", + "headers": { + "cache-control": { + "schema": { + "$ref": "#/components/schemas/string" + } + }, + "content-disposition": { + "schema": { + "$ref": "#/components/schemas/string" + } + }, + "content-security-policy": { + "schema": { + "$ref": "#/components/schemas/string" + } + }, + "etag": { + "schema": { + "$ref": "#/components/schemas/string" + } + }, + "x-content-type-options": { + "schema": { + "$ref": "#/components/schemas/string" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/drive/grants": { + "get": { + "operationId": "drive_grant_list", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/DriveGrantListQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveGrantListResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "drive_grant_create", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveGrantCreateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveGrantResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/drive/grants/{grant_id}": { + "delete": { + "operationId": "drive_grant_revoke", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "grant_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveGrantResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/drive/list": { + "get": { + "operationId": "drive_list", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/DriveListQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveListResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/drive/metadata": { + "get": { + "operationId": "drive_metadata", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/DriveEntryQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveEntry" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/drive/mutate": { + "post": { + "operationId": "drive_mutate", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveMutationRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveMutationResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/drive/read-chunk": { + "get": { + "operationId": "drive_read_chunk", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/DriveReadChunkQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/drive/read-text": { + "get": { + "operationId": "drive_read_text", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/DriveReadTextQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveReadTextResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/drive/requests/{request_id}": { + "get": { + "operationId": "drive_request_status", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "request_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveRequestStatusResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/drive/root": { + "get": { + "operationId": "drive_root", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveEntry" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/drive/search": { + "get": { + "operationId": "drive_search", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/DriveSearchQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveListResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/drive/upload": { + "put": { + "operationId": "drive_upload", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/DriveUploadQuery" + }, + "style": "form" + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveMutationResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "413": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DriveApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/external-workdir-grants": { "post": { "operationId": "external_workdir_grant_create", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 7bfebc5ca..3aab46a30 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933) +// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/drive-api.ts b/web/workspace/src/lib/generated/drive-api.ts new file mode 100644 index 000000000..a278862ba --- /dev/null +++ b/web/workspace/src/lib/generated/drive-api.ts @@ -0,0 +1,203 @@ +// Generated from server-api. Do not edit by hand. +// Regenerate: cargo run -q -p server-api --features typescript --example generate_drive_api_types > web/workspace/src/lib/generated/drive-api.ts + +export type DriveEntryRef = { workspace_id: string; node_id: string }; + +export type DriveEntryKind = "folder" | "file"; + +export type DriveEntry = { + entry: DriveEntryRef; + parent: DriveEntryRef | null; + name: string; + kind: DriveEntryKind; + revision: string; + /** + * File length in bytes; absent for folders. + */ + size: number | null; + content_type: string | null; + updated_by: string; + /** + * Timestamp projected from storage's updated_at_ms; not a creation time. + */ + updated_at: string; + /** + * Stable relative authenticated URL in this same Workspace. No bearer token + * or cross-Workspace link is embedded in this metadata. + */ + latest_url: string; +}; + +export type DriveEntryQuery = { entry_workspace_id: string; id: string }; + +export type DriveListQuery = { + entry_workspace_id: string; + /** + * Folder to list. + */ + id: string; + limit: number | null; + /** + * Opaque service-issued page cursor, not a bare node ID. + */ + after: string | null; +}; + +export type DriveSearchQuery = { + query: string; + /** + * Search file text as well as names only when explicitly requested. + */ + include_text: boolean; + limit: number | null; + after: string | null; +}; + +export type DriveListResponse = { + entries: Array; + next_after: string | null; +}; + +export type DriveReadTextQuery = { + entry_workspace_id: string; + id: string; + max_bytes: number; +}; + +export type DriveReadTextResponse = { + entry: DriveEntry; + text: string; + truncated: boolean; +}; + +export type DriveReadChunkQuery = { + entry_workspace_id: string; + id: string; + /** + * Bind a multi-chunk read to the same file revision. + */ + expected_revision: string; + offset: number; + length: number; +}; + +export type DriveDownloadQuery = { + entry_workspace_id: string; + id: string; + expected_revision: string | null; +}; + +export type DriveMutation = { + "operation": "create_folder"; + parent: DriveEntryRef; + name: string; +} | { + "operation": "create_text"; + parent: DriveEntryRef; + name: string; + text: string; + content_type: string; +} | { + "operation": "update_text"; + id: DriveEntryRef; + expected_revision: string; + text: string; + content_type: string; +} | { + "operation": "relocate"; + id: DriveEntryRef; + expected_revision: string; + parent: DriveEntryRef; + name: string; +} | { "operation": "delete"; id: DriveEntryRef; expected_revision: string }; + +export type DriveMutationRequest = { + request_id: string; + mutation: DriveMutation; +}; + +export type DriveMutationResponse = { + request_id: string; + /** + * A deleted entry has no live metadata. + */ + entry: DriveEntry | null; +}; + +export type DriveUploadOperation = "create" | "update"; + +export type DriveUploadQuery = { + operation: DriveUploadOperation; + request_id: string; + entry_workspace_id: string; + parent_id: string | null; + name: string | null; + id: string | null; + expected_revision: string | null; + content_type: string; + /** + * Values above the file limit still decode as u32 so the service returns + * typed Limit rather than a generic request-decoding error. + */ + size: number; + sha256: string; +}; + +export type DriveRequestState = "uncommitted" | "committed"; + +export type DriveRequestStatusResponse = { + request_id: string; + state: DriveRequestState; + response: DriveMutationResponse | null; +}; + +export type DriveAccess = "read_only" | "read_write"; + +export type DriveGrantCreateRequest = { + runtime_id: string; + worker_id: string; + access: DriveAccess; +}; + +export type DriveGrantResponse = { + grant_id: string; + workspace_id: string; + runtime_id: string; + worker_id: string; + access: DriveAccess; + revoked: boolean; + created_by: string; + created_at: string; + revoked_by: string | null; + revoked_at: string | null; +}; + +export type DriveGrantListQuery = { + limit: number | null; + /** + * Canonical grant ID cursor within this Workspace. + */ + after: string | null; +}; + +export type DriveGrantListResponse = { + grants: Array; + next_after: string | null; +}; + +export type DriveApiErrorCode = + | "denied" + | "not_found" + | "conflict" + | "invalid" + | "limit" + | "storage_unavailable" + | "outcome_unknown"; + +export type DriveFailureClassification = "not_committed" | "unknown"; + +export type DriveApiError = { + code: DriveApiErrorCode; + classification: DriveFailureClassification; + message: string; +}; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 5ed83fa35..f08303328 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933) +// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 902f3c282..485d1cbbf 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933) +// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 9a6cd8e3b..79e959295 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933) +// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 38127d28d..7e8af935c 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:16796bde0da04973310a14f90cf6eac14b2580bbe184c7c12afde3b6225ce933) +// Input contract: 0.1.0 (sha256:5f767ab90e6507d2e9486cd2efe74f62d613912ffa830ad617a088b2848f20da) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check