Merge commit '4ca8ea1694d88db501c48dd4947fdbd6f2828136' into work/T-584-agen-typed-interceptor
This commit is contained in:
@@ -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<String>,
|
||||
pub token_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum BackendAuthClientError {
|
||||
Http(reqwest::Error),
|
||||
@@ -74,16 +60,6 @@ impl From<reqwest::Error> 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<Option<String>, 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<T: for<'de> Deserialize<'de>>(
|
||||
}
|
||||
Ok(response.json::<T>().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::<DeviceLoginStartResponse>(valid.clone()).is_ok());
|
||||
|
||||
let mut expired = valid;
|
||||
expired["expires_in"] = serde_json::json!(0);
|
||||
assert!(serde_json::from_value::<DeviceLoginStartResponse>(expired).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn device_login_poll_response_rejects_unknown_status() {
|
||||
assert!(
|
||||
serde_json::from_value::<DeviceLoginPollResponse>(
|
||||
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
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<MemoryDocumentResponse, BackendWorkspaceClientError> {
|
||||
self.get_json("/memory")
|
||||
}
|
||||
|
||||
pub fn list_memory_staging(
|
||||
&self,
|
||||
limit: usize,
|
||||
) -> Result<MemoryStagingListResponse, BackendWorkspaceClientError> {
|
||||
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":[]}"#;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<String>,
|
||||
@@ -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")]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
print!("{}", workspace_api::auth_api_typescript());
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
print!("{}", workspace_api::memory_api_typescript());
|
||||
}
|
||||
@@ -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<RequestActor>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
|
||||
pub browser_origin: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
|
||||
pub browser_origin: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
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<D>(deserializer: D) -> Result<Self, D::Error>
|
||||
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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
|
||||
pub token_type: Option<DeviceAccessTokenType>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub workspace_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub runtime_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub worker_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub flow_selector: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub flow_definition_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional, type = "number | null"))]
|
||||
pub flow_definition_revision: Option<u64>,
|
||||
}
|
||||
|
||||
/// 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<MemoryEvidenceOrigin>,
|
||||
pub excerpt: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
pub segment_id: Option<String>,
|
||||
#[cfg_attr(feature = "typescript", ts(type = "[number, number] | null"))]
|
||||
pub entry_range: Option<[u64; 2]>,
|
||||
pub evidence_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(
|
||||
feature = "typescript",
|
||||
ts(optional, type = "MemoryEvidenceOrigin | null")
|
||||
)]
|
||||
pub origin: Option<MemoryEvidenceOrigin>,
|
||||
pub evidence_kind: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
pub evidence: Vec<MemoryStagingEvidence>,
|
||||
pub source_refs: Vec<MemorySourceEvidenceRef>,
|
||||
}
|
||||
|
||||
/// 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<MemoryStagingEntry>,
|
||||
pub diagnostics: Vec<Diagnostic>,
|
||||
}
|
||||
|
||||
#[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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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::<String>()
|
||||
.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::<MemoryEvidenceOrigin>(
|
||||
serde_json::json!({"kind": "future_origin"})
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
serde_json::from_value::<MemoryEvidenceOrigin>(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::<WhoamiResponse>(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::<AuthPublicConfig>(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::<AuthUserResponse>(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::<PasskeyRegistrationOptionsResponse>(
|
||||
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::<PasskeyRegistrationCompleteRequest>(registration_complete)
|
||||
.expect("server registration-complete fixture should match shared DTO");
|
||||
let encoded = serde_json::to_value(decoded).unwrap();
|
||||
serde_json::from_value::<PasskeyRegistrationCompleteRequest>(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::<PasskeyLoginOptionsResponse>(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::<PasskeyLoginCompleteRequest>(login_complete)
|
||||
.expect("server login-complete fixture should match shared DTO");
|
||||
let encoded = serde_json::to_value(decoded).unwrap();
|
||||
serde_json::from_value::<PasskeyLoginCompleteRequest>(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::<DeviceLoginStartResponse>(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::<DeviceLoginPollResponse>(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::<DeviceLoginStartResponse>(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::<PasskeyLoginCompleteRequest>(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::<WhoamiResponse>(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::<String>()
|
||||
.replace(";}", "}")
|
||||
.replace("=|", "=")
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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<String> {
|
||||
let normalized = handle.trim().to_ascii_lowercase();
|
||||
let valid = !normalized.is_empty()
|
||||
|
||||
@@ -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<MemoryStagingEntrySummary>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub evidence: Vec<MemoryStagingEvidenceSummary>,
|
||||
pub source_refs: Vec<MemorySourceEvidenceRefSummary>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct MemorySourceEvidenceRefSummary {
|
||||
pub session_id: Option<String>,
|
||||
pub segment_id: Option<String>,
|
||||
pub entry_range: Option<[u64; 2]>,
|
||||
pub evidence_id: Option<String>,
|
||||
pub evidence_kind: Option<String>,
|
||||
pub label: Option<String>,
|
||||
pub summary: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct MemoryStagingBacklogSummary {
|
||||
pub candidate_count: usize,
|
||||
@@ -90,14 +41,24 @@ pub fn list_memory_staging_from_authority<A: MemoryAuthority>(
|
||||
};
|
||||
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<A: MemoryAuthority>(
|
||||
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<A: MemoryAuthority>(
|
||||
})
|
||||
}
|
||||
|
||||
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]
|
||||
|
||||
@@ -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<WorkerSummary> {
|
||||
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<WorkspaceApi>,
|
||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct AuthUserResponse {
|
||||
user: AuthenticatedUser,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct PasskeyRegistrationOptionsRequest {
|
||||
handle: String,
|
||||
#[serde(default)]
|
||||
display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
browser_origin: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(default)]
|
||||
browser_origin: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
token_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct WhoamiResponse {
|
||||
actor: Option<RequestActor>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct LogoutResponse {
|
||||
status: String,
|
||||
}
|
||||
|
||||
async fn get_auth_config(State(api): State<ServerAuthApi>) -> ApiResult<Json<AuthPublicConfig>> {
|
||||
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"
|
||||
|
||||
Reference in New Issue
Block a user