diff --git a/Cargo.lock b/Cargo.lock index bd0014b8..e6038be2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6710,6 +6710,7 @@ dependencies = [ "serde", "serde_json", "ts-rs", + "webauthn-rs-proto", ] [[package]] diff --git a/crates/client/src/backend_auth.rs b/crates/client/src/backend_auth.rs index 8f4c1392..1237c473 100644 --- a/crates/client/src/backend_auth.rs +++ b/crates/client/src/backend_auth.rs @@ -1,8 +1,11 @@ use crate::BackendOrigin; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use std::fmt; use std::time::Duration; +use workspace_api::{DeviceLoginPollRequest, DeviceLoginPollStatus, DeviceLoginStartRequest}; +pub use workspace_api::{DeviceLoginPollResponse, DeviceLoginStartResponse}; + #[derive(Debug, Clone, PartialEq, Eq)] pub struct BackendAuthTarget { pub base_url: String, @@ -28,23 +31,6 @@ impl BackendAuthTarget { } } -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct DeviceLoginStartResponse { - pub device_code: String, - pub user_code: String, - pub verification_uri: String, - pub verification_uri_complete: String, - pub expires_in: u64, - pub interval: u64, -} - -#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] -pub struct DeviceLoginPollResponse { - pub status: String, - pub access_token: Option, - pub token_type: Option, -} - #[derive(Debug)] pub enum BackendAuthClientError { Http(reqwest::Error), @@ -74,16 +60,6 @@ impl From for BackendAuthClientError { } } -#[derive(Debug, Serialize)] -struct DeviceLoginStartRequest<'a> { - client_name: Option<&'a str>, -} - -#[derive(Debug, Serialize)] -struct DeviceLoginPollRequest<'a> { - device_code: &'a str, -} - pub async fn start_device_login( target: &BackendAuthTarget, client_name: Option<&str>, @@ -91,7 +67,9 @@ pub async fn start_device_login( let client = reqwest::Client::new(); let response = client .post(target.api_url("/api/auth/device-login/start")) - .json(&DeviceLoginStartRequest { client_name }) + .json(&DeviceLoginStartRequest { + client_name: client_name.map(ToOwned::to_owned), + }) .send() .await?; parse_json_response(response).await @@ -104,12 +82,38 @@ pub async fn poll_device_login( let client = reqwest::Client::new(); let response = client .post(target.api_url("/api/auth/device-login/poll")) - .json(&DeviceLoginPollRequest { device_code }) + .json(&DeviceLoginPollRequest { + device_code: device_code.to_string(), + }) .send() .await?; parse_json_response(response).await } +fn device_login_poll_result( + response: DeviceLoginPollResponse, +) -> Result, BackendAuthClientError> { + match response.status { + DeviceLoginPollStatus::Approved => response + .access_token + .ok_or(BackendAuthClientError::MissingAccessToken) + .map(Some), + DeviceLoginPollStatus::Expired => Err(BackendAuthClientError::BackendStatus { + status: 410, + body: "device login expired".to_string(), + }), + DeviceLoginPollStatus::Denied => Err(BackendAuthClientError::BackendStatus { + status: 403, + body: "device login was denied".to_string(), + }), + DeviceLoginPollStatus::Consumed => Err(BackendAuthClientError::BackendStatus { + status: 409, + body: "device login was already consumed".to_string(), + }), + DeviceLoginPollStatus::Pending => Ok(None), + } +} + pub async fn wait_for_device_login( target: &BackendAuthTarget, device_code: &str, @@ -119,25 +123,8 @@ pub async fn wait_for_device_login( let started = std::time::Instant::now(); loop { let response = poll_device_login(target, device_code).await?; - match response.status.as_str() { - "approved" => { - return response - .access_token - .ok_or(BackendAuthClientError::MissingAccessToken); - } - "expired" => { - return Err(BackendAuthClientError::BackendStatus { - status: 410, - body: "device login expired".to_string(), - }); - } - "consumed" => { - return Err(BackendAuthClientError::BackendStatus { - status: 409, - body: "device login was already consumed".to_string(), - }); - } - _ => {} + if let Some(access_token) = device_login_poll_result(response)? { + return Ok(access_token); } if started.elapsed() >= expires_in { return Err(BackendAuthClientError::BackendStatus { @@ -162,3 +149,81 @@ async fn parse_json_response Deserialize<'de>>( } Ok(response.json::().await?) } + +#[cfg(test)] +mod tests { + use super::*; + use workspace_api::DeviceAccessTokenType; + + fn poll_response(status: DeviceLoginPollStatus) -> DeviceLoginPollResponse { + DeviceLoginPollResponse { + status, + access_token: None, + token_type: None, + } + } + + #[test] + fn device_login_start_response_enforces_shared_expiry_bounds() { + let valid = serde_json::json!({ + "device_code": "device-secret", + "user_code": "ABCD-EFGH", + "verification_uri": "https://yoi.example/login/device", + "verification_uri_complete": "https://yoi.example/login/device?user_code=ABCD-EFGH", + "expires_in": 600, + "interval": 5 + }); + assert!(serde_json::from_value::(valid.clone()).is_ok()); + + let mut expired = valid; + expired["expires_in"] = serde_json::json!(0); + assert!(serde_json::from_value::(expired).is_err()); + } + + #[test] + fn device_login_poll_response_rejects_unknown_status() { + assert!( + serde_json::from_value::( + serde_json::json!({"status": "future_status"}), + ) + .is_err() + ); + } + + #[test] + fn device_login_poll_result_handles_pending_and_terminal_states() { + assert!( + device_login_poll_result(poll_response(DeviceLoginPollStatus::Pending)) + .unwrap() + .is_none() + ); + + let approved = DeviceLoginPollResponse { + status: DeviceLoginPollStatus::Approved, + access_token: Some("access-secret".to_string()), + token_type: Some(DeviceAccessTokenType::Bearer), + }; + assert_eq!( + device_login_poll_result(approved).unwrap(), + Some("access-secret".to_string()) + ); + assert!(matches!( + device_login_poll_result(poll_response(DeviceLoginPollStatus::Approved)), + Err(BackendAuthClientError::MissingAccessToken) + )); + + for (status, expected_http_status) in [ + (DeviceLoginPollStatus::Expired, 410), + (DeviceLoginPollStatus::Denied, 403), + (DeviceLoginPollStatus::Consumed, 409), + ] { + assert!(matches!( + device_login_poll_result(poll_response(status)), + Err(BackendAuthClientError::BackendStatus { + status, + .. + }) if status == expected_http_status + )); + } + } +} diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index c4a41f20..70b6a6af 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -10,10 +10,10 @@ use ticket::{ }; use workspace_api::{ BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, - CreateWorkspaceWorkerRequest, ListResponse, ObjectiveCreateRequest, ObjectiveDetail, - ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, ObjectiveSummary, - TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, - WorkerLaunchOptionsResponse, + CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse, + ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest, + ObjectiveStateRequest, ObjectiveSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, + TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse, }; use crate::{BackendApiClient, BackendWorkspaceClientError}; @@ -241,6 +241,17 @@ impl BackendWorkspaceProductClient { ) } + pub fn memory_document(&self) -> Result { + self.get_json("/memory") + } + + pub fn list_memory_staging( + &self, + limit: usize, + ) -> Result { + self.get_json(&format!("/memory/staging?limit={limit}")) + } + pub fn launch_ticket_intake( &self, ticket_id: &str, @@ -668,6 +679,82 @@ mod tests { (format!("http://{address}"), receiver, handle) } + #[test] + fn memory_document_uses_shared_workspace_scoped_response() { + let body = r##"{"body_md":"# Memory\\n","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-02T00:00:00Z","bytes":10,"record_source":"workspace-sqlite"}"##; + let (base_url, request, handle) = one_response_server("200 OK", body); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "test-backend-token", + ) + .unwrap(); + + let response = client.memory_document().unwrap(); + + assert_eq!(response.record_source, "workspace-sqlite"); + assert!( + request + .recv() + .unwrap() + .starts_with("GET /api/w/workspace-a/memory ") + ); + handle.join().unwrap(); + } + + #[test] + fn memory_staging_uses_shared_dto_with_typed_origin() { + let body = r#"{"limit":10,"returned_count":1,"total_valid_count":1,"invalid_count":0,"truncated":false,"order":"imported_at_desc_candidate_id_asc","record_authority":"sqlite_workspace_authority.memory_staging","items":[{"id":"candidate-1","byte_len":128,"record":{"schema_version":1,"id":"candidate-1","extract_run_id":"run-1","source":{"segment_id":"segment-1","range":[1,2]},"kind":"decision","claim":"Keep typed provenance.","why_useful":"Prevents trust loss.","staleness":null,"evidence":[],"source_refs":[{"session_id":"session-1","segment_id":"segment-1","entry_range":[1,2],"evidence_id":"evidence-1","origin":{"kind":"worker_input","workspace_id":"workspace-a","runtime_id":"runtime-1","worker_id":"worker-1"},"evidence_kind":"worker_session_entry","label":null,"summary":null}]}}],"diagnostics":[]}"#; + let (base_url, request, handle) = one_response_server("200 OK", body); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "test-backend-token", + ) + .unwrap(); + + let response = client.list_memory_staging(10).unwrap(); + + assert_eq!( + response.items[0].record.source_refs[0] + .origin + .as_ref() + .unwrap() + .kind, + workspace_api::MemoryEvidenceOriginKind::WorkerInput + ); + assert!( + request + .recv() + .unwrap() + .starts_with("GET /api/w/workspace-a/memory/staging?limit=10 ") + ); + handle.join().unwrap(); + } + + #[test] + fn memory_staging_rejects_unknown_origin_kind() { + let body = r#"{"limit":10,"returned_count":1,"total_valid_count":1,"invalid_count":0,"truncated":false,"order":"order","record_authority":"authority","items":[{"id":"candidate-1","byte_len":1,"record":{"schema_version":1,"id":"candidate-1","extract_run_id":"run-1","source":{"segment_id":"segment-1","range":[1,2]},"kind":"decision","claim":"claim","why_useful":"useful","staleness":null,"evidence":[],"source_refs":[{"session_id":null,"segment_id":null,"entry_range":null,"evidence_id":null,"origin":{"kind":"future_origin"},"evidence_kind":null,"label":null,"summary":null}]}}],"diagnostics":[]}"#; + let (base_url, request, handle) = one_response_server("200 OK", body); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "test-backend-token", + ) + .unwrap(); + + let error = client.list_memory_staging(10).unwrap_err(); + + assert!(matches!(error, BackendWorkspaceClientError::Http(_))); + assert!( + request + .recv() + .unwrap() + .starts_with("GET /api/w/workspace-a/memory/staging?limit=10 ") + ); + handle.join().unwrap(); + } + #[test] fn objective_list_uses_workspace_scoped_backend_route() { let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#; diff --git a/crates/memory/src/extract/payload.rs b/crates/memory/src/extract/payload.rs index 42f87a29..74571285 100644 --- a/crates/memory/src/extract/payload.rs +++ b/crates/memory/src/extract/payload.rs @@ -74,6 +74,7 @@ impl ExtractedPayload { /// Bounded evidence snippet copied into a flat staging record. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct StagingEvidence { pub id: String, pub kind: EvidenceKind, @@ -89,6 +90,7 @@ pub struct StagingEvidence { /// One flat staging record. One record is one consolidation decision unit. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct StagingRecord { pub schema_version: u32, pub id: String, diff --git a/crates/memory/src/schema/common.rs b/crates/memory/src/schema/common.rs index a5396632..65f520ef 100644 --- a/crates/memory/src/schema/common.rs +++ b/crates/memory/src/schema/common.rs @@ -22,6 +22,7 @@ impl<'de> Deserialize<'de> for SourceRef { D: serde::Deserializer<'de>, { #[derive(Deserialize)] + #[serde(deny_unknown_fields)] struct RawSourceRef { #[serde(default)] segment_id: Option, @@ -83,6 +84,7 @@ pub enum EvidenceOriginKind { /// Bounded origin snapshot attached to extraction evidence. This is audit /// metadata only and cannot authorize Workspace operations. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct EvidenceOrigin { pub kind: EvidenceOriginKind, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -107,6 +109,7 @@ pub struct EvidenceOrigin { /// ranges, and short labels/summaries. It must not carry raw message bodies or /// full tool result content. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] pub struct SourceEvidenceRef { /// Stable session id when the anchor crosses or disambiguates segments. #[serde(default, skip_serializing_if = "Option::is_none")] diff --git a/crates/workspace-api/Cargo.toml b/crates/workspace-api/Cargo.toml index 979231a2..a4e5f31e 100644 --- a/crates/workspace-api/Cargo.toml +++ b/crates/workspace-api/Cargo.toml @@ -13,6 +13,7 @@ typescript = ["dep:ts-rs", "protocol/typescript"] protocol.workspace = true serde = { workspace = true, features = ["derive"] } ts-rs = { version = "12.0.1", optional = true } +webauthn-rs-proto = "0.5.5" [[example]] name = "generate_typescript" @@ -33,6 +34,14 @@ required-features = ["typescript"] name = "generate_companion_api_types" required-features = ["typescript"] +[[example]] +name = "generate_memory_api_types" +required-features = ["typescript"] + +[[example]] +name = "generate_auth_api_types" +required-features = ["typescript"] + [[example]] name = "generate_repository_access_types" required-features = ["typescript"] diff --git a/crates/workspace-api/examples/generate_auth_api_types.rs b/crates/workspace-api/examples/generate_auth_api_types.rs new file mode 100644 index 00000000..b8506b21 --- /dev/null +++ b/crates/workspace-api/examples/generate_auth_api_types.rs @@ -0,0 +1,3 @@ +fn main() { + print!("{}", workspace_api::auth_api_typescript()); +} diff --git a/crates/workspace-api/examples/generate_memory_api_types.rs b/crates/workspace-api/examples/generate_memory_api_types.rs new file mode 100644 index 00000000..87a9de91 --- /dev/null +++ b/crates/workspace-api/examples/generate_memory_api_types.rs @@ -0,0 +1,3 @@ +fn main() { + print!("{}", workspace_api::memory_api_typescript()); +} diff --git a/crates/workspace-api/src/lib.rs b/crates/workspace-api/src/lib.rs index f6aa4e78..ea076429 100644 --- a/crates/workspace-api/src/lib.rs +++ b/crates/workspace-api/src/lib.rs @@ -5,6 +5,313 @@ //! callers must explicitly construct these Workspace-authoritative resources. use serde::{Deserialize, Serialize}; +use webauthn_rs_proto::{ + CreationChallengeResponse, PublicKeyCredential, RegisterPublicKeyCredential, + RequestChallengeResponse, +}; + +/// Public browser-authentication configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct AuthPublicConfig { + pub rp_id: String, + pub origin: String, + pub public_base_url: String, + pub cookie_name: String, +} + +/// Authentication method that established the current request actor. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum ActorAuthMethod { + BrowserSession, + ApiToken, +} + +/// Public user identity returned by authentication operations. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct AuthenticatedUser { + pub user_id: String, + pub account_id: String, + pub handle: String, + pub display_name: String, +} + +/// Authenticated actor returned by `GET /api/auth/whoami`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct RequestActor { + pub user_id: String, + pub account_id: String, + pub handle: String, + pub display_name: String, + pub auth_method: ActorAuthMethod, +} + +impl RequestActor { + pub fn user(&self) -> AuthenticatedUser { + AuthenticatedUser { + user_id: self.user_id.clone(), + account_id: self.account_id.clone(), + handle: self.handle.clone(), + display_name: self.display_name.clone(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WhoamiResponse { + pub actor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct AuthBootstrapUserRequest { + pub handle: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub display_name: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct AuthUserResponse { + pub user: AuthenticatedUser, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PasskeyRegistrationOptionsRequest { + pub handle: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub display_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub browser_origin: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PasskeyRegistrationOptionsResponse { + pub challenge_id: String, + #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + pub public_key: CreationChallengeResponse, +} + +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PasskeyRegistrationCompleteRequest { + pub challenge_id: String, + #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + pub credential: RegisterPublicKeyCredential, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PasskeyLoginOptionsRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub handle: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub browser_origin: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PasskeyLoginOptionsResponse { + pub challenge_id: String, + #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + pub public_key: RequestChallengeResponse, +} + +#[derive(Debug, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PasskeyLoginCompleteRequest { + pub challenge_id: String, + #[cfg_attr(feature = "typescript", ts(type = "unknown"))] + pub credential: PublicKeyCredential, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DeviceLoginStartRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub client_name: Option, +} + +const DEVICE_LOGIN_EXPIRES_IN_MAX_SECONDS: u64 = 24 * 60 * 60; +const DEVICE_LOGIN_POLL_INTERVAL_MAX_SECONDS: u64 = 60; + +#[derive(Clone, Serialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct DeviceLoginStartResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub verification_uri_complete: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub expires_in: u64, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub interval: u64, +} + +impl<'de> Deserialize<'de> for DeviceLoginStartResponse { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields)] + struct Wire { + device_code: String, + user_code: String, + verification_uri: String, + verification_uri_complete: String, + expires_in: u64, + interval: u64, + } + + let wire = Wire::deserialize(deserializer)?; + if wire.expires_in == 0 || wire.expires_in > DEVICE_LOGIN_EXPIRES_IN_MAX_SECONDS { + return Err(serde::de::Error::custom(format!( + "expires_in must be between 1 and {DEVICE_LOGIN_EXPIRES_IN_MAX_SECONDS}" + ))); + } + if wire.interval == 0 || wire.interval > DEVICE_LOGIN_POLL_INTERVAL_MAX_SECONDS { + return Err(serde::de::Error::custom(format!( + "interval must be between 1 and {DEVICE_LOGIN_POLL_INTERVAL_MAX_SECONDS}" + ))); + } + Ok(Self { + device_code: wire.device_code, + user_code: wire.user_code, + verification_uri: wire.verification_uri, + verification_uri_complete: wire.verification_uri_complete, + expires_in: wire.expires_in, + interval: wire.interval, + }) + } +} + +impl std::fmt::Debug for DeviceLoginStartResponse { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DeviceLoginStartResponse") + .field("device_code", &"[redacted]") + .field("user_code", &self.user_code) + .field("verification_uri", &self.verification_uri) + .field("verification_uri_complete", &self.verification_uri_complete) + .field("expires_in", &self.expires_in) + .field("interval", &self.interval) + .finish() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DeviceLoginApproveRequest { + pub user_code: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum DeviceLoginApprovalStatus { + Approved, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DeviceLoginApproveResponse { + pub status: DeviceLoginApprovalStatus, + pub user: AuthenticatedUser, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DeviceLoginPollRequest { + pub device_code: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub enum DeviceAccessTokenType { + Bearer, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum DeviceLoginPollStatus { + Pending, + Approved, + Expired, + Denied, + Consumed, +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct DeviceLoginPollResponse { + pub status: DeviceLoginPollStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub access_token: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub token_type: Option, +} + +impl std::fmt::Debug for DeviceLoginPollResponse { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("DeviceLoginPollResponse") + .field("status", &self.status) + .field( + "access_token", + &self.access_token.as_ref().map(|_| "[redacted]"), + ) + .field("token_type", &self.token_type) + .finish() + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum LogoutStatus { + LoggedOut, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct LogoutResponse { + pub status: LogoutStatus, +} pub const REPOSITORY_KEY_MIN_LEN: usize = 1; pub const REPOSITORY_KEY_MAX_LEN: usize = 64; @@ -1291,6 +1598,197 @@ pub struct WorkerRestoreResponse { pub result: WorkerRestoreResult, } +/// Public Workspace Memory document projection. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct MemoryDocumentResponse { + pub body_md: String, + pub created_at: String, + pub updated_at: String, + pub bytes: usize, + pub record_source: String, +} + +/// Candidate kinds exposed by the Memory staging resource. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum MemoryCandidateKind { + Preference, + WorkingAssumption, + Constraint, + Decision, + OpenQuestion, + Lesson, +} + +/// Typed, bounded provenance classification for public Memory evidence anchors. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum MemoryEvidenceOriginKind { + HumanInput, + WorkerInput, + FlowInstruction, + BackendInstruction, + ModelOutput, + ToolOutput, + DerivedSummary, + LegacyUnknown, +} + +/// Bounded origin metadata copied from one typed Memory evidence anchor. +/// +/// This is provenance only. It carries no message body, prompt, reasoning, +/// secret, tool output, or authorization authority. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "typescript", ts(optional_fields = nullable))] +#[serde(deny_unknown_fields)] +pub struct MemoryEvidenceOrigin { + pub kind: MemoryEvidenceOriginKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flow_selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub flow_definition_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))] + pub flow_definition_revision: Option, +} + +/// Record-level source range for one Memory staging candidate. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct MemorySourceRef { + pub segment_id: String, + #[cfg_attr(feature = "typescript", ts(type = "[number, number]"))] + pub range: [u64; 2], +} + +/// Bounded evidence snippet included in one Memory staging record. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct MemoryStagingEvidence { + pub id: String, + pub kind: String, + #[cfg_attr(feature = "typescript", ts(type = "[number, number] | null"))] + pub entry_range: Option<[u64; 2]>, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr( + feature = "typescript", + ts(optional, type = "MemoryEvidenceOrigin | null") + )] + pub origin: Option, + pub excerpt: Option, + pub summary: Option, +} + +/// Bounded source anchor included in one Memory staging record. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct MemorySourceEvidenceRef { + pub session_id: Option, + pub segment_id: Option, + #[cfg_attr(feature = "typescript", ts(type = "[number, number] | null"))] + pub entry_range: Option<[u64; 2]>, + pub evidence_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr( + feature = "typescript", + ts(optional, type = "MemoryEvidenceOrigin | null") + )] + pub origin: Option, + pub evidence_kind: Option, + pub label: Option, + pub summary: Option, +} + +/// Public projection of one valid Memory staging record. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct MemoryStagingRecord { + pub schema_version: u32, + pub id: String, + pub extract_run_id: String, + pub source: MemorySourceRef, + pub kind: MemoryCandidateKind, + pub claim: String, + pub why_useful: String, + pub staleness: Option, + pub evidence: Vec, + pub source_refs: Vec, +} + +/// Public list entry for one valid Memory staging record. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct MemoryStagingEntry { + pub id: String, + #[cfg_attr(feature = "typescript", ts(type = "number"))] + pub byte_len: u64, + pub record: MemoryStagingRecord, +} + +/// Public response returned by the Workspace Memory staging list resource. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct MemoryStagingListResponse { + pub limit: usize, + pub returned_count: usize, + pub total_valid_count: usize, + pub invalid_count: usize, + pub truncated: bool, + pub order: String, + pub record_authority: String, + pub items: Vec, + pub diagnostics: Vec, +} + +#[cfg(feature = "typescript")] +pub fn memory_api_typescript() -> String { + use ts_rs::TS; + + let config = ts_rs::Config::default(); + let declarations = [ + DiagnosticSeverity::decl(&config), + Diagnostic::decl(&config), + MemoryDocumentResponse::decl(&config), + MemoryCandidateKind::decl(&config), + MemoryEvidenceOriginKind::decl(&config), + MemoryEvidenceOrigin::decl(&config), + MemorySourceRef::decl(&config), + MemoryStagingEvidence::decl(&config), + MemorySourceEvidenceRef::decl(&config), + MemoryStagingRecord::decl(&config), + MemoryStagingEntry::decl(&config), + MemoryStagingListResponse::decl(&config), + ]; + + format!( + "// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts\n\n{}\n", + declarations + .into_iter() + .map(|declaration| format!("export {declaration}")) + .collect::>() + .join("\n\n") + ) +} + /// Workspace-owned Memory settings returned by the shared Server API. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] @@ -1507,6 +2005,47 @@ pub fn repository_access_api_typescript() -> String { ) } +#[cfg(feature = "typescript")] +pub fn auth_api_typescript() -> String { + use ts_rs::TS; + + let config = ts_rs::Config::default(); + let declarations = [ + AuthPublicConfig::decl(&config), + ActorAuthMethod::decl(&config), + AuthenticatedUser::decl(&config), + RequestActor::decl(&config), + WhoamiResponse::decl(&config), + AuthBootstrapUserRequest::decl(&config), + AuthUserResponse::decl(&config), + PasskeyRegistrationOptionsRequest::decl(&config), + PasskeyRegistrationOptionsResponse::decl(&config), + PasskeyRegistrationCompleteRequest::decl(&config), + PasskeyLoginOptionsRequest::decl(&config), + PasskeyLoginOptionsResponse::decl(&config), + PasskeyLoginCompleteRequest::decl(&config), + DeviceLoginStartRequest::decl(&config), + DeviceLoginStartResponse::decl(&config), + DeviceLoginApproveRequest::decl(&config), + DeviceLoginApprovalStatus::decl(&config), + DeviceLoginApproveResponse::decl(&config), + DeviceLoginPollRequest::decl(&config), + DeviceAccessTokenType::decl(&config), + DeviceLoginPollStatus::decl(&config), + DeviceLoginPollResponse::decl(&config), + LogoutStatus::decl(&config), + LogoutResponse::decl(&config), + ]; + format!( + "// Generated from workspace-api. Do not edit by hand.\n// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts\n\n{}\n", + declarations + .into_iter() + .map(|declaration| format!("export {declaration}")) + .collect::>() + .join("\n\n") + ) +} + #[cfg(feature = "typescript")] pub fn workdir_api_typescript() -> String { use ts_rs::TS; @@ -1603,6 +2142,36 @@ mod worker_launch_typescript_tests { } } +#[cfg(all(test, feature = "typescript"))] +mod memory_typescript_tests { + #[test] + fn generated_memory_api_contract_is_current() { + let expected = super::memory_api_typescript(); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../web/workspace/src/lib/generated/memory-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 Memory API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-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(all(test, feature = "typescript"))] mod workdir_typescript_tests { #[test] @@ -1636,6 +2205,52 @@ mod workdir_typescript_tests { mod tests { use super::*; + #[test] + fn memory_evidence_origins_round_trip_as_typed_provenance() { + let kinds = [ + MemoryEvidenceOriginKind::HumanInput, + MemoryEvidenceOriginKind::WorkerInput, + MemoryEvidenceOriginKind::FlowInstruction, + MemoryEvidenceOriginKind::BackendInstruction, + MemoryEvidenceOriginKind::ModelOutput, + MemoryEvidenceOriginKind::ToolOutput, + MemoryEvidenceOriginKind::DerivedSummary, + MemoryEvidenceOriginKind::LegacyUnknown, + ]; + for kind in kinds { + let origin = MemoryEvidenceOrigin { + kind, + account_id: Some("account-1".to_string()), + workspace_id: Some("workspace-1".to_string()), + runtime_id: Some("runtime-1".to_string()), + worker_id: Some("worker-1".to_string()), + flow_selector: Some("builtin:coder-review".to_string()), + flow_definition_id: Some("flow-1".to_string()), + flow_definition_revision: Some(7), + }; + let encoded = serde_json::to_value(&origin).unwrap(); + let decoded: MemoryEvidenceOrigin = serde_json::from_value(encoded).unwrap(); + assert_eq!(decoded, origin); + } + } + + #[test] + fn memory_evidence_origin_rejects_unknown_kind_and_fields() { + assert!( + serde_json::from_value::( + serde_json::json!({"kind": "future_origin"}) + ) + .is_err() + ); + assert!( + serde_json::from_value::(serde_json::json!({ + "kind": "human_input", + "future_field": "not current schema" + })) + .is_err() + ); + } + fn worker_launch_summary() -> WorkerLaunchWorkerSummary { WorkerLaunchWorkerSummary { runtime_id: "runtime-a".to_string(), @@ -2022,6 +2637,242 @@ mod tests { } } + #[test] + fn auth_server_fixtures_round_trip_through_shared_dtos() { + let whoami = serde_json::json!({ + "actor": { + "user_id": "user-1", + "account_id": "account-1", + "handle": "hare", + "display_name": "Hare", + "auth_method": "browser_session" + } + }); + let decoded = serde_json::from_value::(whoami.clone()) + .expect("server whoami fixture should match shared DTO"); + assert_eq!(serde_json::to_value(decoded).unwrap(), whoami); + + let auth_config = serde_json::json!({ + "rp_id": "yoi.example", + "origin": "https://yoi.example", + "public_base_url": "https://yoi.example", + "cookie_name": "yoi_workspace_session" + }); + let decoded = serde_json::from_value::(auth_config.clone()) + .expect("server auth-config fixture should match shared DTO"); + assert_eq!(serde_json::to_value(decoded).unwrap(), auth_config); + + let auth_user = serde_json::json!({ + "user": { + "user_id": "user-1", + "account_id": "account-1", + "handle": "hare", + "display_name": "Hare" + } + }); + let decoded = serde_json::from_value::(auth_user.clone()) + .expect("server auth-user fixture should match shared DTO"); + assert_eq!(serde_json::to_value(decoded).unwrap(), auth_user); + + let registration_options = serde_json::json!({ + "challenge_id": "challenge-1", + "public_key": { + "publicKey": { + "challenge": "AQID", + "rp": {"id": "localhost", "name": "Yoi"}, + "user": {"id": "BAUG", "name": "hare", "displayName": "Hare"}, + "pubKeyCredParams": [{"type": "public-key", "alg": -7}] + } + } + }); + let decoded = serde_json::from_value::( + registration_options.clone(), + ) + .expect("server registration options fixture should match shared DTO"); + assert_eq!(serde_json::to_value(decoded).unwrap(), registration_options); + + let registration_complete = serde_json::json!({ + "challenge_id": "challenge-1", + "credential": { + "id": "AQID", + "rawId": "AQID", + "response": { + "attestationObject": "AQID", + "clientDataJSON": "AQID", + "transports": ["internal"] + }, + "type": "public-key", + "clientExtensionResults": {}, + "authenticatorAttachment": "platform" + } + }); + let decoded = + serde_json::from_value::(registration_complete) + .expect("server registration-complete fixture should match shared DTO"); + let encoded = serde_json::to_value(decoded).unwrap(); + serde_json::from_value::(encoded) + .expect("registration-complete DTO should round-trip"); + + let login_options = serde_json::json!({ + "challenge_id": "challenge-2", + "public_key": { + "publicKey": { + "challenge": "AQID", + "rpId": "localhost", + "allowCredentials": [], + "userVerification": "preferred" + } + } + }); + let decoded = serde_json::from_value::(login_options.clone()) + .expect("server login-options fixture should match shared DTO"); + assert_eq!(serde_json::to_value(decoded).unwrap(), login_options); + + let login_complete = serde_json::json!({ + "challenge_id": "challenge-2", + "credential": { + "id": "AQID", + "rawId": "AQID", + "response": { + "authenticatorData": "AQID", + "clientDataJSON": "AQID", + "signature": "AQID", + "userHandle": null + }, + "type": "public-key", + "clientExtensionResults": {}, + "authenticatorAttachment": "platform" + } + }); + let decoded = serde_json::from_value::(login_complete) + .expect("server login-complete fixture should match shared DTO"); + let encoded = serde_json::to_value(decoded).unwrap(); + serde_json::from_value::(encoded) + .expect("login-complete DTO should round-trip"); + + let device_start = serde_json::json!({ + "device_code": "device-secret", + "user_code": "ABCD-EFGH", + "verification_uri": "https://yoi.example/login/device", + "verification_uri_complete": "https://yoi.example/login/device?user_code=ABCD-EFGH", + "expires_in": 600, + "interval": 2 + }); + let decoded = serde_json::from_value::(device_start.clone()) + .expect("server device-login fixture should match shared DTO"); + assert_eq!(serde_json::to_value(decoded).unwrap(), device_start); + + let approved_user = AuthenticatedUser { + user_id: "user-1".to_string(), + account_id: "account-1".to_string(), + handle: "hare".to_string(), + display_name: "Hare".to_string(), + }; + round_trip(DeviceLoginApproveResponse { + status: DeviceLoginApprovalStatus::Approved, + user: approved_user, + }); + for status in [ + DeviceLoginPollStatus::Pending, + DeviceLoginPollStatus::Expired, + DeviceLoginPollStatus::Denied, + DeviceLoginPollStatus::Consumed, + ] { + round_trip(DeviceLoginPollResponse { + status, + access_token: None, + token_type: None, + }); + } + round_trip(DeviceLoginPollResponse { + status: DeviceLoginPollStatus::Approved, + access_token: Some("access-secret".to_string()), + token_type: Some(DeviceAccessTokenType::Bearer), + }); + round_trip(LogoutResponse { + status: LogoutStatus::LoggedOut, + }); + } + + #[test] + fn auth_dtos_reject_unknown_status_and_fields() { + let unknown_status = serde_json::json!({"status": "future_status"}); + assert!( + serde_json::from_value::(unknown_status).is_err(), + "unknown device-login statuses must fail closed" + ); + + let unsafe_expiry = serde_json::json!({ + "device_code": "device-secret", + "user_code": "ABCD-EFGH", + "verification_uri": "https://yoi.example/login/device", + "verification_uri_complete": "https://yoi.example/login/device?user_code=ABCD-EFGH", + "expires_in": 0, + "interval": 2 + }); + assert!(serde_json::from_value::(unsafe_expiry).is_err()); + + let malformed_credential = serde_json::json!({ + "challenge_id": "challenge-1", + "credential": { + "id": "AQID", + "rawId": "AQID", + "type": "public-key", + "response": {"clientDataJSON": 42} + } + }); + assert!( + serde_json::from_value::(malformed_credential).is_err(), + "malformed passkey credential payloads must fail closed" + ); + + let unexpected_field = serde_json::json!({ + "actor": null, + "access_token": "must-not-be-accepted" + }); + assert!(serde_json::from_value::(unexpected_field).is_err()); + } + + #[test] + fn device_login_debug_output_redacts_secret_material() { + let start = DeviceLoginStartResponse { + device_code: "device-secret".to_string(), + user_code: "ABCD-EFGH".to_string(), + verification_uri: "https://yoi.example/login/device".to_string(), + verification_uri_complete: "https://yoi.example/login/device?user_code=ABCD-EFGH" + .to_string(), + expires_in: 600, + interval: 2, + }; + let start_debug = format!("{start:?}"); + assert!(!start_debug.contains("device-secret")); + assert!(start_debug.contains("[redacted]")); + + let poll = DeviceLoginPollResponse { + status: DeviceLoginPollStatus::Approved, + access_token: Some("access-secret".to_string()), + token_type: Some(DeviceAccessTokenType::Bearer), + }; + let poll_debug = format!("{poll:?}"); + assert!(!poll_debug.contains("access-secret")); + assert!(poll_debug.contains("[redacted]")); + } + + #[cfg(feature = "typescript")] + #[test] + fn generated_auth_api_contract_is_current() { + let expected = auth_api_typescript(); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../web/workspace/src/lib/generated/auth-api.ts"); + let actual = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("failed to read {}: {error}", path.display())); + assert_eq!( + normalize_typescript(&actual), + normalize_typescript(&expected), + "regenerate auth API TypeScript types with `cargo run -q -p workspace-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts` and format the generated file", + ); + } + #[test] fn companion_transcript_fixture_round_trips() { round_trip(CompanionTranscriptProjection { @@ -2116,6 +2967,7 @@ mod tests { }) .collect::() .replace(";}", "}") + .replace("=|", "=") } #[test] diff --git a/crates/workspace-server/src/auth.rs b/crates/workspace-server/src/auth.rs index c014e37a..a135f5e0 100644 --- a/crates/workspace-server/src/auth.rs +++ b/crates/workspace-server/src/auth.rs @@ -1,54 +1,11 @@ use axum::http::{HeaderMap, header}; use chrono::{DateTime, Duration, Utc}; -use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use uuid::Uuid; +pub use workspace_api::{ActorAuthMethod, AuthPublicConfig, AuthenticatedUser, RequestActor}; use crate::{Error, Result, store::ControlPlaneStore}; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct AuthPublicConfig { - pub rp_id: String, - pub origin: String, - pub public_base_url: String, - pub cookie_name: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RequestActor { - pub user_id: String, - pub account_id: String, - pub handle: String, - pub display_name: String, - pub auth_method: ActorAuthMethod, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "snake_case")] -pub enum ActorAuthMethod { - BrowserSession, - ApiToken, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct AuthenticatedUser { - pub user_id: String, - pub account_id: String, - pub handle: String, - pub display_name: String, -} - -impl RequestActor { - pub fn user(&self) -> AuthenticatedUser { - AuthenticatedUser { - user_id: self.user_id.clone(), - account_id: self.account_id.clone(), - handle: self.handle.clone(), - display_name: self.display_name.clone(), - } - } -} - pub fn normalize_handle(handle: &str) -> Result { let normalized = handle.trim().to_ascii_lowercase(); let valid = !normalized.is_empty() diff --git a/crates/workspace-server/src/memory_staging.rs b/crates/workspace-server/src/memory_staging.rs index f3478847..5ab7e01a 100644 --- a/crates/workspace-server/src/memory_staging.rs +++ b/crates/workspace-server/src/memory_staging.rs @@ -1,6 +1,10 @@ -use memory::extract::StagingRecord; -use memory::schema::{SourceEvidenceRef, SourceRef}; -use serde::{Deserialize, Serialize}; +use memory::extract::{CandidateKind, StagingRecord}; +use memory::schema::{EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef, SourceRef}; +use workspace_api::{ + Diagnostic, DiagnosticSeverity, MemoryCandidateKind, MemoryEvidenceOrigin, + MemoryEvidenceOriginKind, MemorySourceEvidenceRef, MemorySourceRef, MemoryStagingEntry, + MemoryStagingEvidence, MemoryStagingListResponse, MemoryStagingRecord, +}; use crate::Result; use crate::authority::MemoryAuthority; @@ -8,59 +12,6 @@ use crate::authority::MemoryAuthority; const DEFAULT_MEMORY_STAGING_LIMIT: usize = 100; const MAX_MEMORY_STAGING_LIMIT: usize = 500; -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct MemoryStagingListResponse { - pub limit: usize, - pub returned_count: usize, - pub total_valid_count: usize, - pub invalid_count: usize, - pub truncated: bool, - pub order: String, - pub record_authority: String, - pub items: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct MemoryStagingEntrySummary { - pub id: String, - pub byte_len: u64, - pub record: MemoryStagingRecordSummary, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct MemoryStagingRecordSummary { - pub schema_version: u32, - pub id: String, - pub extract_run_id: String, - pub source: SourceRef, - pub kind: String, - pub claim: String, - pub why_useful: String, - pub staleness: Option, - pub evidence: Vec, - pub source_refs: Vec, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct MemoryStagingEvidenceSummary { - pub id: String, - pub kind: String, - pub entry_range: Option<[u64; 2]>, - pub excerpt: Option, - pub summary: Option, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct MemorySourceEvidenceRefSummary { - pub session_id: Option, - pub segment_id: Option, - pub entry_range: Option<[u64; 2]>, - pub evidence_id: Option, - pub evidence_kind: Option, - pub label: Option, - pub summary: Option, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct MemoryStagingBacklogSummary { pub candidate_count: usize, @@ -90,14 +41,24 @@ pub fn list_memory_staging_from_authority( }; total_valid_count += 1; if valid_items.len() < limit { - valid_items.push(MemoryStagingEntrySummary { + valid_items.push(MemoryStagingEntry { id: entry.candidate_id, byte_len: entry.raw_json.len() as u64, - record: memory_staging_record_summary(record), + record: memory_staging_record_projection(record), }); } } let returned_count = valid_items.len(); + let diagnostics = (invalid_count > 0) + .then(|| Diagnostic { + code: "memory_staging_record_invalid".to_string(), + message: format!( + "{invalid_count} Memory staging record(s) were excluded because they did not match the current schema." + ), + severity: DiagnosticSeverity::Error, + }) + .into_iter() + .collect(); Ok(MemoryStagingListResponse { limit, returned_count, @@ -107,6 +68,7 @@ pub fn list_memory_staging_from_authority( order: "imported_at_desc_candidate_id_asc".to_string(), record_authority: "sqlite_workspace_authority.memory_staging".to_string(), items: valid_items, + diagnostics, }) } @@ -133,23 +95,24 @@ pub fn memory_staging_backlog_from_authority( }) } -fn memory_staging_record_summary(record: StagingRecord) -> MemoryStagingRecordSummary { - MemoryStagingRecordSummary { +fn memory_staging_record_projection(record: StagingRecord) -> MemoryStagingRecord { + MemoryStagingRecord { schema_version: record.schema_version, id: record.id, extract_run_id: record.extract_run_id, - source: record.source, - kind: record.kind.as_str().to_string(), + source: memory_source_ref_projection(record.source), + kind: memory_candidate_kind_projection(record.kind), claim: record.claim, why_useful: record.why_useful, staleness: record.staleness, evidence: record .evidence .into_iter() - .map(|evidence| MemoryStagingEvidenceSummary { + .map(|evidence| MemoryStagingEvidence { id: evidence.id, kind: evidence.kind.as_str().to_string(), entry_range: evidence.entry_range, + origin: evidence.origin.map(memory_evidence_origin_projection), excerpt: evidence.excerpt, summary: evidence.summary, }) @@ -157,19 +120,36 @@ fn memory_staging_record_summary(record: StagingRecord) -> MemoryStagingRecordSu source_refs: record .source_refs .into_iter() - .map(memory_source_evidence_ref_summary) + .map(memory_source_evidence_ref_projection) .collect(), } } -fn memory_source_evidence_ref_summary( - source_ref: SourceEvidenceRef, -) -> MemorySourceEvidenceRefSummary { - MemorySourceEvidenceRefSummary { +fn memory_source_ref_projection(source_ref: SourceRef) -> MemorySourceRef { + MemorySourceRef { + segment_id: source_ref.segment_id, + range: source_ref.range, + } +} + +fn memory_candidate_kind_projection(kind: CandidateKind) -> MemoryCandidateKind { + match kind { + CandidateKind::Preference => MemoryCandidateKind::Preference, + CandidateKind::WorkingAssumption => MemoryCandidateKind::WorkingAssumption, + CandidateKind::Constraint => MemoryCandidateKind::Constraint, + CandidateKind::Decision => MemoryCandidateKind::Decision, + CandidateKind::OpenQuestion => MemoryCandidateKind::OpenQuestion, + CandidateKind::Lesson => MemoryCandidateKind::Lesson, + } +} + +fn memory_source_evidence_ref_projection(source_ref: SourceEvidenceRef) -> MemorySourceEvidenceRef { + MemorySourceEvidenceRef { session_id: source_ref.session_id, segment_id: source_ref.segment_id, entry_range: source_ref.entry_range, evidence_id: source_ref.evidence_id, + origin: source_ref.origin.map(memory_evidence_origin_projection), evidence_kind: source_ref .evidence_kind .map(|evidence_kind| evidence_kind.as_str().to_string()), @@ -178,12 +158,35 @@ fn memory_source_evidence_ref_summary( } } +fn memory_evidence_origin_projection(origin: EvidenceOrigin) -> MemoryEvidenceOrigin { + MemoryEvidenceOrigin { + kind: match origin.kind { + EvidenceOriginKind::HumanInput => MemoryEvidenceOriginKind::HumanInput, + EvidenceOriginKind::WorkerInput => MemoryEvidenceOriginKind::WorkerInput, + EvidenceOriginKind::FlowInstruction => MemoryEvidenceOriginKind::FlowInstruction, + EvidenceOriginKind::BackendInstruction => MemoryEvidenceOriginKind::BackendInstruction, + EvidenceOriginKind::ModelOutput => MemoryEvidenceOriginKind::ModelOutput, + EvidenceOriginKind::ToolOutput => MemoryEvidenceOriginKind::ToolOutput, + EvidenceOriginKind::DerivedSummary => MemoryEvidenceOriginKind::DerivedSummary, + EvidenceOriginKind::LegacyUnknown => MemoryEvidenceOriginKind::LegacyUnknown, + }, + account_id: origin.account_id, + workspace_id: origin.workspace_id, + runtime_id: origin.runtime_id, + worker_id: origin.worker_id, + flow_selector: origin.flow_selector, + flow_definition_id: origin.flow_definition_id, + flow_definition_revision: origin.flow_definition_revision, + } +} + #[cfg(test)] mod tests { use super::*; use crate::authority::{MemoryAuthority, SqliteWorkspaceAuthority}; use crate::store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord}; use memory::extract::{CandidateKind, ExtractedCandidate, StagingRecord}; + use memory::schema::{EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef}; use tempfile::TempDir; fn source() -> SourceRef { @@ -258,7 +261,114 @@ mod tests { response.record_authority, "sqlite_workspace_authority.memory_staging" ); - assert_eq!(response.items[0].record.kind, "decision"); + assert_eq!(response.items[0].record.kind, MemoryCandidateKind::Decision); + assert!(response.diagnostics.is_empty()); + } + + #[test] + fn projects_every_typed_evidence_origin_without_flattening() { + let cases = [ + ( + EvidenceOriginKind::HumanInput, + MemoryEvidenceOriginKind::HumanInput, + ), + ( + EvidenceOriginKind::WorkerInput, + MemoryEvidenceOriginKind::WorkerInput, + ), + ( + EvidenceOriginKind::FlowInstruction, + MemoryEvidenceOriginKind::FlowInstruction, + ), + ( + EvidenceOriginKind::BackendInstruction, + MemoryEvidenceOriginKind::BackendInstruction, + ), + ( + EvidenceOriginKind::ModelOutput, + MemoryEvidenceOriginKind::ModelOutput, + ), + ( + EvidenceOriginKind::ToolOutput, + MemoryEvidenceOriginKind::ToolOutput, + ), + ( + EvidenceOriginKind::DerivedSummary, + MemoryEvidenceOriginKind::DerivedSummary, + ), + ( + EvidenceOriginKind::LegacyUnknown, + MemoryEvidenceOriginKind::LegacyUnknown, + ), + ]; + + for (domain_kind, api_kind) in cases { + let projected = memory_source_evidence_ref_projection(SourceEvidenceRef { + session_id: Some("session-1".to_string()), + origin: Some(EvidenceOrigin { + kind: domain_kind, + account_id: Some("account-1".to_string()), + workspace_id: Some("workspace-test".to_string()), + runtime_id: Some("runtime-1".to_string()), + worker_id: Some("worker-1".to_string()), + flow_selector: Some("builtin:coder-review".to_string()), + flow_definition_id: Some("flow-1".to_string()), + flow_definition_revision: Some(7), + }), + ..SourceEvidenceRef::default() + }); + let origin = projected.origin.unwrap(); + assert_eq!(origin.kind, api_kind); + assert_eq!(origin.account_id.as_deref(), Some("account-1")); + assert_eq!(origin.workspace_id.as_deref(), Some("workspace-test")); + assert_eq!(origin.runtime_id.as_deref(), Some("runtime-1")); + assert_eq!(origin.worker_id.as_deref(), Some("worker-1")); + assert_eq!( + origin.flow_selector.as_deref(), + Some("builtin:coder-review") + ); + assert_eq!(origin.flow_definition_id.as_deref(), Some("flow-1")); + assert_eq!(origin.flow_definition_revision, Some(7)); + } + } + + #[tokio::test] + async fn invalid_or_newer_origin_shapes_are_excluded_with_bounded_diagnostic() { + let (_temp, authority) = authority().await; + for (id, origin) in [ + ( + "unknown-origin-kind", + serde_json::json!({"kind": "future_origin_kind"}), + ), + ( + "newer-origin-shape", + serde_json::json!({"kind": "human_input", "future_field": "do not echo me"}), + ), + ] { + let mut record: serde_json::Value = + serde_json::from_str(&record_json(id, "claim")).unwrap(); + record["source_refs"] = serde_json::json!([{"origin": origin}]); + authority + .upsert_memory_staging_record(id, &serde_json::to_string(&record).unwrap(), None) + .unwrap(); + } + + let response = list_memory_staging_from_authority(&authority, None).unwrap(); + + assert_eq!(response.returned_count, 0); + assert_eq!(response.invalid_count, 2); + assert_eq!(response.diagnostics.len(), 1); + assert_eq!( + response.diagnostics[0].code, + "memory_staging_record_invalid" + ); + assert_eq!(response.diagnostics[0].severity, DiagnosticSeverity::Error); + assert!( + !response.diagnostics[0] + .message + .contains("future_origin_kind") + ); + assert!(!response.diagnostics[0].message.contains("do not echo me")); } #[tokio::test] diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 83e8904d..584cb96c 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -41,9 +41,7 @@ use tower::ServiceExt; use url::Url; use uuid::Uuid; use webauthn_rs::prelude::{ - CreationChallengeResponse, Passkey, PasskeyAuthentication, PasskeyRegistration, - PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse, Webauthn, - WebauthnBuilder, + Passkey, PasskeyAuthentication, PasskeyRegistration, Webauthn, WebauthnBuilder, }; use workdir::http::{ WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError, @@ -58,18 +56,26 @@ use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjec use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest}; use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend}; use workspace_api::{ - BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, CreateRemoteRuntimeRequest, - CreateRepositorySshCredentialRequest, CreateWorkspaceRepositoryRequest, - CreateWorkspaceRepositoryResponse, CreateWorkspaceWorkerRequest, - CreateWorkspaceWorkerTicketAssignmentRequest, DeleteRepositorySshCredentialRequest, - DeleteRepositorySshHostTrustRequest, ObjectiveCreateRequest, ObjectiveEditRequest, - ObjectiveLinkTicketRequest, ObjectiveStateRequest, ProfileSettingsResponse, - PutRepositorySshHostTrustRequest, RepositoryAccessProjection, RepositoryDetailResponse, - RepositoryListResponse, RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, + ActorAuthMethod, AuthBootstrapUserRequest, AuthPublicConfig, AuthUserResponse, + AuthenticatedUser, BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, + CreateRemoteRuntimeRequest, CreateRepositorySshCredentialRequest, + CreateWorkspaceRepositoryRequest, CreateWorkspaceRepositoryResponse, + CreateWorkspaceWorkerRequest, CreateWorkspaceWorkerTicketAssignmentRequest, + DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest, + DeviceAccessTokenType, DeviceLoginApprovalStatus, DeviceLoginApproveRequest, + DeviceLoginApproveResponse, DeviceLoginPollRequest, DeviceLoginPollResponse, + DeviceLoginPollStatus, DeviceLoginStartRequest, DeviceLoginStartResponse, LogoutResponse, + LogoutStatus, MemoryDocumentResponse, MemoryStagingListResponse, ObjectiveCreateRequest, + ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, + PasskeyLoginCompleteRequest, PasskeyLoginOptionsRequest, PasskeyLoginOptionsResponse, + PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsRequest, + PasskeyRegistrationOptionsResponse, ProfileSettingsResponse, PutRepositorySshHostTrustRequest, + RepositoryAccessProjection, RepositoryDetailResponse, RepositoryListResponse, + RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust, RequestActor, RotateRepositorySshCredentialRequest, RuntimeConnectionTestResponse, RuntimeManagementSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH, - UpdateWorkspaceMetadataRequest, WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate, - WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary, + UpdateWorkspaceMetadataRequest, WhoamiResponse, WorkerLaunchOptionsResponse, + WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary, WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest, WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse, WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse, @@ -84,9 +90,9 @@ use workspace_api::{ }; use crate::auth::{ - ActorAuthMethod, AuthPublicConfig, AuthenticatedUser, RequestActor, SessionCookiePolicy, - auth_error, is_expired, mint_secret, new_id, new_user_code, normalize_handle, parse_cookie, - resolve_request_actor, rfc3339_after, session_set_cookie, token_hash, + SessionCookiePolicy, auth_error, is_expired, mint_secret, new_id, new_user_code, + normalize_handle, parse_cookie, resolve_request_actor, rfc3339_after, session_set_cookie, + token_hash, }; use crate::authority::{ MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput, @@ -113,8 +119,7 @@ use crate::hosts::{ use crate::identity::WorkspaceIdentity; use crate::memory_backend::execute_memory_backend_operation_with_authority; use crate::memory_staging::{ - MemoryStagingListResponse, list_memory_staging_from_authority, - memory_staging_backlog_from_authority, + list_memory_staging_from_authority, memory_staging_backlog_from_authority, }; use crate::observation::{ BackendObservationProxy, ObservationProxyError, RuntimeObservationClient, @@ -7917,15 +7922,6 @@ fn find_workspace_orchestrator(api: &WorkspaceApi) -> Option { None } -#[derive(Debug, Clone, Serialize)] -struct MemoryDocumentResponse { - body_md: String, - created_at: String, - updated_at: String, - bytes: usize, - record_source: String, -} - async fn scoped_get_memory_document( State(api): State, AxumPath(path): AxumPath, @@ -11486,118 +11482,6 @@ async fn scoped_list_host_workers( list_host_workers(State(api), AxumPath(path.host_id)).await } -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct AuthBootstrapUserRequest { - handle: String, - #[serde(default)] - display_name: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct AuthUserResponse { - user: AuthenticatedUser, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PasskeyRegistrationOptionsRequest { - handle: String, - #[serde(default)] - display_name: Option, - #[serde(default)] - browser_origin: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct PasskeyRegistrationOptionsResponse { - challenge_id: String, - public_key: CreationChallengeResponse, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PasskeyRegistrationCompleteRequest { - challenge_id: String, - credential: RegisterPublicKeyCredential, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PasskeyLoginOptionsRequest { - #[serde(default)] - handle: Option, - #[serde(default)] - browser_origin: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct PasskeyLoginOptionsResponse { - challenge_id: String, - public_key: RequestChallengeResponse, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct PasskeyLoginCompleteRequest { - challenge_id: String, - credential: PublicKeyCredential, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DeviceLoginStartRequest { - #[serde(default)] - client_name: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct DeviceLoginStartResponse { - device_code: String, - user_code: String, - verification_uri: String, - verification_uri_complete: String, - expires_in: u64, - interval: u64, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DeviceLoginApproveRequest { - user_code: String, -} - -#[derive(Debug, Serialize, Deserialize)] -struct DeviceLoginApproveResponse { - status: String, - user: AuthenticatedUser, -} - -#[derive(Debug, Deserialize)] -#[serde(deny_unknown_fields)] -struct DeviceLoginPollRequest { - device_code: String, -} - -#[derive(Debug, Serialize, Deserialize)] -struct DeviceLoginPollResponse { - status: String, - #[serde(skip_serializing_if = "Option::is_none")] - access_token: Option, - #[serde(skip_serializing_if = "Option::is_none")] - token_type: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct WhoamiResponse { - actor: Option, -} - -#[derive(Debug, Serialize, Deserialize)] -struct LogoutResponse { - status: String, -} - async fn get_auth_config(State(api): State) -> ApiResult> { Ok(Json(auth_public_config(&api.config))) } @@ -12002,7 +11886,7 @@ async fn post_device_login_approve( .into()); } Ok(Json(DeviceLoginApproveResponse { - status: "approved".to_string(), + status: DeviceLoginApprovalStatus::Approved, user: actor.user(), })) } @@ -12019,14 +11903,14 @@ async fn post_device_login_poll( }; if is_expired(&flow.expires_at) { return Ok(Json(DeviceLoginPollResponse { - status: "expired".to_string(), + status: DeviceLoginPollStatus::Expired, access_token: None, token_type: None, })); } if flow.approved_at.is_none() { return Ok(Json(DeviceLoginPollResponse { - status: "pending".to_string(), + status: DeviceLoginPollStatus::Pending, access_token: None, token_type: None, })); @@ -12036,15 +11920,15 @@ async fn post_device_login_poll( .consume_device_login_token(&request.device_code, &crate::auth::now_rfc3339())? else { return Ok(Json(DeviceLoginPollResponse { - status: "consumed".to_string(), + status: DeviceLoginPollStatus::Consumed, access_token: None, token_type: None, })); }; Ok(Json(DeviceLoginPollResponse { - status: "approved".to_string(), + status: DeviceLoginPollStatus::Approved, access_token: consumed.issued_access_token, - token_type: Some("Bearer".to_string()), + token_type: Some(DeviceAccessTokenType::Bearer), })) } @@ -12082,7 +11966,7 @@ async fn post_auth_logout( Ok(( response_headers, Json(LogoutResponse { - status: "logged_out".to_string(), + status: LogoutStatus::LoggedOut, }), ) .into_response()) @@ -26478,6 +26362,8 @@ mod tests { let memory_document = get_json(app.clone(), &format!("/api/w/{TEST_WORKSPACE_ID}/memory")).await; + let _: workspace_api::MemoryDocumentResponse = + serde_json::from_value(memory_document.clone()).unwrap(); assert_eq!(memory_document["created_at"], "2026-01-01T00:00:00Z"); assert_eq!(memory_document["updated_at"], "2026-01-02T00:00:00Z"); assert_eq!(memory_document["bytes"], 63); @@ -26494,6 +26380,8 @@ mod tests { &format!("/api/w/{TEST_WORKSPACE_ID}/memory/staging?limit=10"), ) .await; + let _: workspace_api::MemoryStagingListResponse = + serde_json::from_value(memory_staging.clone()).unwrap(); assert_eq!( memory_staging["record_authority"], "sqlite_workspace_authority.memory_staging" diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 76398e59..8714b347 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", + "test": "deno test --allow-read=src,test,tests --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts src/lib/workspace/auth/model.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/markdown.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/ui.test.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview" }, diff --git a/web/workspace/src/lib/generated/auth-api.ts b/web/workspace/src/lib/generated/auth-api.ts new file mode 100644 index 00000000..9fa3291f --- /dev/null +++ b/web/workspace/src/lib/generated/auth-api.ts @@ -0,0 +1,107 @@ +// Generated from workspace-api. Do not edit by hand. +// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_auth_api_types > web/workspace/src/lib/generated/auth-api.ts + +export type AuthPublicConfig = { + rp_id: string; + origin: string; + public_base_url: string; + cookie_name: string; +}; + +export type ActorAuthMethod = "browser_session" | "api_token"; + +export type AuthenticatedUser = { + user_id: string; + account_id: string; + handle: string; + display_name: string; +}; + +export type RequestActor = { + user_id: string; + account_id: string; + handle: string; + display_name: string; + auth_method: ActorAuthMethod; +}; + +export type WhoamiResponse = { actor: RequestActor | null }; + +export type AuthBootstrapUserRequest = { + handle: string; + display_name?: string | null; +}; + +export type AuthUserResponse = { user: AuthenticatedUser }; + +export type PasskeyRegistrationOptionsRequest = { + handle: string; + display_name?: string | null; + browser_origin?: string | null; +}; + +export type PasskeyRegistrationOptionsResponse = { + challenge_id: string; + public_key: unknown; +}; + +export type PasskeyRegistrationCompleteRequest = { + challenge_id: string; + credential: unknown; +}; + +export type PasskeyLoginOptionsRequest = { + handle?: string | null; + browser_origin?: string | null; +}; + +export type PasskeyLoginOptionsResponse = { + challenge_id: string; + public_key: unknown; +}; + +export type PasskeyLoginCompleteRequest = { + challenge_id: string; + credential: unknown; +}; + +export type DeviceLoginStartRequest = { client_name?: string | null }; + +export type DeviceLoginStartResponse = { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; +}; + +export type DeviceLoginApproveRequest = { user_code: string }; + +export type DeviceLoginApprovalStatus = "approved"; + +export type DeviceLoginApproveResponse = { + status: DeviceLoginApprovalStatus; + user: AuthenticatedUser; +}; + +export type DeviceLoginPollRequest = { device_code: string }; + +export type DeviceAccessTokenType = "Bearer"; + +export type DeviceLoginPollStatus = + | "pending" + | "approved" + | "expired" + | "denied" + | "consumed"; + +export type DeviceLoginPollResponse = { + status: DeviceLoginPollStatus; + access_token?: string | null; + token_type?: DeviceAccessTokenType | null; +}; + +export type LogoutStatus = "logged_out"; + +export type LogoutResponse = { status: LogoutStatus }; diff --git a/web/workspace/src/lib/generated/memory-api.ts b/web/workspace/src/lib/generated/memory-api.ts new file mode 100644 index 00000000..f6247280 --- /dev/null +++ b/web/workspace/src/lib/generated/memory-api.ts @@ -0,0 +1,100 @@ +// Generated from workspace-api. Do not edit by hand. +// Regenerate: cargo run -q -p workspace-api --features typescript --example generate_memory_api_types > web/workspace/src/lib/generated/memory-api.ts + +export type DiagnosticSeverity = "info" | "warning" | "error"; + +export type Diagnostic = { + code: string; + severity: DiagnosticSeverity; + message: string; +}; + +export type MemoryDocumentResponse = { + body_md: string; + created_at: string; + updated_at: string; + bytes: number; + record_source: string; +}; + +export type MemoryCandidateKind = + | "preference" + | "working_assumption" + | "constraint" + | "decision" + | "open_question" + | "lesson"; + +export type MemoryEvidenceOriginKind = + | "human_input" + | "worker_input" + | "flow_instruction" + | "backend_instruction" + | "model_output" + | "tool_output" + | "derived_summary" + | "legacy_unknown"; + +export type MemoryEvidenceOrigin = { + kind: MemoryEvidenceOriginKind; + account_id?: string | null; + workspace_id?: string | null; + runtime_id?: string | null; + worker_id?: string | null; + flow_selector?: string | null; + flow_definition_id?: string | null; + flow_definition_revision?: number | null; +}; + +export type MemorySourceRef = { segment_id: string; range: [number, number] }; + +export type MemoryStagingEvidence = { + id: string; + kind: string; + entry_range: [number, number] | null; + origin?: MemoryEvidenceOrigin | null; + excerpt: string | null; + summary: string | null; +}; + +export type MemorySourceEvidenceRef = { + session_id: string | null; + segment_id: string | null; + entry_range: [number, number] | null; + evidence_id: string | null; + origin?: MemoryEvidenceOrigin | null; + evidence_kind: string | null; + label: string | null; + summary: string | null; +}; + +export type MemoryStagingRecord = { + schema_version: number; + id: string; + extract_run_id: string; + source: MemorySourceRef; + kind: MemoryCandidateKind; + claim: string; + why_useful: string; + staleness: string | null; + evidence: Array; + source_refs: Array; +}; + +export type MemoryStagingEntry = { + id: string; + byte_len: number; + record: MemoryStagingRecord; +}; + +export type MemoryStagingListResponse = { + limit: number; + returned_count: number; + total_valid_count: number; + invalid_count: number; + truncated: boolean; + order: string; + record_authority: string; + items: Array; + diagnostics: Array; +}; diff --git a/web/workspace/src/lib/workspace/auth/api.ts b/web/workspace/src/lib/workspace/auth/api.ts index 94bf2531..b2d25914 100644 --- a/web/workspace/src/lib/workspace/auth/api.ts +++ b/web/workspace/src/lib/workspace/auth/api.ts @@ -1,118 +1,194 @@ +import type { + DeviceLoginApproveRequest, + PasskeyLoginCompleteRequest, + PasskeyLoginOptionsRequest, + PasskeyRegistrationCompleteRequest, + PasskeyRegistrationOptionsRequest, +} from "$lib/generated/auth-api.ts"; import { authenticationCredentialToJson, + type AuthUser, type DeviceApprovalResponse, - isPublicKeyCredential, - type PasskeyLoginOptionsResponse, - type PasskeyRegistrationOptionsResponse, - type PasskeyUserResponse, + parseAuthUserResponse, + parseDeviceApprovalResponse, + parseLogoutResponse, + parseWhoamiResponse, prepareLoginOptions, prepareRegistrationOptions, registrationCredentialToJson, type WhoamiResponse, -} from "./model"; +} from "$lib/workspace/auth/model"; -async function jsonOrThrow(response: Response): Promise { - const text = await response.text(); - if (!response.ok) { - throw new Error( - `${response.status} ${response.statusText}${text ? `: ${text}` : ""}`, - ); +const MAX_AUTH_RESPONSE_BYTES = 256 * 1024; + +export async function readBoundedAuthResponseJson( + response: Response, +): Promise { + const contentLength = response.headers.get("content-length"); + if (contentLength !== null) { + const parsed = Number(contentLength); + if ( + !Number.isSafeInteger(parsed) || parsed < 0 || + parsed > MAX_AUTH_RESPONSE_BYTES + ) { + await response.body?.cancel(); + throw new Error( + "Invalid auth response: response body exceeds the size limit.", + ); + } + } + if (response.body === null) { + throw new Error("Invalid auth response: response body is missing."); + } + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_AUTH_RESPONSE_BYTES) { + await reader.cancel(); + throw new Error( + "Invalid auth response: response body exceeds the size limit.", + ); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + try { + return JSON.parse( + new TextDecoder("utf-8", { fatal: true }).decode(bytes), + ) as unknown; + } catch { + throw new Error("Invalid auth response: response body is not valid JSON."); } - return text ? JSON.parse(text) as T : (null as T); } -function browserOrigin(): string | null { - return globalThis.location?.origin ?? null; +async function requestJson(path: string, init?: RequestInit): Promise { + const response = await fetch(path, { + credentials: "same-origin", + ...init, + headers: { + "content-type": "application/json", + ...(init?.headers ?? {}), + }, + }); + if (!response.ok) { + await response.body?.cancel(); + throw new Error(`Auth request failed (${response.status}).`); + } + return await readBoundedAuthResponseJson(response); } -export async function loadWhoami( - fetcher: typeof fetch = fetch, -): Promise { - return await fetcher("/api/auth/whoami", { credentials: "same-origin" }).then( - jsonOrThrow, +export async function loadWhoami(): Promise { + return parseWhoamiResponse(await requestJson("/api/auth/whoami")); +} + +export async function logout(): Promise { + parseLogoutResponse( + await requestJson("/api/auth/logout", { + method: "POST", + body: "{}", + }), ); } export async function registerPasskey( handle: string, - displayName: string, - fetcher: typeof fetch = fetch, -): Promise { - const options = await fetcher("/api/auth/passkeys/registration/options", { + displayName?: string, +): Promise { + const optionsRequest: PasskeyRegistrationOptionsRequest = { + handle, + display_name: displayName ?? null, + browser_origin: window.location.origin, + }; + const options = await requestJson("/api/auth/passkeys/registration/options", { method: "POST", - headers: { "content-type": "application/json" }, - credentials: "same-origin", - body: JSON.stringify({ - handle, - display_name: displayName, - browser_origin: browserOrigin(), - }), - }).then(jsonOrThrow); - + body: JSON.stringify(optionsRequest), + }); + const optionsRecord = typeof options === "object" && options !== null + ? options as Record + : null; + const challengeId = optionsRecord?.challenge_id; + if (typeof challengeId !== "string" || challengeId.length === 0) { + throw new Error( + "Invalid auth payload: registration_options.challenge_id is required.", + ); + } const credential = await navigator.credentials.create({ publicKey: prepareRegistrationOptions(options), }); - if (!isPublicKeyCredential(credential)) { - throw new Error( - "Passkey registration did not return a public-key credential.", - ); - } + if (!credential) throw new Error("Passkey registration was cancelled"); - return await fetcher("/api/auth/passkeys/registration/complete", { - method: "POST", - headers: { "content-type": "application/json" }, - credentials: "same-origin", - body: JSON.stringify({ - challenge_id: options.challenge_id, - credential: registrationCredentialToJson(credential), + const completeRequest: PasskeyRegistrationCompleteRequest = { + challenge_id: challengeId, + credential: registrationCredentialToJson(credential), + }; + const result = parseAuthUserResponse( + await requestJson("/api/auth/passkeys/registration/complete", { + method: "POST", + body: JSON.stringify(completeRequest), }), - }).then(jsonOrThrow); + ); + return result.user; } -export async function loginWithPasskey( - handle: string, - fetcher: typeof fetch = fetch, -): Promise { - const options = await fetcher("/api/auth/passkeys/login/options", { +export async function loginWithPasskey(handle?: string): Promise { + const optionsRequest: PasskeyLoginOptionsRequest = { + handle: handle ?? null, + browser_origin: window.location.origin, + }; + const options = await requestJson("/api/auth/passkeys/login/options", { method: "POST", - headers: { "content-type": "application/json" }, - credentials: "same-origin", - body: JSON.stringify({ handle, browser_origin: browserOrigin() }), - }).then(jsonOrThrow); - + body: JSON.stringify(optionsRequest), + }); + const optionsRecord = typeof options === "object" && options !== null + ? options as Record + : null; + const challengeId = optionsRecord?.challenge_id; + if (typeof challengeId !== "string" || challengeId.length === 0) { + throw new Error( + "Invalid auth payload: login_options.challenge_id is required.", + ); + } const credential = await navigator.credentials.get({ publicKey: prepareLoginOptions(options), }); - if (!isPublicKeyCredential(credential)) { - throw new Error("Passkey login did not return a public-key credential."); - } + if (!credential) throw new Error("Passkey login was cancelled"); - return await fetcher("/api/auth/passkeys/login/complete", { - method: "POST", - headers: { "content-type": "application/json" }, - credentials: "same-origin", - body: JSON.stringify({ - challenge_id: options.challenge_id, - credential: authenticationCredentialToJson(credential), + const completeRequest: PasskeyLoginCompleteRequest = { + challenge_id: challengeId, + credential: authenticationCredentialToJson(credential), + }; + const result = parseAuthUserResponse( + await requestJson("/api/auth/passkeys/login/complete", { + method: "POST", + body: JSON.stringify(completeRequest), }), - }).then(jsonOrThrow); -} - -export async function logout(fetcher: typeof fetch = fetch): Promise { - await fetcher("/api/auth/logout", { - method: "POST", - credentials: "same-origin", - }).then(jsonOrThrow); + ); + return result.user; } export async function approveDeviceLogin( userCode: string, - fetcher: typeof fetch = fetch, ): Promise { - return await fetcher("/api/auth/device-login/approve", { - method: "POST", - headers: { "content-type": "application/json" }, - credentials: "same-origin", - body: JSON.stringify({ user_code: userCode }), - }).then(jsonOrThrow); + const request: DeviceLoginApproveRequest = { user_code: userCode }; + return parseDeviceApprovalResponse( + await requestJson("/api/auth/device-login/approve", { + method: "POST", + body: JSON.stringify(request), + }), + ); } diff --git a/web/workspace/src/lib/workspace/auth/model.test.ts b/web/workspace/src/lib/workspace/auth/model.test.ts index d014086c..b4972a31 100644 --- a/web/workspace/src/lib/workspace/auth/model.test.ts +++ b/web/workspace/src/lib/workspace/auth/model.test.ts @@ -1,8 +1,13 @@ import { + authenticationCredentialToJson, base64UrlToBuffer, bufferToBase64Url, + parseDeviceLoginPollResponse, + parseDeviceLoginStartResponse, + parseWhoamiResponse, prepareLoginOptions, prepareRegistrationOptions, + registrationCredentialToJson, } from "./model.ts"; declare const Deno: { @@ -17,6 +22,21 @@ function assertEquals(actual: T, expected: T): void { } } +function assertThrows(fn: () => unknown, message: string): void { + try { + fn(); + } catch (error) { + if ( + !(error instanceof Error) || + !error.message.startsWith("Invalid auth payload:") + ) { + throw new Error(`${message}: unexpected error`); + } + return; + } + throw new Error(`${message}: expected an error`); +} + function bytes(buffer: BufferSource): number[] { if (buffer instanceof ArrayBuffer) { return [...new Uint8Array(buffer)]; @@ -39,18 +59,20 @@ Deno.test("prepareRegistrationOptions decodes binary public key fields", () => { const options = prepareRegistrationOptions({ challenge_id: "challenge-1", public_key: { - challenge: "AQID" as unknown as BufferSource, - rp: { id: "localhost", name: "Yoi" }, - user: { - id: "BAUG" as unknown as BufferSource, - name: "local", - displayName: "Local User", + publicKey: { + challenge: "AQID" as unknown as BufferSource, + rp: { id: "localhost", name: "Yoi" }, + user: { + id: "BAUG" as unknown as BufferSource, + name: "local", + displayName: "Local User", + }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], + excludeCredentials: [{ + type: "public-key", + id: "BwgJ" as unknown as BufferSource, + }], }, - pubKeyCredParams: [{ type: "public-key", alg: -7 }], - excludeCredentials: [{ - type: "public-key", - id: "BwgJ" as unknown as BufferSource, - }], }, }); @@ -67,11 +89,13 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", () const options = prepareLoginOptions({ challenge_id: "challenge-1", public_key: { - challenge: "AQID" as unknown as BufferSource, - allowCredentials: [{ - type: "public-key", - id: "BwgJ" as unknown as BufferSource, - }], + publicKey: { + challenge: "AQID" as unknown as BufferSource, + allowCredentials: [{ + type: "public-key", + id: "BwgJ" as unknown as BufferSource, + }], + }, }, }); @@ -82,3 +106,120 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", () 9, ]); }); + +Deno.test("whoami rejects unknown auth methods and extra fields", () => { + assertEquals(parseWhoamiResponse({ actor: null }), { actor: null }); + assertThrows( + () => + parseWhoamiResponse({ + actor: { + user_id: "user-1", + account_id: "account-1", + handle: "hare", + display_name: "Hare", + auth_method: "future_method", + }, + }), + "unknown auth method", + ); + assertThrows( + () => parseWhoamiResponse({ actor: null, token: "secret" }), + "unexpected whoami field", + ); +}); + +Deno.test("device login rejects unsafe expiry and unknown status", () => { + const start = { + device_code: "device-1", + user_code: "ABCD-EFGH", + verification_uri: "https://yoi.example/login/device", + verification_uri_complete: + "https://yoi.example/login/device?user_code=ABCD-EFGH", + expires_in: 600, + interval: 2, + }; + assertEquals(parseDeviceLoginStartResponse(start), start); + assertThrows( + () => + parseDeviceLoginStartResponse({ + ...start, + expires_in: Number.MAX_SAFE_INTEGER + 1, + }), + "unsafe expiry", + ); + assertEquals(parseDeviceLoginPollResponse({ status: "denied" }), { + status: "denied", + }); + assertThrows( + () => parseDeviceLoginPollResponse({ status: "future_status" }), + "unknown device-login status", + ); + assertThrows( + () => parseDeviceLoginPollResponse({ status: "approved" }), + "approved response without token", + ); +}); + +Deno.test("auth validation enforces cumulative budgets without echoing unknown keys", () => { + const extensionArrays = Object.fromEntries( + Array.from({ length: 9 }, (_, index) => [ + `field-${index}`, + Array.from({ length: 128 }, () => 1), + ]), + ); + assertThrows( + () => + prepareLoginOptions({ + challenge_id: "challenge-1", + public_key: { + publicKey: { + challenge: "AQID", + extensions: extensionArrays, + }, + }, + }), + "cumulative value budget", + ); + + const attackerKey = `secret-${"x".repeat(512)}`; + try { + parseWhoamiResponse({ actor: null, [attackerKey]: true }); + throw new Error("expected an error"); + } catch (error) { + if (!(error instanceof Error) || error.message.includes(attackerKey)) { + throw new Error("diagnostic included an attacker-controlled key"); + } + } +}); + +Deno.test("passkey credential conversion fails closed on malformed payloads", () => { + const bytes = new Uint8Array([1, 2, 3]).buffer; + const registration = { + id: "AQID", + rawId: bytes, + type: "public-key", + authenticatorAttachment: "platform", + getClientExtensionResults: () => ({ credProps: { rk: true } }), + response: { + clientDataJSON: bytes, + attestationObject: bytes, + getTransports: () => ["internal"], + }, + }; + const converted = registrationCredentialToJson(registration) as Record< + string, + unknown + >; + assertEquals(converted.id, "AQID"); + assertEquals(converted.clientExtensionResults, { credProps: { rk: true } }); + + assertThrows( + () => registrationCredentialToJson({ ...registration, rawId: "AQID" }), + "registration rawId string", + ); + assertThrows( + () => + authenticationCredentialToJson({ ...registration, type: "future-key" }), + "unknown credential type", + ); +}); diff --git a/web/workspace/src/lib/workspace/auth/model.ts b/web/workspace/src/lib/workspace/auth/model.ts index 5d6711d2..3912db05 100644 --- a/web/workspace/src/lib/workspace/auth/model.ts +++ b/web/workspace/src/lib/workspace/auth/model.ts @@ -1,72 +1,191 @@ -export type AuthenticatedUser = { - user_id: string; - account_id: string; - handle: string; - display_name: string; +import type { + ActorAuthMethod, + AuthenticatedUser, + AuthPublicConfig, + AuthUserResponse, + DeviceLoginApproveResponse, + DeviceLoginPollResponse, + DeviceLoginPollStatus, + DeviceLoginStartResponse, + LogoutResponse, + PasskeyLoginOptionsResponse, + PasskeyRegistrationOptionsResponse, + RequestActor, + WhoamiResponse, +} from "$lib/generated/auth-api.ts"; + +export type AuthUser = AuthenticatedUser; +export type PasskeyUserResponse = AuthUserResponse; +export type DeviceApprovalResponse = DeviceLoginApproveResponse; +export type { + AuthPublicConfig, + DeviceLoginPollResponse, + DeviceLoginStartResponse, + PasskeyLoginOptionsResponse, + PasskeyRegistrationOptionsResponse, + RequestActor, + WhoamiResponse, }; -export type RequestActor = AuthenticatedUser & { - auth_method: "browser_session" | "api_token" | string; -}; +const MAX_AUTH_STRING_LENGTH = 16 * 1024; +const MAX_AUTH_ARRAY_LENGTH = 128; +const MAX_AUTH_OBJECT_KEYS = 128; +const MAX_AUTH_VALUE_DEPTH = 8; +const MAX_AUTH_VALUE_NODES = 1_024; +const MAX_AUTH_VALUE_STRING_UNITS = 64 * 1024; +const MAX_DEVICE_LOGIN_SECONDS = 24 * 60 * 60; +const MAX_WEBAUTHN_TIMEOUT_MS = 10 * 60 * 1000; +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+={0,2}$/; -export type WhoamiResponse = { - actor: RequestActor | null; -}; - -export type PasskeyRegistrationOptionsResponse = { - challenge_id: string; - public_key: PublicKeyCredentialCreationOptions | { - publicKey: PublicKeyCredentialCreationOptions; - }; -}; - -export type PasskeyLoginOptionsResponse = { - challenge_id: string; - public_key: PublicKeyCredentialRequestOptions | { - publicKey: PublicKeyCredentialRequestOptions; - }; -}; - -export type PasskeyUserResponse = { - user: AuthenticatedUser; -}; - -export type DeviceApprovalResponse = { - status: string; -}; - -export type RegistrationCredentialJson = { - id: string; - rawId: string; - type: string; - response: { - clientDataJSON: string; - attestationObject: string; - transports: string[]; - }; -}; - -export type AuthenticationCredentialJson = { - id: string; - rawId: string; - type: string; - response: { - clientDataJSON: string; - authenticatorData: string; - signature: string; - userHandle: string | null; - }; -}; - -export function base64UrlToBuffer(value: string): ArrayBuffer { - const padding = "=".repeat((4 - (value.length % 4)) % 4); - const base64 = `${value}${padding}`.replace(/-/g, "+").replace(/_/g, "/"); - const binary = atob(base64); - return Uint8Array.from(binary, (char) => char.charCodeAt(0)).buffer; +function invalid(path: string, reason: string): never { + throw new Error(`Invalid auth payload: ${path} ${reason}.`); } -export function bufferToBase64Url(buffer: ArrayBuffer): string { - const bytes = new Uint8Array(buffer); +function asRecord(value: unknown, path: string): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + invalid(path, "must be an object"); + } + return value as Record; +} + +function requireExactKeys( + value: Record, + path: string, + required: readonly string[], + optional: readonly string[] = [], +): void { + const allowed = new Set([...required, ...optional]); + for (const key of required) { + if (!(key in value)) invalid(`${path}.${key}`, "is required"); + } + for (const key of Object.keys(value)) { + if (key.length === 0 || key.length > 256) { + invalid(path, "has an invalid field name"); + } + if (!allowed.has(key)) invalid(path, "contains an unknown field"); + } +} + +function boundedString( + value: unknown, + path: string, + { allowEmpty = false }: { allowEmpty?: boolean } = {}, +): string { + if (typeof value !== "string") invalid(path, "must be a string"); + if ( + (!allowEmpty && value.length === 0) || value.length > MAX_AUTH_STRING_LENGTH + ) { + invalid(path, "has an invalid length"); + } + return value; +} + +function optionalString(value: unknown, path: string): string | null { + if (value === null || value === undefined) return null; + return boundedString(value, path); +} + +function positiveSafeInteger( + value: unknown, + path: string, + maximum: number, +): number { + if ( + typeof value !== "number" || + !Number.isSafeInteger(value) || + value <= 0 || + value > maximum + ) { + invalid(path, "must be a bounded positive safe integer"); + } + return value; +} + +interface ValidationBudget { + remainingNodes: number; + remainingStringUnits: number; +} + +function boundedJson( + value: unknown, + path: string, + depth = 0, + budget: ValidationBudget = { + remainingNodes: MAX_AUTH_VALUE_NODES, + remainingStringUnits: MAX_AUTH_VALUE_STRING_UNITS, + }, +): void { + budget.remainingNodes -= 1; + if (budget.remainingNodes < 0) invalid(path, "has too many values"); + if (depth > MAX_AUTH_VALUE_DEPTH) invalid(path, "is too deeply nested"); + if (value === null || typeof value === "boolean") return; + if (typeof value === "string") { + boundedString(value, path, { allowEmpty: true }); + budget.remainingStringUnits -= value.length; + if (budget.remainingStringUnits < 0) { + invalid(path, "has too much string data"); + } + return; + } + if (typeof value === "number") { + if (!Number.isFinite(value)) invalid(path, "must be finite"); + return; + } + if (Array.isArray(value)) { + if (value.length > MAX_AUTH_ARRAY_LENGTH) { + invalid(path, "has too many items"); + } + value.forEach((item, index) => + boundedJson(item, `${path}[${index}]`, depth + 1, budget) + ); + return; + } + const record = asRecord(value, path); + const keys = Object.keys(record); + if (keys.length > MAX_AUTH_OBJECT_KEYS) invalid(path, "has too many fields"); + for (const key of keys) { + if (key.length === 0 || key.length > 256) { + invalid(path, "has an invalid field name"); + } + budget.remainingStringUnits -= key.length; + if (budget.remainingStringUnits < 0) { + invalid(path, "has too much field-name data"); + } + boundedJson(record[key], `${path}.field`, depth + 1, budget); + } +} + +function base64Url(value: unknown, path: string): string { + const encoded = boundedString(value, path); + if (!BASE64URL_PATTERN.test(encoded)) invalid(path, "must be base64url"); + return encoded; +} + +function fromBase64Url(value: unknown, path: string): ArrayBuffer { + const encoded = base64Url(value, path); + try { + const normalized = encoded.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + const binary = atob(padded); + const bytes = Uint8Array.from( + binary, + (character) => character.charCodeAt(0), + ); + if (bytes.byteLength === 0 || bytes.byteLength > MAX_AUTH_STRING_LENGTH) { + invalid(path, "decodes to an invalid length"); + } + return bytes.buffer; + } catch { + invalid(path, "must be valid base64url"); + } +} + +function toBase64Url(value: unknown, path: string): string { + if (!(value instanceof ArrayBuffer)) invalid(path, "must be an ArrayBuffer"); + if (value.byteLength === 0 || value.byteLength > MAX_AUTH_STRING_LENGTH) { + invalid(path, "has an invalid byte length"); + } + const bytes = new Uint8Array(value); let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace( @@ -75,86 +194,571 @@ export function bufferToBase64Url(buffer: ArrayBuffer): string { ); } -function unwrapPublicKey(value: T | { publicKey: T }): T { - if (value && typeof value === "object" && "publicKey" in value) { - return (value as { publicKey: T }).publicKey; +export function base64UrlToBuffer(value: string): ArrayBuffer { + return fromBase64Url(value, "base64url"); +} + +export function bufferToBase64Url(value: ArrayBuffer): string { + return toBase64Url(value, "buffer"); +} + +function parseAuthenticatorTransport( + value: unknown, + path: string, +): AuthenticatorTransport { + const transport = boundedString(value, path); + if (!["ble", "hybrid", "internal", "nfc", "usb"].includes(transport)) { + invalid(path, "contains an unknown authenticator transport"); } - return value as T; + return transport as AuthenticatorTransport; +} + +function parseCredentialDescriptor( + value: unknown, + path: string, +): PublicKeyCredentialDescriptor { + const descriptor = asRecord(value, path); + requireExactKeys(descriptor, path, ["type", "id"], ["transports"]); + if (descriptor.type !== "public-key") { + invalid(`${path}.type`, "must be public-key"); + } + const transports = descriptor.transports === undefined ? undefined : (() => { + if (!Array.isArray(descriptor.transports)) { + invalid(`${path}.transports`, "must be an array"); + } + if (descriptor.transports.length > MAX_AUTH_ARRAY_LENGTH) { + invalid(`${path}.transports`, "has too many items"); + } + return descriptor.transports.map((transport, index) => + parseAuthenticatorTransport(transport, `${path}.transports[${index}]`) + ); + })(); + return { + type: "public-key", + id: fromBase64Url(descriptor.id, `${path}.id`), + ...(transports === undefined ? {} : { transports }), + }; +} + +function unwrapPublicKey( + value: unknown, + path: string, +): Record { + const envelope = asRecord(value, path); + if ("publicKey" in envelope) { + requireExactKeys(envelope, path, ["publicKey"]); + const publicKey = asRecord(envelope.publicKey, `${path}.publicKey`); + boundedJson(publicKey, `${path}.publicKey`); + return publicKey; + } + boundedJson(envelope, path); + return envelope; +} + +function parseCreationOptions( + value: unknown, +): PublicKeyCredentialCreationOptions { + const options = unwrapPublicKey(value, "public_key"); + for ( + const field of ["rp", "user", "challenge", "pubKeyCredParams"] as const + ) { + if (!(field in options)) { + invalid( + `public_key.publicKey.${field}`, + "is required", + ); + } + } + + const rp = asRecord(options.rp, "public_key.publicKey.rp"); + requireExactKeys(rp, "public_key.publicKey.rp", ["name"], ["id"]); + const normalizedRp: PublicKeyCredentialRpEntity = { + name: boundedString(rp.name, "public_key.publicKey.rp.name"), + ...(rp.id === undefined + ? {} + : { id: boundedString(rp.id, "public_key.publicKey.rp.id") }), + }; + + const user = asRecord(options.user, "public_key.publicKey.user"); + requireExactKeys(user, "public_key.publicKey.user", [ + "id", + "name", + "displayName", + ]); + const normalizedUser: PublicKeyCredentialUserEntity = { + id: fromBase64Url(user.id, "public_key.publicKey.user.id"), + name: boundedString(user.name, "public_key.publicKey.user.name"), + displayName: boundedString( + user.displayName, + "public_key.publicKey.user.displayName", + ), + }; + + if ( + !Array.isArray(options.pubKeyCredParams) || + options.pubKeyCredParams.length === 0 + ) { + invalid( + "public_key.publicKey.pubKeyCredParams", + "must be a non-empty array", + ); + } + if (options.pubKeyCredParams.length > MAX_AUTH_ARRAY_LENGTH) { + invalid("public_key.publicKey.pubKeyCredParams", "has too many items"); + } + const pubKeyCredParams = options.pubKeyCredParams.map((value, index) => { + const parameter = asRecord( + value, + `public_key.publicKey.pubKeyCredParams[${index}]`, + ); + requireExactKeys( + parameter, + `public_key.publicKey.pubKeyCredParams[${index}]`, + ["type", "alg"], + ); + if (parameter.type !== "public-key") { + invalid( + `public_key.publicKey.pubKeyCredParams[${index}].type`, + "must be public-key", + ); + } + if ( + typeof parameter.alg !== "number" || !Number.isSafeInteger(parameter.alg) + ) { + invalid( + `public_key.publicKey.pubKeyCredParams[${index}].alg`, + "must be a safe integer", + ); + } + return { type: "public-key" as const, alg: parameter.alg }; + }); + + let excludeCredentials: PublicKeyCredentialDescriptor[] | undefined; + if (options.excludeCredentials !== undefined) { + if (!Array.isArray(options.excludeCredentials)) { + invalid("public_key.publicKey.excludeCredentials", "must be an array"); + } + if (options.excludeCredentials.length > MAX_AUTH_ARRAY_LENGTH) { + invalid("public_key.publicKey.excludeCredentials", "has too many items"); + } + excludeCredentials = options.excludeCredentials.map((descriptor, index) => + parseCredentialDescriptor( + descriptor, + `public_key.publicKey.excludeCredentials[${index}]`, + ) + ); + } + + const timeout = options.timeout === undefined + ? undefined + : positiveSafeInteger( + options.timeout, + "public_key.publicKey.timeout", + MAX_WEBAUTHN_TIMEOUT_MS, + ); + + return { + ...(options as unknown as PublicKeyCredentialCreationOptions), + rp: normalizedRp, + user: normalizedUser, + challenge: fromBase64Url( + options.challenge, + "public_key.publicKey.challenge", + ), + pubKeyCredParams, + ...(excludeCredentials === undefined ? {} : { excludeCredentials }), + ...(timeout === undefined ? {} : { timeout }), + }; +} + +function parseRequestOptions( + value: unknown, +): PublicKeyCredentialRequestOptions { + const options = unwrapPublicKey(value, "public_key"); + if (!("challenge" in options)) { + invalid("public_key.publicKey.challenge", "is required"); + } + + let allowCredentials: PublicKeyCredentialDescriptor[] | undefined; + if (options.allowCredentials !== undefined) { + if (!Array.isArray(options.allowCredentials)) { + invalid("public_key.publicKey.allowCredentials", "must be an array"); + } + if (options.allowCredentials.length > MAX_AUTH_ARRAY_LENGTH) { + invalid("public_key.publicKey.allowCredentials", "has too many items"); + } + allowCredentials = options.allowCredentials.map((descriptor, index) => + parseCredentialDescriptor( + descriptor, + `public_key.publicKey.allowCredentials[${index}]`, + ) + ); + } + const timeout = options.timeout === undefined + ? undefined + : positiveSafeInteger( + options.timeout, + "public_key.publicKey.timeout", + MAX_WEBAUTHN_TIMEOUT_MS, + ); + if (options.rpId !== undefined) { + boundedString(options.rpId, "public_key.publicKey.rpId"); + } + + return { + ...(options as unknown as PublicKeyCredentialRequestOptions), + challenge: fromBase64Url( + options.challenge, + "public_key.publicKey.challenge", + ), + ...(allowCredentials === undefined ? {} : { allowCredentials }), + ...(timeout === undefined ? {} : { timeout }), + }; +} + +function parseAuthenticatedUser( + value: unknown, + path: string, +): AuthenticatedUser { + const user = asRecord(value, path); + requireExactKeys(user, path, [ + "user_id", + "account_id", + "handle", + "display_name", + ]); + return { + user_id: boundedString(user.user_id, `${path}.user_id`), + account_id: boundedString(user.account_id, `${path}.account_id`), + handle: boundedString(user.handle, `${path}.handle`), + display_name: boundedString(user.display_name, `${path}.display_name`, { + allowEmpty: true, + }), + }; +} + +function parseActor(value: unknown, path: string): RequestActor { + const actor = asRecord(value, path); + requireExactKeys(actor, path, [ + "user_id", + "account_id", + "handle", + "display_name", + "auth_method", + ]); + const authMethod = actor.auth_method; + if (authMethod !== "browser_session" && authMethod !== "api_token") { + invalid(`${path}.auth_method`, "contains an unknown value"); + } + return { + user_id: boundedString(actor.user_id, `${path}.user_id`), + account_id: boundedString(actor.account_id, `${path}.account_id`), + handle: boundedString(actor.handle, `${path}.handle`), + display_name: boundedString(actor.display_name, `${path}.display_name`, { + allowEmpty: true, + }), + auth_method: authMethod as ActorAuthMethod, + }; +} + +export function parseWhoamiResponse(value: unknown): WhoamiResponse { + const response = asRecord(value, "whoami"); + requireExactKeys(response, "whoami", ["actor"]); + return { + actor: response.actor === null + ? null + : parseActor(response.actor, "whoami.actor"), + }; +} + +export function parseAuthPublicConfig(value: unknown): AuthPublicConfig { + const response = asRecord(value, "auth_config"); + requireExactKeys(response, "auth_config", [ + "rp_id", + "origin", + "public_base_url", + "cookie_name", + ]); + const result = { + rp_id: boundedString(response.rp_id, "auth_config.rp_id"), + origin: boundedString(response.origin, "auth_config.origin"), + public_base_url: boundedString( + response.public_base_url, + "auth_config.public_base_url", + ), + cookie_name: boundedString(response.cookie_name, "auth_config.cookie_name"), + }; + for ( + const [field, url] of [["origin", result.origin], [ + "public_base_url", + result.public_base_url, + ]] + ) { + try { + const parsed = new URL(url); + if ( + !(["http:", "https:"].includes(parsed.protocol)) || parsed.username || + parsed.password + ) { + invalid(`auth_config.${field}`, "must be a safe HTTP(S) URL"); + } + } catch { + invalid(`auth_config.${field}`, "must be a valid URL"); + } + } + return result; +} + +export function parseAuthUserResponse(value: unknown): PasskeyUserResponse { + const response = asRecord(value, "auth_user"); + requireExactKeys(response, "auth_user", ["user"]); + return { user: parseAuthenticatedUser(response.user, "auth_user.user") }; } export function prepareRegistrationOptions( - options: PasskeyRegistrationOptionsResponse, + value: unknown, ): PublicKeyCredentialCreationOptions { - const publicKey = structuredClone(unwrapPublicKey(options.public_key)); - publicKey.challenge = base64UrlToBuffer( - publicKey.challenge as unknown as string, - ); - publicKey.user = { - ...publicKey.user, - id: base64UrlToBuffer(publicKey.user.id as unknown as string), - }; - publicKey.excludeCredentials = publicKey.excludeCredentials?.map(( - credential, - ) => ({ - ...credential, - id: base64UrlToBuffer(credential.id as unknown as string), - })); - return publicKey; + const response = asRecord(value, "registration_options"); + requireExactKeys(response, "registration_options", [ + "challenge_id", + "public_key", + ]); + boundedString(response.challenge_id, "registration_options.challenge_id"); + return parseCreationOptions(response.public_key); } export function prepareLoginOptions( - options: PasskeyLoginOptionsResponse, + value: unknown, ): PublicKeyCredentialRequestOptions { - const publicKey = structuredClone(unwrapPublicKey(options.public_key)); - publicKey.challenge = base64UrlToBuffer( - publicKey.challenge as unknown as string, - ); - publicKey.allowCredentials = publicKey.allowCredentials?.map(( + const response = asRecord(value, "login_options"); + requireExactKeys(response, "login_options", ["challenge_id", "public_key"]); + boundedString(response.challenge_id, "login_options.challenge_id"); + return parseRequestOptions(response.public_key); +} + +function credentialCore(credential: unknown): Record { + const result = asRecord(credential, "credential"); + if (result.type !== "public-key") { + invalid("credential.type", "must be public-key"); + } + boundedString(result.id, "credential.id"); + toBase64Url(result.rawId, "credential.rawId"); + if (typeof result.getClientExtensionResults !== "function") { + invalid("credential.getClientExtensionResults", "must be a function"); + } + return result; +} + +function clientExtensionResults( + credential: Record, +): AuthenticationExtensionsClientOutputs { + const value = (credential.getClientExtensionResults as () => unknown).call( credential, - ) => ({ - ...credential, - id: base64UrlToBuffer(credential.id as unknown as string), - })); - return publicKey; + ); + boundedJson(value, "credential.clientExtensionResults"); + return asRecord( + value, + "credential.clientExtensionResults", + ) as AuthenticationExtensionsClientOutputs; } -export function registrationCredentialToJson( - credential: PublicKeyCredential, -): RegistrationCredentialJson { - const response = credential.response as AuthenticatorAttestationResponse; +export function registrationCredentialToJson(credential: unknown): unknown { + const core = credentialCore(credential); + const response = asRecord(core.response, "credential.response"); + if (typeof response.getTransports !== "function") { + invalid("credential.response.getTransports", "must be a function"); + } + const transportsValue = (response.getTransports as () => unknown).call( + response, + ); + if ( + !Array.isArray(transportsValue) || + transportsValue.length > MAX_AUTH_ARRAY_LENGTH + ) { + invalid("credential.response.transports", "must be a bounded array"); + } + const transports = transportsValue.map((transport, index) => + parseAuthenticatorTransport( + transport, + `credential.response.transports[${index}]`, + ) + ); + const authenticatorAttachment = optionalString( + core.authenticatorAttachment, + "credential.authenticatorAttachment", + ); return { - id: credential.id, - rawId: bufferToBase64Url(credential.rawId), - type: credential.type, + id: boundedString(core.id, "credential.id"), + rawId: toBase64Url(core.rawId, "credential.rawId"), + type: "public-key", response: { - clientDataJSON: bufferToBase64Url(response.clientDataJSON), - attestationObject: bufferToBase64Url(response.attestationObject), - transports: response.getTransports?.() ?? [], + clientDataJSON: toBase64Url( + response.clientDataJSON, + "credential.response.clientDataJSON", + ), + attestationObject: toBase64Url( + response.attestationObject, + "credential.response.attestationObject", + ), + transports, }, + clientExtensionResults: clientExtensionResults(core), + ...(authenticatorAttachment === null ? {} : { authenticatorAttachment }), }; } -export function authenticationCredentialToJson( - credential: PublicKeyCredential, -): AuthenticationCredentialJson { - const response = credential.response as AuthenticatorAssertionResponse; +export function authenticationCredentialToJson(credential: unknown): unknown { + const core = credentialCore(credential); + const response = asRecord(core.response, "credential.response"); + const authenticatorAttachment = optionalString( + core.authenticatorAttachment, + "credential.authenticatorAttachment", + ); return { - id: credential.id, - rawId: bufferToBase64Url(credential.rawId), - type: credential.type, + id: boundedString(core.id, "credential.id"), + rawId: toBase64Url(core.rawId, "credential.rawId"), + type: "public-key", response: { - clientDataJSON: bufferToBase64Url(response.clientDataJSON), - authenticatorData: bufferToBase64Url(response.authenticatorData), - signature: bufferToBase64Url(response.signature), - userHandle: response.userHandle - ? bufferToBase64Url(response.userHandle) - : null, + clientDataJSON: toBase64Url( + response.clientDataJSON, + "credential.response.clientDataJSON", + ), + authenticatorData: toBase64Url( + response.authenticatorData, + "credential.response.authenticatorData", + ), + signature: toBase64Url( + response.signature, + "credential.response.signature", + ), + userHandle: response.userHandle === null + ? null + : toBase64Url(response.userHandle, "credential.response.userHandle"), }, + clientExtensionResults: clientExtensionResults(core), + ...(authenticatorAttachment === null ? {} : { authenticatorAttachment }), }; } -export function isPublicKeyCredential( - credential: Credential | null, -): credential is PublicKeyCredential { - return credential != null && credential.type === "public-key"; +export function parseDeviceLoginStartResponse( + value: unknown, +): DeviceLoginStartResponse { + const response = asRecord(value, "device_login_start"); + requireExactKeys(response, "device_login_start", [ + "device_code", + "user_code", + "verification_uri", + "verification_uri_complete", + "expires_in", + "interval", + ]); + const result = { + device_code: boundedString( + response.device_code, + "device_login_start.device_code", + ), + user_code: boundedString( + response.user_code, + "device_login_start.user_code", + ), + verification_uri: boundedString( + response.verification_uri, + "device_login_start.verification_uri", + ), + verification_uri_complete: boundedString( + response.verification_uri_complete, + "device_login_start.verification_uri_complete", + ), + expires_in: positiveSafeInteger( + response.expires_in, + "device_login_start.expires_in", + MAX_DEVICE_LOGIN_SECONDS, + ), + interval: positiveSafeInteger( + response.interval, + "device_login_start.interval", + 60, + ), + }; + for ( + const [field, url] of [ + ["verification_uri", result.verification_uri], + ["verification_uri_complete", result.verification_uri_complete], + ] + ) { + try { + const parsed = new URL(url); + if ( + !(["http:", "https:"].includes(parsed.protocol)) || parsed.username || + parsed.password + ) { + invalid(`device_login_start.${field}`, "must be a safe HTTP(S) URL"); + } + } catch { + invalid(`device_login_start.${field}`, "must be a valid URL"); + } + } + return result; +} + +export function parseDeviceLoginPollResponse( + value: unknown, +): DeviceLoginPollResponse { + const response = asRecord(value, "device_login_poll"); + requireExactKeys(response, "device_login_poll", ["status"], [ + "access_token", + "token_type", + ]); + const status = response.status; + if ( + !["pending", "approved", "expired", "denied", "consumed"].includes( + String(status), + ) + ) { + invalid("device_login_poll.status", "contains an unknown value"); + } + const typedStatus = status as DeviceLoginPollStatus; + const accessToken = optionalString( + response.access_token, + "device_login_poll.access_token", + ); + const tokenType = optionalString( + response.token_type, + "device_login_poll.token_type", + ); + if (typedStatus === "approved") { + if (accessToken === null || tokenType !== "Bearer") { + invalid("device_login_poll", "has invalid approved token fields"); + } + } else if (accessToken !== null || tokenType !== null) { + invalid("device_login_poll", "has token fields for a non-approved status"); + } + return { + status: typedStatus, + ...(accessToken === null ? {} : { access_token: accessToken }), + ...(tokenType === null ? {} : { token_type: "Bearer" as const }), + }; +} + +export function parseDeviceApprovalResponse( + value: unknown, +): DeviceApprovalResponse { + const response = asRecord(value, "device_login_approval"); + requireExactKeys(response, "device_login_approval", ["status", "user"]); + if (response.status !== "approved") { + invalid("device_login_approval.status", "contains an unknown value"); + } + return { + status: "approved", + user: parseAuthenticatedUser(response.user, "device_login_approval.user"), + }; +} + +export function parseLogoutResponse(value: unknown): LogoutResponse { + const response = asRecord(value, "logout"); + requireExactKeys(response, "logout", ["status"]); + if (response.status !== "logged_out") { + invalid("logout.status", "contains an unknown value"); + } + return { status: "logged_out" }; } diff --git a/web/workspace/src/lib/workspace/memory/api.ts b/web/workspace/src/lib/workspace/memory/api.ts new file mode 100644 index 00000000..5a209740 --- /dev/null +++ b/web/workspace/src/lib/workspace/memory/api.ts @@ -0,0 +1,392 @@ +import type { + Diagnostic, + DiagnosticSeverity, + MemoryCandidateKind, + MemoryDocumentResponse, + MemoryEvidenceOrigin, + MemoryEvidenceOriginKind, + MemorySourceEvidenceRef, + MemorySourceRef, + MemoryStagingEntry, + MemoryStagingEvidence, + MemoryStagingListResponse, + MemoryStagingRecord, +} from "$lib/generated/memory-api"; + +const MAX_STAGING_ITEMS = 500; +const MAX_EVIDENCE_PER_RECORD = 500; +const MAX_SOURCE_REFS_PER_RECORD = 500; +const MAX_ORIGIN_VALUE_LENGTH = 512; + +const candidateKinds = new Set([ + "preference", + "working_assumption", + "constraint", + "decision", + "open_question", + "lesson", +]); +const originKinds = new Set([ + "human_input", + "worker_input", + "flow_instruction", + "backend_instruction", + "model_output", + "tool_output", + "derived_summary", + "legacy_unknown", +]); +const diagnosticSeverities = new Set([ + "info", + "warning", + "error", +]); + +export function parseMemoryDocumentResponse( + value: unknown, +): MemoryDocumentResponse { + const record = strictRecord( + value, + ["body_md", "created_at", "updated_at", "bytes", "record_source"], + "Memory document response", + ); + return { + body_md: requiredString(record, "body_md"), + created_at: requiredString(record, "created_at"), + updated_at: requiredString(record, "updated_at"), + bytes: requiredNonNegativeInteger(record, "bytes"), + record_source: requiredString(record, "record_source"), + }; +} + +export function parseMemoryStagingListResponse( + value: unknown, +): MemoryStagingListResponse { + const record = strictRecord( + value, + [ + "limit", + "returned_count", + "total_valid_count", + "invalid_count", + "truncated", + "order", + "record_authority", + "items", + "diagnostics", + ], + "Memory staging list response", + ); + const items = boundedArray(record.items, MAX_STAGING_ITEMS, "items").map( + parseStagingEntry, + ); + const diagnostics = boundedArray( + record.diagnostics, + MAX_STAGING_ITEMS, + "diagnostics", + ).map(parseDiagnostic); + const returnedCount = requiredNonNegativeInteger(record, "returned_count"); + if (returnedCount !== items.length) { + invalid("returned_count does not match items"); + } + return { + limit: requiredNonNegativeInteger(record, "limit"), + returned_count: returnedCount, + total_valid_count: requiredNonNegativeInteger(record, "total_valid_count"), + invalid_count: requiredNonNegativeInteger(record, "invalid_count"), + truncated: requiredBoolean(record, "truncated"), + order: requiredString(record, "order"), + record_authority: requiredString(record, "record_authority"), + items, + diagnostics, + }; +} + +function parseStagingEntry(value: unknown): MemoryStagingEntry { + const record = strictRecord( + value, + ["id", "byte_len", "record"], + "Memory staging entry", + ); + return { + id: requiredString(record, "id"), + byte_len: requiredNonNegativeInteger(record, "byte_len"), + record: parseStagingRecord(record.record), + }; +} + +function parseStagingRecord(value: unknown): MemoryStagingRecord { + const record = strictRecord( + value, + [ + "schema_version", + "id", + "extract_run_id", + "source", + "kind", + "claim", + "why_useful", + "staleness", + "evidence", + "source_refs", + ], + "Memory staging record", + ); + const kind = requiredString(record, "kind") as MemoryCandidateKind; + if (!candidateKinds.has(kind)) { + invalid("unknown Memory candidate kind"); + } + return { + schema_version: requiredNonNegativeInteger(record, "schema_version"), + id: requiredString(record, "id"), + extract_run_id: requiredString(record, "extract_run_id"), + source: parseSourceRef(record.source), + kind, + claim: requiredString(record, "claim"), + why_useful: requiredString(record, "why_useful"), + staleness: nullableString(record, "staleness"), + evidence: boundedArray( + record.evidence, + MAX_EVIDENCE_PER_RECORD, + "evidence", + ).map(parseStagingEvidence), + source_refs: boundedArray( + record.source_refs, + MAX_SOURCE_REFS_PER_RECORD, + "source_refs", + ).map(parseSourceEvidenceRef), + }; +} + +function parseSourceRef(value: unknown): MemorySourceRef { + const record = strictRecord( + value, + ["segment_id", "range"], + "Memory source ref", + ); + return { + segment_id: requiredString(record, "segment_id"), + range: parseEntryRange(record.range, "range"), + }; +} + +function parseStagingEvidence(value: unknown): MemoryStagingEvidence { + const record = strictRecord( + value, + ["id", "kind", "entry_range", "origin", "excerpt", "summary"], + "Memory staging evidence", + ["origin"], + ); + const result: MemoryStagingEvidence = { + id: requiredString(record, "id"), + kind: requiredString(record, "kind"), + entry_range: parseNullableEntryRange(record.entry_range, "entry_range"), + excerpt: nullableString(record, "excerpt"), + summary: nullableString(record, "summary"), + }; + if ("origin" in record) { + result.origin = record.origin === null + ? null + : parseEvidenceOrigin(record.origin); + } + return result; +} + +function parseSourceEvidenceRef(value: unknown): MemorySourceEvidenceRef { + const record = strictRecord( + value, + [ + "session_id", + "segment_id", + "entry_range", + "evidence_id", + "origin", + "evidence_kind", + "label", + "summary", + ], + "Memory source evidence ref", + ["origin"], + ); + const result: MemorySourceEvidenceRef = { + session_id: nullableString(record, "session_id"), + segment_id: nullableString(record, "segment_id"), + entry_range: parseNullableEntryRange(record.entry_range, "entry_range"), + evidence_id: nullableString(record, "evidence_id"), + evidence_kind: nullableString(record, "evidence_kind"), + label: nullableString(record, "label"), + summary: nullableString(record, "summary"), + }; + if ("origin" in record) { + result.origin = record.origin === null + ? null + : parseEvidenceOrigin(record.origin); + } + return result; +} + +function parseEvidenceOrigin(value: unknown): MemoryEvidenceOrigin { + const optional = [ + "account_id", + "workspace_id", + "runtime_id", + "worker_id", + "flow_selector", + "flow_definition_id", + "flow_definition_revision", + ] as const; + const record = strictRecord( + value, + ["kind", ...optional], + "Memory evidence origin", + [...optional], + ); + const kind = requiredString(record, "kind") as MemoryEvidenceOriginKind; + if (!originKinds.has(kind)) { + invalid("unknown Memory evidence origin kind"); + } + const result: MemoryEvidenceOrigin = { kind }; + for ( + const key of [ + "account_id", + "workspace_id", + "runtime_id", + "worker_id", + "flow_selector", + "flow_definition_id", + ] as const + ) { + if (key in record) { + const text = nullableString(record, key); + if (text !== null && text.length > MAX_ORIGIN_VALUE_LENGTH) { + invalid(`${key} exceeds the Memory origin limit`); + } + result[key] = text; + } + } + if ("flow_definition_revision" in record) { + result.flow_definition_revision = record.flow_definition_revision === null + ? null + : nonNegativeInteger( + record.flow_definition_revision, + "flow_definition_revision", + ); + } + return result; +} + +function parseDiagnostic(value: unknown): Diagnostic { + const record = strictRecord( + value, + ["code", "severity", "message"], + "Memory diagnostic", + ); + const severity = requiredString(record, "severity") as DiagnosticSeverity; + if (!diagnosticSeverities.has(severity)) { + invalid("unknown diagnostic severity"); + } + return { + code: requiredString(record, "code"), + severity, + message: requiredString(record, "message"), + }; +} + +function strictRecord( + value: unknown, + keys: readonly string[], + label: string, + optionalKeys: readonly string[] = [], +): Record { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + invalid(`${label} must be an object`); + } + const record = value as Record; + const allowed = new Set(keys); + for (const key of Object.keys(record)) { + if (!allowed.has(key)) { + invalid(`${label} has an unknown field`); + } + } + const optional = new Set(optionalKeys); + for (const key of keys) { + if (!optional.has(key) && !(key in record)) { + invalid(`${label} is missing a required field`); + } + } + return record; +} + +function boundedArray( + value: unknown, + maximum: number, + label: string, +): unknown[] { + if (!Array.isArray(value) || value.length > maximum) { + invalid(`${label} must be a bounded array`); + } + return value; +} + +function requiredString(record: Record, key: string): string { + if (typeof record[key] !== "string") { + invalid(`${key} must be a string`); + } + return record[key]; +} + +function nullableString( + record: Record, + key: string, +): string | null { + const value = record[key]; + if (value !== null && typeof value !== "string") { + invalid(`${key} must be a string or null`); + } + return value; +} + +function requiredBoolean( + record: Record, + key: string, +): boolean { + if (typeof record[key] !== "boolean") { + invalid(`${key} must be a boolean`); + } + return record[key]; +} + +function requiredNonNegativeInteger( + record: Record, + key: string, +): number { + return nonNegativeInteger(record[key], key); +} + +function nonNegativeInteger(value: unknown, label: string): number { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + invalid(`${label} must be a non-negative safe integer`); + } + return value as number; +} + +function parseEntryRange(value: unknown, label: string): [number, number] { + if (!Array.isArray(value) || value.length !== 2) { + invalid(`${label} must be a two-item entry range`); + } + return [ + nonNegativeInteger(value[0], label), + nonNegativeInteger(value[1], label), + ]; +} + +function parseNullableEntryRange( + value: unknown, + label: string, +): [number, number] | null { + return value === null ? null : parseEntryRange(value, label); +} + +function invalid(message: string): never { + throw new Error(`Invalid Memory API response: ${message}`); +} diff --git a/web/workspace/src/lib/workspace/sidebar/types.ts b/web/workspace/src/lib/workspace/sidebar/types.ts index ec758cd7..67d7aa10 100644 --- a/web/workspace/src/lib/workspace/sidebar/types.ts +++ b/web/workspace/src/lib/workspace/sidebar/types.ts @@ -206,75 +206,6 @@ export type RepositoryListResponse = SharedRepositoryListResponse; export type RepositoryDetailResponse = SharedRepositoryDetailResponse; export type RepositoryLogResponse = SharedRepositoryLogResponse; -export type MemoryDocumentResponse = { - body_md: string; - created_at: string; - updated_at: string; - bytes: number; - record_source: string; -}; - -export type MemoryCandidateKind = - | "preference" - | "working_assumption" - | "constraint" - | "decision" - | "open_question" - | "lesson"; - -export type MemorySourceRef = { - segment_id: string; - range: [number, number]; -}; - -export type MemoryStagingEvidence = { - id: string; - kind: string; - entry_range?: [number, number] | null; - excerpt?: string | null; - summary?: string | null; -}; - -export type MemorySourceEvidenceRef = { - session_id?: string | null; - segment_id?: string | null; - entry_range?: [number, number] | null; - evidence_id?: string | null; - evidence_kind?: string | null; - label?: string | null; - summary?: string | null; -}; - -export type MemoryStagingRecord = { - schema_version: number; - id: string; - extract_run_id: string; - source: MemorySourceRef; - kind: MemoryCandidateKind; - claim: string; - why_useful: string; - staleness?: string | null; - evidence?: MemoryStagingEvidence[]; - source_refs?: MemorySourceEvidenceRef[]; -}; - -export type MemoryStagingEntry = { - id: string; - byte_len: number; - record: MemoryStagingRecord; -}; - -export type MemoryStagingListResponse = { - limit: number; - returned_count: number; - total_valid_count: number; - invalid_count: number; - truncated: boolean; - order: string; - record_authority: string; - items: MemoryStagingEntry[]; -}; - export type { DerivedTicketRelation, TicketDetail, diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts b/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts index 21ce4191..9e25c841 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/memory/+page.ts @@ -1,13 +1,15 @@ import { loadJson, workspaceApiPath } from "$lib/workspace/api/http"; -import type { MemoryDocumentResponse } from "$lib/workspace/sidebar/types"; +import { parseMemoryDocumentResponse } from "$lib/workspace/memory/api"; import type { PageLoad } from "./$types"; export const load: PageLoad = async ({ fetch, params }) => { return { workspaceId: params.workspaceId, - memory: await loadJson( + memory: await loadJson( fetch, workspaceApiPath(params.workspaceId, "/memory"), + undefined, + parseMemoryDocumentResponse, ), }; }; diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/staging/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/staging/+page.svelte index 3cc98778..1cdee6ed 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/staging/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/staging/+page.svelte @@ -1,5 +1,5 @@