Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ca8ea1694 | ||
|
|
3a94c845cf | ||
|
|
bc810beb3b | ||
|
|
c1dfb1add5 | ||
|
|
5cec2eef60 |
Generated
+1
@@ -6710,6 +6710,7 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"ts-rs",
|
"ts-rs",
|
||||||
|
"webauthn-rs-proto",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
use crate::BackendOrigin;
|
use crate::BackendOrigin;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::Deserialize;
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use workspace_api::{DeviceLoginPollRequest, DeviceLoginPollStatus, DeviceLoginStartRequest};
|
||||||
|
pub use workspace_api::{DeviceLoginPollResponse, DeviceLoginStartResponse};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct BackendAuthTarget {
|
pub struct BackendAuthTarget {
|
||||||
pub base_url: String,
|
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)]
|
#[derive(Debug)]
|
||||||
pub enum BackendAuthClientError {
|
pub enum BackendAuthClientError {
|
||||||
Http(reqwest::Error),
|
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(
|
pub async fn start_device_login(
|
||||||
target: &BackendAuthTarget,
|
target: &BackendAuthTarget,
|
||||||
client_name: Option<&str>,
|
client_name: Option<&str>,
|
||||||
@@ -91,7 +67,9 @@ pub async fn start_device_login(
|
|||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let response = client
|
let response = client
|
||||||
.post(target.api_url("/api/auth/device-login/start"))
|
.post(target.api_url("/api/auth/device-login/start"))
|
||||||
.json(&DeviceLoginStartRequest { client_name })
|
.json(&DeviceLoginStartRequest {
|
||||||
|
client_name: client_name.map(ToOwned::to_owned),
|
||||||
|
})
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
parse_json_response(response).await
|
parse_json_response(response).await
|
||||||
@@ -104,12 +82,38 @@ pub async fn poll_device_login(
|
|||||||
let client = reqwest::Client::new();
|
let client = reqwest::Client::new();
|
||||||
let response = client
|
let response = client
|
||||||
.post(target.api_url("/api/auth/device-login/poll"))
|
.post(target.api_url("/api/auth/device-login/poll"))
|
||||||
.json(&DeviceLoginPollRequest { device_code })
|
.json(&DeviceLoginPollRequest {
|
||||||
|
device_code: device_code.to_string(),
|
||||||
|
})
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
parse_json_response(response).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(
|
pub async fn wait_for_device_login(
|
||||||
target: &BackendAuthTarget,
|
target: &BackendAuthTarget,
|
||||||
device_code: &str,
|
device_code: &str,
|
||||||
@@ -119,25 +123,8 @@ pub async fn wait_for_device_login(
|
|||||||
let started = std::time::Instant::now();
|
let started = std::time::Instant::now();
|
||||||
loop {
|
loop {
|
||||||
let response = poll_device_login(target, device_code).await?;
|
let response = poll_device_login(target, device_code).await?;
|
||||||
match response.status.as_str() {
|
if let Some(access_token) = device_login_poll_result(response)? {
|
||||||
"approved" => {
|
return Ok(access_token);
|
||||||
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 started.elapsed() >= expires_in {
|
if started.elapsed() >= expires_in {
|
||||||
return Err(BackendAuthClientError::BackendStatus {
|
return Err(BackendAuthClientError::BackendStatus {
|
||||||
@@ -162,3 +149,81 @@ async fn parse_json_response<T: for<'de> Deserialize<'de>>(
|
|||||||
}
|
}
|
||||||
Ok(response.json::<T>().await?)
|
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::{
|
use workspace_api::{
|
||||||
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
||||||
CreateWorkspaceWorkerRequest, ListResponse, ObjectiveCreateRequest, ObjectiveDetail,
|
CreateWorkspaceWorkerRequest, ListResponse, MemoryDocumentResponse, MemoryStagingListResponse,
|
||||||
ObjectiveEditRequest, ObjectiveLinkTicketRequest, ObjectiveStateRequest, ObjectiveSummary,
|
ObjectiveCreateRequest, ObjectiveDetail, ObjectiveEditRequest, ObjectiveLinkTicketRequest,
|
||||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
ObjectiveStateRequest, ObjectiveSummary, TICKET_ORCHESTRATION_PLANS_QUERY_PATH,
|
||||||
WorkerLaunchOptionsResponse,
|
TICKET_RELATIONS_QUERY_PATH, WorkerLaunchOptionsResponse,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{BackendApiClient, BackendWorkspaceClientError};
|
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(
|
pub fn launch_ticket_intake(
|
||||||
&self,
|
&self,
|
||||||
ticket_id: &str,
|
ticket_id: &str,
|
||||||
@@ -668,6 +679,82 @@ mod tests {
|
|||||||
(format!("http://{address}"), receiver, handle)
|
(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]
|
#[test]
|
||||||
fn objective_list_uses_workspace_scoped_backend_route() {
|
fn objective_list_uses_workspace_scoped_backend_route() {
|
||||||
let body = r#"{"workspace_id":"workspace-a","limit":1000,"items":[],"source":"sqlite","diagnostics":[]}"#;
|
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.
|
/// Bounded evidence snippet copied into a flat staging record.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct StagingEvidence {
|
pub struct StagingEvidence {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
pub kind: EvidenceKind,
|
pub kind: EvidenceKind,
|
||||||
@@ -89,6 +90,7 @@ pub struct StagingEvidence {
|
|||||||
|
|
||||||
/// One flat staging record. One record is one consolidation decision unit.
|
/// One flat staging record. One record is one consolidation decision unit.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct StagingRecord {
|
pub struct StagingRecord {
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ impl<'de> Deserialize<'de> for SourceRef {
|
|||||||
D: serde::Deserializer<'de>,
|
D: serde::Deserializer<'de>,
|
||||||
{
|
{
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
struct RawSourceRef {
|
struct RawSourceRef {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
segment_id: Option<String>,
|
segment_id: Option<String>,
|
||||||
@@ -83,6 +84,7 @@ pub enum EvidenceOriginKind {
|
|||||||
/// Bounded origin snapshot attached to extraction evidence. This is audit
|
/// Bounded origin snapshot attached to extraction evidence. This is audit
|
||||||
/// metadata only and cannot authorize Workspace operations.
|
/// metadata only and cannot authorize Workspace operations.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct EvidenceOrigin {
|
pub struct EvidenceOrigin {
|
||||||
pub kind: EvidenceOriginKind,
|
pub kind: EvidenceOriginKind,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[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
|
/// ranges, and short labels/summaries. It must not carry raw message bodies or
|
||||||
/// full tool result content.
|
/// full tool result content.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct SourceEvidenceRef {
|
pub struct SourceEvidenceRef {
|
||||||
/// Stable session id when the anchor crosses or disambiguates segments.
|
/// Stable session id when the anchor crosses or disambiguates segments.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ typescript = ["dep:ts-rs", "protocol/typescript"]
|
|||||||
protocol.workspace = true
|
protocol.workspace = true
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
ts-rs = { version = "12.0.1", optional = true }
|
ts-rs = { version = "12.0.1", optional = true }
|
||||||
|
webauthn-rs-proto = "0.5.5"
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "generate_typescript"
|
name = "generate_typescript"
|
||||||
@@ -33,6 +34,14 @@ required-features = ["typescript"]
|
|||||||
name = "generate_companion_api_types"
|
name = "generate_companion_api_types"
|
||||||
required-features = ["typescript"]
|
required-features = ["typescript"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "generate_memory_api_types"
|
||||||
|
required-features = ["typescript"]
|
||||||
|
|
||||||
|
[[example]]
|
||||||
|
name = "generate_auth_api_types"
|
||||||
|
required-features = ["typescript"]
|
||||||
|
|
||||||
[[example]]
|
[[example]]
|
||||||
name = "generate_repository_access_types"
|
name = "generate_repository_access_types"
|
||||||
required-features = ["typescript"]
|
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.
|
//! callers must explicitly construct these Workspace-authoritative resources.
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
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_MIN_LEN: usize = 1;
|
||||||
pub const REPOSITORY_KEY_MAX_LEN: usize = 64;
|
pub const REPOSITORY_KEY_MAX_LEN: usize = 64;
|
||||||
@@ -1291,6 +1598,197 @@ pub struct WorkerRestoreResponse {
|
|||||||
pub result: WorkerRestoreResult,
|
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.
|
/// Workspace-owned Memory settings returned by the shared Server API.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[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")]
|
#[cfg(feature = "typescript")]
|
||||||
pub fn workdir_api_typescript() -> String {
|
pub fn workdir_api_typescript() -> String {
|
||||||
use ts_rs::TS;
|
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"))]
|
#[cfg(all(test, feature = "typescript"))]
|
||||||
mod workdir_typescript_tests {
|
mod workdir_typescript_tests {
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1636,6 +2205,52 @@ mod workdir_typescript_tests {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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 {
|
fn worker_launch_summary() -> WorkerLaunchWorkerSummary {
|
||||||
WorkerLaunchWorkerSummary {
|
WorkerLaunchWorkerSummary {
|
||||||
runtime_id: "runtime-a".to_string(),
|
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]
|
#[test]
|
||||||
fn companion_transcript_fixture_round_trips() {
|
fn companion_transcript_fixture_round_trips() {
|
||||||
round_trip(CompanionTranscriptProjection {
|
round_trip(CompanionTranscriptProjection {
|
||||||
@@ -2116,6 +2967,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.collect::<String>()
|
.collect::<String>()
|
||||||
.replace(";}", "}")
|
.replace(";}", "}")
|
||||||
|
.replace("=|", "=")
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,54 +1,11 @@
|
|||||||
use axum::http::{HeaderMap, header};
|
use axum::http::{HeaderMap, header};
|
||||||
use chrono::{DateTime, Duration, Utc};
|
use chrono::{DateTime, Duration, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
pub use workspace_api::{ActorAuthMethod, AuthPublicConfig, AuthenticatedUser, RequestActor};
|
||||||
|
|
||||||
use crate::{Error, Result, store::ControlPlaneStore};
|
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> {
|
pub fn normalize_handle(handle: &str) -> Result<String> {
|
||||||
let normalized = handle.trim().to_ascii_lowercase();
|
let normalized = handle.trim().to_ascii_lowercase();
|
||||||
let valid = !normalized.is_empty()
|
let valid = !normalized.is_empty()
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
use memory::extract::StagingRecord;
|
use memory::extract::{CandidateKind, StagingRecord};
|
||||||
use memory::schema::{SourceEvidenceRef, SourceRef};
|
use memory::schema::{EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef, SourceRef};
|
||||||
use serde::{Deserialize, Serialize};
|
use workspace_api::{
|
||||||
|
Diagnostic, DiagnosticSeverity, MemoryCandidateKind, MemoryEvidenceOrigin,
|
||||||
|
MemoryEvidenceOriginKind, MemorySourceEvidenceRef, MemorySourceRef, MemoryStagingEntry,
|
||||||
|
MemoryStagingEvidence, MemoryStagingListResponse, MemoryStagingRecord,
|
||||||
|
};
|
||||||
|
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
use crate::authority::MemoryAuthority;
|
use crate::authority::MemoryAuthority;
|
||||||
@@ -8,59 +12,6 @@ use crate::authority::MemoryAuthority;
|
|||||||
const DEFAULT_MEMORY_STAGING_LIMIT: usize = 100;
|
const DEFAULT_MEMORY_STAGING_LIMIT: usize = 100;
|
||||||
const MAX_MEMORY_STAGING_LIMIT: usize = 500;
|
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)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
pub struct MemoryStagingBacklogSummary {
|
pub struct MemoryStagingBacklogSummary {
|
||||||
pub candidate_count: usize,
|
pub candidate_count: usize,
|
||||||
@@ -90,14 +41,24 @@ pub fn list_memory_staging_from_authority<A: MemoryAuthority>(
|
|||||||
};
|
};
|
||||||
total_valid_count += 1;
|
total_valid_count += 1;
|
||||||
if valid_items.len() < limit {
|
if valid_items.len() < limit {
|
||||||
valid_items.push(MemoryStagingEntrySummary {
|
valid_items.push(MemoryStagingEntry {
|
||||||
id: entry.candidate_id,
|
id: entry.candidate_id,
|
||||||
byte_len: entry.raw_json.len() as u64,
|
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 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 {
|
Ok(MemoryStagingListResponse {
|
||||||
limit,
|
limit,
|
||||||
returned_count,
|
returned_count,
|
||||||
@@ -107,6 +68,7 @@ pub fn list_memory_staging_from_authority<A: MemoryAuthority>(
|
|||||||
order: "imported_at_desc_candidate_id_asc".to_string(),
|
order: "imported_at_desc_candidate_id_asc".to_string(),
|
||||||
record_authority: "sqlite_workspace_authority.memory_staging".to_string(),
|
record_authority: "sqlite_workspace_authority.memory_staging".to_string(),
|
||||||
items: valid_items,
|
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 {
|
fn memory_staging_record_projection(record: StagingRecord) -> MemoryStagingRecord {
|
||||||
MemoryStagingRecordSummary {
|
MemoryStagingRecord {
|
||||||
schema_version: record.schema_version,
|
schema_version: record.schema_version,
|
||||||
id: record.id,
|
id: record.id,
|
||||||
extract_run_id: record.extract_run_id,
|
extract_run_id: record.extract_run_id,
|
||||||
source: record.source,
|
source: memory_source_ref_projection(record.source),
|
||||||
kind: record.kind.as_str().to_string(),
|
kind: memory_candidate_kind_projection(record.kind),
|
||||||
claim: record.claim,
|
claim: record.claim,
|
||||||
why_useful: record.why_useful,
|
why_useful: record.why_useful,
|
||||||
staleness: record.staleness,
|
staleness: record.staleness,
|
||||||
evidence: record
|
evidence: record
|
||||||
.evidence
|
.evidence
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|evidence| MemoryStagingEvidenceSummary {
|
.map(|evidence| MemoryStagingEvidence {
|
||||||
id: evidence.id,
|
id: evidence.id,
|
||||||
kind: evidence.kind.as_str().to_string(),
|
kind: evidence.kind.as_str().to_string(),
|
||||||
entry_range: evidence.entry_range,
|
entry_range: evidence.entry_range,
|
||||||
|
origin: evidence.origin.map(memory_evidence_origin_projection),
|
||||||
excerpt: evidence.excerpt,
|
excerpt: evidence.excerpt,
|
||||||
summary: evidence.summary,
|
summary: evidence.summary,
|
||||||
})
|
})
|
||||||
@@ -157,19 +120,36 @@ fn memory_staging_record_summary(record: StagingRecord) -> MemoryStagingRecordSu
|
|||||||
source_refs: record
|
source_refs: record
|
||||||
.source_refs
|
.source_refs
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(memory_source_evidence_ref_summary)
|
.map(memory_source_evidence_ref_projection)
|
||||||
.collect(),
|
.collect(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn memory_source_evidence_ref_summary(
|
fn memory_source_ref_projection(source_ref: SourceRef) -> MemorySourceRef {
|
||||||
source_ref: SourceEvidenceRef,
|
MemorySourceRef {
|
||||||
) -> MemorySourceEvidenceRefSummary {
|
segment_id: source_ref.segment_id,
|
||||||
MemorySourceEvidenceRefSummary {
|
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,
|
session_id: source_ref.session_id,
|
||||||
segment_id: source_ref.segment_id,
|
segment_id: source_ref.segment_id,
|
||||||
entry_range: source_ref.entry_range,
|
entry_range: source_ref.entry_range,
|
||||||
evidence_id: source_ref.evidence_id,
|
evidence_id: source_ref.evidence_id,
|
||||||
|
origin: source_ref.origin.map(memory_evidence_origin_projection),
|
||||||
evidence_kind: source_ref
|
evidence_kind: source_ref
|
||||||
.evidence_kind
|
.evidence_kind
|
||||||
.map(|evidence_kind| evidence_kind.as_str().to_string()),
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::authority::{MemoryAuthority, SqliteWorkspaceAuthority};
|
use crate::authority::{MemoryAuthority, SqliteWorkspaceAuthority};
|
||||||
use crate::store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
|
use crate::store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord};
|
||||||
use memory::extract::{CandidateKind, ExtractedCandidate, StagingRecord};
|
use memory::extract::{CandidateKind, ExtractedCandidate, StagingRecord};
|
||||||
|
use memory::schema::{EvidenceOrigin, EvidenceOriginKind, SourceEvidenceRef};
|
||||||
use tempfile::TempDir;
|
use tempfile::TempDir;
|
||||||
|
|
||||||
fn source() -> SourceRef {
|
fn source() -> SourceRef {
|
||||||
@@ -258,7 +261,114 @@ mod tests {
|
|||||||
response.record_authority,
|
response.record_authority,
|
||||||
"sqlite_workspace_authority.memory_staging"
|
"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]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -41,9 +41,7 @@ use tower::ServiceExt;
|
|||||||
use url::Url;
|
use url::Url;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use webauthn_rs::prelude::{
|
use webauthn_rs::prelude::{
|
||||||
CreationChallengeResponse, Passkey, PasskeyAuthentication, PasskeyRegistration,
|
Passkey, PasskeyAuthentication, PasskeyRegistration, Webauthn, WebauthnBuilder,
|
||||||
PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse, Webauthn,
|
|
||||||
WebauthnBuilder,
|
|
||||||
};
|
};
|
||||||
use workdir::http::{
|
use workdir::http::{
|
||||||
WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError,
|
WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError,
|
||||||
@@ -58,18 +56,26 @@ use worker::feature::builtin::{WorkerObservationSubject, WorkerObservationSubjec
|
|||||||
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
|
use worker_runtime::resource::{BackendResourceError, BackendResourceFetchRequest};
|
||||||
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
use worker_runtime::worker_backend::{ProfileRuntimeWorkerFactory, WorkerRuntimeExecutionBackend};
|
||||||
use workspace_api::{
|
use workspace_api::{
|
||||||
BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse, CreateRemoteRuntimeRequest,
|
ActorAuthMethod, AuthBootstrapUserRequest, AuthPublicConfig, AuthUserResponse,
|
||||||
CreateRepositorySshCredentialRequest, CreateWorkspaceRepositoryRequest,
|
AuthenticatedUser, BrowserCreateWorkerResponse, BrowserWorkspaceOrchestratorResponse,
|
||||||
CreateWorkspaceRepositoryResponse, CreateWorkspaceWorkerRequest,
|
CreateRemoteRuntimeRequest, CreateRepositorySshCredentialRequest,
|
||||||
CreateWorkspaceWorkerTicketAssignmentRequest, DeleteRepositorySshCredentialRequest,
|
CreateWorkspaceRepositoryRequest, CreateWorkspaceRepositoryResponse,
|
||||||
DeleteRepositorySshHostTrustRequest, ObjectiveCreateRequest, ObjectiveEditRequest,
|
CreateWorkspaceWorkerRequest, CreateWorkspaceWorkerTicketAssignmentRequest,
|
||||||
ObjectiveLinkTicketRequest, ObjectiveStateRequest, ProfileSettingsResponse,
|
DeleteRepositorySshCredentialRequest, DeleteRepositorySshHostTrustRequest,
|
||||||
PutRepositorySshHostTrustRequest, RepositoryAccessProjection, RepositoryDetailResponse,
|
DeviceAccessTokenType, DeviceLoginApprovalStatus, DeviceLoginApproveRequest,
|
||||||
RepositoryListResponse, RepositoryLogResponse, RepositorySshCredential, RepositorySshHostTrust,
|
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,
|
RotateRepositorySshCredentialRequest, RuntimeConnectionTestResponse, RuntimeManagementSummary,
|
||||||
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_PATH,
|
||||||
UpdateWorkspaceMetadataRequest, WorkerLaunchOptionsResponse, WorkerLaunchProfileCandidate,
|
UpdateWorkspaceMetadataRequest, WhoamiResponse, WorkerLaunchOptionsResponse,
|
||||||
WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary,
|
WorkerLaunchProfileCandidate, WorkerLaunchRuntimeOption, WorkerLaunchWorkerSummary,
|
||||||
WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
|
WorkingDirectoryCreateRequest as BrowserWorkingDirectoryCreateRequest,
|
||||||
WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse,
|
WorkingDirectoryCreateResponse as BrowserWorkingDirectoryCreateResponse,
|
||||||
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
|
WorkingDirectoryDetailResponse as BrowserWorkingDirectoryDetailResponse,
|
||||||
@@ -84,9 +90,9 @@ use workspace_api::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::auth::{
|
use crate::auth::{
|
||||||
ActorAuthMethod, AuthPublicConfig, AuthenticatedUser, RequestActor, SessionCookiePolicy,
|
SessionCookiePolicy, auth_error, is_expired, mint_secret, new_id, new_user_code,
|
||||||
auth_error, is_expired, mint_secret, new_id, new_user_code, normalize_handle, parse_cookie,
|
normalize_handle, parse_cookie, resolve_request_actor, rfc3339_after, session_set_cookie,
|
||||||
resolve_request_actor, rfc3339_after, session_set_cookie, token_hash,
|
token_hash,
|
||||||
};
|
};
|
||||||
use crate::authority::{
|
use crate::authority::{
|
||||||
MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput,
|
MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput,
|
||||||
@@ -113,8 +119,7 @@ use crate::hosts::{
|
|||||||
use crate::identity::WorkspaceIdentity;
|
use crate::identity::WorkspaceIdentity;
|
||||||
use crate::memory_backend::execute_memory_backend_operation_with_authority;
|
use crate::memory_backend::execute_memory_backend_operation_with_authority;
|
||||||
use crate::memory_staging::{
|
use crate::memory_staging::{
|
||||||
MemoryStagingListResponse, list_memory_staging_from_authority,
|
list_memory_staging_from_authority, memory_staging_backlog_from_authority,
|
||||||
memory_staging_backlog_from_authority,
|
|
||||||
};
|
};
|
||||||
use crate::observation::{
|
use crate::observation::{
|
||||||
BackendObservationProxy, ObservationProxyError, RuntimeObservationClient,
|
BackendObservationProxy, ObservationProxyError, RuntimeObservationClient,
|
||||||
@@ -7917,15 +7922,6 @@ fn find_workspace_orchestrator(api: &WorkspaceApi) -> Option<WorkerSummary> {
|
|||||||
None
|
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(
|
async fn scoped_get_memory_document(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
AxumPath(path): AxumPath<ScopedWorkspacePath>,
|
||||||
@@ -11486,118 +11482,6 @@ async fn scoped_list_host_workers(
|
|||||||
list_host_workers(State(api), AxumPath(path.host_id)).await
|
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>> {
|
async fn get_auth_config(State(api): State<ServerAuthApi>) -> ApiResult<Json<AuthPublicConfig>> {
|
||||||
Ok(Json(auth_public_config(&api.config)))
|
Ok(Json(auth_public_config(&api.config)))
|
||||||
}
|
}
|
||||||
@@ -12002,7 +11886,7 @@ async fn post_device_login_approve(
|
|||||||
.into());
|
.into());
|
||||||
}
|
}
|
||||||
Ok(Json(DeviceLoginApproveResponse {
|
Ok(Json(DeviceLoginApproveResponse {
|
||||||
status: "approved".to_string(),
|
status: DeviceLoginApprovalStatus::Approved,
|
||||||
user: actor.user(),
|
user: actor.user(),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
@@ -12019,14 +11903,14 @@ async fn post_device_login_poll(
|
|||||||
};
|
};
|
||||||
if is_expired(&flow.expires_at) {
|
if is_expired(&flow.expires_at) {
|
||||||
return Ok(Json(DeviceLoginPollResponse {
|
return Ok(Json(DeviceLoginPollResponse {
|
||||||
status: "expired".to_string(),
|
status: DeviceLoginPollStatus::Expired,
|
||||||
access_token: None,
|
access_token: None,
|
||||||
token_type: None,
|
token_type: None,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
if flow.approved_at.is_none() {
|
if flow.approved_at.is_none() {
|
||||||
return Ok(Json(DeviceLoginPollResponse {
|
return Ok(Json(DeviceLoginPollResponse {
|
||||||
status: "pending".to_string(),
|
status: DeviceLoginPollStatus::Pending,
|
||||||
access_token: None,
|
access_token: None,
|
||||||
token_type: 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())?
|
.consume_device_login_token(&request.device_code, &crate::auth::now_rfc3339())?
|
||||||
else {
|
else {
|
||||||
return Ok(Json(DeviceLoginPollResponse {
|
return Ok(Json(DeviceLoginPollResponse {
|
||||||
status: "consumed".to_string(),
|
status: DeviceLoginPollStatus::Consumed,
|
||||||
access_token: None,
|
access_token: None,
|
||||||
token_type: None,
|
token_type: None,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
Ok(Json(DeviceLoginPollResponse {
|
Ok(Json(DeviceLoginPollResponse {
|
||||||
status: "approved".to_string(),
|
status: DeviceLoginPollStatus::Approved,
|
||||||
access_token: consumed.issued_access_token,
|
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((
|
Ok((
|
||||||
response_headers,
|
response_headers,
|
||||||
Json(LogoutResponse {
|
Json(LogoutResponse {
|
||||||
status: "logged_out".to_string(),
|
status: LogoutStatus::LoggedOut,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.into_response())
|
.into_response())
|
||||||
@@ -26478,6 +26362,8 @@ mod tests {
|
|||||||
|
|
||||||
let memory_document =
|
let memory_document =
|
||||||
get_json(app.clone(), &format!("/api/w/{TEST_WORKSPACE_ID}/memory")).await;
|
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["created_at"], "2026-01-01T00:00:00Z");
|
||||||
assert_eq!(memory_document["updated_at"], "2026-01-02T00:00:00Z");
|
assert_eq!(memory_document["updated_at"], "2026-01-02T00:00:00Z");
|
||||||
assert_eq!(memory_document["bytes"], 63);
|
assert_eq!(memory_document["bytes"], 63);
|
||||||
@@ -26494,6 +26380,8 @@ mod tests {
|
|||||||
&format!("/api/w/{TEST_WORKSPACE_ID}/memory/staging?limit=10"),
|
&format!("/api/w/{TEST_WORKSPACE_ID}/memory/staging?limit=10"),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
let _: workspace_api::MemoryStagingListResponse =
|
||||||
|
serde_json::from_value(memory_staging.clone()).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
memory_staging["record_authority"],
|
memory_staging["record_authority"],
|
||||||
"sqlite_workspace_authority.memory_staging"
|
"sqlite_workspace_authority.memory_staging"
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
"dev": "deno run -A npm:vite@7.2.7 dev",
|
"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",
|
"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",
|
"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",
|
"build": "deno run -A npm:vite@7.2.7 build",
|
||||||
"preview": "deno run -A npm:vite@7.2.7 preview"
|
"preview": "deno run -A npm:vite@7.2.7 preview"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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<MemoryStagingEvidence>;
|
||||||
|
source_refs: Array<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: Array<MemoryStagingEntry>;
|
||||||
|
diagnostics: Array<Diagnostic>;
|
||||||
|
};
|
||||||
@@ -1,118 +1,194 @@
|
|||||||
|
import type {
|
||||||
|
DeviceLoginApproveRequest,
|
||||||
|
PasskeyLoginCompleteRequest,
|
||||||
|
PasskeyLoginOptionsRequest,
|
||||||
|
PasskeyRegistrationCompleteRequest,
|
||||||
|
PasskeyRegistrationOptionsRequest,
|
||||||
|
} from "$lib/generated/auth-api.ts";
|
||||||
import {
|
import {
|
||||||
authenticationCredentialToJson,
|
authenticationCredentialToJson,
|
||||||
|
type AuthUser,
|
||||||
type DeviceApprovalResponse,
|
type DeviceApprovalResponse,
|
||||||
isPublicKeyCredential,
|
parseAuthUserResponse,
|
||||||
type PasskeyLoginOptionsResponse,
|
parseDeviceApprovalResponse,
|
||||||
type PasskeyRegistrationOptionsResponse,
|
parseLogoutResponse,
|
||||||
type PasskeyUserResponse,
|
parseWhoamiResponse,
|
||||||
prepareLoginOptions,
|
prepareLoginOptions,
|
||||||
prepareRegistrationOptions,
|
prepareRegistrationOptions,
|
||||||
registrationCredentialToJson,
|
registrationCredentialToJson,
|
||||||
type WhoamiResponse,
|
type WhoamiResponse,
|
||||||
} from "./model";
|
} from "$lib/workspace/auth/model";
|
||||||
|
|
||||||
async function jsonOrThrow<T>(response: Response): Promise<T> {
|
const MAX_AUTH_RESPONSE_BYTES = 256 * 1024;
|
||||||
const text = await response.text();
|
|
||||||
if (!response.ok) {
|
export async function readBoundedAuthResponseJson(
|
||||||
|
response: Response,
|
||||||
|
): Promise<unknown> {
|
||||||
|
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(
|
throw new Error(
|
||||||
`${response.status} ${response.statusText}${text ? `: ${text}` : ""}`,
|
"Invalid auth response: response body exceeds the size limit.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return text ? JSON.parse(text) as T : (null as T);
|
}
|
||||||
|
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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function browserOrigin(): string | null {
|
async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||||
return globalThis.location?.origin ?? null;
|
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(
|
export async function loadWhoami(): Promise<WhoamiResponse> {
|
||||||
fetcher: typeof fetch = fetch,
|
return parseWhoamiResponse(await requestJson("/api/auth/whoami"));
|
||||||
): Promise<WhoamiResponse> {
|
}
|
||||||
return await fetcher("/api/auth/whoami", { credentials: "same-origin" }).then(
|
|
||||||
jsonOrThrow<WhoamiResponse>,
|
export async function logout(): Promise<void> {
|
||||||
|
parseLogoutResponse(
|
||||||
|
await requestJson("/api/auth/logout", {
|
||||||
|
method: "POST",
|
||||||
|
body: "{}",
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function registerPasskey(
|
export async function registerPasskey(
|
||||||
handle: string,
|
handle: string,
|
||||||
displayName: string,
|
displayName?: string,
|
||||||
fetcher: typeof fetch = fetch,
|
): Promise<AuthUser> {
|
||||||
): Promise<PasskeyUserResponse> {
|
const optionsRequest: PasskeyRegistrationOptionsRequest = {
|
||||||
const options = await fetcher("/api/auth/passkeys/registration/options", {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
credentials: "same-origin",
|
|
||||||
body: JSON.stringify({
|
|
||||||
handle,
|
handle,
|
||||||
display_name: displayName,
|
display_name: displayName ?? null,
|
||||||
browser_origin: browserOrigin(),
|
browser_origin: window.location.origin,
|
||||||
}),
|
};
|
||||||
}).then(jsonOrThrow<PasskeyRegistrationOptionsResponse>);
|
const options = await requestJson("/api/auth/passkeys/registration/options", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(optionsRequest),
|
||||||
|
});
|
||||||
|
const optionsRecord = typeof options === "object" && options !== null
|
||||||
|
? options as Record<string, unknown>
|
||||||
|
: 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({
|
const credential = await navigator.credentials.create({
|
||||||
publicKey: prepareRegistrationOptions(options),
|
publicKey: prepareRegistrationOptions(options),
|
||||||
});
|
});
|
||||||
if (!isPublicKeyCredential(credential)) {
|
if (!credential) throw new Error("Passkey registration was cancelled");
|
||||||
throw new Error(
|
|
||||||
"Passkey registration did not return a public-key credential.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return await fetcher("/api/auth/passkeys/registration/complete", {
|
const completeRequest: PasskeyRegistrationCompleteRequest = {
|
||||||
method: "POST",
|
challenge_id: challengeId,
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
credentials: "same-origin",
|
|
||||||
body: JSON.stringify({
|
|
||||||
challenge_id: options.challenge_id,
|
|
||||||
credential: registrationCredentialToJson(credential),
|
credential: registrationCredentialToJson(credential),
|
||||||
|
};
|
||||||
|
const result = parseAuthUserResponse(
|
||||||
|
await requestJson("/api/auth/passkeys/registration/complete", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(completeRequest),
|
||||||
}),
|
}),
|
||||||
}).then(jsonOrThrow<PasskeyUserResponse>);
|
);
|
||||||
|
return result.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loginWithPasskey(
|
export async function loginWithPasskey(handle?: string): Promise<AuthUser> {
|
||||||
handle: string,
|
const optionsRequest: PasskeyLoginOptionsRequest = {
|
||||||
fetcher: typeof fetch = fetch,
|
handle: handle ?? null,
|
||||||
): Promise<PasskeyUserResponse> {
|
browser_origin: window.location.origin,
|
||||||
const options = await fetcher("/api/auth/passkeys/login/options", {
|
};
|
||||||
|
const options = await requestJson("/api/auth/passkeys/login/options", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
body: JSON.stringify(optionsRequest),
|
||||||
credentials: "same-origin",
|
});
|
||||||
body: JSON.stringify({ handle, browser_origin: browserOrigin() }),
|
const optionsRecord = typeof options === "object" && options !== null
|
||||||
}).then(jsonOrThrow<PasskeyLoginOptionsResponse>);
|
? options as Record<string, unknown>
|
||||||
|
: 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({
|
const credential = await navigator.credentials.get({
|
||||||
publicKey: prepareLoginOptions(options),
|
publicKey: prepareLoginOptions(options),
|
||||||
});
|
});
|
||||||
if (!isPublicKeyCredential(credential)) {
|
if (!credential) throw new Error("Passkey login was cancelled");
|
||||||
throw new Error("Passkey login did not return a public-key credential.");
|
|
||||||
}
|
|
||||||
|
|
||||||
return await fetcher("/api/auth/passkeys/login/complete", {
|
const completeRequest: PasskeyLoginCompleteRequest = {
|
||||||
method: "POST",
|
challenge_id: challengeId,
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
credentials: "same-origin",
|
|
||||||
body: JSON.stringify({
|
|
||||||
challenge_id: options.challenge_id,
|
|
||||||
credential: authenticationCredentialToJson(credential),
|
credential: authenticationCredentialToJson(credential),
|
||||||
}),
|
};
|
||||||
}).then(jsonOrThrow<PasskeyUserResponse>);
|
const result = parseAuthUserResponse(
|
||||||
}
|
await requestJson("/api/auth/passkeys/login/complete", {
|
||||||
|
|
||||||
export async function logout(fetcher: typeof fetch = fetch): Promise<void> {
|
|
||||||
await fetcher("/api/auth/logout", {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "same-origin",
|
body: JSON.stringify(completeRequest),
|
||||||
}).then(jsonOrThrow<unknown>);
|
}),
|
||||||
|
);
|
||||||
|
return result.user;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function approveDeviceLogin(
|
export async function approveDeviceLogin(
|
||||||
userCode: string,
|
userCode: string,
|
||||||
fetcher: typeof fetch = fetch,
|
|
||||||
): Promise<DeviceApprovalResponse> {
|
): Promise<DeviceApprovalResponse> {
|
||||||
return await fetcher("/api/auth/device-login/approve", {
|
const request: DeviceLoginApproveRequest = { user_code: userCode };
|
||||||
|
return parseDeviceApprovalResponse(
|
||||||
|
await requestJson("/api/auth/device-login/approve", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
body: JSON.stringify(request),
|
||||||
credentials: "same-origin",
|
}),
|
||||||
body: JSON.stringify({ user_code: userCode }),
|
);
|
||||||
}).then(jsonOrThrow<DeviceApprovalResponse>);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
|
authenticationCredentialToJson,
|
||||||
base64UrlToBuffer,
|
base64UrlToBuffer,
|
||||||
bufferToBase64Url,
|
bufferToBase64Url,
|
||||||
|
parseDeviceLoginPollResponse,
|
||||||
|
parseDeviceLoginStartResponse,
|
||||||
|
parseWhoamiResponse,
|
||||||
prepareLoginOptions,
|
prepareLoginOptions,
|
||||||
prepareRegistrationOptions,
|
prepareRegistrationOptions,
|
||||||
|
registrationCredentialToJson,
|
||||||
} from "./model.ts";
|
} from "./model.ts";
|
||||||
|
|
||||||
declare const Deno: {
|
declare const Deno: {
|
||||||
@@ -17,6 +22,21 @@ function assertEquals<T>(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[] {
|
function bytes(buffer: BufferSource): number[] {
|
||||||
if (buffer instanceof ArrayBuffer) {
|
if (buffer instanceof ArrayBuffer) {
|
||||||
return [...new Uint8Array(buffer)];
|
return [...new Uint8Array(buffer)];
|
||||||
@@ -39,6 +59,7 @@ Deno.test("prepareRegistrationOptions decodes binary public key fields", () => {
|
|||||||
const options = prepareRegistrationOptions({
|
const options = prepareRegistrationOptions({
|
||||||
challenge_id: "challenge-1",
|
challenge_id: "challenge-1",
|
||||||
public_key: {
|
public_key: {
|
||||||
|
publicKey: {
|
||||||
challenge: "AQID" as unknown as BufferSource,
|
challenge: "AQID" as unknown as BufferSource,
|
||||||
rp: { id: "localhost", name: "Yoi" },
|
rp: { id: "localhost", name: "Yoi" },
|
||||||
user: {
|
user: {
|
||||||
@@ -52,6 +73,7 @@ Deno.test("prepareRegistrationOptions decodes binary public key fields", () => {
|
|||||||
id: "BwgJ" as unknown as BufferSource,
|
id: "BwgJ" as unknown as BufferSource,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assertEquals(bytes(options.challenge), [1, 2, 3]);
|
assertEquals(bytes(options.challenge), [1, 2, 3]);
|
||||||
@@ -67,12 +89,14 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", ()
|
|||||||
const options = prepareLoginOptions({
|
const options = prepareLoginOptions({
|
||||||
challenge_id: "challenge-1",
|
challenge_id: "challenge-1",
|
||||||
public_key: {
|
public_key: {
|
||||||
|
publicKey: {
|
||||||
challenge: "AQID" as unknown as BufferSource,
|
challenge: "AQID" as unknown as BufferSource,
|
||||||
allowCredentials: [{
|
allowCredentials: [{
|
||||||
type: "public-key",
|
type: "public-key",
|
||||||
id: "BwgJ" as unknown as BufferSource,
|
id: "BwgJ" as unknown as BufferSource,
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
assertEquals(bytes(options.challenge), [1, 2, 3]);
|
assertEquals(bytes(options.challenge), [1, 2, 3]);
|
||||||
@@ -82,3 +106,120 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", ()
|
|||||||
9,
|
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",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,72 +1,191 @@
|
|||||||
export type AuthenticatedUser = {
|
import type {
|
||||||
user_id: string;
|
ActorAuthMethod,
|
||||||
account_id: string;
|
AuthenticatedUser,
|
||||||
handle: string;
|
AuthPublicConfig,
|
||||||
display_name: string;
|
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 & {
|
const MAX_AUTH_STRING_LENGTH = 16 * 1024;
|
||||||
auth_method: "browser_session" | "api_token" | string;
|
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 = {
|
function invalid(path: string, reason: string): never {
|
||||||
actor: RequestActor | null;
|
throw new Error(`Invalid auth payload: ${path} ${reason}.`);
|
||||||
};
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function bufferToBase64Url(buffer: ArrayBuffer): string {
|
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
||||||
const bytes = new Uint8Array(buffer);
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
invalid(path, "must be an object");
|
||||||
|
}
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireExactKeys(
|
||||||
|
value: Record<string, unknown>,
|
||||||
|
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 = "";
|
let binary = "";
|
||||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||||
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
|
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(
|
||||||
@@ -75,86 +194,571 @@ export function bufferToBase64Url(buffer: ArrayBuffer): string {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function unwrapPublicKey<T>(value: T | { publicKey: T }): T {
|
export function base64UrlToBuffer(value: string): ArrayBuffer {
|
||||||
if (value && typeof value === "object" && "publicKey" in value) {
|
return fromBase64Url(value, "base64url");
|
||||||
return (value as { publicKey: T }).publicKey;
|
}
|
||||||
|
|
||||||
|
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<string, unknown> {
|
||||||
|
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(
|
export function prepareRegistrationOptions(
|
||||||
options: PasskeyRegistrationOptionsResponse,
|
value: unknown,
|
||||||
): PublicKeyCredentialCreationOptions {
|
): PublicKeyCredentialCreationOptions {
|
||||||
const publicKey = structuredClone(unwrapPublicKey(options.public_key));
|
const response = asRecord(value, "registration_options");
|
||||||
publicKey.challenge = base64UrlToBuffer(
|
requireExactKeys(response, "registration_options", [
|
||||||
publicKey.challenge as unknown as string,
|
"challenge_id",
|
||||||
);
|
"public_key",
|
||||||
publicKey.user = {
|
]);
|
||||||
...publicKey.user,
|
boundedString(response.challenge_id, "registration_options.challenge_id");
|
||||||
id: base64UrlToBuffer(publicKey.user.id as unknown as string),
|
return parseCreationOptions(response.public_key);
|
||||||
};
|
|
||||||
publicKey.excludeCredentials = publicKey.excludeCredentials?.map((
|
|
||||||
credential,
|
|
||||||
) => ({
|
|
||||||
...credential,
|
|
||||||
id: base64UrlToBuffer(credential.id as unknown as string),
|
|
||||||
}));
|
|
||||||
return publicKey;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prepareLoginOptions(
|
export function prepareLoginOptions(
|
||||||
options: PasskeyLoginOptionsResponse,
|
value: unknown,
|
||||||
): PublicKeyCredentialRequestOptions {
|
): PublicKeyCredentialRequestOptions {
|
||||||
const publicKey = structuredClone(unwrapPublicKey(options.public_key));
|
const response = asRecord(value, "login_options");
|
||||||
publicKey.challenge = base64UrlToBuffer(
|
requireExactKeys(response, "login_options", ["challenge_id", "public_key"]);
|
||||||
publicKey.challenge as unknown as string,
|
boundedString(response.challenge_id, "login_options.challenge_id");
|
||||||
);
|
return parseRequestOptions(response.public_key);
|
||||||
publicKey.allowCredentials = publicKey.allowCredentials?.map((
|
}
|
||||||
|
|
||||||
|
function credentialCore(credential: unknown): Record<string, unknown> {
|
||||||
|
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<string, unknown>,
|
||||||
|
): AuthenticationExtensionsClientOutputs {
|
||||||
|
const value = (credential.getClientExtensionResults as () => unknown).call(
|
||||||
credential,
|
credential,
|
||||||
) => ({
|
);
|
||||||
...credential,
|
boundedJson(value, "credential.clientExtensionResults");
|
||||||
id: base64UrlToBuffer(credential.id as unknown as string),
|
return asRecord(
|
||||||
}));
|
value,
|
||||||
return publicKey;
|
"credential.clientExtensionResults",
|
||||||
|
) as AuthenticationExtensionsClientOutputs;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registrationCredentialToJson(
|
export function registrationCredentialToJson(credential: unknown): unknown {
|
||||||
credential: PublicKeyCredential,
|
const core = credentialCore(credential);
|
||||||
): RegistrationCredentialJson {
|
const response = asRecord(core.response, "credential.response");
|
||||||
const response = credential.response as AuthenticatorAttestationResponse;
|
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 {
|
return {
|
||||||
id: credential.id,
|
id: boundedString(core.id, "credential.id"),
|
||||||
rawId: bufferToBase64Url(credential.rawId),
|
rawId: toBase64Url(core.rawId, "credential.rawId"),
|
||||||
type: credential.type,
|
type: "public-key",
|
||||||
response: {
|
response: {
|
||||||
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
clientDataJSON: toBase64Url(
|
||||||
attestationObject: bufferToBase64Url(response.attestationObject),
|
response.clientDataJSON,
|
||||||
transports: response.getTransports?.() ?? [],
|
"credential.response.clientDataJSON",
|
||||||
|
),
|
||||||
|
attestationObject: toBase64Url(
|
||||||
|
response.attestationObject,
|
||||||
|
"credential.response.attestationObject",
|
||||||
|
),
|
||||||
|
transports,
|
||||||
},
|
},
|
||||||
|
clientExtensionResults: clientExtensionResults(core),
|
||||||
|
...(authenticatorAttachment === null ? {} : { authenticatorAttachment }),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function authenticationCredentialToJson(
|
export function authenticationCredentialToJson(credential: unknown): unknown {
|
||||||
credential: PublicKeyCredential,
|
const core = credentialCore(credential);
|
||||||
): AuthenticationCredentialJson {
|
const response = asRecord(core.response, "credential.response");
|
||||||
const response = credential.response as AuthenticatorAssertionResponse;
|
const authenticatorAttachment = optionalString(
|
||||||
|
core.authenticatorAttachment,
|
||||||
|
"credential.authenticatorAttachment",
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
id: credential.id,
|
id: boundedString(core.id, "credential.id"),
|
||||||
rawId: bufferToBase64Url(credential.rawId),
|
rawId: toBase64Url(core.rawId, "credential.rawId"),
|
||||||
type: credential.type,
|
type: "public-key",
|
||||||
response: {
|
response: {
|
||||||
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
|
clientDataJSON: toBase64Url(
|
||||||
authenticatorData: bufferToBase64Url(response.authenticatorData),
|
response.clientDataJSON,
|
||||||
signature: bufferToBase64Url(response.signature),
|
"credential.response.clientDataJSON",
|
||||||
userHandle: response.userHandle
|
),
|
||||||
? bufferToBase64Url(response.userHandle)
|
authenticatorData: toBase64Url(
|
||||||
: null,
|
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(
|
export function parseDeviceLoginStartResponse(
|
||||||
credential: Credential | null,
|
value: unknown,
|
||||||
): credential is PublicKeyCredential {
|
): DeviceLoginStartResponse {
|
||||||
return credential != null && credential.type === "public-key";
|
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" };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<MemoryCandidateKind>([
|
||||||
|
"preference",
|
||||||
|
"working_assumption",
|
||||||
|
"constraint",
|
||||||
|
"decision",
|
||||||
|
"open_question",
|
||||||
|
"lesson",
|
||||||
|
]);
|
||||||
|
const originKinds = new Set<MemoryEvidenceOriginKind>([
|
||||||
|
"human_input",
|
||||||
|
"worker_input",
|
||||||
|
"flow_instruction",
|
||||||
|
"backend_instruction",
|
||||||
|
"model_output",
|
||||||
|
"tool_output",
|
||||||
|
"derived_summary",
|
||||||
|
"legacy_unknown",
|
||||||
|
]);
|
||||||
|
const diagnosticSeverities = new Set<DiagnosticSeverity>([
|
||||||
|
"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<string, unknown> {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||||
|
invalid(`${label} must be an object`);
|
||||||
|
}
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
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<string, unknown>, key: string): string {
|
||||||
|
if (typeof record[key] !== "string") {
|
||||||
|
invalid(`${key} must be a string`);
|
||||||
|
}
|
||||||
|
return record[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
function nullableString(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
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<string, unknown>,
|
||||||
|
key: string,
|
||||||
|
): boolean {
|
||||||
|
if (typeof record[key] !== "boolean") {
|
||||||
|
invalid(`${key} must be a boolean`);
|
||||||
|
}
|
||||||
|
return record[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
function requiredNonNegativeInteger(
|
||||||
|
record: Record<string, unknown>,
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
@@ -206,75 +206,6 @@ export type RepositoryListResponse = SharedRepositoryListResponse;
|
|||||||
export type RepositoryDetailResponse = SharedRepositoryDetailResponse;
|
export type RepositoryDetailResponse = SharedRepositoryDetailResponse;
|
||||||
export type RepositoryLogResponse = SharedRepositoryLogResponse;
|
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 {
|
export type {
|
||||||
DerivedTicketRelation,
|
DerivedTicketRelation,
|
||||||
TicketDetail,
|
TicketDetail,
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
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";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch, params }) => {
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
return {
|
return {
|
||||||
workspaceId: params.workspaceId,
|
workspaceId: params.workspaceId,
|
||||||
memory: await loadJson<MemoryDocumentResponse>(
|
memory: await loadJson(
|
||||||
fetch,
|
fetch,
|
||||||
workspaceApiPath(params.workspaceId, "/memory"),
|
workspaceApiPath(params.workspaceId, "/memory"),
|
||||||
|
undefined,
|
||||||
|
parseMemoryDocumentResponse,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { MemoryStagingEntry, MemoryStagingRecord } from '$lib/workspace/sidebar/types';
|
import type { MemoryStagingEntry, MemoryStagingRecord } from '$lib/generated/memory-api';
|
||||||
import type { PageProps } from './$types';
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
let { data }: PageProps = $props();
|
let { data }: PageProps = $props();
|
||||||
@@ -68,6 +68,12 @@
|
|||||||
<p class="section-note">Showing first {data.staging.data.limit} staged record(s).</p>
|
<p class="section-note">Showing first {data.staging.data.limit} staged record(s).</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#each data.staging.data.diagnostics as diagnostic (diagnostic.code)}
|
||||||
|
<p class:error={diagnostic.severity === 'error'} class="section-note">
|
||||||
|
{diagnostic.message}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
|
||||||
{#if entries.length === 0}
|
{#if entries.length === 0}
|
||||||
<p>No Memory Staging records are present.</p>
|
<p>No Memory Staging records are present.</p>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
import { loadJson, workspaceApiPath } from "$lib/workspace/api/http";
|
||||||
import type { MemoryStagingListResponse } from "$lib/workspace/sidebar/types";
|
import { parseMemoryStagingListResponse } from "$lib/workspace/memory/api";
|
||||||
import type { PageLoad } from "./$types";
|
import type { PageLoad } from "./$types";
|
||||||
|
|
||||||
export const load: PageLoad = async ({ fetch, params }) => {
|
export const load: PageLoad = async ({ fetch, params }) => {
|
||||||
return {
|
return {
|
||||||
workspaceId: params.workspaceId,
|
workspaceId: params.workspaceId,
|
||||||
staging: await loadJson<MemoryStagingListResponse>(
|
staging: await loadJson(
|
||||||
fetch,
|
fetch,
|
||||||
`${workspaceApiPath(params.workspaceId, "/memory/staging")}?limit=200`,
|
`${workspaceApiPath(params.workspaceId, "/memory/staging")}?limit=200`,
|
||||||
|
undefined,
|
||||||
|
parseMemoryStagingListResponse,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => void | Promise<void>): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
import {
|
||||||
|
loadWhoami,
|
||||||
|
readBoundedAuthResponseJson,
|
||||||
|
} from "../src/lib/workspace/auth/api.ts";
|
||||||
|
|
||||||
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertRejects(
|
||||||
|
promise: Promise<unknown>,
|
||||||
|
expectedMessage: string,
|
||||||
|
forbiddenContent?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await promise;
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof Error) || error.message !== expectedMessage) {
|
||||||
|
throw new Error("unexpected rejection");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
forbiddenContent !== undefined && error.message.includes(forbiddenContent)
|
||||||
|
) {
|
||||||
|
throw new Error("diagnostic leaked response content");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error("expected rejection");
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("bounded auth response reader parses a valid JSON object", async () => {
|
||||||
|
const response = new Response('{"status":"pending"}', {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
});
|
||||||
|
assertEquals(await readBoundedAuthResponseJson(response), {
|
||||||
|
status: "pending",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("bounded auth response reader rejects declared and streamed oversize bodies", async () => {
|
||||||
|
await assertRejects(
|
||||||
|
readBoundedAuthResponseJson(
|
||||||
|
new Response("{}", { headers: { "content-length": "262145" } }),
|
||||||
|
),
|
||||||
|
"Invalid auth response: response body exceeds the size limit.",
|
||||||
|
);
|
||||||
|
|
||||||
|
const chunk = new Uint8Array(131_073);
|
||||||
|
const response = new Response(
|
||||||
|
new ReadableStream<Uint8Array>({
|
||||||
|
start(controller) {
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
controller.enqueue(chunk);
|
||||||
|
controller.close();
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await assertRejects(
|
||||||
|
readBoundedAuthResponseJson(response),
|
||||||
|
"Invalid auth response: response body exceeds the size limit.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("auth requests do not expose non-success response content", async () => {
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
globalThis.fetch = () =>
|
||||||
|
Promise.resolve(
|
||||||
|
new Response('{"message":"access-secret"}', {
|
||||||
|
status: 401,
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await assertRejects(
|
||||||
|
loadWhoami(),
|
||||||
|
"Auth request failed (401).",
|
||||||
|
"access-secret",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("bounded auth response reader rejects invalid JSON without echoing content", async () => {
|
||||||
|
const sensitive = "access-secret";
|
||||||
|
await assertRejects(
|
||||||
|
readBoundedAuthResponseJson(new Response(`{${sensitive}`)),
|
||||||
|
"Invalid auth response: response body is not valid JSON.",
|
||||||
|
sensitive,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
declare const Deno: {
|
||||||
|
test(name: string, fn: () => void | Promise<void>): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
import {
|
||||||
|
parseMemoryDocumentResponse,
|
||||||
|
parseMemoryStagingListResponse,
|
||||||
|
} from "../src/lib/workspace/memory/api.ts";
|
||||||
|
|
||||||
|
function assertEquals(actual: unknown, expected: unknown): void {
|
||||||
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||||
|
throw new Error(
|
||||||
|
`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertThrows(fn: () => void, expectedMessage: string): void {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && error.message.includes(expectedMessage)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`expected function to throw ${expectedMessage}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixture(origin: Record<string, unknown>) {
|
||||||
|
return {
|
||||||
|
limit: 100,
|
||||||
|
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: 2,
|
||||||
|
id: "candidate-1",
|
||||||
|
extract_run_id: "extract-run-1",
|
||||||
|
source: { segment_id: "segment-1", range: [10, 20] },
|
||||||
|
kind: "decision",
|
||||||
|
claim: "Keep provenance typed.",
|
||||||
|
why_useful: "Prevents origin loss.",
|
||||||
|
staleness: null,
|
||||||
|
evidence: [{
|
||||||
|
id: "evidence-1",
|
||||||
|
kind: "message",
|
||||||
|
entry_range: [10, 10],
|
||||||
|
origin,
|
||||||
|
excerpt: null,
|
||||||
|
summary: "bounded summary",
|
||||||
|
}],
|
||||||
|
source_refs: [{
|
||||||
|
session_id: "session-1",
|
||||||
|
segment_id: "segment-1",
|
||||||
|
entry_range: [10, 10],
|
||||||
|
evidence_id: "evidence-1",
|
||||||
|
origin,
|
||||||
|
evidence_kind: "message",
|
||||||
|
label: "source",
|
||||||
|
summary: null,
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
diagnostics: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("Memory document response requires the generated DTO fields", () => {
|
||||||
|
assertEquals(
|
||||||
|
parseMemoryDocumentResponse({
|
||||||
|
body_md: "# Memory\n",
|
||||||
|
created_at: "2026-09-01T00:00:00Z",
|
||||||
|
updated_at: "2026-09-01T00:00:00Z",
|
||||||
|
bytes: 9,
|
||||||
|
record_source: "sqlite_workspace_authority.memory_document",
|
||||||
|
}).bytes,
|
||||||
|
9,
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() => parseMemoryDocumentResponse({ body_md: "# Memory\n" }),
|
||||||
|
"missing a required field",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (
|
||||||
|
const [kind, fields] of [
|
||||||
|
["human_input", { account_id: "account-1" }],
|
||||||
|
[
|
||||||
|
"worker_input",
|
||||||
|
{
|
||||||
|
workspace_id: "workspace-1",
|
||||||
|
runtime_id: "runtime-1",
|
||||||
|
worker_id: "worker-1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
["model_output", { runtime_id: "runtime-1", worker_id: "worker-1" }],
|
||||||
|
["tool_output", { runtime_id: "runtime-1", worker_id: "worker-1" }],
|
||||||
|
["legacy_unknown", {}],
|
||||||
|
] as const
|
||||||
|
) {
|
||||||
|
Deno.test(`Memory staging parser preserves ${kind} origin`, () => {
|
||||||
|
const parsed = parseMemoryStagingListResponse(fixture({ kind, ...fields }));
|
||||||
|
assertEquals(parsed.items[0].record.evidence[0].origin, {
|
||||||
|
kind,
|
||||||
|
...fields,
|
||||||
|
});
|
||||||
|
assertEquals(parsed.items[0].record.source_refs[0].origin, {
|
||||||
|
kind,
|
||||||
|
...fields,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.test("Memory staging parser preserves Flow origin fields", () => {
|
||||||
|
const origin = {
|
||||||
|
kind: "flow_instruction" as const,
|
||||||
|
workspace_id: "workspace-1",
|
||||||
|
runtime_id: "runtime-1",
|
||||||
|
worker_id: "worker-1",
|
||||||
|
flow_selector: "builtin:coder-review",
|
||||||
|
flow_definition_id: "flow-1",
|
||||||
|
flow_definition_revision: 7,
|
||||||
|
};
|
||||||
|
const parsed = parseMemoryStagingListResponse(fixture(origin));
|
||||||
|
assertEquals(parsed.items[0].record.source_refs[0].origin, origin);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Memory staging parser rejects unknown or newer origin shapes", () => {
|
||||||
|
assertThrows(
|
||||||
|
() => parseMemoryStagingListResponse(fixture({ kind: "future_origin" })),
|
||||||
|
"unknown Memory evidence origin kind",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseMemoryStagingListResponse(
|
||||||
|
fixture({ kind: "human_input", future_field: "must not be accepted" }),
|
||||||
|
),
|
||||||
|
"unknown field",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Deno.test("Memory staging parser rejects malformed records and unbounded origins", () => {
|
||||||
|
const malformed = fixture({ kind: "legacy_unknown" });
|
||||||
|
malformed.items[0].record.source_refs[0].entry_range = [1] as unknown as [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
];
|
||||||
|
assertThrows(
|
||||||
|
() => parseMemoryStagingListResponse(malformed),
|
||||||
|
"two-item entry range",
|
||||||
|
);
|
||||||
|
assertThrows(
|
||||||
|
() =>
|
||||||
|
parseMemoryStagingListResponse(
|
||||||
|
fixture({ kind: "worker_input", worker_id: "x".repeat(513) }),
|
||||||
|
),
|
||||||
|
"exceeds the Memory origin limit",
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user