From ed99be3e244740c792dac188e10a1819e7cdc280 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 23 Sep 2026 20:15:23 +0900 Subject: [PATCH] fix: type runtime config and test passkey lifecycle --- Cargo.lock | 78 ++++ crates/server-api/src/lib.rs | 264 ++++++++++- crates/worker-runtime/src/config_bundle.rs | 182 +++++++- crates/workspace-server/Cargo.toml | 1 + crates/workspace-server/src/server.rs | 265 ++++++++++- openapi/server-api.json | 433 +++++++++++++++++- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/workdir-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- 12 files changed, 1210 insertions(+), 25 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 572137e23..6b8e51672 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -255,6 +255,28 @@ dependencies = [ "serde_json", ] +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -4014,6 +4036,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_cbor_2" version = "0.13.0" @@ -4847,6 +4879,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + [[package]] name = "tokio-tungstenite" version = "0.28.0" @@ -5571,6 +5615,39 @@ dependencies = [ "uuid", ] +[[package]] +name = "webauthn-authenticator-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "779e9c80ff248c7e12ea967f909249101f6e86f70fccd742d4b66c490c1710b4" +dependencies = [ + "async-stream", + "async-trait", + "base64 0.21.7", + "base64urlsafedata", + "bitflags 1.3.2", + "futures", + "hex", + "nom 7.1.3", + "num-derive", + "num-traits", + "openssl", + "openssl-sys", + "serde", + "serde_bytes", + "serde_cbor_2", + "serde_json", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "unicode-normalization", + "url", + "uuid", + "webauthn-rs-core", + "webauthn-rs-proto", +] + [[package]] name = "webauthn-rs" version = "0.5.5" @@ -6338,6 +6415,7 @@ dependencies = [ "ts-rs", "url", "uuid", + "webauthn-authenticator-rs", "webauthn-rs", "workdir", "worker", diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index ede36288f..e5f7a9071 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -3157,10 +3157,171 @@ pub struct WorkspaceRuntimeConfigQuery { pub profile: String, } -/// Transparent JSON body for the Runtime-owned config bundle wire format. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, JsonSchema)] -#[serde(transparent)] -pub struct WorkspaceRuntimeConfigResponse(pub serde_json::Value); +/// Canonical Runtime config-bundle response projected by Workspace authority. +/// +/// This is intentionally a concrete transport DTO rather than an opaque JSON value so generated +/// clients and OpenAPI consumers receive the same complete wire contract as the Runtime. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeConfigResponse { + pub metadata: WorkspaceRuntimeConfigMetadata, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub profiles: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub declarations: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt_catalog: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_source_archive: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_source_archive_handle: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeConfigMetadata { + pub id: String, + pub digest: String, + pub revision: String, + pub workspace_id: String, + pub created_at: String, + pub provenance: WorkspaceRuntimeConfigProvenance, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeConfigProvenance { + pub source: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeConfigProfile { + pub selector: WorkspaceRuntimeProfileSelector, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum WorkspaceRuntimeProfileSelector { + Builtin(String), + Named(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeConfigDeclaration { + pub kind: WorkspaceRuntimeConfigDeclarationKind, + pub name: String, + pub reference: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceRuntimeConfigDeclarationKind { + SecretRef, + MountGrant, + NetworkPolicy, + ShellPolicy, + GitPolicy, + CapabilityGrant, + Unsupported, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimePromptCatalog { + pub templates: BTreeMap, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub config_revision: u64, + #[serde(default, skip_serializing_if = "String::is_empty")] + pub source_digest: String, + pub schema_fingerprint: String, + pub toolchain_fingerprint: String, + pub catalog_digest: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeProfileSourceArchive { + pub reference: WorkspaceRuntimeProfileSourceArchiveRef, + pub content: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeProfileSourceArchiveRef { + pub id: String, + pub digest: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub size_bytes: u64, + pub source_graph: WorkspaceRuntimeProfileSourceGraph, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeProfileSourceGraph { + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub source_count: usize, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub total_source_bytes: u64, + pub entrypoints: BTreeMap, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] + pub import_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRuntimeResourceHandle { + pub kind: WorkspaceRuntimeResourceKind, + pub workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub scope_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub runtime_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub worker_id: Option, + pub resource_id: String, + pub digest: String, + pub operation: WorkspaceRuntimeResourceOperation, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_i64))] + pub expires_at_unix_seconds: i64, + pub nonce: String, + pub revision: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub generation: Option, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub max_bytes: u64, + pub content_type: String, + pub redaction: WorkspaceRuntimeResourceRedactionPolicy, + pub audit_correlation_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile_source_graph: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceRuntimeResourceKind { + ProfileSourceArchive, + RepositorySshAccess, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceRuntimeResourceOperation { + FetchArchive, + FetchOnce, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceRuntimeResourceRedactionPolicy { + RuntimeInternalOnly, +} #[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] @@ -8962,6 +9123,89 @@ mod skill_typescript_tests { #[cfg(test)] mod tests { use super::*; + + #[test] + fn runtime_config_response_round_trips_the_complete_config_bundle_shape() { + let fixture = serde_json::json!({ + "metadata": { + "id": "bundle-a", + "digest": "0123456789abcdef", + "revision": "revision-a", + "workspace_id": "workspace-a", + "created_at": "2026-01-01T00:00:00Z", + "provenance": { + "source": "workspace_config", + "detail": "projected" + } + }, + "profiles": [{ + "selector": { "kind": "builtin", "value": "builtin:companion" }, + "label": "Companion" + }], + "declarations": [{ + "kind": "secret_ref", + "name": "github-token", + "reference": "secret:github-token" + }], + "prompt_catalog": { + "templates": { "default": "Prompt" }, + "config_revision": 7, + "source_digest": "source-digest", + "schema_fingerprint": "schema-a", + "toolchain_fingerprint": "toolchain-a", + "catalog_digest": "catalog-a" + }, + "profile_source_archive": { + "reference": { + "id": "archive-a", + "digest": "archive-digest", + "size_bytes": 3, + "source_graph": { + "source_count": 1, + "total_source_bytes": 3, + "entrypoints": { "builtin:companion": "companion.dcdl" }, + "import_count": 0 + } + }, + "content": [1, 2, 3] + }, + "profile_source_archive_handle": { + "kind": "profile_source_archive", + "workspace_id": "workspace-a", + "scope_id": "scope-a", + "runtime_id": "runtime-a", + "worker_id": "worker-a", + "resource_id": "resource-a", + "digest": "resource-digest", + "operation": "fetch_archive", + "expires_at_unix_seconds": 1_800_000_000, + "nonce": "nonce-a", + "revision": "revision-a", + "generation": 2, + "max_bytes": 1_048_576, + "content_type": "application/vnd.yoi.profile-source-archive+tar", + "redaction": "runtime_internal_only", + "audit_correlation_id": "audit-a", + "profile_source_graph": { + "source_count": 1, + "total_source_bytes": 3, + "entrypoints": { "builtin:companion": "companion.dcdl" }, + "import_count": 0 + } + } + }); + let response: WorkspaceRuntimeConfigResponse = + serde_json::from_value(fixture.clone()).unwrap(); + assert_eq!(serde_json::to_value(response).unwrap(), fixture); + + let mut unknown_nested_field = fixture; + unknown_nested_field["metadata"]["runtime_local_path"] = + serde_json::Value::String("/private/path".to_string()); + assert!( + serde_json::from_value::(unknown_nested_field).is_err() + ); + } + #[test] fn worker_session_contract_and_flattened_availability_are_stable() { let operations = ServerApiMetadata::OPERATIONS; @@ -10896,6 +11140,18 @@ mod openapi_artifact_tests { runtime_config["responses"]["200"]["content"]["application/json"]["schema"]["$ref"], "#/components/schemas/WorkspaceRuntimeConfigResponse" ); + let runtime_config_schema = + &value["components"]["schemas"]["WorkspaceRuntimeConfigResponse"]; + assert_eq!(runtime_config_schema["type"], "object"); + assert_eq!(runtime_config_schema["additionalProperties"], false); + assert!(runtime_config_schema["properties"]["metadata"].is_object()); + assert!(runtime_config_schema["properties"]["profiles"].is_object()); + assert!(runtime_config_schema["properties"]["declarations"].is_object()); + assert!( + value["components"]["schemas"]["WorkspaceRuntimeConfigMetadata"]["properties"] + ["digest"] + .is_object() + ); assert!(runtime_config["responses"]["304"].get("content").is_none()); for status in ["200", "304"] { assert!(runtime_config["responses"][status]["headers"]["etag"].is_object()); diff --git a/crates/worker-runtime/src/config_bundle.rs b/crates/worker-runtime/src/config_bundle.rs index a57af4e34..dd2b227d5 100644 --- a/crates/worker-runtime/src/config_bundle.rs +++ b/crates/worker-runtime/src/config_bundle.rs @@ -1,10 +1,13 @@ use crate::catalog::{ConfigBundleRef, ProfileSelector}; use crate::error::RuntimeError; use crate::profile_archive::{ - ProfileArchiveError, ProfileSourceArchive, ProfileSourceArchiveRef, + ProfileArchiveError, ProfileSourceArchive, ProfileSourceArchiveRef, ProfileSourceGraphSummary, VerifiedProfileSourceArchive, }; -use crate::resource::{BackendResourceHandle, validate_resource_handle_text}; +use crate::resource::{ + BackendResourceHandle, BackendResourceKind, BackendResourceOperation, ResourceRedactionPolicy, + validate_resource_handle_text, +}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -162,6 +165,181 @@ impl ConfigBundle { } } +impl From for server_api::WorkspaceRuntimeConfigResponse { + fn from(bundle: ConfigBundle) -> Self { + let ConfigBundle { + metadata, + profiles, + declarations, + prompt_catalog, + profile_source_archive, + profile_source_archive_handle, + } = bundle; + Self { + metadata: metadata.into(), + profiles: profiles.into_iter().map(Into::into).collect(), + declarations: declarations.into_iter().map(Into::into).collect(), + prompt_catalog: prompt_catalog.map(|catalog| { + server_api::WorkspaceRuntimePromptCatalog { + templates: catalog.templates, + config_revision: catalog.config_revision, + source_digest: catalog.source_digest, + schema_fingerprint: catalog.schema_fingerprint, + toolchain_fingerprint: catalog.toolchain_fingerprint, + catalog_digest: catalog.catalog_digest, + } + }), + profile_source_archive: profile_source_archive.map(Into::into), + profile_source_archive_handle: profile_source_archive_handle.map(Into::into), + } + } +} + +impl From for server_api::WorkspaceRuntimeConfigMetadata { + fn from(metadata: ConfigBundleMetadata) -> Self { + Self { + id: metadata.id, + digest: metadata.digest, + revision: metadata.revision, + workspace_id: metadata.workspace_id, + created_at: metadata.created_at, + provenance: metadata.provenance.into(), + } + } +} + +impl From for server_api::WorkspaceRuntimeConfigProvenance { + fn from(provenance: ConfigBundleProvenance) -> Self { + Self { + source: provenance.source, + detail: provenance.detail, + } + } +} + +impl From for server_api::WorkspaceRuntimeConfigProfile { + fn from(profile: ConfigProfileDescriptor) -> Self { + Self { + selector: profile.selector.into(), + label: profile.label, + } + } +} + +impl From for server_api::WorkspaceRuntimeProfileSelector { + fn from(selector: ProfileSelector) -> Self { + match selector { + ProfileSelector::Builtin(value) => Self::Builtin(value), + ProfileSelector::Named(value) => Self::Named(value), + } + } +} + +impl From for server_api::WorkspaceRuntimeConfigDeclaration { + fn from(declaration: ConfigDeclaration) -> Self { + Self { + kind: declaration.kind.into(), + name: declaration.name, + reference: declaration.reference, + } + } +} + +impl From for server_api::WorkspaceRuntimeConfigDeclarationKind { + fn from(kind: ConfigDeclarationKind) -> Self { + match kind { + ConfigDeclarationKind::SecretRef => Self::SecretRef, + ConfigDeclarationKind::MountGrant => Self::MountGrant, + ConfigDeclarationKind::NetworkPolicy => Self::NetworkPolicy, + ConfigDeclarationKind::ShellPolicy => Self::ShellPolicy, + ConfigDeclarationKind::GitPolicy => Self::GitPolicy, + ConfigDeclarationKind::CapabilityGrant => Self::CapabilityGrant, + ConfigDeclarationKind::Unsupported => Self::Unsupported, + } + } +} + +impl From for server_api::WorkspaceRuntimeProfileSourceArchive { + fn from(archive: ProfileSourceArchive) -> Self { + Self { + reference: archive.reference.into(), + content: archive.content, + } + } +} + +impl From for server_api::WorkspaceRuntimeProfileSourceArchiveRef { + fn from(reference: ProfileSourceArchiveRef) -> Self { + Self { + id: reference.id, + digest: reference.digest, + size_bytes: reference.size_bytes, + source_graph: reference.source_graph.into(), + } + } +} + +impl From for server_api::WorkspaceRuntimeProfileSourceGraph { + fn from(summary: ProfileSourceGraphSummary) -> Self { + Self { + source_count: summary.source_count, + total_source_bytes: summary.total_source_bytes, + entrypoints: summary.entrypoints, + import_count: summary.import_count, + } + } +} + +impl From for server_api::WorkspaceRuntimeResourceHandle { + fn from(handle: BackendResourceHandle) -> Self { + Self { + kind: handle.kind.into(), + workspace_id: handle.workspace_id, + scope_id: handle.scope_id, + runtime_id: handle.runtime_id, + worker_id: handle.worker_id, + resource_id: handle.resource_id, + digest: handle.digest, + operation: handle.operation.into(), + expires_at_unix_seconds: handle.expires_at_unix_seconds, + nonce: handle.nonce, + revision: handle.revision, + generation: handle.generation, + max_bytes: handle.max_bytes, + content_type: handle.content_type, + redaction: handle.redaction.into(), + audit_correlation_id: handle.audit_correlation_id, + profile_source_graph: handle.profile_source_graph.map(Into::into), + } + } +} + +impl From for server_api::WorkspaceRuntimeResourceKind { + fn from(kind: BackendResourceKind) -> Self { + match kind { + BackendResourceKind::ProfileSourceArchive => Self::ProfileSourceArchive, + BackendResourceKind::RepositorySshAccess => Self::RepositorySshAccess, + } + } +} + +impl From for server_api::WorkspaceRuntimeResourceOperation { + fn from(operation: BackendResourceOperation) -> Self { + match operation { + BackendResourceOperation::FetchArchive => Self::FetchArchive, + BackendResourceOperation::FetchOnce => Self::FetchOnce, + } + } +} + +impl From for server_api::WorkspaceRuntimeResourceRedactionPolicy { + fn from(policy: ResourceRedactionPolicy) -> Self { + match policy { + ResourceRedactionPolicy::RuntimeInternalOnly => Self::RuntimeInternalOnly, + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct ConfigBundleMetadata { pub id: String, diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index b3a176f2b..2438e67c8 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -53,3 +53,4 @@ webauthn-rs = { workspace = true } tempfile.workspace = true tower = { workspace = true, features = ["util"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index b8a2af3a3..eff017198 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -14855,15 +14855,7 @@ fn workspace_runtime_config_result( }, ); } - let body = serde_json::to_value(bundle) - .map(server_api::WorkspaceRuntimeConfigResponse) - .map_err(|error| { - ApiError::from(invalid_contract_value( - "Workspace Runtime Config response", - error, - )) - .into_repository_api_error() - })?; + let body = server_api::WorkspaceRuntimeConfigResponse::from(bundle); Ok( server_api::server_api_responses::WorkspaceRuntimeConfig::Status200 { body, @@ -31612,6 +31604,255 @@ mod tests { assert_ne!(same_origin.status(), StatusCode::FORBIDDEN); } + #[tokio::test] + async fn generated_passkey_routes_complete_registration_login_and_logout_cookie_lifecycle() { + use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; + + let workspace = tempfile::tempdir().unwrap(); + let mut api = test_api(workspace.path()).await; + let AuthConfig::Passkey { + rp_id, + origin, + public_base_url, + cookie_name, + } = &mut api.config.auth; + *rp_id = "workspace.test".to_string(); + *origin = "https://workspace.test".to_string(); + *public_base_url = origin.clone(); + let origin = origin.clone(); + let cookie_name = cookie_name.clone(); + let app = generated_auth_contract_router(ServerApiContractService::Workspace(api)); + let mut authenticator = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let authenticator_origin = url::Url::parse(&origin).unwrap(); + + let registration_options_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/passkeys/registration/options") + .header(ORIGIN, &origin) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&PasskeyRegistrationOptionsRequest { + handle: "generated-passkey-user".to_string(), + display_name: Some("Generated Passkey User".to_string()), + browser_origin: None, + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(registration_options_response.status(), StatusCode::OK); + let registration_options: PasskeyRegistrationOptionsResponse = serde_json::from_slice( + &to_bytes(registration_options_response.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + let registration_credential = authenticator + .do_registration( + authenticator_origin.clone(), + registration_options.public_key, + ) + .unwrap(); + + let registration_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/passkeys/registration/complete") + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&PasskeyRegistrationCompleteRequest { + challenge_id: registration_options.challenge_id, + credential: registration_credential, + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(registration_response.status(), StatusCode::OK); + let registration_cookie = registration_response + .headers() + .get(axum::http::header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert!(registration_cookie.starts_with(&format!("{cookie_name}="))); + assert!(registration_cookie.contains("; Secure")); + assert!(registration_cookie.contains("; HttpOnly; SameSite=Lax")); + let registration_body: AuthUserResponse = serde_json::from_slice( + &to_bytes(registration_response.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(registration_body.user.handle, "generated-passkey-user"); + let registration_cookie_pair = registration_cookie.split(';').next().unwrap().to_string(); + + let registered_whoami = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/whoami") + .header(axum::http::header::COOKIE, ®istration_cookie_pair) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(registered_whoami.status(), StatusCode::OK); + let registered_whoami: Value = serde_json::from_slice( + &to_bytes(registered_whoami.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!( + registered_whoami["actor"]["handle"], + "generated-passkey-user" + ); + + let login_options_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/passkeys/login/options") + .header(ORIGIN, &origin) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&PasskeyLoginOptionsRequest { + handle: Some("generated-passkey-user".to_string()), + browser_origin: None, + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(login_options_response.status(), StatusCode::OK); + let login_options: PasskeyLoginOptionsResponse = serde_json::from_slice( + &to_bytes(login_options_response.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + let login_credential = authenticator + .do_authentication(authenticator_origin, login_options.public_key) + .unwrap(); + + let login_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/passkeys/login/complete") + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&PasskeyLoginCompleteRequest { + challenge_id: login_options.challenge_id, + credential: login_credential, + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(login_response.status(), StatusCode::OK); + let login_cookie = login_response + .headers() + .get(axum::http::header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap() + .to_string(); + assert!(login_cookie.starts_with(&format!("{cookie_name}="))); + assert!(login_cookie.contains("; Secure")); + assert!(login_cookie.contains("; HttpOnly; SameSite=Lax")); + let login_body: AuthUserResponse = serde_json::from_slice( + &to_bytes(login_response.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(login_body.user, registration_body.user); + let login_cookie_pair = login_cookie.split(';').next().unwrap().to_string(); + + let logged_in_whoami = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/whoami") + .header(axum::http::header::COOKIE, &login_cookie_pair) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(logged_in_whoami.status(), StatusCode::OK); + let logged_in_whoami: Value = serde_json::from_slice( + &to_bytes(logged_in_whoami.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!( + logged_in_whoami["actor"]["handle"], + "generated-passkey-user" + ); + + let logout_response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/logout") + .header(axum::http::header::COOKIE, &login_cookie_pair) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(logout_response.status(), StatusCode::OK); + let logout_cookie = logout_response + .headers() + .get(axum::http::header::SET_COOKIE) + .unwrap() + .to_str() + .unwrap(); + assert!(logout_cookie.starts_with(&format!("{cookie_name}="))); + assert!(logout_cookie.contains("Max-Age=0")); + + let logged_out_whoami = app + .oneshot( + Request::builder() + .uri("/api/auth/whoami") + .header(axum::http::header::COOKIE, login_cookie_pair) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(logged_out_whoami.status(), StatusCode::OK); + let logged_out_whoami: Value = serde_json::from_slice( + &to_bytes(logged_out_whoami.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert!(logged_out_whoami["actor"].is_null()); + } + #[tokio::test] async fn browser_session_set_and_clear_cookies_follow_public_https_scheme() { for (scheme, secure) in [("http", false), ("https", true)] { @@ -37723,9 +37964,11 @@ mod tests { assert_eq!(response.status(), StatusCode::OK); let etag = response.headers().get(ETAG).unwrap().clone(); assert_eq!(response.headers().get(CACHE_CONTROL).unwrap(), "no-cache"); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let contract_bundle: server_api::WorkspaceRuntimeConfigResponse = + serde_json::from_slice(&body).unwrap(); let bundle: worker_runtime::config_bundle::ConfigBundle = - serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) - .unwrap(); + serde_json::from_value(serde_json::to_value(contract_bundle).unwrap()).unwrap(); assert!(bundle.profile_source_archive.is_some()); assert_eq!( etag.to_str().unwrap(), diff --git a/openapi/server-api.json b/openapi/server-api.json index ade0cd4ac..3c66a0a8a 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -13128,6 +13128,106 @@ ], "type": "object" }, + "WorkspaceRuntimeConfigDeclaration": { + "additionalProperties": false, + "properties": { + "kind": { + "$ref": "#/components/schemas/WorkspaceRuntimeConfigDeclarationKind" + }, + "name": { + "type": "string" + }, + "reference": { + "type": "string" + } + }, + "required": [ + "kind", + "name", + "reference" + ], + "type": "object" + }, + "WorkspaceRuntimeConfigDeclarationKind": { + "enum": [ + "secret_ref", + "mount_grant", + "network_policy", + "shell_policy", + "git_policy", + "capability_grant", + "unsupported" + ], + "type": "string" + }, + "WorkspaceRuntimeConfigMetadata": { + "additionalProperties": false, + "properties": { + "created_at": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "id": { + "type": "string" + }, + "provenance": { + "$ref": "#/components/schemas/WorkspaceRuntimeConfigProvenance" + }, + "revision": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "id", + "digest", + "revision", + "workspace_id", + "created_at", + "provenance" + ], + "type": "object" + }, + "WorkspaceRuntimeConfigProfile": { + "additionalProperties": false, + "properties": { + "label": { + "type": [ + "string", + "null" + ] + }, + "selector": { + "$ref": "#/components/schemas/WorkspaceRuntimeProfileSelector" + } + }, + "required": [ + "selector" + ], + "type": "object" + }, + "WorkspaceRuntimeConfigProvenance": { + "additionalProperties": false, + "properties": { + "detail": { + "type": [ + "string", + "null" + ] + }, + "source": { + "type": "string" + } + }, + "required": [ + "source" + ], + "type": "object" + }, "WorkspaceRuntimeConfigQuery": { "additionalProperties": false, "properties": { @@ -13141,7 +13241,59 @@ "type": "object" }, "WorkspaceRuntimeConfigResponse": { - "description": "Transparent JSON body for the Runtime-owned config bundle wire format." + "additionalProperties": false, + "description": "Canonical Runtime config-bundle response projected by Workspace authority.\n\nThis is intentionally a concrete transport DTO rather than an opaque JSON value so generated\nclients and OpenAPI consumers receive the same complete wire contract as the Runtime.", + "properties": { + "declarations": { + "items": { + "$ref": "#/components/schemas/WorkspaceRuntimeConfigDeclaration" + }, + "type": "array" + }, + "metadata": { + "$ref": "#/components/schemas/WorkspaceRuntimeConfigMetadata" + }, + "profile_source_archive": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceRuntimeProfileSourceArchive" + }, + { + "type": "null" + } + ] + }, + "profile_source_archive_handle": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceRuntimeResourceHandle" + }, + { + "type": "null" + } + ] + }, + "profiles": { + "items": { + "$ref": "#/components/schemas/WorkspaceRuntimeConfigProfile" + }, + "type": "array" + }, + "prompt_catalog": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceRuntimePromptCatalog" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "metadata" + ], + "type": "object" }, "WorkspaceRuntimeDetail": { "additionalProperties": false, @@ -13213,6 +13365,164 @@ ], "type": "object" }, + "WorkspaceRuntimeProfileSelector": { + "oneOf": [ + { + "properties": { + "kind": { + "const": "builtin", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + }, + { + "properties": { + "kind": { + "const": "named", + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "kind", + "value" + ], + "type": "object" + } + ] + }, + "WorkspaceRuntimeProfileSourceArchive": { + "additionalProperties": false, + "properties": { + "content": { + "items": { + "format": "uint8", + "maximum": 255, + "minimum": 0, + "type": "integer" + }, + "type": "array" + }, + "reference": { + "$ref": "#/components/schemas/WorkspaceRuntimeProfileSourceArchiveRef" + } + }, + "required": [ + "reference", + "content" + ], + "type": "object" + }, + "WorkspaceRuntimeProfileSourceArchiveRef": { + "additionalProperties": false, + "properties": { + "digest": { + "type": "string" + }, + "id": { + "type": "string" + }, + "size_bytes": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "source_graph": { + "$ref": "#/components/schemas/WorkspaceRuntimeProfileSourceGraph" + } + }, + "required": [ + "id", + "digest", + "size_bytes", + "source_graph" + ], + "type": "object" + }, + "WorkspaceRuntimeProfileSourceGraph": { + "additionalProperties": false, + "properties": { + "entrypoints": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "import_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "source_count": { + "format": "uint", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "total_source_bytes": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "source_count", + "total_source_bytes", + "entrypoints", + "import_count" + ], + "type": "object" + }, + "WorkspaceRuntimePromptCatalog": { + "additionalProperties": false, + "properties": { + "catalog_digest": { + "type": "string" + }, + "config_revision": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "schema_fingerprint": { + "type": "string" + }, + "source_digest": { + "type": "string" + }, + "templates": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "toolchain_fingerprint": { + "type": "string" + } + }, + "required": [ + "templates", + "config_revision", + "schema_fingerprint", + "toolchain_fingerprint", + "catalog_digest" + ], + "type": "object" + }, "WorkspaceRuntimeResource": { "properties": { "arch": { @@ -13270,6 +13580,125 @@ ], "type": "object" }, + "WorkspaceRuntimeResourceHandle": { + "additionalProperties": false, + "properties": { + "audit_correlation_id": { + "type": "string" + }, + "content_type": { + "type": "string" + }, + "digest": { + "type": "string" + }, + "expires_at_unix_seconds": { + "format": "int64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "generation": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "kind": { + "$ref": "#/components/schemas/WorkspaceRuntimeResourceKind" + }, + "max_bytes": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "nonce": { + "type": "string" + }, + "operation": { + "$ref": "#/components/schemas/WorkspaceRuntimeResourceOperation" + }, + "profile_source_graph": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceRuntimeProfileSourceGraph" + }, + { + "type": "null" + } + ] + }, + "redaction": { + "$ref": "#/components/schemas/WorkspaceRuntimeResourceRedactionPolicy" + }, + "resource_id": { + "type": "string" + }, + "revision": { + "type": "string" + }, + "runtime_id": { + "type": [ + "string", + "null" + ] + }, + "scope_id": { + "type": [ + "string", + "null" + ] + }, + "worker_id": { + "type": [ + "string", + "null" + ] + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id", + "resource_id", + "digest", + "operation", + "expires_at_unix_seconds", + "nonce", + "revision", + "max_bytes", + "content_type", + "redaction", + "audit_correlation_id" + ], + "type": "object" + }, + "WorkspaceRuntimeResourceKind": { + "enum": [ + "profile_source_archive", + "repository_ssh_access" + ], + "type": "string" + }, + "WorkspaceRuntimeResourceOperation": { + "enum": [ + "fetch_archive", + "fetch_once" + ], + "type": "string" + }, + "WorkspaceRuntimeResourceRedactionPolicy": { + "enum": [ + "runtime_internal_only" + ], + "type": "string" + }, "WorkspaceSigningIdentityPublic": { "additionalProperties": false, "description": "Public metadata for a Workspace signing identity. Private material and its\nstorage reference are deliberately not part of this wire authority.", @@ -13464,7 +13893,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21" + "x-yoi-source-digest": "sha256:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f" }, "openapi": "3.1.0", "paths": { diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index b9066d83f..f32f2c93d 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21) +// Input contract: 0.1.0 (sha256:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index c02f0822b..85db578b9 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21) +// Input contract: 0.1.0 (sha256:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 810391909..f9d1294a9 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21) +// Input contract: 0.1.0 (sha256:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index f253a7a98..d62132ce1 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21) +// Input contract: 0.1.0 (sha256:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index 0a9fa22ff..f828a9ecf 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21) +// Input contract: 0.1.0 (sha256:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 3728989a6..ce6a000c1 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:c76b20e52333efb49a28f9db63d711561bfdc2871e98fd3cbf00d364f2992c21) +// Input contract: 0.1.0 (sha256:2463eb461342aceb84c87e7aa6bdcf73e28e3866349a555bd4a0d795db671a3f) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check