From a90d5649314909b1327a5ebcfc43bba946615770 Mon Sep 17 00:00:00 2001 From: elky Date: Mon, 7 Sep 2026 21:14:27 +0800 Subject: [PATCH] fix: restore security hardening compatibility and validation Restore authorized rule reveal, explicit full HTTP capture and retention, video task business fields, and valid payment URLs. Add opt-in credential preservation for trusted recovery, fix frontend type contracts and async races, and eliminate PostgreSQL test fixture resource leaks. Document audit coverage and successful fmt and CI-scoped Clippy checks. --- .env.example | 6 +- .../control/management_token_permissions.rs | 7 + .../control/route/admin/endpoints_families.rs | 13 + .../src/control/tests/admin_endpoints.rs | 22 + .../src/data/state/integrations.rs | 40 +- .../execution_runtime/attempt_cancellation.rs | 12 +- .../src/execution_runtime/stream/execution.rs | 14 +- .../admin/provider/endpoints_admin/mod.rs | 5 + .../admin/provider/endpoints_admin/reveal.rs | 72 ++ apps/aether-gateway/src/main.rs | 67 +- .../src/maintenance/runtime/config.rs | 11 +- .../src/maintenance/runtime/tests.rs | 41 +- apps/aether-gateway/src/tests/async_task.rs | 21 +- .../src/tests/control/admin/endpoints.rs | 1 + .../control/admin/endpoints/rules_reveal.rs | 151 +++ .../src/tests/control/admin/video_tasks.rs | 75 +- apps/aether-gateway/src/tests/usage/local.rs | 282 ++++- .../src/tests/video/registry_poller.rs | 7 + apps/aether-gateway/src/tests/video/stream.rs | 10 +- crates/aether-admin/src/provider/endpoints.rs | 69 +- crates/aether-admin/src/provider/redaction.rs | 523 +++++++-- .../adapters/postgres/src/usage/cleanup.rs | 862 ++++++++++++-- .../adapters/postgres/src/usage/mod.rs | 98 +- .../adapters/postgres/src/usage/tests.rs | 222 +++- .../adapters/postgres/src/video_tasks.rs | 148 ++- .../contracts/src/repository/usage/policy.rs | 176 ++- .../src/repository/video_tasks/types.rs | 84 +- .../runtime/src/lifecycle/backfill/tests.rs | 197 +--- .../runtime/src/lifecycle/export.rs | 44 +- .../runtime/src/lifecycle/export/postgres.rs | 35 +- .../runtime/src/lifecycle/export/tests.rs | 164 ++- .../runtime/src/lifecycle/migrate/tests.rs | 223 +--- .../aether-data/runtime/src/lifecycle/mod.rs | 2 + .../src/lifecycle/postgres_test_support.rs | 276 +++++ .../runtime/src/repository/usage/memory.rs | 165 ++- .../src/repository/usage/memory/tests.rs | 70 ++ .../src/provider/providers/generic.rs | 126 +- crates/aether-testing/testkit/src/postgres.rs | 127 +- crates/aether-usage/runtime/src/write.rs | 39 +- crates/aether-video-tasks-core/src/gemini.rs | 65 +- crates/aether-video-tasks-core/src/openai.rs | 32 +- .../aether-video-tasks-core/src/snapshot.rs | 6 +- .../src/store_backend.rs | 43 + .../aether-video-tasks-core/src/transport.rs | 24 +- .../src/transport_domain.rs | 13 +- .../security-hardening-audit-manifest.tsv | 1020 +++++++++++++++++ ...security-hardening-compatibility-review.md | 98 ++ .../security-hardening-full-audit.md | 134 +++ frontend/package.json | 2 +- frontend/src/api/__tests__/client.spec.ts | 38 +- .../src/api/__tests__/endpoint-rules.spec.ts | 26 + frontend/src/api/admin-wallets.ts | 32 +- frontend/src/api/admin.ts | 9 +- frontend/src/api/announcements.ts | 20 +- frontend/src/api/async-tasks.ts | 12 +- frontend/src/api/audit.ts | 14 +- frontend/src/api/cache.ts | 36 +- frontend/src/api/client.ts | 2 +- frontend/src/api/dashboard.ts | 16 +- frontend/src/api/endpoints/adaptive.ts | 20 +- frontend/src/api/endpoints/endpoints.ts | 26 +- frontend/src/api/endpoints/global-models.ts | 27 +- frontend/src/api/endpoints/health.ts | 40 +- frontend/src/api/endpoints/keys.ts | 32 +- frontend/src/api/endpoints/models.ts | 30 +- frontend/src/api/endpoints/pool.ts | 7 +- frontend/src/api/endpoints/provider_oauth.ts | 22 +- frontend/src/api/endpoints/providers.ts | 9 +- frontend/src/api/endpoints/types/model.ts | 34 +- frontend/src/api/gemini-files.ts | 12 +- frontend/src/api/me.ts | 102 +- frontend/src/api/providerCredentials.ts | 64 ++ frontend/src/api/providerOps.ts | 5 +- frontend/src/api/public-models.ts | 2 +- frontend/src/api/referrals.ts | 8 +- frontend/src/api/requestTrace.ts | 42 + frontend/src/api/security.ts | 6 +- frontend/src/api/usage.ts | 67 +- frontend/src/api/usageRecords.ts | 69 ++ frontend/src/api/users.ts | 2 +- frontend/src/api/wallet.ts | 21 +- .../src/components/charts/ScatterChart.vue | 26 +- frontend/src/components/charts/types.ts | 10 + .../src/components/common/JsonImportInput.vue | 4 +- .../common/__tests__/JsonImportInput.spec.ts | 6 +- .../src/components/stats/PercentileChart.vue | 2 +- frontend/src/components/ui/button.vue | 3 +- frontend/src/components/ui/input.vue | 5 +- frontend/src/components/ui/label.vue | 3 +- frontend/src/components/ui/select-trigger.vue | 3 +- frontend/src/components/ui/select.vue | 4 +- .../src/components/ui/sortable-table-head.vue | 3 +- frontend/src/components/ui/table-cell.vue | 3 +- frontend/src/components/ui/table-row.vue | 3 +- frontend/src/components/ui/table.vue | 3 +- frontend/src/components/ui/tabs-list.vue | 5 +- frontend/src/composables/useEscapeKey.ts | 2 +- frontend/src/composables/useTTLAnalysis.ts | 4 +- .../auth/components/TurnstileWidget.vue | 3 +- .../components/GlobalModelFormDialog.vue | 2 +- .../models/components/ModelDetailDrawer.vue | 8 +- .../ProcessingTierPricingSummary.vue | 4 +- .../models/components/TieredPricingEditor.vue | 6 +- .../components/PoolAccountBatchDialog.vue | 2 +- .../pool/components/PoolAdvancedDialog.vue | 2 +- .../components/PoolDemandMetricsDialog.vue | 2 +- .../pool/components/PoolKeyStatsPanel.vue | 2 +- .../pool/components/PoolManagementHeader.vue | 15 +- .../pool/components/PoolSchedulingDialog.vue | 2 +- .../__tests__/poolManagementState.spec.ts | 9 +- .../features/pool/utils/poolQuotaRefresh.ts | 3 +- .../providers/auth-templates/schema-utils.ts | 66 +- .../providers/auth-templates/types.ts | 4 +- .../components/EndpointFormDialog.vue | 43 +- .../components/EndpointRulesRevealDialog.vue | 89 ++ .../components/HealthMonitorDetailDrawer.vue | 10 +- .../components/OAuthAccountDialog.vue | 8 +- .../components/ProviderAuthDialog.vue | 34 +- .../components/ProviderDetailDrawer.vue | 27 +- .../ProviderKeyBatchImportDialog.vue | 4 +- .../ProviderKeyImportSettingsFields.vue | 6 +- .../__tests__/EndpointFormDialog.save.spec.ts | 146 +++ .../EndpointRulesRevealDialog.spec.ts | 128 +++ .../__tests__/ModelMappingDialog.spec.ts | 50 +- .../ModelMappingTab.contract.spec.ts | 42 +- .../OAuthAccountDialog.grok-import.spec.ts | 4 +- .../ProviderKeyActionCluster.spec.ts | 2 +- .../ProviderKeyIdentityBlock.spec.ts | 2 +- .../components/codex-reset-credit-display.ts | 2 +- .../provider-tabs/ModelAliasesTab.vue | 3 +- .../components/provider-tabs/ModelsTab.vue | 2 +- .../__tests__/useProviderBalance.spec.ts | 103 ++ .../composables/useProviderBalance.ts | 32 +- .../composables/useUpstreamModelsCache.ts | 5 +- .../routing/__tests__/routingPolicy.spec.ts | 2 +- .../RoutingPriorityPolicyEditor.vue | 2 +- .../__tests__/token-normalization.spec.ts | 5 +- .../components/HorizontalRequestTimeline.vue | 11 +- .../usage/components/IntervalTimelineCard.vue | 5 +- .../usage/components/RequestDetailDrawer.vue | 8 +- .../RequestDetailDrawer/JsonContent.vue | 4 +- .../RequestHeadersContent.vue | 4 +- .../usage/components/ServerUserSelector.vue | 2 +- .../HorizontalRequestTimeline.spec.ts | 2 +- .../RequestDetailDrawer.body-capture.spec.ts | 130 +++ .../usage/composables/useUsageData.ts | 10 +- frontend/src/features/usage/types.ts | 70 +- .../utils/__tests__/service-tier.spec.ts | 4 +- .../users/components/UserBatchQuotaPanel.vue | 2 +- .../users/components/UserBatchRolePanel.vue | 2 +- .../users/components/UserFilterControls.vue | 23 +- .../users/components/UserFormDialog.vue | 6 +- frontend/src/i18n/legacy-ui-messages.ts | 7 + .../main-layout/__tests__/navigation.spec.ts | 40 + .../__tests__/usage-detail-contract.spec.ts | 2 +- .../user-management-contract.spec.ts | 9 +- frontend/src/mocks/data.ts | 28 + frontend/src/mocks/handler.ts | 14 +- frontend/src/router/index.ts | 2 +- frontend/src/stores/__tests__/auth.spec.ts | 49 +- frontend/src/stores/auth.ts | 12 +- frontend/src/utils/__tests__/cache.spec.ts | 11 + .../utils/__tests__/crossTabRefresh.spec.ts | 4 +- .../src/utils/__tests__/nearViewport.spec.ts | 29 +- .../src/utils/__tests__/paymentUrl.spec.ts | 10 +- .../utils/__tests__/providerKeyQuota.spec.ts | 19 + .../utils/__tests__/providerKeyStatus.spec.ts | 7 +- frontend/src/utils/cache.ts | 15 +- frontend/src/utils/crossTabRefresh.ts | 2 +- frontend/src/utils/form.ts | 8 + frontend/src/utils/paymentUrl.ts | 2 +- frontend/src/utils/walletDisplay.ts | 8 +- .../views/admin/AdminOperationsDashboard.vue | 4 +- frontend/src/views/admin/ApiKeys.vue | 1 + frontend/src/views/admin/ModelManagement.vue | 8 +- frontend/src/views/admin/PoolManagement.vue | 9 +- frontend/src/views/admin/RoutingProfiles.vue | 8 +- frontend/src/views/admin/UserStats.vue | 2 +- frontend/src/views/admin/Users.vue | 8 +- .../PoolManagement.codex-cycle-stats.spec.ts | 19 +- .../modelDirectivesConfig.ts | 14 +- .../system-settings/AggregateImportDialog.vue | 2 +- .../system-settings/CleanupPolicySection.vue | 2 +- .../system-settings/ConfigImportDialog.vue | 2 +- .../ManualCleanupConfirmDialog.vue | 4 +- .../system-settings/UsersImportDialog.vue | 2 +- .../__tests__/useSystemConfig.spec.ts | 4 +- .../composables/useConfigExportImport.ts | 2 +- .../composables/useSystemConfig.ts | 2 +- frontend/src/views/public/home-config.ts | 2 +- frontend/src/views/user/WalletCenter.vue | 6 +- 191 files changed, 6785 insertions(+), 1643 deletions(-) create mode 100644 apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/reveal.rs create mode 100644 apps/aether-gateway/src/tests/control/admin/endpoints/rules_reveal.rs create mode 100644 crates/aether-data/runtime/src/lifecycle/postgres_test_support.rs create mode 100644 docs/operations/security-hardening-audit-manifest.tsv create mode 100644 docs/operations/security-hardening-compatibility-review.md create mode 100644 docs/operations/security-hardening-full-audit.md create mode 100644 frontend/src/api/__tests__/endpoint-rules.spec.ts create mode 100644 frontend/src/api/providerCredentials.ts create mode 100644 frontend/src/api/usageRecords.ts create mode 100644 frontend/src/components/charts/types.ts create mode 100644 frontend/src/features/providers/components/EndpointRulesRevealDialog.vue create mode 100644 frontend/src/features/providers/components/__tests__/EndpointFormDialog.save.spec.ts create mode 100644 frontend/src/features/providers/components/__tests__/EndpointRulesRevealDialog.spec.ts create mode 100644 frontend/src/features/providers/composables/__tests__/useProviderBalance.spec.ts create mode 100644 frontend/src/features/usage/components/__tests__/RequestDetailDrawer.body-capture.spec.ts diff --git a/.env.example b/.env.example index 2e5fa5e4c..171e8aa32 100644 --- a/.env.example +++ b/.env.example @@ -115,9 +115,9 @@ ADMIN_USERNAME=admin123456 # Fake-IP 域名及内网 DNS。仅信任管理员配置的上游;没有严格 DNS 过滤开关。 # URL 协议、字面 IP、TLS 证书,以及隧道中继和登录 OAuth 的校验仍保留。 -# 可选 Provider OAuth 客户端。Gemini CLI 授权及刷新必须配置 client secret。 -# Antigravity 默认使用内置 native-app 客户端凭据;自定义 client ID 时必须同时配置 -# 对应的 client secret。未配置 client ID 时使用内置的公开 native-app client ID。 +# 可选 Provider OAuth 客户端。Gemini CLI 和 Antigravity 默认使用内置 native-app +# 客户端凭据;自定义 client ID 时必须同时配置对应的 client secret。 +# 显式配置的 client secret 优先于默认值。 # AETHER_GEMINI_CLI_OAUTH_CLIENT_ID= # AETHER_GEMINI_CLI_OAUTH_CLIENT_SECRET= # AETHER_ANTIGRAVITY_OAUTH_CLIENT_ID= diff --git a/apps/aether-gateway/src/control/management_token_permissions.rs b/apps/aether-gateway/src/control/management_token_permissions.rs index 9052f680c..fef75e8a9 100644 --- a/apps/aether-gateway/src/control/management_token_permissions.rs +++ b/apps/aether-gateway/src/control/management_token_permissions.rs @@ -496,6 +496,7 @@ fn access_for_route(method: &http::Method, decision: &GatewayControlDecision) -> Some("admin:endpoints_manage"), Some( "reveal_key" + | "reveal_endpoint_rules" | "export_key" | "create_provider_key" | "update_key" @@ -1490,6 +1491,12 @@ mod tests { fn plaintext_credential_reads_require_admin_permission() { let read_only_permissions = read_only_management_token_permissions(); let cases = [ + ( + "admin:endpoints_manage", + "reveal_endpoint_rules", + None, + "admin:endpoints_manage:admin", + ), ( "admin:endpoints_manage", "reveal_key", diff --git a/apps/aether-gateway/src/control/route/admin/endpoints_families.rs b/apps/aether-gateway/src/control/route/admin/endpoints_families.rs index 842b6c122..86899388d 100644 --- a/apps/aether-gateway/src/control/route/admin/endpoints_families.rs +++ b/apps/aether-gateway/src/control/route/admin/endpoints_families.rs @@ -302,6 +302,19 @@ pub(super) fn classify_admin_endpoints_family_route( "admin:endpoints_manage", false, )) + } else if method == http::Method::GET + && normalized_path + .strip_prefix("/api/admin/endpoints/") + .and_then(|path| path.strip_suffix("/rules/reveal")) + .is_some_and(|endpoint_id| !endpoint_id.is_empty() && !endpoint_id.contains('/')) + { + Some(classified( + "admin_proxy", + "endpoints_manage", + "reveal_endpoint_rules", + "admin:endpoints_manage", + false, + )) } else if method == http::Method::GET && normalized_path.starts_with("/api/admin/endpoints/") && !normalized_path.starts_with("/api/admin/endpoints/health/") diff --git a/apps/aether-gateway/src/control/tests/admin_endpoints.rs b/apps/aether-gateway/src/control/tests/admin_endpoints.rs index 9594c7221..4492f1e24 100644 --- a/apps/aether-gateway/src/control/tests/admin_endpoints.rs +++ b/apps/aether-gateway/src/control/tests/admin_endpoints.rs @@ -381,6 +381,28 @@ fn classifies_admin_get_endpoint_as_admin_proxy_route() { assert!(!decision.is_execution_runtime_candidate()); } +#[test] +fn classifies_admin_reveal_endpoint_rules_as_admin_proxy_route() { + let headers = headers(&[]); + let uri: Uri = "/api/admin/endpoints/endpoint-1/rules/reveal" + .parse() + .expect("uri should parse"); + let decision = + classify_control_route(&http::Method::GET, &uri, &headers).expect("route should classify"); + + assert_eq!(decision.route_class.as_deref(), Some("admin_proxy")); + assert_eq!(decision.route_family.as_deref(), Some("endpoints_manage")); + assert_eq!( + decision.route_kind.as_deref(), + Some("reveal_endpoint_rules") + ); + assert_eq!( + decision.auth_endpoint_signature.as_deref(), + Some("admin:endpoints_manage") + ); + assert!(!decision.is_execution_runtime_candidate()); +} + #[test] fn classifies_admin_create_endpoint_as_admin_proxy_route() { let headers = http::HeaderMap::new(); diff --git a/apps/aether-gateway/src/data/state/integrations.rs b/apps/aether-gateway/src/data/state/integrations.rs index b453afa3d..3c03415a7 100644 --- a/apps/aether-gateway/src/data/state/integrations.rs +++ b/apps/aether-gateway/src/data/state/integrations.rs @@ -41,16 +41,9 @@ fn usage_request_record_level_from_value(value: Option<&Value>) -> UsageRequestR return UsageRequestRecordLevel::Basic; }; - if value.eq_ignore_ascii_case("basic") - || value.eq_ignore_ascii_case("base") - || value.eq_ignore_ascii_case("headers") - || value.eq_ignore_ascii_case("minimal") - || value.eq_ignore_ascii_case("none") - { - UsageRequestRecordLevel::Basic + if value.eq_ignore_ascii_case("full") { + UsageRequestRecordLevel::Full } else { - // Raw HTTP payload capture is disabled at the runtime boundary. The setting remains - // accepted for compatibility, but no longer authorizes collecting request/response data. UsageRequestRecordLevel::Basic } } @@ -501,7 +494,7 @@ mod tests { } #[tokio::test] - async fn usage_runtime_access_disables_full_http_capture() { + async fn usage_runtime_access_honors_explicit_full_http_capture() { let state = GatewayDataState::disabled().with_system_config_values_for_tests([( "request_record_level".to_string(), json!("full"), @@ -511,7 +504,32 @@ mod tests { .await .expect("request record level should read"); - assert_eq!(level, UsageRequestRecordLevel::Basic); + assert_eq!(level, UsageRequestRecordLevel::Full); + } + + #[tokio::test] + async fn usage_runtime_access_honors_legacy_full_without_overriding_current_config() { + let state = GatewayDataState::disabled().with_system_config_values_for_tests([( + "request_log_level".to_string(), + json!(" FULL "), + )]); + assert_eq!( + UsageRuntimeAccess::request_record_level(&state) + .await + .unwrap(), + UsageRequestRecordLevel::Full + ); + + let state = state.with_system_config_values_for_tests([ + ("request_log_level".to_string(), json!("full")), + ("request_record_level".to_string(), json!("basic")), + ]); + assert_eq!( + UsageRuntimeAccess::request_record_level(&state) + .await + .unwrap(), + UsageRequestRecordLevel::Basic + ); } #[tokio::test] diff --git a/apps/aether-gateway/src/execution_runtime/attempt_cancellation.rs b/apps/aether-gateway/src/execution_runtime/attempt_cancellation.rs index 7bd968827..2edf0c4cf 100644 --- a/apps/aether-gateway/src/execution_runtime/attempt_cancellation.rs +++ b/apps/aether-gateway/src/execution_runtime/attempt_cancellation.rs @@ -426,11 +426,8 @@ mod tests { assert!(candidate.finished_at_unix_ms.is_some()); } - /// The guard holds no request body, and the persistence boundary intentionally - /// rejects request/response capture material. A dropped-attempt settlement - /// must not re-introduce an inline body or a caller-controlled body reference. #[tokio::test] - async fn settling_a_dropped_attempt_does_not_reintroduce_request_body_capture() { + async fn settling_a_dropped_attempt_respects_disabled_request_body_capture() { let usage_repository = Arc::new(InMemoryUsageReadRepository::default()); let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default()); let state = test_state(&usage_repository, &request_candidate_repository); @@ -444,8 +441,6 @@ mod tests { candidate_started_unix_ms, ) .await; - // This deliberately supplies capture material to prove that the usage - // persistence boundary strips it before either lifecycle write stores it. let captured_body = json!({"stream": true, "service_tier": "priority"}); let mut capture = build_pending_usage_record( &plan, @@ -481,7 +476,10 @@ mod tests { .expect("cancelled usage should be recorded"); assert_eq!(usage.provider_request_body, None); assert_eq!(usage.provider_request_body_ref, None); - assert_eq!(usage.provider_request_body_state, None); + assert_eq!( + usage.provider_request_body_state, + Some(UsageBodyCaptureState::Disabled) + ); } #[tokio::test] diff --git a/apps/aether-gateway/src/execution_runtime/stream/execution.rs b/apps/aether-gateway/src/execution_runtime/stream/execution.rs index 1fb659f39..97b9d0f91 100644 --- a/apps/aether-gateway/src/execution_runtime/stream/execution.rs +++ b/apps/aether-gateway/src/execution_runtime/stream/execution.rs @@ -14661,15 +14661,19 @@ mod tests { assert_eq!(usage.status_code, Some(302)); assert_eq!(usage.error_category.as_deref(), Some("redirect")); assert!(usage.error_message.is_none()); - // HTTP capture is intentionally disabled at the persistence boundary. Keep the - // protocol facts above, but do not turn provider/client headers into an audit store. - assert!(usage.client_response_headers.is_none()); - assert!(usage.response_headers.is_none()); + assert_eq!( + usage.client_response_headers.as_ref().unwrap()["content-type"], + json!("application/json") + ); + assert_eq!( + usage.response_headers.as_ref().unwrap()["content-type"], + json!("text/html") + ); assert!( usage.response_body.is_none(), "upstream redirect did not include a body" ); - assert!(usage.client_response_body.is_none()); + assert_eq!(usage.client_response_body.as_ref(), Some(&body_json)); let candidates = request_candidate_repository .list_by_request_id("req-remote-runtime-stream-redirect") .await diff --git a/apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/mod.rs b/apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/mod.rs index bde319b00..8035d118d 100644 --- a/apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/mod.rs +++ b/apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/mod.rs @@ -6,6 +6,7 @@ mod extractors; mod list; pub(crate) mod payloads; mod reads; +mod reveal; mod support; mod update; @@ -41,6 +42,10 @@ pub(crate) async fn maybe_build_local_admin_endpoints_routes_response( return Ok(Some(response)); } + if let Some(response) = reveal::maybe_handle(state, request_context).await? { + return Ok(Some(response)); + } + if let Some(response) = defaults::maybe_handle(state, request_context, request_body).await? { return Ok(Some(response)); } diff --git a/apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/reveal.rs b/apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/reveal.rs new file mode 100644 index 000000000..9d4f869b6 --- /dev/null +++ b/apps/aether-gateway/src/handlers/admin/provider/endpoints_admin/reveal.rs @@ -0,0 +1,72 @@ +use super::extractors::admin_endpoint_id; +use super::support::build_admin_endpoints_data_unavailable_response; +use crate::handlers::admin::request::{AdminAppState, AdminRequestContext}; +use crate::handlers::admin::shared::{ + attach_admin_audit_response, mark_sensitive_admin_response_no_store, +}; +use crate::GatewayError; +use axum::{ + body::Body, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use serde_json::json; + +pub(super) async fn maybe_handle( + state: &AdminAppState<'_>, + request_context: &AdminRequestContext<'_>, +) -> Result>, GatewayError> { + let Some(decision) = request_context.decision() else { + return Ok(None); + }; + if decision.route_family.as_deref() != Some("endpoints_manage") + || decision.route_kind.as_deref() != Some("reveal_endpoint_rules") + { + return Ok(None); + } + if !state.has_provider_catalog_data_reader() { + return Ok(Some(build_admin_endpoints_data_unavailable_response())); + } + let Some(endpoint_id) = request_context + .path() + .strip_suffix("/rules/reveal") + .and_then(admin_endpoint_id) + else { + return Ok(Some( + ( + StatusCode::NOT_FOUND, + Json(json!({ "detail": "Endpoint 不存在" })), + ) + .into_response(), + )); + }; + let Some(endpoint) = state + .read_provider_catalog_endpoints_by_ids(std::slice::from_ref(&endpoint_id)) + .await? + .into_iter() + .next() + else { + return Ok(Some( + ( + StatusCode::NOT_FOUND, + Json(json!({ "detail": "Endpoint 不存在" })), + ) + .into_response(), + )); + }; + let payload = json!({ + "header_rules": endpoint.header_rules.as_ref().and_then(|value| value.as_array()).cloned().unwrap_or_default(), + "body_rules": endpoint.body_rules.as_ref().and_then(|value| value.as_array()).cloned().unwrap_or_default(), + "response_header_rules": endpoint.config.as_ref().and_then(|config| config.get("response_header_rules")).and_then(|value| value.as_array()).cloned().unwrap_or_default(), + }); + Ok(Some(mark_sensitive_admin_response_no_store( + attach_admin_audit_response( + Json(payload).into_response(), + "admin_endpoint_rules_revealed", + "reveal_endpoint_rules", + "provider_endpoint", + &endpoint_id, + ), + ))) +} diff --git a/apps/aether-gateway/src/main.rs b/apps/aether-gateway/src/main.rs index 0721a9d5d..6afdcc016 100644 --- a/apps/aether-gateway/src/main.rs +++ b/apps/aether-gateway/src/main.rs @@ -117,8 +117,8 @@ where use aether_crypto::warm_python_fernet_secret; use aether_data::lifecycle::export::{ - copy_database_records, export_database_jsonl, import_database_jsonl, DataCopyOptions, - ExportDomain, MAX_JSONL_INPUT_BYTES, + copy_database_records, export_database_jsonl, import_database_jsonl_with_options, + DataCopyOptions, DataImportOptions, ExportDomain, MAX_JSONL_INPUT_BYTES, }; use aether_data::{DatabaseDriver, SqlDatabaseConfig, SqlPoolConfig}; use aether_gateway::{ @@ -1351,6 +1351,11 @@ struct DataExportArgs { struct DataImportArgs { #[arg(long)] input: PathBuf, + #[arg( + long, + help = "Preserve passwords and API/management credentials from a trusted import; imported sessions remain revoked. Without this flag identity credentials are revoked." + )] + preserve_credentials: bool, } #[derive(ClapArgs, Debug, Clone)] @@ -1382,6 +1387,11 @@ struct DataCopyArgs { #[arg(long)] omit_request_body_details: bool, + #[arg( + long, + help = "Preserve passwords and API/management credentials from the trusted source; imported sessions remain revoked. The target must use the source encryption key." + )] + preserve_credentials: bool, } impl GatewayLoggingArgs { @@ -2905,12 +2915,23 @@ async fn run_data_import( let driver = database.driver; let input_path = args.input.clone(); let input = tokio::task::spawn_blocking(move || read_data_import_input(&input_path)).await??; - let imported = import_database_jsonl(database, &input).await?; + if !args.preserve_credentials { + warn!("identity credentials will be revoked; use --preserve-credentials only for trusted recovery or migration"); + } + let imported = import_database_jsonl_with_options( + database, + &input, + DataImportOptions { + preserve_credentials: args.preserve_credentials, + }, + ) + .await?; info!( driver = %driver, input = %args.input.display(), imported, + preserve_credentials = args.preserve_credentials, "database import complete" ); println!( @@ -3171,6 +3192,9 @@ async fn run_data_copy(args: &DataCopyArgs) -> Result<(), Box Result<(), Box Result<(), Box, settings: UsageCleanupSettings, @@ -135,9 +132,9 @@ pub(super) fn usage_cleanup_window_with_override( }; let manual_cutoff = now_utc - override_duration; UsageCleanupWindow { - detail_cutoff: policy.detail_cutoff.max(manual_cutoff), - compressed_cutoff: policy.compressed_cutoff.max(manual_cutoff), - header_cutoff: policy.header_cutoff.max(manual_cutoff), - log_cutoff: policy.log_cutoff.max(manual_cutoff), + detail_cutoff: policy.detail_cutoff.min(manual_cutoff), + compressed_cutoff: policy.compressed_cutoff.min(manual_cutoff), + header_cutoff: policy.header_cutoff.min(manual_cutoff), + log_cutoff: policy.log_cutoff.min(manual_cutoff), } } diff --git a/apps/aether-gateway/src/maintenance/runtime/tests.rs b/apps/aether-gateway/src/maintenance/runtime/tests.rs index 03106ced3..94653d1e3 100644 --- a/apps/aether-gateway/src/maintenance/runtime/tests.rs +++ b/apps/aether-gateway/src/maintenance/runtime/tests.rs @@ -1140,19 +1140,40 @@ fn usage_cleanup_window_with_override_is_always_non_aggressive() { let override_duration = chrono::Duration::days(180); let clamped = usage_cleanup_window_with_override(now_utc, settings, Some(override_duration)); - assert_eq!(clamped.detail_cutoff, policy.detail_cutoff); - assert_eq!(clamped.compressed_cutoff, policy.compressed_cutoff); - assert_eq!(clamped.header_cutoff, policy.header_cutoff); - assert_eq!(clamped.log_cutoff, now_utc - override_duration); - assert!(clamped.log_cutoff > policy.log_cutoff); + assert_eq!(clamped.detail_cutoff, now_utc - override_duration); + assert_eq!(clamped.compressed_cutoff, now_utc - override_duration); + assert_eq!(clamped.header_cutoff, now_utc - override_duration); + assert_eq!(clamped.log_cutoff, policy.log_cutoff); + assert!(clamped.log_cutoff <= policy.log_cutoff); let far_override = chrono::Duration::days(5); let far = usage_cleanup_window_with_override(now_utc, settings, Some(far_override)); - assert_eq!(far.detail_cutoff, now_utc - far_override); - assert_eq!(far.compressed_cutoff, now_utc - far_override); - assert_eq!(far.header_cutoff, now_utc - far_override); - assert_eq!(far.log_cutoff, now_utc - far_override); - assert!(far.log_cutoff > policy.log_cutoff); + assert_eq!(far, policy); + + for days in [0, 5, 30, 180, 400] { + let cutoff = now_utc - chrono::Duration::days(days); + let window = usage_cleanup_window_with_override( + now_utc, + settings, + Some(chrono::Duration::days(days)), + ); + for (actual, configured) in [ + (window.detail_cutoff, policy.detail_cutoff), + (window.compressed_cutoff, policy.compressed_cutoff), + (window.header_cutoff, policy.header_cutoff), + (window.log_cutoff, policy.log_cutoff), + ] { + assert!(actual <= configured); + assert!(actual <= cutoff); + for age in [1, 7, 15, 30, 90, 180, 365, 401] { + let created_at = now_utc - chrono::Duration::days(age); + if created_at < actual { + assert!(created_at < configured); + assert!(created_at < cutoff); + } + } + } + } let passthrough = usage_cleanup_window_with_override(now_utc, settings, None); assert_eq!(passthrough, policy); diff --git a/apps/aether-gateway/src/tests/async_task.rs b/apps/aether-gateway/src/tests/async_task.rs index 2af4bfa87..753dd8307 100644 --- a/apps/aether-gateway/src/tests/async_task.rs +++ b/apps/aether-gateway/src/tests/async_task.rs @@ -326,7 +326,7 @@ async fn gateway_reads_video_task_detail_via_internal_async_task_endpoint() { } #[tokio::test] -async fn gateway_does_not_redirect_sanitized_openai_video_url_from_internal_endpoint() { +async fn gateway_redirects_persisted_openai_video_url_from_authenticated_internal_endpoint() { let repository = Arc::new(InMemoryVideoTaskRepository::default()); let mut task = sample_video_task( "task-redirect", @@ -341,7 +341,10 @@ async fn gateway_does_not_redirect_sanitized_openai_video_url_from_internal_endp .upsert(task) .await .expect("upsert should succeed"); - assert_eq!(stored.video_url, None); + assert_eq!( + stored.video_url.as_deref(), + Some("https://8.8.8.8/video-task-redirect.mp4") + ); let state = AppState::new() .expect("gateway state should build") @@ -349,7 +352,10 @@ async fn gateway_does_not_redirect_sanitized_openai_video_url_from_internal_endp let (gateway_url, gateway_handle, access_token) = start_authenticated_operational_server(state).await; - let client = authenticated_operational_client(&access_token); + let client = super::authenticated_operational_client_with_builder( + reqwest::Client::builder().redirect(reqwest::redirect::Policy::none()), + &access_token, + ); let response = client .get(format!( "{gateway_url}/_gateway/async-tasks/video-tasks/task-redirect/video" @@ -358,7 +364,14 @@ async fn gateway_does_not_redirect_sanitized_openai_video_url_from_internal_endp .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + response + .headers() + .get("location") + .and_then(|value| value.to_str().ok()), + stored.video_url.as_deref() + ); gateway_handle.abort(); } diff --git a/apps/aether-gateway/src/tests/control/admin/endpoints.rs b/apps/aether-gateway/src/tests/control/admin/endpoints.rs index c062f78fb..3a866ea2c 100644 --- a/apps/aether-gateway/src/tests/control/admin/endpoints.rs +++ b/apps/aether-gateway/src/tests/control/admin/endpoints.rs @@ -1,3 +1,4 @@ mod keys; mod quota; mod routes; +mod rules_reveal; diff --git a/apps/aether-gateway/src/tests/control/admin/endpoints/rules_reveal.rs b/apps/aether-gateway/src/tests/control/admin/endpoints/rules_reveal.rs new file mode 100644 index 000000000..7d5fcff2c --- /dev/null +++ b/apps/aether-gateway/src/tests/control/admin/endpoints/rules_reveal.rs @@ -0,0 +1,151 @@ +use std::sync::Arc; + +use aether_data::repository::provider_catalog::InMemoryProviderCatalogReadRepository; +use axum::body::Body; +use http::{HeaderMap, HeaderValue, Method, Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{json, Value}; + +use super::super::super::{build_router_with_state, sample_endpoint, sample_provider, AppState}; +use crate::admin_api::{maybe_build_local_admin_response, AdminRouteRequest}; +use crate::audit::AdminAuditEvent; +use crate::constants::{ + GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER, + TRUSTED_ADMIN_USER_ROLE_HEADER, +}; +use crate::control::resolve_public_request_context; +use crate::data::GatewayDataState; +use crate::tests::send_request; + +fn seeded_state() -> AppState { + let mut endpoint = sample_endpoint( + "endpoint-rules", + "provider-rules", + "openai:chat", + "https://example.test", + ); + endpoint.header_rules = + Some(json!([{"action": "set", "key": "x-auth", "value": "request-secret"}])); + endpoint.body_rules = + Some(json!([{"action": "set", "path": "auth.token", "value": "body-secret"}])); + endpoint.config = Some(json!({ + "private_token": "unrelated-secret", + "response_header_rules": [{"action": "set", "key": "x-auth", "value": "response-secret"}] + })); + let repository = Arc::new(InMemoryProviderCatalogReadRepository::seed( + vec![sample_provider("provider-rules", "custom", 10)], + vec![endpoint], + vec![], + )); + AppState::new().unwrap().with_data_state_for_tests( + GatewayDataState::with_provider_catalog_reader_for_tests(repository), + ) +} + +fn admin_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + for (name, value) in [ + (GATEWAY_HEADER, "rust-phase3b"), + (TRUSTED_ADMIN_USER_ID_HEADER, "admin-user"), + (TRUSTED_ADMIN_USER_ROLE_HEADER, "admin"), + (TRUSTED_ADMIN_SESSION_ID_HEADER, "admin-session"), + ] { + headers.insert(name, HeaderValue::from_static(value)); + } + headers +} + +#[tokio::test] +async fn endpoint_rules_reveal_is_scoped_audited_and_not_cached() { + let state = seeded_state(); + let context = resolve_public_request_context( + &state, + &Method::GET, + &"/api/admin/endpoints/endpoint-rules/rules/reveal" + .parse() + .unwrap(), + &admin_headers(), + "reveal-test", + ) + .await + .unwrap(); + let response = maybe_build_local_admin_response(AdminRouteRequest::new( + &state, + &context, + &"127.0.0.1:12345".parse().unwrap(), + &admin_headers(), + None, + )) + .await + .unwrap() + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[http::header::CACHE_CONTROL], "no-store"); + assert_eq!(response.headers()[http::header::PRAGMA], "no-cache"); + let audit = response.extensions().get::().unwrap(); + assert_eq!(audit.event_name, "admin_endpoint_rules_revealed"); + assert_eq!(audit.action, "reveal_endpoint_rules"); + assert_eq!(audit.target_id, "endpoint-rules"); + let body = response.into_body().collect().await.unwrap().to_bytes(); + let payload: Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(payload["header_rules"][0]["value"], "request-secret"); + assert_eq!(payload["body_rules"][0]["value"], "body-secret"); + assert_eq!( + payload["response_header_rules"][0]["value"], + "response-secret" + ); + assert_eq!(payload.as_object().unwrap().len(), 3); + assert!(!payload.to_string().contains("unrelated-secret")); +} + +#[tokio::test] +async fn endpoint_rules_reveal_denies_anonymous_and_non_admin_requests() { + let router = build_router_with_state(seeded_state()); + for role in [None, Some("user")] { + let mut request = + Request::builder().uri("/api/admin/endpoints/endpoint-rules/rules/reveal"); + if let Some(role) = role { + request = request + .header(GATEWAY_HEADER, "rust-phase3b") + .header(TRUSTED_ADMIN_USER_ID_HEADER, "normal-user") + .header(TRUSTED_ADMIN_USER_ROLE_HEADER, role) + .header(TRUSTED_ADMIN_SESSION_ID_HEADER, "user-session"); + } + let response = send_request(router.clone(), request.body(Body::empty()).unwrap()).await; + assert!(matches!( + response.status(), + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN + )); + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert!(!String::from_utf8_lossy(&body).contains("request-secret")); + } +} + +#[tokio::test] +async fn endpoint_rules_reveal_returns_not_found_and_data_unavailable_without_fallback() { + for (state, expected) in [ + (seeded_state(), StatusCode::NOT_FOUND), + (AppState::new().unwrap(), StatusCode::SERVICE_UNAVAILABLE), + ] { + let context = resolve_public_request_context( + &state, + &Method::GET, + &"/api/admin/endpoints/missing/rules/reveal".parse().unwrap(), + &admin_headers(), + "reveal-missing-test", + ) + .await + .unwrap(); + let response = maybe_build_local_admin_response(AdminRouteRequest::new( + &state, + &context, + &"127.0.0.1:12345".parse().unwrap(), + &admin_headers(), + None, + )) + .await + .unwrap() + .unwrap(); + assert_eq!(response.status(), expected); + } +} diff --git a/apps/aether-gateway/src/tests/control/admin/video_tasks.rs b/apps/aether-gateway/src/tests/control/admin/video_tasks.rs index 5a91004b8..0a8f15687 100644 --- a/apps/aether-gateway/src/tests/control/admin/video_tasks.rs +++ b/apps/aether-gateway/src/tests/control/admin/video_tasks.rs @@ -221,13 +221,17 @@ async fn gateway_handles_admin_video_tasks_list_locally_with_trusted_admin_princ assert_eq!(payload["pages"], json!(1)); assert_eq!(payload["items"].as_array().map(Vec::len), Some(1)); assert_eq!(payload["items"][0]["id"], "task-completed"); - // Video-task persistence intentionally drops user-facing PII. The admin - // projection must therefore use the privacy-safe fallback when no separate - // user snapshot is joined. - assert_eq!(payload["items"][0]["username"], "Unknown"); + assert_eq!(payload["items"][0]["username"], "alice"); assert_eq!(payload["items"][0]["provider_name"], "OpenAI"); assert_eq!(payload["items"][0]["status"], "completed"); - assert!(payload["items"][0]["prompt"].is_null()); + assert_eq!( + payload["items"][0]["prompt"], + format!("{}...", "x".repeat(100)) + ); + assert_eq!( + payload["items"][0]["video_url"], + "https://8.8.8.8/task-completed.mp4" + ); assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); gateway_handle.abort(); @@ -393,7 +397,9 @@ async fn gateway_handles_admin_video_task_detail_locally_with_trusted_admin_prin assert_eq!(response.status(), StatusCode::OK); let payload: serde_json::Value = response.json().await.expect("json body should parse"); assert_eq!(payload["id"], "task-detail"); - assert_eq!(payload["username"], "Unknown"); + assert_eq!(payload["prompt"], "detail prompt"); + assert_eq!(payload["video_url"], "https://8.8.8.8/task-detail.mp4"); + assert_eq!(payload["username"], "charlie"); assert_eq!(payload["provider_name"], "OpenAI"); assert_eq!(payload["endpoint"]["id"], "endpoint-1"); assert_eq!(payload["endpoint"]["api_format"], "openai:video"); @@ -734,7 +740,7 @@ async fn local_admin_video_task_cancel_attaches_explicit_audit() { } #[tokio::test] -async fn gateway_does_not_redirect_sanitized_openai_video_url_or_forward_upstream() { +async fn gateway_redirects_persisted_openai_video_url_without_forwarding_admin_request() { let upstream_hits = Arc::new(Mutex::new(0usize)); let upstream_hits_clone = Arc::clone(&upstream_hits); let upstream = Router::new().route( @@ -762,7 +768,10 @@ async fn gateway_does_not_redirect_sanitized_openai_video_url_or_forward_upstrea )) .await .expect("task should upsert"); - assert_eq!(stored.video_url, None); + assert_eq!( + stored.video_url.as_deref(), + Some("https://8.8.8.8/task-redirect.mp4") + ); let (_upstream_url, upstream_handle) = start_server(upstream).await; let gateway = build_router_with_state( @@ -788,7 +797,14 @@ async fn gateway_does_not_redirect_sanitized_openai_video_url_or_forward_upstrea .await .expect("request should succeed"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + response + .headers() + .get(http::header::LOCATION) + .and_then(|value| value.to_str().ok()), + stored.video_url.as_deref() + ); assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0); gateway_handle.abort(); @@ -796,22 +812,22 @@ async fn gateway_does_not_redirect_sanitized_openai_video_url_or_forward_upstrea } #[tokio::test] -async fn local_admin_video_task_video_is_unavailable_after_openai_url_sanitization() { +async fn local_admin_video_task_download_preserves_signed_url_and_attaches_audit() { let repository = Arc::new(InMemoryVideoTaskRepository::default()); - let stored = repository - .upsert(sample_admin_video_task( - "task-video-audit", - VideoTaskStatus::Completed, - 1_710_000_550, - "user-5", - "frank", - "provider-openai", - "gpt-video", - "video audit prompt", - )) - .await - .expect("task should upsert"); - assert_eq!(stored.video_url, None); + let mut task = sample_admin_video_task( + "task-video-audit", + VideoTaskStatus::Completed, + 1_710_000_550, + "user-5", + "frank", + "provider-openai", + "gpt-video", + "video audit prompt", + ); + task.video_url = + Some("https://8.8.8.8/video.mp4?signature=a%2Fb%2Bc%3D&part=2&part=1".to_string()); + let stored = repository.upsert(task).await.expect("task should upsert"); + assert_eq!(stored.prompt.as_deref(), Some("video audit prompt")); let state = AppState::new() .expect("gateway state should build") @@ -825,8 +841,15 @@ async fn local_admin_video_task_video_is_unavailable_after_openai_url_sanitizati ) .await; - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert!(response.extensions().get::().is_none()); + assert_eq!(response.status(), StatusCode::TEMPORARY_REDIRECT); + assert_eq!( + response + .headers() + .get(http::header::LOCATION) + .and_then(|value| value.to_str().ok()), + stored.video_url.as_deref() + ); + assert!(response.extensions().get::().is_some()); } #[tokio::test] diff --git a/apps/aether-gateway/src/tests/usage/local.rs b/apps/aether-gateway/src/tests/usage/local.rs index 401252eed..cb60dc59b 100644 --- a/apps/aether-gateway/src/tests/usage/local.rs +++ b/apps/aether-gateway/src/tests/usage/local.rs @@ -12,6 +12,7 @@ use super::{ UsageReadRepository, UsageRuntimeConfig, DEVELOPMENT_ENCRYPTION_KEY, TRACE_ID_HEADER, }; use crate::constants::LOCAL_EXECUTION_RUNTIME_MISS_REASON_HEADER; +use aether_data_contracts::repository::usage::UsageBodyCaptureState; fn deep_nested_metadata(levels: usize) -> serde_json::Value { let mut current = json!({"leaf": "value"}); @@ -84,6 +85,58 @@ where stored.expect("usage should be present once the expected status is observed") } +async fn load_admin_usage_capture_detail( + state: &crate::AppState, + usage_id: &str, + include_bodies: bool, +) -> serde_json::Value { + use crate::admin_api::{maybe_build_local_admin_response, AdminRouteRequest}; + use crate::constants::{ + GATEWAY_HEADER, TRUSTED_ADMIN_SESSION_ID_HEADER, TRUSTED_ADMIN_USER_ID_HEADER, + TRUSTED_ADMIN_USER_ROLE_HEADER, + }; + use crate::control::resolve_public_request_context; + use http_body_util::BodyExt; + + let mut headers = http::HeaderMap::new(); + for (name, value) in [ + (GATEWAY_HEADER, "rust-phase3b"), + (TRUSTED_ADMIN_USER_ID_HEADER, "admin-user"), + (TRUSTED_ADMIN_USER_ROLE_HEADER, "admin"), + (TRUSTED_ADMIN_SESSION_ID_HEADER, "admin-session"), + ] { + headers.insert(name, HeaderValue::from_static(value)); + } + let uri = format!("/api/admin/usage/{usage_id}?include_bodies={include_bodies}") + .parse() + .unwrap(); + let context = resolve_public_request_context( + state, + &http::Method::GET, + &uri, + &headers, + "usage-full-detail", + ) + .await + .unwrap(); + let response = maybe_build_local_admin_response(AdminRouteRequest::new( + state, + &context, + &"127.0.0.1:12345".parse().unwrap(), + &headers, + None, + )) + .await + .unwrap() + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert!(response + .extensions() + .get::() + .is_some()); + serde_json::from_slice(&response.into_body().collect().await.unwrap().to_bytes()).unwrap() +} + #[test] fn gateway_handles_local_openai_chat_sync_report_with_local_reporting_when_usage_runtime_enabled() { run_async_test_on_large_stack( @@ -348,7 +401,7 @@ async fn gateway_truncates_deep_request_echo_for_local_openai_chat_sync_usage_im Arc::clone(&request_candidate_repository), Arc::clone(&usage_repository), DEVELOPMENT_ENCRYPTION_KEY, - ), + ).with_system_config_values_for_tests([("request_record_level".to_string(), json!("full"))]), ) .with_usage_runtime_for_tests(UsageRuntimeConfig { enabled: true, @@ -402,10 +455,28 @@ async fn gateway_truncates_deep_request_echo_for_local_openai_chat_sync_usage_im let stored_usage = stored_usage.expect("usage should be recorded"); assert_eq!(stored_usage.status, "completed"); assert_eq!(stored_usage.total_tokens, 5); - assert!(stored_usage.request_body.is_none()); + let request_body = stored_usage.request_body.as_ref().unwrap(); + assert_eq!( + request_body["messages"][0]["content"] + .as_str() + .unwrap() + .len(), + 128 * 1024 + ); + assert!( + request_body["metadata"]["child"]["child"]["child"]["child"]["child"] + .get("depth") + .is_some() + ); assert!(stored_usage.request_body_ref.is_none()); - assert!(stored_usage.request_body_state.is_none()); - assert!(stored_usage.request_headers.is_none()); + assert_eq!( + stored_usage.request_body_state, + Some(UsageBodyCaptureState::Inline) + ); + assert_eq!( + stored_usage.request_headers.as_ref().unwrap()["authorization"], + "[redacted]" + ); gateway_handle.abort(); execution_runtime_handle.abort(); @@ -489,10 +560,10 @@ async fn gateway_ignores_legacy_max_request_body_size_for_local_openai_chat_sync Arc::clone(&usage_repository), DEVELOPMENT_ENCRYPTION_KEY, ) - .with_system_config_values_for_tests([( - "max_request_body_size".to_string(), - json!(128), - )]), + .with_system_config_values_for_tests([ + ("max_request_body_size".to_string(), json!(128)), + ("request_record_level".to_string(), json!("full")), + ]), ) .with_usage_runtime_for_tests(UsageRuntimeConfig { enabled: true, @@ -535,12 +606,30 @@ async fn gateway_ignores_legacy_max_request_body_size_for_local_openai_chat_sync ) .await; assert_eq!(stored_usage.total_tokens, 5); - assert!(stored_usage.request_body.is_none()); + assert!( + stored_usage.request_body.as_ref().unwrap()["messages"][0]["content"] + .as_str() + .unwrap() + .len() + > 128 + ); assert!(stored_usage.request_body_ref.is_none()); - assert!(stored_usage.request_body_state.is_none()); - assert!(stored_usage.provider_request_body.is_none()); + assert_eq!( + stored_usage.request_body_state, + Some(UsageBodyCaptureState::Inline) + ); + assert!( + stored_usage.provider_request_body.as_ref().unwrap()["messages"][0]["content"] + .as_str() + .unwrap() + .len() + > 128 + ); assert!(stored_usage.provider_request_body_ref.is_none()); - assert!(stored_usage.provider_request_body_state.is_none()); + assert_eq!( + stored_usage.provider_request_body_state, + Some(UsageBodyCaptureState::Inline) + ); gateway_handle.abort(); execution_runtime_handle.abort(); @@ -551,11 +640,19 @@ async fn gateway_ignores_legacy_max_request_body_size_for_local_openai_chat_sync fn gateway_strips_request_and_response_bodies_when_request_record_level_is_base() { run_async_test_on_large_stack( "gateway_strips_request_and_response_bodies_when_request_record_level_is_base", - gateway_strips_request_and_response_bodies_when_request_record_level_is_base_impl(), + gateway_honors_request_record_level_impl("base"), ); } -async fn gateway_strips_request_and_response_bodies_when_request_record_level_is_base_impl() { +#[test] +fn gateway_full_request_record_level_preserves_sync_bodies_in_admin_detail() { + run_async_test_on_large_stack( + "gateway_full_request_record_level_preserves_sync_bodies_in_admin_detail", + gateway_honors_request_record_level_impl("full"), + ); +} + +async fn gateway_honors_request_record_level_impl(record_level: &str) { let usage_repository = Arc::new(InMemoryUsageReadRepository::default()); let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default()); @@ -630,14 +727,14 @@ async fn gateway_strips_request_and_response_bodies_when_request_record_level_is ) .with_system_config_values_for_tests([( "request_record_level".to_string(), - json!("base"), + json!(record_level), )]), ) .with_usage_runtime_for_tests(UsageRuntimeConfig { enabled: true, ..UsageRuntimeConfig::default() }); - let gateway = build_router_with_state(gateway_state); + let gateway = build_router_with_state(gateway_state.clone()); let (gateway_url, gateway_handle) = start_server(gateway).await; let response = reqwest::Client::new() @@ -678,14 +775,39 @@ async fn gateway_strips_request_and_response_bodies_when_request_record_level_is assert_eq!(stored_usage.status, "completed"); assert_eq!(stored_usage.total_tokens, 5); assert_eq!(stored_usage.response_time_ms, Some(25)); - assert!(stored_usage.request_body.is_none()); - assert!(stored_usage.request_body_ref.is_none()); - assert!(stored_usage.provider_request_body.is_none()); - assert!(stored_usage.provider_request_body_ref.is_none()); - assert!(stored_usage.response_body.is_none()); - assert!(stored_usage.response_body_ref.is_none()); - assert!(stored_usage.client_response_body.is_none()); - assert!(stored_usage.client_response_body_ref.is_none()); + let detail = load_admin_usage_capture_detail(&gateway_state, &stored_usage.id, true).await; + let shallow = load_admin_usage_capture_detail(&gateway_state, &stored_usage.id, false).await; + for field in [ + "request_body", + "provider_request_body", + "response_body", + "client_response_body", + ] { + assert!(shallow[field].is_null()); + let expected_captured = record_level == "full" && field != "client_response_body"; + assert_eq!( + shallow[format!("has_{field}")], + expected_captured, + "availability for {field}" + ); + if expected_captured { + assert!(!detail[field].is_null(), "full should expose {field}"); + } else { + assert!( + detail[field].is_null(), + "uncaptured {field} must remain absent" + ); + } + } + if record_level == "full" { + assert_eq!( + detail["request_body"]["messages"][0]["content"], + "request body should not be persisted" + ); + assert_eq!(detail["provider_request_body"]["model"], "gpt-5-upstream"); + assert_eq!(detail["response_body"], body_json); + assert!(detail["client_response_body"].is_null()); + } let stored_candidates = request_candidate_repository .list_by_request_id("trace-openai-chat-local-report-sync-base-123") @@ -825,10 +947,16 @@ async fn gateway_records_failed_usage_when_all_local_openai_chat_candidates_exha ); assert!(stored_usage.response_body.is_none()); assert!(stored_usage.response_body_ref.is_none()); - assert!(stored_usage.response_body_state.is_none()); + assert_eq!( + stored_usage.response_body_state, + Some(UsageBodyCaptureState::Disabled) + ); assert!(stored_usage.client_response_body.is_none()); assert!(stored_usage.client_response_body_ref.is_none()); - assert!(stored_usage.client_response_body_state.is_none()); + assert_eq!( + stored_usage.client_response_body_state, + Some(UsageBodyCaptureState::Disabled) + ); let stored_candidates = request_candidate_repository .list_by_request_id("trace-openai-chat-local-report-sync-failure-123") @@ -930,7 +1058,10 @@ async fn gateway_records_failed_usage_when_sync_runtime_transport_is_unavailable assert_eq!(stored_usage.status_code, Some(503)); assert!(stored_usage.response_body.is_none()); assert!(stored_usage.response_body_ref.is_none()); - assert!(stored_usage.response_body_state.is_none()); + assert_eq!( + stored_usage.response_body_state, + Some(UsageBodyCaptureState::Disabled) + ); let stored_candidates = request_candidate_repository .list_by_request_id("trace-openai-chat-local-transport-unavailable-123") @@ -1272,7 +1403,10 @@ async fn gateway_records_failed_usage_for_claude_runtime_miss_without_execution_ ); assert!(stored_usage.client_response_body.is_none()); assert!(stored_usage.client_response_body_ref.is_none()); - assert!(stored_usage.client_response_body_state.is_none()); + assert_eq!( + stored_usage.client_response_body_state, + Some(UsageBodyCaptureState::Disabled) + ); assert!(stored_usage.error_message.is_none()); let stored_candidates = request_candidate_repository @@ -1296,11 +1430,20 @@ fn gateway_handles_local_openai_chat_stream_report_with_local_reporting_when_usa { run_async_test_on_large_stack( "gateway_handles_local_openai_chat_stream_report_with_local_reporting_when_usage_runtime_enabled", - gateway_handles_local_openai_chat_stream_report_with_local_reporting_when_usage_runtime_enabled_impl(), + gateway_handles_local_openai_chat_stream_report_with_local_reporting_when_usage_runtime_enabled_impl("basic"), + ); +} + +#[test] +fn gateway_full_request_record_level_preserves_stream_bodies_in_admin_detail() { + run_async_test_on_large_stack( + "gateway_full_request_record_level_preserves_stream_bodies_in_admin_detail", + gateway_handles_local_openai_chat_stream_report_with_local_reporting_when_usage_runtime_enabled_impl("full"), ); } async fn gateway_handles_local_openai_chat_stream_report_with_local_reporting_when_usage_runtime_enabled_impl( + record_level: &str, ) { let usage_repository = Arc::new(InMemoryUsageReadRepository::default()); let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default()); @@ -1406,13 +1549,13 @@ async fn gateway_handles_local_openai_chat_stream_report_with_local_reporting_wh Arc::clone(&request_candidate_repository), Arc::clone(&usage_repository), DEVELOPMENT_ENCRYPTION_KEY, - ), + ).with_system_config_values_for_tests([("request_record_level".to_string(), json!(record_level))]), ) .with_usage_runtime_for_tests(UsageRuntimeConfig { enabled: true, ..UsageRuntimeConfig::default() }); - let gateway = build_router_with_state(gateway_state); + let gateway = build_router_with_state(gateway_state.clone()); let (gateway_url, gateway_handle) = start_server(gateway).await; let response = reqwest::Client::new() @@ -1448,6 +1591,30 @@ async fn gateway_handles_local_openai_chat_stream_report_with_local_reporting_wh assert!(stored_usage.response_time_ms >= stored_usage.first_byte_time_ms); assert!(stored_usage.is_stream); + let detail = load_admin_usage_capture_detail(&gateway_state, &stored_usage.id, true).await; + for field in [ + "request_body", + "provider_request_body", + "response_body", + "client_response_body", + ] { + if record_level == "full" { + assert!( + !detail[field].is_null(), + "full stream should expose {field}" + ); + } else { + assert!( + detail[field].is_null(), + "basic stream must not persist {field}" + ); + } + } + if record_level == "full" { + assert!(detail["response_body"].to_string().contains("hello")); + assert!(detail["client_response_body"].to_string().contains("hello")); + } + let stored_candidates = request_candidate_repository .list_by_request_id("trace-openai-chat-local-report-stream-123") .await @@ -1585,10 +1752,10 @@ async fn gateway_ignores_legacy_max_response_body_size_for_stream_usage_impl() { Arc::clone(&usage_repository), DEVELOPMENT_ENCRYPTION_KEY, ) - .with_system_config_values_for_tests([( - "max_response_body_size".to_string(), - json!(128), - )]), + .with_system_config_values_for_tests([ + ("max_response_body_size".to_string(), json!(128)), + ("request_record_level".to_string(), json!("full")), + ]), ) .with_usage_runtime_for_tests(UsageRuntimeConfig { enabled: true, @@ -1624,12 +1791,34 @@ async fn gateway_ignores_legacy_max_response_body_size_for_stream_usage_impl() { ) .await; assert_eq!(stored_usage.total_tokens, 6); - assert!(stored_usage.response_body.is_none()); + assert!( + stored_usage + .response_body + .as_ref() + .unwrap() + .to_string() + .len() + > 128 + ); assert!(stored_usage.response_body_ref.is_none()); - assert!(stored_usage.response_body_state.is_none()); - assert!(stored_usage.client_response_body.is_none()); + assert_eq!( + stored_usage.response_body_state, + Some(UsageBodyCaptureState::Inline) + ); + assert!( + stored_usage + .client_response_body + .as_ref() + .unwrap() + .to_string() + .len() + > 128 + ); assert!(stored_usage.client_response_body_ref.is_none()); - assert!(stored_usage.client_response_body_state.is_none()); + assert_eq!( + stored_usage.client_response_body_state, + Some(UsageBodyCaptureState::Inline) + ); gateway_handle.abort(); execution_runtime_handle.abort(); @@ -1903,10 +2092,16 @@ async fn gateway_records_failed_usage_when_all_local_claude_cli_candidates_are_s Some("all_candidates_skipped") ); assert!(stored_usage.error_message.is_none()); - assert!(stored_usage.request_headers.is_none()); + assert_eq!( + stored_usage.request_headers.as_ref().unwrap()["authorization"], + "[redacted]" + ); assert!(stored_usage.request_body.is_none()); assert!(stored_usage.request_body_ref.is_none()); - assert!(stored_usage.request_body_state.is_none()); + assert_eq!( + stored_usage.request_body_state, + Some(UsageBodyCaptureState::Disabled) + ); assert!(stored_usage.provider_request_body.is_none()); assert_eq!( stored_usage @@ -2169,7 +2364,10 @@ fn gateway_keeps_failed_usage_request_capture_lightweight_for_large_local_claude ) .await; assert_eq!(stored_usage.status, "failed"); - assert!(stored_usage.request_body_state.is_none()); + assert_eq!( + stored_usage.request_body_state, + Some(UsageBodyCaptureState::Disabled) + ); assert!(stored_usage.request_body.is_none()); assert!(stored_usage.request_body_ref.is_none()); assert!(stored_usage.provider_request_body.is_none()); diff --git a/apps/aether-gateway/src/tests/video/registry_poller.rs b/apps/aether-gateway/src/tests/video/registry_poller.rs index 1f176d20d..b47b5938a 100644 --- a/apps/aether-gateway/src/tests/video/registry_poller.rs +++ b/apps/aether-gateway/src/tests/video/registry_poller.rs @@ -213,6 +213,13 @@ async fn gateway_background_video_task_poller_refreshes_due_openai_task_from_rep }; assert_eq!(stored.status, VideoTaskStatus::Processing); + assert_eq!(stored.prompt.as_deref(), Some("hello")); + assert_eq!(stored.username.as_deref(), Some("video-user")); + assert_eq!(stored.api_key_name.as_deref(), Some("video-key")); + assert_eq!(stored.duration_seconds, Some(4)); + assert_eq!(stored.resolution.as_deref(), Some("720p")); + assert_eq!(stored.aspect_ratio.as_deref(), Some("16:9")); + assert_eq!(stored.size.as_deref(), Some("1280x720")); assert_eq!(stored.progress_percent, 37); assert_eq!(stored.poll_count, 1); assert!( diff --git a/apps/aether-gateway/src/tests/video/stream.rs b/apps/aether-gateway/src/tests/video/stream.rs index 1e50daecd..1b2ce7607 100644 --- a/apps/aether-gateway/src/tests/video/stream.rs +++ b/apps/aether-gateway/src/tests/video/stream.rs @@ -32,6 +32,7 @@ async fn gateway_executes_openai_video_content_from_reconstructed_data_task_with struct SeenExecutionRuntimeStreamRequest { method: String, url: String, + headers: serde_json::Value, } fn hash_api_key(value: &str) -> String { @@ -159,6 +160,7 @@ async fn gateway_executes_openai_video_content_from_reconstructed_data_task_with .and_then(|value| value.as_str()) .unwrap_or_default() .to_string(), + headers: payload.get("headers").cloned().unwrap_or_else(|| json!({})), }); let frames = [ @@ -252,7 +254,10 @@ async fn gateway_executes_openai_video_content_from_reconstructed_data_task_with updated_at_unix_secs: 456, error_code: None, error_message: None, - video_url: Some("https://cdn.example.com/video-content.mp4".to_string()), + video_url: Some( + "https://cdn.example.com/video-content.mp4?signature=a%2Fb%2Bc%3D&part=2&part=1" + .to_string(), + ), request_metadata: None, }) .await @@ -358,8 +363,9 @@ async fn gateway_executes_openai_video_content_from_reconstructed_data_task_with assert_eq!(seen_stream_request.method, "GET"); assert_eq!( seen_stream_request.url, - "https://api.openai.example/v1/videos/ext-video-content-followup-123/content" + "https://cdn.example.com/video-content.mp4?signature=a%2Fb%2Bc%3D&part=2&part=1" ); + assert!(seen_stream_request.headers.get("authorization").is_none()); assert_eq!(*decision_stream_hits.lock().expect("mutex should lock"), 0); assert_eq!(*execute_stream_hits.lock().expect("mutex should lock"), 0); assert_eq!(*public_hits.lock().expect("mutex should lock"), 0); diff --git a/crates/aether-admin/src/provider/endpoints.rs b/crates/aether-admin/src/provider/endpoints.rs index 26d7643c0..d85460934 100644 --- a/crates/aether-admin/src/provider/endpoints.rs +++ b/crates/aether-admin/src/provider/endpoints.rs @@ -10,7 +10,8 @@ use super::redaction::{ admin_restore_secret_safe_body_rules, admin_restore_secret_safe_header_rules, admin_restore_secret_safe_json, admin_restore_secret_safe_proxy, admin_restore_secret_safe_url, admin_secret_safe_body_rules, admin_secret_safe_header_rules, admin_secret_safe_json, - admin_secret_safe_proxy, admin_secret_safe_url, + admin_secret_safe_proxy, admin_secret_safe_url, admin_validate_retained_body_rule_secrets, + admin_validate_retained_header_rule_secrets, }; pub fn normalize_endpoint_api_format(api_format: &str) -> String { @@ -200,6 +201,55 @@ mod endpoint_key_count_tests { assert_eq!(active, total); } + #[test] + fn endpoint_updates_reject_unresolved_rule_masks_before_persistence() { + let header_rules = json!([{"action": "set", "key": "x-auth", "value": "header-secret"}]); + let body_rules = json!([{"action": "set", "path": "auth.token", "value": "body-secret"}]); + let mut endpoint = sample_endpoint("chat", "openai:chat"); + endpoint.header_rules = Some(header_rules.clone()); + endpoint.body_rules = Some(body_rules.clone()); + endpoint.config = Some(json!({"response_header_rules": header_rules})); + let moved_header = + json!([{"action": "set", "key": "x-other-auth", "value": "***", "has_value": true}]); + let cases = [ + ( + "header_rules", + super::AdminProviderEndpointUpdateFields { + header_rules: Some(moved_header.clone()), + ..Default::default() + }, + ), + ( + "body_rules", + super::AdminProviderEndpointUpdateFields { + body_rules: Some( + json!([{"action": "set", "path": "auth.api_key", "value": "***", "has_value": true}]), + ), + ..Default::default() + }, + ), + ( + "config", + super::AdminProviderEndpointUpdateFields { + config: Some(json!({"response_header_rules": moved_header})), + ..Default::default() + }, + ), + ]; + for (field, payload) in cases { + let error = super::apply_admin_provider_endpoint_update_fields( + &endpoint, + |key| key == field, + |_| false, + &payload, + ) + .expect_err("unresolved masks must not overwrite saved secrets"); + assert!(error.contains("无法匹配")); + assert!(!error.contains("header-secret")); + assert!(!error.contains("body-secret")); + } + } + #[test] fn inherited_endpoint_counts_only_include_active_formats() { let responses_endpoint = sample_endpoint("responses", "openai:responses"); @@ -352,6 +402,10 @@ where if !header_rules.is_array() { return Err("header_rules 必须是数组或 null".to_string()); } + admin_validate_retained_header_rule_secrets( + existing_endpoint.header_rules.as_ref(), + header_rules, + )?; Some(admin_restore_secret_safe_header_rules( existing_endpoint.header_rules.as_ref(), header_rules, @@ -369,6 +423,10 @@ where if !body_rules.is_array() { return Err("body_rules 必须是数组或 null".to_string()); } + admin_validate_retained_body_rule_secrets( + existing_endpoint.body_rules.as_ref(), + body_rules, + )?; Some(admin_restore_secret_safe_body_rules( existing_endpoint.body_rules.as_ref(), body_rules, @@ -407,6 +465,15 @@ where if !config.is_object() { return Err("config 必须是对象或 null".to_string()); } + if let Some(rules) = config.get("response_header_rules") { + admin_validate_retained_header_rule_secrets( + existing_endpoint + .config + .as_ref() + .and_then(|config| config.get("response_header_rules")), + rules, + )?; + } Some(admin_restore_secret_safe_json( existing_endpoint.config.as_ref(), config, diff --git a/crates/aether-admin/src/provider/redaction.rs b/crates/aether-admin/src/provider/redaction.rs index 39890da95..12027842c 100644 --- a/crates/aether-admin/src/provider/redaction.rs +++ b/crates/aether-admin/src/provider/redaction.rs @@ -1,5 +1,4 @@ use serde_json::{json, Map, Value}; -use std::collections::BTreeMap; const REDACTED_VALUE: &str = "***"; const REDACTED_UPSTREAM_DIAGNOSTIC: &str = "[REDACTED upstream diagnostic]"; @@ -190,6 +189,20 @@ pub fn admin_restore_secret_safe_body_rules(existing: Option<&Value>, incoming: restore_rule_array(existing, incoming, RuleKind::Body) } +pub fn admin_validate_retained_header_rule_secrets( + existing: Option<&Value>, + incoming: &Value, +) -> Result<(), String> { + validate_retained_rule_secrets(existing, incoming, RuleKind::Header) +} + +pub fn admin_validate_retained_body_rule_secrets( + existing: Option<&Value>, + incoming: &Value, +) -> Result<(), String> { + validate_retained_rule_secrets(existing, incoming, RuleKind::Body) +} + pub fn admin_secret_safe_url(value: Option<&str>) -> Value { value .and_then(sanitize_network_url) @@ -1234,11 +1247,7 @@ fn redact_proxy_json_value_for_key(key: &str, value: &Value) -> Value { return redact_proxy_secret_value(value); } if json_url_key(&compact_key) { - return value - .as_str() - .and_then(sanitize_network_url) - .map(Value::String) - .unwrap_or(Value::Null); + return redact_json_url_value(value); } if compact_key == "proxy" { return admin_secret_safe_proxy(Some(value)); @@ -1261,11 +1270,7 @@ fn redact_json_value_for_key(key: &str, value: &Value) -> Value { return redact_secret_value(value); } if json_url_key(&compact_key) { - return value - .as_str() - .and_then(sanitize_network_url) - .map(Value::String) - .unwrap_or(Value::Null); + return redact_json_url_value(value); } if compact_key == "proxy" { return admin_secret_safe_proxy(Some(value)); @@ -1282,6 +1287,17 @@ fn redact_json_value_for_key(key: &str, value: &Value) -> Value { redact_json_value(value) } +fn redact_json_url_value(value: &Value) -> Value { + match value { + Value::String(raw) if url::Url::parse(raw).is_ok_and(|url| url.scheme() == "data") => { + value.clone() + } + Value::String(raw) => admin_secret_safe_url(Some(raw)), + Value::Array(values) => Value::Array(values.iter().map(redact_json_url_value).collect()), + _ => redact_json_value(value), + } +} + fn redact_body_rule(rule: &Value) -> Value { let Some(rule) = rule.as_object() else { return redact_json_value(rule); @@ -1320,7 +1336,12 @@ fn redact_header_rule(rule: &Value) -> Value { .map(|(key, value)| (key.clone(), redact_json_value_for_key(key, value))) .collect::>(); let is_set = normalized_string_field(rule, "action").as_deref() == Some("set"); - if is_set { + if is_set + && rule + .get("key") + .and_then(Value::as_str) + .is_none_or(header_value_is_secret) + { redact_rule_secret_field(rule, &mut projected, "value", "has_value"); } if let Some(condition) = rule.get("condition") { @@ -1374,7 +1395,14 @@ fn redact_header_values(value: &Value) -> Value { Value::Object( headers .iter() - .map(|(key, value)| (key.clone(), redact_secret_value(value))) + .map(|(key, value)| { + let value = if header_value_is_secret(key) { + redact_secret_value(value) + } else { + redact_json_value(value) + }; + (key.clone(), value) + }) .collect(), ) } @@ -1395,10 +1423,25 @@ fn restore_json_value(existing: Option<&Value>, incoming: &Value, key: Option<&s return restore_header_values(existing, incoming); } if json_url_key(&compact_key) { - return incoming - .as_str() - .map(|incoming_url| restore_url_value(existing, incoming_url)) - .unwrap_or_else(|| incoming.clone()); + if let Some(incoming_url) = incoming.as_str() { + return restore_url_value(existing, incoming_url); + } + if let Some(values) = incoming.as_array() { + let existing_values = existing.and_then(Value::as_array); + return Value::Array( + values + .iter() + .enumerate() + .map(|(index, value)| { + restore_json_value( + existing_values.and_then(|values| values.get(index)), + value, + Some(key), + ) + }) + .collect(), + ); + } } if json_secret_key(&compact_key, incoming) { return restore_masked_secret(existing, incoming, true); @@ -1448,29 +1491,20 @@ fn restore_rule_array(existing: Option<&Value>, incoming: &Value, kind: RuleKind let Some(incoming_values) = incoming.as_array() else { return incoming.clone(); }; - let existing_values = existing.and_then(Value::as_array); - let incoming_identities = identity_counts(incoming_values, kind); - let existing_identities = existing_values - .map(|values| identity_counts(values, kind)) + if unchanged_projected_rules(existing, incoming, kind) { + return existing.cloned().unwrap_or_else(|| incoming.clone()); + } + let existing_values = existing + .and_then(Value::as_array) + .map(Vec::as_slice) .unwrap_or_default(); Value::Array( incoming_values .iter() .map(|incoming_rule| { - let identity = rule_identity(incoming_rule, kind); - let existing_rule = identity.as_ref().and_then(|identity| { - (incoming_identities.get(identity) == Some(&1) - && existing_identities.get(identity) == Some(&1)) - .then(|| { - existing_values.and_then(|values| { - values.iter().find(|candidate| { - rule_identity(candidate, kind).as_ref() == Some(identity) - }) - }) - }) - .flatten() - }); + let existing_rule = + match_existing_rule(existing_values, incoming_values, incoming_rule, kind); restore_rule(existing_rule, incoming_rule, kind) }) .collect(), @@ -1546,7 +1580,10 @@ fn restore_condition(existing: Option<&Value>, incoming: &Value) -> Value { continue; } let existing_value = existing_object.and_then(|object| object.get(key)); - let value = if key == "value" && condition_value_is_secret(incoming_object) { + let value = if key == "value" + && (condition_value_is_secret(incoming_object) + || incoming_object.get("has_value").and_then(Value::as_bool) == Some(true)) + { let marker_set = incoming_object.get("has_value").and_then(Value::as_bool) == Some(true); restore_masked_secret(existing_value, incoming_value, marker_set) @@ -1562,29 +1599,25 @@ fn restore_condition_array(existing: Option<&Value>, incoming: &Value) -> Value let Some(incoming_values) = incoming.as_array() else { return incoming.clone(); }; - let existing_values = existing.and_then(Value::as_array); - let incoming_counts = condition_identity_counts(incoming_values); - let existing_counts = existing_values - .map(|values| condition_identity_counts(values)) + let existing_values = existing + .and_then(Value::as_array) + .map(Vec::as_slice) .unwrap_or_default(); + if projected_conditions_unchanged(existing_values, incoming_values) { + return Value::Array(existing_values.to_vec()); + } Value::Array( incoming_values .iter() .map(|incoming_condition| { - let identity = condition_identity(incoming_condition); - let existing_condition = identity.as_ref().and_then(|identity| { - (incoming_counts.get(identity) == Some(&1) - && existing_counts.get(identity) == Some(&1)) - .then(|| { - existing_values.and_then(|values| { - values.iter().find(|candidate| { - condition_identity(candidate).as_ref() == Some(identity) - }) - }) - }) - .flatten() - }); + let existing_condition = match_existing_entry( + existing_values, + incoming_values, + incoming_condition, + condition_identity, + redact_condition, + ); restore_condition(existing_condition, incoming_condition) }) .collect(), @@ -1633,20 +1666,174 @@ fn restore_url_value(existing: Option<&Value>, incoming_url: &str) -> Value { Value::String(incoming_url.to_string()) } -fn identity_counts(values: &[Value], kind: RuleKind) -> BTreeMap { - let mut counts = BTreeMap::new(); - for identity in values.iter().filter_map(|value| rule_identity(value, kind)) { - *counts.entry(identity).or_insert(0) += 1; +fn project_rule(rule: &Value, kind: RuleKind) -> Value { + match kind { + RuleKind::Header => redact_header_rule(rule), + RuleKind::Body => redact_body_rule(rule), } - counts } -fn condition_identity_counts(values: &[Value]) -> BTreeMap { - let mut counts = BTreeMap::new(); - for identity in values.iter().filter_map(condition_identity) { - *counts.entry(identity).or_insert(0) += 1; +fn unchanged_projected_rules(existing: Option<&Value>, incoming: &Value, kind: RuleKind) -> bool { + existing.and_then(Value::as_array).is_some_and(|rules| { + Value::Array(rules.iter().map(|rule| project_rule(rule, kind)).collect()) == *incoming + }) +} + +fn rule_match_shape(rule: &Value, kind: RuleKind) -> Value { + let mut projected = project_rule(rule, kind); + if let Some(object) = projected.as_object_mut() { + for field in [ + "enabled", + "value", + "has_value", + "pattern", + "has_pattern", + "replacement", + "has_replacement", + ] { + object.remove(field); + } } - counts + projected +} + +fn match_existing_rule<'a>( + existing: &'a [Value], + incoming: &[Value], + rule: &Value, + kind: RuleKind, +) -> Option<&'a Value> { + match_existing_entry( + existing, + incoming, + rule, + |value| rule_identity(value, kind), + |value| rule_match_shape(value, kind), + ) +} + +fn match_existing_entry<'a>( + existing: &'a [Value], + incoming: &[Value], + entry: &Value, + identity: impl Fn(&Value) -> Option, + project: impl Fn(&Value) -> Value, +) -> Option<&'a Value> { + let entry_identity = identity(entry)?; + let same_identity = |value: &&Value| identity(value).as_ref() == Some(&entry_identity); + let candidates = existing.iter().filter(same_identity).collect::>(); + if candidates.len() == 1 && incoming.iter().filter(same_identity).count() == 1 { + return candidates.first().copied(); + } + let projected = project(entry); + let mut matching = candidates + .into_iter() + .filter(|candidate| project(candidate) == projected); + let matched = matching.next()?; + if matching.next().is_some() + || incoming + .iter() + .filter(same_identity) + .filter(|value| project(value) == projected) + .count() + != 1 + { + return None; + } + Some(matched) +} + +fn projected_conditions_unchanged(existing: &[Value], incoming: &[Value]) -> bool { + existing.len() == incoming.len() + && existing + .iter() + .zip(incoming) + .all(|(existing, incoming)| redact_condition(existing) == *incoming) +} + +fn validate_retained_rule_secrets( + existing: Option<&Value>, + incoming: &Value, + kind: RuleKind, +) -> Result<(), String> { + let Some(incoming_values) = incoming.as_array() else { + return Ok(()); + }; + if unchanged_projected_rules(existing, incoming, kind) { + return Ok(()); + } + let existing_values = existing + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + for rule in incoming_values { + let existing_rule = match_existing_rule(existing_values, incoming_values, rule, kind); + validate_retained_secret_fields(existing_rule, rule)?; + if let Some(condition) = rule.get("condition") { + validate_retained_condition_secrets( + existing_rule.and_then(|rule| rule.get("condition")), + condition, + )?; + } + } + Ok(()) +} + +fn validate_retained_secret_fields( + existing: Option<&Value>, + incoming: &Value, +) -> Result<(), String> { + for (field, marker) in [ + ("value", "has_value"), + ("pattern", "has_pattern"), + ("replacement", "has_replacement"), + ] { + if incoming.get(marker).and_then(Value::as_bool) == Some(true) + && incoming.get(field).and_then(Value::as_str) == Some(REDACTED_VALUE) + && existing + .and_then(|value| value.get(field)) + .filter(|value| secret_value_is_set(value)) + .is_none() + { + return Err( + "无法匹配脱敏规则的原值,请查看原值后重新填写,避免将占位符保存为实际配置" + .to_string(), + ); + } + } + Ok(()) +} + +fn validate_retained_condition_secrets( + existing: Option<&Value>, + incoming: &Value, +) -> Result<(), String> { + for group_key in ["all", "any"] { + if let Some(children) = incoming.get(group_key).and_then(Value::as_array) { + let existing_children = existing + .and_then(|value| value.get(group_key)) + .and_then(Value::as_array) + .map(Vec::as_slice) + .unwrap_or_default(); + if projected_conditions_unchanged(existing_children, children) { + return Ok(()); + } + for child in children { + let existing_child = match_existing_entry( + existing_children, + children, + child, + condition_identity, + redact_condition, + ); + validate_retained_condition_secrets(existing_child, child)?; + } + return Ok(()); + } + } + let existing = + existing.filter(|value| condition_identity(value) == condition_identity(incoming)); + validate_retained_secret_fields(existing, incoming) } fn rule_identity(value: &Value, kind: RuleKind) -> Option { @@ -1676,8 +1863,18 @@ fn rule_identity(value: &Value, kind: RuleKind) -> Option { fn condition_identity(value: &Value) -> Option { let value = value.as_object()?; - if value.contains_key("all") || value.contains_key("any") { - return None; + for group_key in ["all", "any"] { + if let Some(children) = value.get(group_key).and_then(Value::as_array) { + let mut identities = children + .iter() + .map(condition_identity) + .collect::>>()?; + identities.sort(); + return Some(format!( + "condition:{group_key}:{}", + serde_json::to_string(&identities).ok()? + )); + } } let path = trimmed_string_field(value, "path")?; let op = normalized_string_field(value, "op")?; @@ -1720,11 +1917,36 @@ fn is_rule_marker(key: &str) -> bool { fn condition_value_is_secret(condition: &Map) -> bool { let source = normalized_condition_source(condition.get("source").and_then(Value::as_str)); - source == "request_headers" - || condition - .get("path") - .and_then(Value::as_str) - .is_some_and(json_path_targets_secret) + let path = condition.get("path").and_then(Value::as_str); + if source == "request_headers" { + path.is_none_or(header_value_is_secret) + } else { + path.is_some_and(json_path_targets_secret) + } +} + +fn header_value_is_secret(name: &str) -> bool { + !matches!( + name.trim().to_ascii_lowercase().as_str(), + "accept" + | "accept-encoding" + | "accept-language" + | "cache-control" + | "content-encoding" + | "content-type" + | "user-agent" + | "anthropic-version" + | "anthropic-beta" + | "openai-beta" + | "x-stainless-lang" + | "x-stainless-package-version" + | "x-stainless-os" + | "x-stainless-arch" + | "x-stainless-runtime" + | "x-stainless-runtime-version" + | "x-stainless-retry-count" + | "x-stainless-timeout" + ) } fn normalized_condition_source(source: Option<&str>) -> String { @@ -2542,4 +2764,167 @@ mod tests { assert_eq!(projected, "https://api.example/v1"); assert_eq!(admin_secret_safe_url(Some("not a url")), json!(null)); } + + #[test] + fn public_protocol_headers_and_conditions_remain_editable() { + let rules = json!([ + {"action": "set", "key": "Content-Type", "value": "application/json"}, + {"action": "set", "key": "User-Agent", "value": "client/1.0"}, + {"action": "set", "key": "anthropic-version", "value": "2023-06-01"}, + {"action": "set", "key": "OpenAI-Beta", "value": "responses=experimental", "condition": { + "source": "request_headers", "path": "Accept", "op": "eq", "value": "text/event-stream" + }} + ]); + assert_eq!(admin_secret_safe_header_rules(Some(&rules)), rules); + let mut legacy_projection = rules.clone(); + legacy_projection[0]["value"] = json!("***"); + legacy_projection[0]["has_value"] = json!(true); + legacy_projection[3]["condition"]["value"] = json!("***"); + legacy_projection[3]["condition"]["has_value"] = json!(true); + assert_eq!( + admin_restore_secret_safe_header_rules(Some(&rules), &legacy_projection), + rules + ); + } + + #[test] + fn header_maps_keep_protocol_values_but_hide_credentials_and_unknown_headers() { + let projected = admin_secret_safe_json(Some(&json!({"headers": { + "Content-Type": "application/json", + "User-Agent": "client/1.0", + "Authorization": "Bearer secret", + "Cookie": "session=secret", + "x-custom-auth": "custom-secret" + }}))); + assert_eq!(projected["headers"]["Content-Type"], "application/json"); + assert_eq!(projected["headers"]["User-Agent"], "client/1.0"); + for header in ["Authorization", "Cookie", "x-custom-auth"] { + assert_eq!(projected["headers"][header], "***"); + } + } + + #[test] + fn duplicate_conditional_rules_retain_secrets_when_reordered_or_disabled() { + let existing = json!([ + {"action": "set", "key": "x-auth", "value": "first-secret", "condition": { + "path": "model", "op": "eq", "value": "first-model" + }}, + {"action": "set", "key": "x-auth", "value": "second-secret", "condition": { + "path": "model", "op": "eq", "value": "second-model" + }} + ]); + let mut incoming = admin_secret_safe_header_rules(Some(&existing)); + incoming.as_array_mut().unwrap().reverse(); + incoming[0]["enabled"] = json!(false); + super::admin_validate_retained_header_rule_secrets(Some(&existing), &incoming).unwrap(); + let restored = admin_restore_secret_safe_header_rules(Some(&existing), &incoming); + assert_eq!(restored[0]["value"], "second-secret"); + assert_eq!(restored[0]["enabled"], false); + assert_eq!(restored[1]["value"], "first-secret"); + assert!(restored[0].get("has_value").is_none()); + } + + #[test] + fn unchanged_duplicate_body_rules_preserve_their_original_values() { + let existing = json!([ + {"action": "append", "path": "auth.cookies", "value": "first-secret"}, + {"action": "append", "path": "auth.cookies", "value": "second-secret"} + ]); + let incoming = admin_secret_safe_body_rules(Some(&existing)); + super::admin_validate_retained_body_rule_secrets(Some(&existing), &incoming).unwrap(); + assert_eq!( + admin_restore_secret_safe_body_rules(Some(&existing), &incoming), + existing + ); + } + + #[test] + fn nested_condition_groups_preserve_secrets_after_sibling_edits_and_reordering() { + let existing = json!([{ + "action": "set", "key": "x-output", "value": "header-secret", + "condition": {"all": [ + {"any": [ + {"path": "auth.token", "op": "eq", "value": "condition-secret"}, + {"path": "model", "op": "eq", "value": "old-model"} + ]}, + {"path": "metadata.enabled", "op": "eq", "value": true} + ]} + }]); + let mut incoming = admin_secret_safe_header_rules(Some(&existing)); + incoming[0]["condition"]["all"][0]["any"][1]["value"] = json!("new-model"); + incoming[0]["condition"]["all"] + .as_array_mut() + .unwrap() + .reverse(); + super::admin_validate_retained_header_rule_secrets(Some(&existing), &incoming).unwrap(); + let restored = admin_restore_secret_safe_header_rules(Some(&existing), &incoming); + assert_eq!(restored[0]["value"], "header-secret"); + assert_eq!( + restored[0]["condition"]["all"][1]["any"][0]["value"], + "condition-secret" + ); + assert_eq!( + restored[0]["condition"]["all"][1]["any"][1]["value"], + "new-model" + ); + assert!(!restored.to_string().contains("has_value")); + } + + #[test] + fn retained_masks_cannot_silently_overwrite_changed_or_ambiguous_rules() { + let existing = json!([ + {"action": "set", "key": "x-auth", "value": "first-secret"}, + {"action": "set", "key": "x-auth", "value": "second-secret"} + ]); + let mut incoming = admin_secret_safe_header_rules(Some(&existing)); + incoming[0]["enabled"] = json!(false); + assert!( + super::admin_validate_retained_header_rule_secrets(Some(&existing), &incoming).is_err() + ); + + let existing = json!([{"action": "set", "path": "auth.token", "value": "secret"}]); + let mut incoming = admin_secret_safe_body_rules(Some(&existing)); + incoming[0]["path"] = json!("auth.api_key"); + assert!( + super::admin_validate_retained_body_rule_secrets(Some(&existing), &incoming).is_err() + ); + incoming[0]["value"] = json!("replacement-secret"); + incoming[0].as_object_mut().unwrap().remove("has_value"); + assert!( + super::admin_validate_retained_body_rule_secrets(Some(&existing), &incoming).is_ok() + ); + } + + #[test] + fn body_rule_projection_preserves_structured_image_urls_and_inline_data() { + let existing = json!([{ + "action": "append", "path": "messages[0].content", + "value": {"type": "image_url", "image_url": {"url": "data:image/png;base64,aW1hZ2U=", "detail": "high"}} + }]); + let projected = admin_secret_safe_body_rules(Some(&existing)); + assert_eq!(projected, existing); + assert_eq!( + admin_restore_secret_safe_body_rules(Some(&existing), &projected), + existing + ); + } + + #[test] + fn structured_url_values_still_hide_and_restore_network_credentials() { + let existing = json!({ + "image_url": {"url": "https://user:password@example.test/image?token=secret", "detail": "auto"}, + "url": ["https://example.test/file?token=secret", "data:image/png;base64,aW1hZ2U="] + }); + let projected = admin_secret_safe_json(Some(&existing)); + assert_eq!(projected["image_url"]["url"], "https://example.test/image"); + assert_eq!(projected["image_url"]["detail"], "auto"); + assert_eq!(projected["url"][0], "https://example.test/file"); + assert_eq!(projected["url"][1], existing["url"][1]); + assert!(!projected.to_string().contains("secret")); + assert!(!projected.to_string().contains("password")); + assert_eq!( + admin_restore_secret_safe_json(Some(&existing), &projected), + existing + ); + } } diff --git a/crates/aether-data/adapters/postgres/src/usage/cleanup.rs b/crates/aether-data/adapters/postgres/src/usage/cleanup.rs index 509164877..21deef1b0 100644 --- a/crates/aether-data/adapters/postgres/src/usage/cleanup.rs +++ b/crates/aether-data/adapters/postgres/src/usage/cleanup.rs @@ -1,8 +1,11 @@ +use std::io::Write; + use aether_data_contracts::repository::usage::{ - UsageCleanupExecutionMode, UsageCleanupPreviewCounts, UsageCleanupSummary, UsageCleanupTargets, - UsageCleanupWindow, + parse_usage_body_ref, usage_body_ref, UsageBodyField, UsageCleanupExecutionMode, + UsageCleanupPreviewCounts, UsageCleanupSummary, UsageCleanupTargets, UsageCleanupWindow, }; use chrono::{DateTime, Utc}; +use flate2::{write::GzEncoder, Compression}; use futures_util::TryStreamExt; use serde_json::Value; use sqlx::Row; @@ -211,7 +214,8 @@ SET request_body_ref = NULL, WHERE request_id = ANY($1) "#; const SELECT_USAGE_BODY_COMPRESSION_BATCH_SQL: &str = r#" -SELECT id, request_id +SELECT + id FROM usage WHERE created_at < $1 AND ($2::timestamptz IS NULL OR created_at >= $2) @@ -224,26 +228,27 @@ WHERE created_at < $1 OR provider_request_body_compressed IS NOT NULL OR client_response_body IS NOT NULL OR client_response_body_compressed IS NOT NULL - OR EXISTS ( - SELECT 1 - FROM usage_body_blobs - WHERE usage_body_blobs.request_id = usage.request_id - ) - OR EXISTS ( - SELECT 1 - FROM usage_http_audits - WHERE usage_http_audits.request_id = usage.request_id - AND ( - usage_http_audits.request_body_ref IS NOT NULL - OR usage_http_audits.provider_request_body_ref IS NOT NULL - OR usage_http_audits.response_body_ref IS NOT NULL - OR usage_http_audits.client_response_body_ref IS NOT NULL - ) - ) ) ORDER BY created_at ASC, id ASC LIMIT $3 "#; +const SELECT_USAGE_BODY_COMPRESSION_ROW_SQL: &str = r#" +SELECT + id, + request_id, + request_body, + request_body_compressed, + response_body, + response_body_compressed, + provider_request_body, + provider_request_body_compressed, + client_response_body, + client_response_body_compressed +FROM usage +WHERE id = $1 +LIMIT 1 +FOR UPDATE +"#; const SELECT_EXPIRED_ACTIVE_API_KEYS_SQL: &str = r#" SELECT id, auto_delete_on_expiry FROM api_keys @@ -270,6 +275,30 @@ WHERE id = $1 AND is_active IS TRUE "#; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsageDetachedBodyBlobWrite { + pub body_ref: String, + pub body_field: &'static str, + pub payload_gzip: Vec, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UsageDetachedBodyRefs { + pub request_body_ref: Option, + pub provider_request_body_ref: Option, + pub response_body_ref: Option, + pub client_response_body_ref: Option, +} + +impl UsageDetachedBodyRefs { + pub fn any_present(&self) -> bool { + self.request_body_ref.is_some() + || self.provider_request_body_ref.is_some() + || self.response_body_ref.is_some() + || self.client_response_body_ref.is_some() + } +} + #[derive(Debug, Clone, PartialEq)] pub struct UsageLegacyBodyRefMetadataRow { pub id: String, @@ -277,11 +306,32 @@ pub struct UsageLegacyBodyRefMetadataRow { pub request_metadata: Option, } -#[derive(Debug, Clone, PartialEq)] -pub struct UsageLegacyBodyRefPurgePlan { +#[derive(Debug, Clone, Default, PartialEq)] +pub struct UsageLegacyBodyRefMigrationPlan { + pub refs: UsageDetachedBodyRefs, pub request_metadata: Option, } +#[derive(Debug, Clone, PartialEq)] +pub struct UsageBodyCompressionRow { + pub id: String, + pub request_id: String, + pub request_body: Option, + pub request_body_compressed: Option>, + pub response_body: Option, + pub response_body_compressed: Option>, + pub provider_request_body: Option, + pub provider_request_body_compressed: Option>, + pub client_response_body: Option, + pub client_response_body_compressed: Option>, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct UsageBodyExternalizationPlan { + pub blobs: Vec, + pub refs: UsageDetachedBodyRefs, +} + #[derive(Debug, Clone, PartialEq)] struct UsageBodyCleanupRow { id: String, @@ -294,23 +344,57 @@ struct ExpiredApiKeyRow<'a> { auto_delete_on_expiry: Option, } -pub fn purge_legacy_body_ref_metadata_plan( +pub fn compress_usage_json_value(value: &Value) -> Result, DataLayerError> { + let bytes = serde_json::to_vec(value).map_err(|err| { + DataLayerError::UnexpectedValue(format!("failed to serialize usage json for gzip: {err}")) + })?; + let mut encoder = GzEncoder::new(Vec::new(), Compression::new(6)); + encoder.write_all(&bytes).map_err(|err| { + DataLayerError::UnexpectedValue(format!("failed to gzip usage json: {err}")) + })?; + encoder.finish().map_err(|err| { + DataLayerError::UnexpectedValue(format!("failed to finish gzipped usage json: {err}")) + }) +} + +pub fn migrate_legacy_body_ref_metadata_plan( + request_id: &str, request_metadata: Option, -) -> Option { +) -> Option { let mut metadata = match request_metadata { Some(Value::Object(object)) => object, _ => return None, }; + let mut refs = UsageDetachedBodyRefs::default(); let mut removed_any = false; - for key in [ - "request_body_ref", - "provider_request_body_ref", - "response_body_ref", - "client_response_body_ref", + for field in [ + UsageBodyField::RequestBody, + UsageBodyField::ProviderRequestBody, + UsageBodyField::ResponseBody, + UsageBodyField::ClientResponseBody, ] { - if metadata.remove(key).is_some() { - removed_any = true; + let key = field.as_ref_key(); + let Some(value) = metadata.remove(key) else { + continue; + }; + removed_any = true; + let parsed = value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .and_then(parse_usage_body_ref) + .filter(|(parsed_request_id, parsed_field)| { + parsed_request_id == request_id && *parsed_field == field + }) + .map(|(parsed_request_id, parsed_field)| { + usage_body_ref(&parsed_request_id, parsed_field) + }); + match field { + UsageBodyField::RequestBody => refs.request_body_ref = parsed, + UsageBodyField::ProviderRequestBody => refs.provider_request_body_ref = parsed, + UsageBodyField::ResponseBody => refs.response_body_ref = parsed, + UsageBodyField::ClientResponseBody => refs.client_response_body_ref = parsed, } } @@ -318,11 +402,47 @@ pub fn purge_legacy_body_ref_metadata_plan( return None; } - Some(UsageLegacyBodyRefPurgePlan { + Some(UsageLegacyBodyRefMigrationPlan { + refs, request_metadata: (!metadata.is_empty()).then_some(Value::Object(metadata)), }) } +pub fn build_usage_body_externalization( + row: &UsageBodyCompressionRow, +) -> Result { + let mut plan = UsageBodyExternalizationPlan::default(); + maybe_externalize_usage_body_field( + &mut plan, + &row.request_id, + UsageBodyField::RequestBody, + row.request_body.as_ref(), + row.request_body_compressed.as_deref(), + )?; + maybe_externalize_usage_body_field( + &mut plan, + &row.request_id, + UsageBodyField::ProviderRequestBody, + row.provider_request_body.as_ref(), + row.provider_request_body_compressed.as_deref(), + )?; + maybe_externalize_usage_body_field( + &mut plan, + &row.request_id, + UsageBodyField::ResponseBody, + row.response_body.as_ref(), + row.response_body_compressed.as_deref(), + )?; + maybe_externalize_usage_body_field( + &mut plan, + &row.request_id, + UsageBodyField::ClientResponseBody, + row.client_response_body.as_ref(), + row.client_response_body_compressed.as_deref(), + )?; + Ok(plan) +} + impl SqlxUsageReadRepository { pub async fn cleanup_usage( &self, @@ -392,7 +512,7 @@ impl SqlxUsageReadRepository { }; let detail_body_newer_than = detail_body_newer_than(window, targets); let legacy_body_refs_migrated = if targets.detail_body { - purge_legacy_usage_body_ref_metadata( + migrate_legacy_usage_body_ref_metadata( &self.pool, window.detail_cutoff, batch_size, @@ -403,7 +523,7 @@ impl SqlxUsageReadRepository { 0 }; let body_externalized = if targets.detail_body { - purge_usage_detail_body_fields( + compress_usage_body_fields( &self.pool, window.detail_cutoff, batch_size, @@ -879,7 +999,7 @@ async fn delete_old_usage_records( Ok(total_deleted) } -async fn purge_legacy_usage_body_ref_metadata( +async fn migrate_legacy_usage_body_ref_metadata( pool: &PostgresPool, cutoff_time: DateTime, batch_size: usize, @@ -889,7 +1009,7 @@ async fn purge_legacy_usage_body_ref_metadata( warn!( cutoff_time = %cutoff_time, newer_than = ?newer_than, - "usage cleanup legacy body-ref purge skipped due to invalid window" + "usage cleanup legacy body-ref migration skipped due to invalid window" ); return Ok(0); } @@ -920,12 +1040,33 @@ async fn purge_legacy_usage_body_ref_metadata( break; } - let mut batch_purged = 0usize; + let mut batch_migrated = 0usize; for row in rows { - let Some(plan) = purge_legacy_body_ref_metadata_plan(row.request_metadata) else { + let mut tx = pool.begin().await.map_err(postgres_error)?; + let current_metadata = sqlx::query_scalar::<_, Option>( + "SELECT request_metadata FROM usage WHERE id = $1 FOR UPDATE", + ) + .bind(&row.id) + .fetch_optional(&mut *tx) + .await + .map_err(postgres_error)?; + let Some(plan) = + migrate_legacy_body_ref_metadata_plan(&row.request_id, current_metadata.flatten()) + else { continue; }; - let mut tx = pool.begin().await.map_err(postgres_error)?; + if plan.refs.any_present() { + sqlx::query(UPSERT_USAGE_HTTP_AUDIT_BODY_REFS_SQL) + .bind(&row.request_id) + .bind(plan.refs.request_body_ref.as_deref()) + .bind(plan.refs.provider_request_body_ref.as_deref()) + .bind(plan.refs.response_body_ref.as_deref()) + .bind(plan.refs.client_response_body_ref.as_deref()) + .bind("ref_backed") + .execute(&mut *tx) + .await + .map_err(postgres_error)?; + } let updated = sqlx::query(UPDATE_USAGE_REQUEST_METADATA_SQL) .bind(&row.id) .bind(plan.request_metadata) @@ -933,30 +1074,14 @@ async fn purge_legacy_usage_body_ref_metadata( .await .map_err(postgres_error)? .rows_affected(); - let request_ids = vec![row.request_id]; - sqlx::query(DELETE_USAGE_BODY_BLOBS_SQL) - .bind(&request_ids) - .execute(&mut *tx) - .await - .map_err(postgres_error)?; - sqlx::query(CLEAR_USAGE_HTTP_AUDIT_BODY_REFS_SQL) - .bind(&request_ids) - .execute(&mut *tx) - .await - .map_err(postgres_error)?; - sqlx::query(DELETE_EMPTY_USAGE_HTTP_AUDITS_SQL) - .bind(request_ids) - .execute(&mut *tx) - .await - .map_err(postgres_error)?; tx.commit().await.map_err(postgres_error)?; if updated > 0 { - batch_purged += 1; + batch_migrated += 1; } } - total_migrated += batch_purged; - if batch_purged == 0 || batch_purged < batch_size { + total_migrated += batch_migrated; + if batch_migrated == 0 || batch_migrated < batch_size { break; } } @@ -1099,7 +1224,7 @@ async fn cleanup_usage_stale_body_fields( Ok(total_cleaned) } -async fn purge_usage_detail_body_fields( +async fn compress_usage_body_fields( pool: &PostgresPool, cutoff_time: DateTime, batch_size: usize, @@ -1109,65 +1234,130 @@ async fn purge_usage_detail_body_fields( warn!( cutoff_time = %cutoff_time, newer_than = ?newer_than, - "usage cleanup detail body purge skipped due to invalid window" + "usage cleanup body compression skipped due to invalid window" ); return Ok(0); } - let mut total_purged = 0usize; + let mut total_compressed = 0usize; + let mut no_progress_count = 0usize; + let batch_size = batch_size.clamp(1, 25); loop { let mut stream = sqlx::query(SELECT_USAGE_BODY_COMPRESSION_BATCH_SQL) .bind(cutoff_time) .bind(newer_than) .bind(i64::try_from(batch_size).unwrap_or(i64::MAX)) .fetch(pool); - let mut rows = Vec::new(); + let mut ids = Vec::new(); while let Some(row) = stream.try_next().await.map_err(postgres_error)? { - rows.push(UsageBodyCleanupRow { + ids.push(row.try_get::("id").map_err(postgres_error)?); + } + if ids.is_empty() { + break; + } + + let mut batch_success = 0usize; + for id in ids { + let mut tx = pool.begin().await.map_err(postgres_error)?; + let row = sqlx::query(SELECT_USAGE_BODY_COMPRESSION_ROW_SQL) + .bind(&id) + .fetch_optional(&mut *tx) + .await + .map_err(postgres_error)?; + let Some(row) = row else { + continue; + }; + let row = UsageBodyCompressionRow { id: row.try_get::("id").map_err(postgres_error)?, request_id: row .try_get::("request_id") .map_err(postgres_error)?, - }); + request_body: row + .try_get::, _>("request_body") + .map_err(postgres_error)?, + request_body_compressed: row + .try_get::>, _>("request_body_compressed") + .map_err(postgres_error)?, + response_body: row + .try_get::, _>("response_body") + .map_err(postgres_error)?, + response_body_compressed: row + .try_get::>, _>("response_body_compressed") + .map_err(postgres_error)?, + provider_request_body: row + .try_get::, _>("provider_request_body") + .map_err(postgres_error)?, + provider_request_body_compressed: row + .try_get::>, _>("provider_request_body_compressed") + .map_err(postgres_error)?, + client_response_body: row + .try_get::, _>("client_response_body") + .map_err(postgres_error)?, + client_response_body_compressed: row + .try_get::>, _>("client_response_body_compressed") + .map_err(postgres_error)?, + }; + let detached = build_usage_body_externalization(&row)?; + if detached.refs.any_present() { + for blob in &detached.blobs { + sqlx::query(super::UPSERT_USAGE_BODY_BLOB_SQL) + .bind(&blob.body_ref) + .bind(&row.request_id) + .bind(blob.body_field) + .bind(&blob.payload_gzip) + .execute(&mut *tx) + .await + .map_err(postgres_error)?; + } + sqlx::query(UPSERT_USAGE_HTTP_AUDIT_BODY_REFS_SQL) + .bind(&row.request_id) + .bind(detached.refs.request_body_ref.as_deref()) + .bind(detached.refs.provider_request_body_ref.as_deref()) + .bind(detached.refs.response_body_ref.as_deref()) + .bind(detached.refs.client_response_body_ref.as_deref()) + .bind("ref_backed") + .execute(&mut *tx) + .await + .map_err(postgres_error)?; + let updated = sqlx::query(UPDATE_USAGE_BODY_COMPRESSION_SQL) + .bind(&row.id) + .execute(&mut *tx) + .await + .map_err(postgres_error)? + .rows_affected(); + tx.commit().await.map_err(postgres_error)?; + if updated > 0 { + batch_success += 1; + } + continue; + } + + let updated = sqlx::query(UPDATE_USAGE_BODY_COMPRESSION_SQL) + .bind(&row.id) + .execute(&mut *tx) + .await + .map_err(postgres_error)? + .rows_affected(); + tx.commit().await.map_err(postgres_error)?; + if updated > 0 { + batch_success += 1; + } } - if rows.is_empty() { - break; - } - let row_count = rows.len(); - let ids = rows.iter().map(|row| row.id.clone()).collect::>(); - let request_ids = rows - .iter() - .map(|row| row.request_id.clone()) - .collect::>(); - let mut tx = pool.begin().await.map_err(postgres_error)?; - let updated = sqlx::query(CLEAR_USAGE_BODY_FIELDS_SQL) - .bind(ids) - .execute(&mut *tx) - .await - .map_err(postgres_error)? - .rows_affected(); - sqlx::query(DELETE_USAGE_BODY_BLOBS_SQL) - .bind(&request_ids) - .execute(&mut *tx) - .await - .map_err(postgres_error)?; - sqlx::query(CLEAR_USAGE_HTTP_AUDIT_BODY_REFS_SQL) - .bind(&request_ids) - .execute(&mut *tx) - .await - .map_err(postgres_error)?; - sqlx::query(DELETE_EMPTY_USAGE_HTTP_AUDITS_SQL) - .bind(request_ids) - .execute(&mut *tx) - .await - .map_err(postgres_error)?; - tx.commit().await.map_err(postgres_error)?; - total_purged = total_purged.saturating_add(usize::try_from(updated).unwrap_or(usize::MAX)); - if row_count < batch_size { - break; + + if batch_success == 0 { + no_progress_count += 1; + if no_progress_count >= 3 { + warn!( + "usage cleanup body compression stopped after repeated zero-progress batches" + ); + break; + } + } else { + no_progress_count = 0; } + total_compressed += batch_success; } - Ok(total_purged) + Ok(total_compressed) } async fn cleanup_expired_api_keys( @@ -1218,22 +1408,387 @@ async fn cleanup_expired_api_keys( Ok(cleaned) } +fn maybe_externalize_usage_body_field( + plan: &mut UsageBodyExternalizationPlan, + request_id: &str, + field: UsageBodyField, + inline_body: Option<&Value>, + compressed_body: Option<&[u8]>, +) -> Result<(), DataLayerError> { + let Some(payload_gzip) = (match inline_body { + Some(value) => Some(compress_usage_json_value(value)?), + None => compressed_body.map(|value| value.to_vec()), + }) else { + return Ok(()); + }; + let body_ref = usage_body_ref(request_id, field); + plan.blobs.push(UsageDetachedBodyBlobWrite { + body_ref: body_ref.clone(), + body_field: field.as_storage_field(), + payload_gzip, + }); + match field { + UsageBodyField::RequestBody => plan.refs.request_body_ref = Some(body_ref), + UsageBodyField::ProviderRequestBody => plan.refs.provider_request_body_ref = Some(body_ref), + UsageBodyField::ResponseBody => plan.refs.response_body_ref = Some(body_ref), + UsageBodyField::ClientResponseBody => plan.refs.client_response_body_ref = Some(body_ref), + } + Ok(()) +} + +const UPSERT_USAGE_HTTP_AUDIT_BODY_REFS_SQL: &str = r#" +INSERT INTO usage_http_audits ( + request_id, + request_body_ref, + provider_request_body_ref, + response_body_ref, + client_response_body_ref, + body_capture_mode +) +VALUES ( + $1, + $2, + $3, + $4, + $5, + $6 +) +ON CONFLICT (request_id) +DO UPDATE SET + request_body_ref = COALESCE(EXCLUDED.request_body_ref, usage_http_audits.request_body_ref), + provider_request_body_ref = COALESCE( + EXCLUDED.provider_request_body_ref, + usage_http_audits.provider_request_body_ref + ), + response_body_ref = COALESCE(EXCLUDED.response_body_ref, usage_http_audits.response_body_ref), + client_response_body_ref = COALESCE( + EXCLUDED.client_response_body_ref, + usage_http_audits.client_response_body_ref + ), + body_capture_mode = CASE + WHEN EXCLUDED.request_body_ref IS NOT NULL + OR EXCLUDED.provider_request_body_ref IS NOT NULL + OR EXCLUDED.response_body_ref IS NOT NULL + OR EXCLUDED.client_response_body_ref IS NOT NULL + THEN EXCLUDED.body_capture_mode + ELSE usage_http_audits.body_capture_mode + END, + updated_at = NOW() +"#; + const UPDATE_USAGE_REQUEST_METADATA_SQL: &str = r#" UPDATE usage -SET request_metadata = $2::json, - updated_at = NOW() +SET request_metadata = $2::json +WHERE id = $1 +"#; + +const UPDATE_USAGE_BODY_COMPRESSION_SQL: &str = r#" +UPDATE usage +SET request_body = NULL, + response_body = NULL, + provider_request_body = NULL, + client_response_body = NULL, + request_body_compressed = NULL, + response_body_compressed = NULL, + provider_request_body_compressed = NULL, + client_response_body_compressed = NULL WHERE id = $1 "#; #[cfg(test)] mod tests { + use std::io::Read; + + use flate2::read::GzDecoder; use serde_json::json; use super::{ - purge_legacy_body_ref_metadata_plan, SELECT_USAGE_BODY_COMPRESSION_BATCH_SQL, + build_usage_body_externalization, compress_usage_json_value, + migrate_legacy_body_ref_metadata_plan, UsageBodyCompressionRow, SELECT_USAGE_LEGACY_BODY_REF_METADATA_BATCH_SQL, }; + #[test] + fn detail_retention_only_externalizes_legacy_bodies_and_locks_the_source_row() { + let selection = super::SELECT_USAGE_BODY_COMPRESSION_BATCH_SQL; + assert!(selection.contains("request_body IS NOT NULL")); + assert!(selection.contains("response_body_compressed IS NOT NULL")); + assert!(!selection.contains("usage_body_blobs")); + assert!(!selection.contains("usage_http_audits")); + assert!(super::SELECT_USAGE_BODY_COMPRESSION_ROW_SQL.contains("FOR UPDATE")); + } + + #[tokio::test] + #[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL migrations"] + async fn live_body_retention_externalizes_without_prematurely_deleting_full_capture() { + use aether_data_contracts::repository::usage::{usage_body_ref, UsageBodyField}; + use chrono::{Duration, Utc}; + use sqlx::Row; + + let database_url = std::env::var("AETHER_TEST_DATABASE_URL") + .expect("AETHER_TEST_DATABASE_URL must point at the test database"); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .expect("test database should connect"); + crate::run_migrations(&pool) + .await + .expect("test database should migrate"); + for table in ["usage", "usage_body_blobs", "usage_http_audits"] { + sqlx::query(&format!( + "CREATE TEMP TABLE {table} (LIKE public.{table} INCLUDING ALL)" + )) + .execute(&pool) + .await + .expect("isolated table should be created"); + } + let now = Utc::now(); + let fields = [ + UsageBodyField::RequestBody, + UsageBodyField::ProviderRequestBody, + UsageBodyField::ResponseBody, + UsageBodyField::ClientResponseBody, + ]; + for (request_id, age_days) in [ + ("legacy-inline", 12), + ("legacy-gzip", 10), + ("detached-full", 10), + ("legacy-ref", 10), + ("foreign-ref", 10), + ("expired-inline", 40), + ("expired-detached", 40), + ("recent-inline", 1), + ] { + sqlx::query( + "INSERT INTO usage (id, request_id, provider_name, model, created_at) VALUES ($1, $2, 'test', 'test', $3)", + ) + .bind(uuid::Uuid::new_v4().to_string()) + .bind(request_id) + .bind(now - Duration::days(age_days)) + .execute(&pool) + .await + .expect("usage should be seeded"); + for field in fields { + let payload = json!({"request": request_id, "field": field.as_storage_field()}); + if request_id.ends_with("inline") || request_id == "legacy-gzip" { + let column = field.as_storage_field(); + if request_id == "legacy-gzip" { + sqlx::query(&format!( + "UPDATE usage SET {column}_compressed = $1 WHERE request_id = $2" + )) + .bind(compress_usage_json_value(&payload).unwrap()) + .bind(request_id) + .execute(&pool) + .await + .expect("legacy compressed body should be seeded"); + } else { + sqlx::query(&format!( + "UPDATE usage SET {column} = $1 WHERE request_id = $2" + )) + .bind(payload) + .bind(request_id) + .execute(&pool) + .await + .expect("legacy inline body should be seeded"); + } + } else if request_id != "foreign-ref" { + sqlx::query(super::super::UPSERT_USAGE_BODY_BLOB_SQL) + .bind(usage_body_ref(request_id, field)) + .bind(request_id) + .bind(field.as_storage_field()) + .bind(compress_usage_json_value(&payload).unwrap()) + .execute(&pool) + .await + .expect("detached body should be seeded"); + } + } + if matches!(request_id, "detached-full" | "expired-detached") { + sqlx::query(super::UPSERT_USAGE_HTTP_AUDIT_BODY_REFS_SQL) + .bind(request_id) + .bind(usage_body_ref(request_id, fields[0])) + .bind(usage_body_ref(request_id, fields[1])) + .bind(usage_body_ref(request_id, fields[2])) + .bind(usage_body_ref(request_id, fields[3])) + .bind("ref_backed") + .execute(&pool) + .await + .expect("detached refs should be seeded"); + } + if matches!(request_id, "legacy-ref" | "foreign-ref") { + let ref_owner = if request_id == "foreign-ref" { + "detached-full" + } else { + request_id + }; + let mut metadata = json!({"keep": "business fact"}); + for field in fields { + metadata[format!("{}_ref", field.as_storage_field())] = + json!(usage_body_ref(ref_owner, field)); + } + sqlx::query("UPDATE usage SET request_metadata = $1 WHERE request_id = $2") + .bind(metadata) + .bind(request_id) + .execute(&pool) + .await + .expect("legacy refs should be seeded"); + } + } + + let repository = super::SqlxUsageReadRepository::new(pool.clone()); + let window = super::UsageCleanupWindow { + detail_cutoff: now - Duration::days(7), + compressed_cutoff: now - Duration::days(30), + header_cutoff: now - Duration::days(90), + log_cutoff: now - Duration::days(365), + }; + let targets = super::UsageCleanupTargets::body_targets(); + sqlx::query( + "ALTER TABLE usage_body_blobs ADD CONSTRAINT reject_externalization CHECK (request_id <> 'legacy-inline' OR body_field <> 'response_body')", + ) + .execute(&pool) + .await + .unwrap(); + assert!(super::compress_usage_body_fields( + &pool, + now - Duration::days(11), + 1, + Some(window.compressed_cutoff), + ) + .await + .is_err()); + let inline_preserved: bool = sqlx::query_scalar( + "SELECT request_body IS NOT NULL AND provider_request_body IS NOT NULL AND response_body IS NOT NULL AND client_response_body IS NOT NULL FROM usage WHERE request_id = 'legacy-inline'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert!(inline_preserved); + let partial_blobs: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM usage_body_blobs WHERE request_id = 'legacy-inline'", + ) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(partial_blobs, 0); + sqlx::query("ALTER TABLE usage_body_blobs DROP CONSTRAINT reject_externalization") + .execute(&pool) + .await + .unwrap(); + let preview = super::preview_usage_cleanup_impl( + &pool, + &window, + targets, + super::UsageCleanupExecutionMode::Policy, + ) + .await + .expect("cleanup preview should succeed"); + assert_eq!(preview.detail, 4); + assert_eq!(preview.compressed, 2); + let summary = repository + .cleanup_usage( + &window, + 1, + false, + targets, + super::UsageCleanupExecutionMode::Policy, + ) + .await + .expect("retention cleanup should succeed"); + assert_eq!(summary.body_externalized, 2); + assert_eq!(summary.legacy_body_refs_migrated, 2); + assert_eq!(summary.body_cleaned, 2); + assert_eq!(summary.records_deleted, 0); + + for request_id in [ + "legacy-inline", + "legacy-gzip", + "detached-full", + "legacy-ref", + ] { + let audit = sqlx::query("SELECT * FROM usage_http_audits WHERE request_id = $1") + .bind(request_id) + .fetch_one(&pool) + .await + .expect("body refs should remain available"); + for field in fields { + let body_ref = usage_body_ref(request_id, field); + assert_eq!( + audit.get::(format!("{}_ref", field.as_storage_field()).as_str()), + body_ref + ); + assert_eq!( + repository.resolve_body_ref(&body_ref).await.unwrap(), + Some(json!({"request": request_id, "field": field.as_storage_field()})) + ); + } + } + for request_id in ["expired-inline", "expired-detached", "foreign-ref"] { + for field in fields { + assert!(repository + .resolve_body_ref(&usage_body_ref(request_id, field)) + .await + .unwrap() + .is_none()); + } + } + for request_id in ["legacy-ref", "foreign-ref"] { + let metadata: serde_json::Value = + sqlx::query_scalar("SELECT request_metadata FROM usage WHERE request_id = $1") + .bind(request_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(metadata, json!({"keep": "business fact"})); + } + let rerun = repository + .cleanup_usage( + &window, + 1, + false, + targets, + super::UsageCleanupExecutionMode::Policy, + ) + .await + .unwrap(); + assert_eq!(rerun, super::UsageCleanupSummary::default()); + + let delete_now = super::UsageCleanupWindow { + detail_cutoff: now, + compressed_cutoff: now, + ..window + }; + repository + .cleanup_usage( + &delete_now, + 1, + false, + targets, + super::UsageCleanupExecutionMode::BeforeNowBodyFields, + ) + .await + .expect("explicit immediate body deletion should still succeed"); + let blob_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM usage_body_blobs") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(blob_count, 0); + let usage_count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM usage") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(usage_count, 8); + } + + fn inflate_json(bytes: &[u8]) -> serde_json::Value { + let mut decoder = GzDecoder::new(bytes); + let mut decoded = Vec::new(); + decoder + .read_to_end(&mut decoded) + .expect("gzip should decode"); + serde_json::from_slice(&decoded).expect("json should decode") + } + #[test] fn legacy_body_ref_cleanup_index_is_embedded_and_matches_batch_predicate() { const MIGRATION_VERSION: i64 = 20_260_715_000_000; @@ -1282,21 +1837,88 @@ mod tests { } #[test] - fn detail_body_cleanup_selects_detached_capture_for_deletion() { - assert!(SELECT_USAGE_BODY_COMPRESSION_BATCH_SQL.contains("usage_body_blobs")); - assert!(SELECT_USAGE_BODY_COMPRESSION_BATCH_SQL.contains("usage_http_audits")); - assert!(!SELECT_USAGE_BODY_COMPRESSION_BATCH_SQL.contains("payload_gzip")); + fn usage_body_externalization_moves_inline_json_into_ref_backed_blobs() { + let row = UsageBodyCompressionRow { + id: "usage-1".to_string(), + request_id: "req-1".to_string(), + request_body: Some(json!({"hello": "world"})), + request_body_compressed: None, + response_body: None, + response_body_compressed: None, + provider_request_body: Some(json!({"provider": true})), + provider_request_body_compressed: None, + client_response_body: None, + client_response_body_compressed: None, + }; + + let plan = build_usage_body_externalization(&row).expect("plan should build"); + + assert_eq!(plan.blobs.len(), 2); + assert_eq!( + plan.refs.request_body_ref.as_deref(), + Some("usage://request/req-1/request_body") + ); + assert_eq!( + plan.refs.provider_request_body_ref.as_deref(), + Some("usage://request/req-1/provider_request_body") + ); + assert_eq!( + inflate_json(&plan.blobs[0].payload_gzip), + json!({"hello": "world"}) + ); + assert_eq!( + inflate_json(&plan.blobs[1].payload_gzip), + json!({"provider": true}) + ); } #[test] - fn legacy_body_ref_metadata_purge_strips_all_ref_keys() { - let plan = purge_legacy_body_ref_metadata_plan(Some(json!({ - "trace_id": "trace-1", - "request_body_ref": "usage://request/req-1/request_body", - "response_body_ref": "usage://request/req-1/response_body" - }))) + fn usage_body_externalization_reuses_existing_compressed_payloads() { + let compressed = compress_usage_json_value(&json!({"legacy": true})) + .expect("compressed payload should build"); + let row = UsageBodyCompressionRow { + id: "usage-1".to_string(), + request_id: "req-legacy".to_string(), + request_body: None, + request_body_compressed: Some(compressed.clone()), + response_body: None, + response_body_compressed: None, + provider_request_body: None, + provider_request_body_compressed: None, + client_response_body: None, + client_response_body_compressed: None, + }; + + let plan = build_usage_body_externalization(&row).expect("plan should build"); + + assert_eq!(plan.blobs.len(), 1); + assert_eq!(plan.blobs[0].payload_gzip, compressed); + assert_eq!( + plan.refs.request_body_ref.as_deref(), + Some("usage://request/req-legacy/request_body") + ); + } + + #[test] + fn legacy_body_ref_metadata_migration_moves_matching_refs_and_strips_keys() { + let plan = migrate_legacy_body_ref_metadata_plan( + "req-1", + Some(json!({ + "trace_id": "trace-1", + "request_body_ref": "usage://request/req-1/request_body", + "response_body_ref": "usage://request/req-1/response_body" + })), + ) .expect("migration plan should exist"); + assert_eq!( + plan.refs.request_body_ref.as_deref(), + Some("usage://request/req-1/request_body") + ); + assert_eq!( + plan.refs.response_body_ref.as_deref(), + Some("usage://request/req-1/response_body") + ); assert_eq!( plan.request_metadata, Some(json!({ @@ -1306,14 +1928,18 @@ mod tests { } #[test] - fn legacy_body_ref_metadata_purge_does_not_preserve_untrusted_refs() { - let plan = purge_legacy_body_ref_metadata_plan(Some(json!({ - "request_body_ref": "blob://legacy-request", - "provider_request_body_ref": "usage://request/req-other/provider_request_body", - "candidate_index": 2 - }))) + fn legacy_body_ref_metadata_migration_strips_invalid_and_cross_request_refs() { + let plan = migrate_legacy_body_ref_metadata_plan( + "req-1", + Some(json!({ + "request_body_ref": "blob://legacy-request", + "provider_request_body_ref": "usage://request/req-other/provider_request_body", + "candidate_index": 2 + })), + ) .expect("migration plan should exist"); + assert!(!plan.refs.any_present()); assert_eq!( plan.request_metadata, Some(json!({ diff --git a/crates/aether-data/adapters/postgres/src/usage/mod.rs b/crates/aether-data/adapters/postgres/src/usage/mod.rs index 0e50692de..8c6ebbf6b 100644 --- a/crates/aether-data/adapters/postgres/src/usage/mod.rs +++ b/crates/aether-data/adapters/postgres/src/usage/mod.rs @@ -1374,8 +1374,8 @@ WHERE u.request_id = ANY($1) "#; const UPSERT_USAGE_ROUTING_SNAPSHOT_SQL: &str = include_str!("queries/upsert_usage_routing_snapshot_sql.sql"); -#[cfg(test)] const UPSERT_USAGE_HTTP_AUDIT_SQL: &str = include_str!("queries/upsert_usage_http_audit_sql.sql"); +const UPSERT_USAGE_BODY_BLOB_SQL: &str = include_str!("queries/upsert_usage_body_blob_sql.sql"); const UPSERT_USAGE_SETTLEMENT_PRICING_SNAPSHOT_SQL: &str = include_str!("queries/upsert_usage_settlement_pricing_snapshot_sql.sql"); @@ -13362,19 +13362,36 @@ async fn sync_usage_body_blob_storage<'e, E>( executor: E, request_id: &str, field: UsageBodyField, - _value: Option<&Value>, - _storage: &UsageBodyStorage, - _clear_existing: bool, + value: Option<&Value>, + storage: &UsageBodyStorage, + clear_existing: bool, ) -> Result<(), DataLayerError> where E: sqlx::Executor<'e, Database = Postgres>, { let body_ref = usage_body_ref(request_id, field); - sqlx::query(DELETE_USAGE_BODY_BLOB_SQL) - .bind(&body_ref) - .execute(executor) - .await - .map_postgres_err()?; + if clear_existing { + sqlx::query(DELETE_USAGE_BODY_BLOB_SQL) + .bind(&body_ref) + .execute(executor) + .await + .map_postgres_err()?; + } else if let Some(payload_gzip) = storage.detached_blob_bytes.as_ref() { + sqlx::query(UPSERT_USAGE_BODY_BLOB_SQL) + .bind(&body_ref) + .bind(request_id) + .bind(field.as_storage_field()) + .bind(payload_gzip) + .execute(executor) + .await + .map_postgres_err()?; + } else if value.is_some() { + sqlx::query(DELETE_USAGE_BODY_BLOB_SQL) + .bind(&body_ref) + .execute(executor) + .await + .map_postgres_err()?; + } Ok(()) } @@ -13383,43 +13400,46 @@ async fn sync_usage_http_audit_storage<'e, E>( request_id: &str, headers: &UsageHttpAuditHeaders<'_>, refs: &UsageHttpAuditRefs, - _states: &UsageHttpAuditStates, + states: &UsageHttpAuditStates, body_capture_mode: &str, ) -> Result<(), DataLayerError> where E: sqlx::Executor<'e, Database = Postgres>, { - if headers.any_present() || refs.any_present() || body_capture_mode != "none" { - return Err(DataLayerError::InvalidInput( - "usage HTTP capture persistence is disabled".to_string(), - )); + if !headers.any_present() + && !refs.any_present() + && !states.any_present() + && body_capture_mode == "none" + { + return Ok(()); } - sqlx::query( - r#" -WITH deleted_audit AS ( - DELETE FROM usage_http_audits WHERE request_id = $1 -) -UPDATE usage -SET request_headers = NULL, - request_body = NULL, - provider_request_headers = NULL, - provider_request_body = NULL, - response_headers = NULL, - response_body = NULL, - client_response_headers = NULL, - client_response_body = NULL, - request_body_compressed = NULL, - provider_request_body_compressed = NULL, - response_body_compressed = NULL, - client_response_body_compressed = NULL -WHERE request_id = $1 -"#, - ) - .bind(request_id) - .execute(executor) - .await - .map_postgres_err()?; + sqlx::query(UPSERT_USAGE_HTTP_AUDIT_SQL) + .bind(request_id) + .bind(headers.request_headers_json) + .bind(headers.provider_request_headers_json) + .bind(headers.response_headers_json) + .bind(headers.client_response_headers_json) + .bind(refs.request_body_ref.as_deref()) + .bind(refs.provider_request_body_ref.as_deref()) + .bind(refs.response_body_ref.as_deref()) + .bind(refs.client_response_body_ref.as_deref()) + .bind(usage_body_capture_state_bind_text( + states.request_body_state, + )) + .bind(usage_body_capture_state_bind_text( + states.provider_request_body_state, + )) + .bind(usage_body_capture_state_bind_text( + states.response_body_state, + )) + .bind(usage_body_capture_state_bind_text( + states.client_response_body_state, + )) + .bind(body_capture_mode) + .execute(executor) + .await + .map_postgres_err()?; Ok(()) } diff --git a/crates/aether-data/adapters/postgres/src/usage/tests.rs b/crates/aether-data/adapters/postgres/src/usage/tests.rs index 7dc47bd80..272f8718e 100644 --- a/crates/aether-data/adapters/postgres/src/usage/tests.rs +++ b/crates/aether-data/adapters/postgres/src/usage/tests.rs @@ -187,6 +187,152 @@ async fn pending_batch_is_opt_in_and_rejects_non_pending_before_connecting() { .contains("pending usage batch requires pending status")); } +#[tokio::test] +#[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL migrations"] +async fn live_full_http_capture_round_trips_for_direct_and_batch_writes() { + let factory = PostgresPoolFactory::new(PostgresPoolConfig { + database_url: std::env::var("AETHER_TEST_DATABASE_URL").unwrap(), + min_connections: 1, + max_connections: 2, + acquire_timeout_ms: 10_000, + idle_timeout_ms: 30_000, + max_lifetime_ms: 60_000, + statement_cache_capacity: 64, + require_ssl: false, + }) + .unwrap(); + let repository = SqlxUsageReadRepository::new(factory.connect_lazy().unwrap()); + crate::run_migrations(repository.pool()).await.unwrap(); + + for batch in [false, true] { + let request_id = format!("req-full-capture-{}", uuid::Uuid::new_v4().simple()); + let now_unix_secs = Utc::now().timestamp() as u64; + let mut pending = fast_clear_usage_record( + &request_id, + "full-capture-test", + now_unix_secs, + false, + UsageBodyCaptureState::Inline, + None, + ); + pending.request_headers = + Some(json!({"content-type": "application/json", "authorization": "Bearer private"})); + pending.request_body = + Some(json!({"messages": [{"role": "user", "content": "original request"}]})); + pending.request_body_state = Some(UsageBodyCaptureState::Inline); + pending.provider_request_body = Some(json!({"input": "provider request"})); + pending.response_body = Some(json!("pending response")); + pending.response_body_state = Some(UsageBodyCaptureState::Inline); + pending.client_response_body = Some(json!("pending client response")); + pending.client_response_body_state = Some(UsageBodyCaptureState::Inline); + if batch { + repository + .upsert_pending_many(vec![pending.clone()]) + .await + .unwrap(); + } else { + repository.upsert(pending.clone()).await.unwrap(); + } + for (field, expected) in [ + (UsageBodyField::RequestBody, pending.request_body.as_ref()), + ( + UsageBodyField::ProviderRequestBody, + pending.provider_request_body.as_ref(), + ), + (UsageBodyField::ResponseBody, pending.response_body.as_ref()), + ( + UsageBodyField::ClientResponseBody, + pending.client_response_body.as_ref(), + ), + ] { + assert_eq!( + repository + .resolve_body_ref(&usage_body_ref(&request_id, field)) + .await + .unwrap() + .as_ref(), + expected, + "batch={batch}, field={field:?}" + ); + } + + let mut terminal = fast_clear_usage_record( + &request_id, + "full-capture-test", + now_unix_secs, + true, + UsageBodyCaptureState::None, + None, + ); + terminal.provider_request_body_state = None; + terminal.response_headers = + Some(json!({"content-type": "text/event-stream", "set-cookie": "private"})); + terminal.response_body = Some(json!(format!( + "data: {}\n\ndata: [DONE]\n\n", + "streamed text".repeat(8192) + ))); + terminal.response_body_state = Some(UsageBodyCaptureState::Inline); + terminal.client_response_body = Some(json!({"output": "final response"})); + terminal.client_response_body_state = Some(UsageBodyCaptureState::Inline); + repository.upsert(terminal.clone()).await.unwrap(); + + let stored = repository + .find_by_request_id_shallow(&request_id) + .await + .unwrap() + .unwrap(); + assert_eq!( + stored.request_headers, + Some(json!({"content-type": "application/json", "authorization": "[redacted]"})) + ); + assert_eq!( + stored.response_headers, + Some(json!({"content-type": "text/event-stream", "set-cookie": "[redacted]"})) + ); + for (field, expected) in [ + (UsageBodyField::RequestBody, pending.request_body.as_ref()), + ( + UsageBodyField::ProviderRequestBody, + pending.provider_request_body.as_ref(), + ), + ( + UsageBodyField::ResponseBody, + terminal.response_body.as_ref(), + ), + ( + UsageBodyField::ClientResponseBody, + terminal.client_response_body.as_ref(), + ), + ] { + assert_eq!( + stored.body_state(field), + Some(UsageBodyCaptureState::Reference) + ); + assert_eq!( + stored.body_ref(field), + Some(usage_body_ref(&request_id, field).as_str()) + ); + assert_eq!( + repository + .resolve_body_ref(stored.body_ref(field).unwrap()) + .await + .unwrap() + .as_ref(), + expected, + "batch={batch}, field={field:?}" + ); + } + let legacy_content_present: bool = sqlx::query_scalar("SELECT request_body IS NOT NULL OR request_headers IS NOT NULL OR response_body IS NOT NULL FROM usage WHERE request_id = $1") + .bind(&request_id).fetch_one(repository.pool()).await.unwrap(); + assert!(!legacy_content_present); + sqlx::query("DELETE FROM usage WHERE request_id = $1") + .bind(&request_id) + .execute(repository.pool()) + .await + .unwrap(); + } +} + #[tokio::test] #[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL migrations"] async fn live_stale_terminal_event_is_a_full_transaction_noop() { @@ -253,7 +399,7 @@ async fn live_stale_terminal_event_is_a_full_transaction_noop() { .unwrap(), ); let settlement_before = sqlx::query( - "SELECT billing_status, billing_total_cost_usd FROM usage_settlement_snapshots WHERE request_id = $1", + "SELECT billing_status, billing_total_cost_usd::DOUBLE PRECISION AS billing_total_cost_usd FROM usage_settlement_snapshots WHERE request_id = $1", ) .bind(&request_id) .fetch_one(repository.pool()) @@ -318,7 +464,7 @@ async fn live_stale_terminal_event_is_a_full_transaction_noop() { .unwrap(), ); let settlement_after = sqlx::query( - "SELECT billing_status, billing_total_cost_usd FROM usage_settlement_snapshots WHERE request_id = $1", + "SELECT billing_status, billing_total_cost_usd::DOUBLE PRECISION AS billing_total_cost_usd FROM usage_settlement_snapshots WHERE request_id = $1", ) .bind(&request_id) .fetch_one(repository.pool()) @@ -542,7 +688,7 @@ async fn live_pending_batch_persists_auxiliary_state_and_preserves_terminal_conf .fetch_one(repository.pool()) .await .expect("HTTP audit count should be readable"); - assert_eq!(http_count, 0); + assert_eq!(http_count, 1); let blob_count = sqlx::query_scalar::<_, i64>( "SELECT COUNT(*)::BIGINT FROM usage_body_blobs WHERE request_id = $1", ) @@ -550,7 +696,23 @@ async fn live_pending_batch_persists_auxiliary_state_and_preserves_terminal_conf .fetch_one(repository.pool()) .await .expect("body blob count should be readable"); - assert_eq!(blob_count, 0); + assert_eq!(blob_count, 4); + let captured = repository + .find_by_request_id_shallow(&rich_request_id) + .await + .unwrap() + .unwrap(); + assert_eq!( + captured.request_headers, + Some(json!({"x-request": "[redacted]"})) + ); + assert_eq!( + repository + .resolve_body_ref(captured.body_ref(UsageBodyField::RequestBody).unwrap()) + .await + .unwrap(), + Some(json!({"messages": [{"role": "user", "content": "hello"}]})) + ); let routing = sqlx::query( "SELECT candidate_id, candidate_index, selected_provider_api_key_id FROM usage_routing_snapshots WHERE request_id = $1", @@ -667,7 +829,7 @@ async fn live_pending_batch_and_terminal_upserts_count_each_provider_request_onc let suffix = uuid::Uuid::new_v4().simple().to_string(); let provider_name = format!("pending-terminal-race-provider-{suffix}"); - let provider_key_id = format!("pending-terminal-race-key-{suffix}"); + let provider_key_id = uuid::Uuid::new_v4().to_string(); let now_unix_secs = Utc::now().timestamp().max(0) as u64; let request_ids = (0..REQUESTS) .map(|index| format!("req-pending-terminal-race-{index}-{suffix}")) @@ -793,7 +955,7 @@ async fn live_first_byte_fast_path_is_atomic_and_preserves_terminal_state() { let existing_request_id = format!("req-first-byte-existing-{suffix}"); let metadata_fill_request_id = format!("req-first-byte-metadata-fill-{suffix}"); let provider_name = format!("first-byte-fast-{suffix}"); - let missing_provider_key_id = format!("key-first-byte-missing-{suffix}"); + let missing_provider_key_id = uuid::Uuid::new_v4().to_string(); let now_unix_secs = Utc::now().timestamp().max(0) as u64; let mut missing_first_byte = first_byte_usage_record( @@ -1064,7 +1226,7 @@ async fn live_first_byte_reads_provider_contribution_after_waiting_for_canonical let suffix = uuid::Uuid::new_v4().simple().to_string(); let request_id = format!("req-first-byte-lock-snapshot-{suffix}"); let provider_name = format!("first-byte-lock-snapshot-{suffix}"); - let provider_key_id = format!("key-first-byte-lock-snapshot-{suffix}"); + let provider_key_id = uuid::Uuid::new_v4().to_string(); let now_unix_secs = Utc::now().timestamp().max(0) as u64; let mut pending = first_byte_usage_record( &request_id, @@ -1209,14 +1371,14 @@ async fn live_first_byte_batch_preserves_duplicate_order_and_terminal_guards() { let request_b = format!("req-first-byte-batch-b-{suffix}"); let request_missing = format!("req-first-byte-batch-missing-{suffix}"); let request_terminal = format!("req-first-byte-batch-terminal-{suffix}"); - let missing_provider_key_id = format!("key-first-byte-batch-missing-{suffix}"); + let missing_provider_key_id = uuid::Uuid::new_v4().to_string(); let now_unix_secs = Utc::now().timestamp().max(0) as u64; let mut pending_a = first_byte_usage_record( &request_a, &provider_name, now_unix_secs, - Some(json!({"seed": "a"})), + Some(json!({"trace_id": "seed-a"})), ); pending_a.status = "pending".to_string(); pending_a.first_byte_time_ms = None; @@ -1241,7 +1403,7 @@ async fn live_first_byte_batch_preserves_duplicate_order_and_terminal_guards() { ); terminal.is_stream = Some(true); terminal.first_byte_time_ms = Some(44); - terminal.request_metadata = Some(json!({"terminal": true})); + terminal.request_metadata = Some(json!({"trace_id": "terminal"})); repository .upsert(pending_a) @@ -1260,7 +1422,7 @@ async fn live_first_byte_batch_preserves_duplicate_order_and_terminal_guards() { &request_a, &provider_name, now_unix_secs + 1, - Some(json!({"incoming": "a"})), + Some(json!({"trace_id": "incoming-a"})), ); first_a.first_byte_time_ms = Some(30); first_a.has_format_conversion = None; @@ -1272,7 +1434,7 @@ async fn live_first_byte_batch_preserves_duplicate_order_and_terminal_guards() { &request_b, &provider_name, now_unix_secs + 1, - Some(json!({"incoming": "b"})), + Some(json!({"trace_id": "incoming-b"})), ); first_b.has_format_conversion = Some(true); @@ -1280,7 +1442,7 @@ async fn live_first_byte_batch_preserves_duplicate_order_and_terminal_guards() { &request_terminal, &provider_name, now_unix_secs + 2, - Some(json!({"late": true})), + Some(json!({"trace_id": "late"})), ); late_terminal.first_byte_time_ms = Some(3); late_terminal.has_format_conversion = None; @@ -1288,7 +1450,7 @@ async fn live_first_byte_batch_preserves_duplicate_order_and_terminal_guards() { &request_missing, &provider_name, now_unix_secs + 1, - Some(json!({"incoming": "missing"})), + Some(json!({"trace_id": "incoming-missing"})), ); first_missing.provider_api_key_id = Some(missing_provider_key_id.clone()); @@ -1345,7 +1507,7 @@ async fn live_first_byte_batch_preserves_duplicate_order_and_terminal_guards() { row_a .try_get::, _>("request_metadata") .unwrap(), - Some(json!({"seed": "a"})), + Some(json!({"trace_id": "seed-a"})), "existing metadata remains authoritative" ); @@ -1364,7 +1526,7 @@ async fn live_first_byte_batch_preserves_duplicate_order_and_terminal_guards() { row_b .try_get::, _>("request_metadata") .unwrap(), - Some(json!({"incoming": "b"})) + Some(json!({"trace_id": "incoming-b"})) ); let row_terminal = rows @@ -2044,7 +2206,7 @@ async fn live_provider_performance_grouping_sets_matches_separate_queries() { } #[tokio::test] -#[ignore = "requires AETHER_TEST_DATABASE_URL and a populated PostgreSQL database"] +#[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL migrations"] async fn live_dashboard_daily_breakdown_uses_canonical_covering_read_path() { let database_url = std::env::var("AETHER_TEST_DATABASE_URL") .expect("AETHER_TEST_DATABASE_URL must point at the test database"); @@ -2062,6 +2224,20 @@ async fn live_dashboard_daily_breakdown_uses_canonical_covering_read_path() { let repository = SqlxUsageReadRepository::new(factory.connect_lazy().expect("lazy pool should build")); let until = Utc::now().timestamp().max(0) as u64; + crate::run_migrations(repository.pool()).await.unwrap(); + let request_id = format!("daily-breakdown-{}", uuid::Uuid::new_v4().simple()); + let provider_name = format!("daily-provider-{}", uuid::Uuid::new_v4().simple()); + repository + .upsert(fast_clear_usage_record( + &request_id, + &provider_name, + until.saturating_sub(60), + true, + UsageBodyCaptureState::None, + None, + )) + .await + .unwrap(); let started = std::time::Instant::now(); let rows = repository .list_dashboard_daily_breakdown(&UsageDashboardDailyBreakdownQuery { @@ -2077,7 +2253,17 @@ async fn live_dashboard_daily_breakdown_uses_canonical_covering_read_path() { started.elapsed(), rows.len() ); - assert!(!rows.is_empty()); + let seeded = rows + .iter() + .find(|row| row.provider == provider_name) + .unwrap(); + assert_eq!(seeded.requests, 1); + assert_eq!(seeded.total_tokens, 2); + sqlx::query("DELETE FROM \"usage\" WHERE request_id = $1") + .bind(&request_id) + .execute(repository.pool()) + .await + .unwrap(); } #[tokio::test] diff --git a/crates/aether-data/adapters/postgres/src/video_tasks.rs b/crates/aether-data/adapters/postgres/src/video_tasks.rs index 38905f92b..0265aec9c 100644 --- a/crates/aether-data/adapters/postgres/src/video_tasks.rs +++ b/crates/aether-data/adapters/postgres/src/video_tasks.rs @@ -81,12 +81,12 @@ fn select_video_task_full_columns() -> String { fn select_video_task_claim_columns() -> String { select_video_task_columns( - "NULL::TEXT", + "prompt", "NULL::jsonb", - "NULL::INTEGER", - "NULL::TEXT", - "NULL::TEXT", - "NULL::TEXT", + "duration_seconds", + "resolution", + "aspect_ratio", + "size", ) } @@ -1176,7 +1176,7 @@ fn map_video_task_row(row: &PgRow) -> Result { #[cfg(test)] mod tests { - use super::{update_if_active_sql, upsert_sql, SqlxVideoTaskRepository}; + use super::{claim_due_sql, update_if_active_sql, upsert_sql, SqlxVideoTaskRepository}; use crate::{PostgresPoolConfig, PostgresPoolFactory}; use aether_data_contracts::repository::video_tasks::{ UpsertVideoTask, VideoTaskLookupKey, VideoTaskQueryFilter, VideoTaskReadRepository, @@ -1240,6 +1240,142 @@ mod tests { assert!(update.contains("created_at = COALESCE(created_at, TO_TIMESTAMP($34))")); } + #[test] + fn poll_claim_returns_business_fields_required_by_identity_guards() { + let sql = claim_due_sql(); + for field in [ + "prompt", + "duration_seconds", + "resolution", + "aspect_ratio", + "size", + ] { + assert!( + sql.contains(&format!("{field} AS {field}")), + "claim must retain {field}" + ); + } + assert!(sql.contains("NULL::jsonb AS original_request_body")); + assert!(sql.contains("FOR UPDATE SKIP LOCKED")); + } + + #[tokio::test] + #[ignore = "requires AETHER_TEST_DATABASE_URL and PostgreSQL migrations"] + async fn live_video_task_capture_claim_and_completion_preserve_business_fields() { + let database_url = std::env::var("AETHER_TEST_DATABASE_URL") + .expect("AETHER_TEST_DATABASE_URL must point at the test database"); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .expect("test database should connect"); + crate::run_migrations(&pool) + .await + .expect("test database should migrate"); + sqlx::query("CREATE TEMP TABLE video_tasks (LIKE public.video_tasks INCLUDING ALL)") + .execute(&pool) + .await + .expect("isolated task table should be created"); + let repository = SqlxVideoTaskRepository::new(pool); + for api_format in ["openai:video", "gemini:video"] { + let task_id = uuid::Uuid::new_v4().to_string(); + let original = UpsertVideoTask { + id: task_id.clone(), + short_id: Some(uuid::Uuid::new_v4().simple().to_string()[..16].to_string()), + request_id: format!("request-{task_id}"), + user_id: None, + api_key_id: None, + username: Some("alice".to_string()), + api_key_name: Some("video-client".to_string()), + external_task_id: Some("upstream-task-1".to_string()), + provider_id: None, + endpoint_id: None, + key_id: None, + client_api_format: Some(api_format.to_string()), + provider_api_format: Some(api_format.to_string()), + format_converted: false, + model: Some("video-model".to_string()), + prompt: Some("business prompt".to_string()), + original_request_body: Some(serde_json::json!({"token": "private"})), + duration_seconds: Some(8), + resolution: Some("1080p".to_string()), + aspect_ratio: Some("16:9".to_string()), + size: Some("1920x1080".to_string()), + status: VideoTaskStatus::Submitted, + progress_percent: 0, + progress_message: None, + retry_count: 0, + poll_interval_seconds: 10, + next_poll_at_unix_secs: Some(10), + poll_count: 0, + max_poll_count: 360, + created_at_unix_ms: 1, + submitted_at_unix_secs: Some(1), + completed_at_unix_secs: None, + updated_at_unix_secs: 1, + error_code: None, + error_message: None, + video_url: None, + request_metadata: Some(serde_json::json!({"authorization": "private"})), + }; + let stored = repository + .upsert(original.clone()) + .await + .expect("task should persist"); + assert_eq!(stored.prompt, original.prompt); + assert_eq!(stored.username, original.username); + assert_eq!(stored.api_key_name, original.api_key_name); + assert!(stored.original_request_body.is_none()); + assert!(stored.request_metadata.is_none()); + + let mut claimed = repository + .claim_due(20, 50, 10) + .await + .expect("task should be claimed"); + assert_eq!(claimed.len(), 1); + let mut completion: UpsertVideoTask = claimed.pop().expect("claimed task").into(); + stored + .ensure_immutable_identity_matches(&completion) + .expect("claim must preserve task identity"); + assert_eq!(completion.prompt, original.prompt); + let mut mismatched = completion.clone(); + mismatched.duration_seconds = Some(99); + assert!(repository + .update_if_active(mismatched) + .await + .expect("guarded update should execute") + .is_none()); + completion.status = VideoTaskStatus::Completed; + completion.progress_percent = 100; + completion.next_poll_at_unix_secs = None; + completion.completed_at_unix_secs = Some(21); + completion.updated_at_unix_secs = 21; + completion.video_url = Some( + "https://cdn.example.test/video.mp4?alt=media&signature=a%2Fb%2Bc%3D&part=2&part=1" + .to_string(), + ); + let completed = repository + .update_if_active(completion.clone()) + .await + .expect("completion should execute") + .expect("matching active task should complete"); + assert_eq!(completed.video_url, completion.video_url); + let reloaded = repository + .find(VideoTaskLookupKey::Id(&task_id)) + .await + .expect("task should reload") + .expect("task should exist"); + assert_eq!(reloaded.status, VideoTaskStatus::Completed); + assert_eq!(reloaded.prompt, original.prompt); + assert_eq!(reloaded.video_url, completion.video_url); + assert_eq!(reloaded.duration_seconds, original.duration_seconds); + assert_eq!(reloaded.size, original.size); + assert_eq!(reloaded.username, original.username); + assert!(reloaded.request_metadata.is_none()); + } + repository.pool().close().await; + } + #[tokio::test] async fn repository_constructs_from_lazy_pool() { let repository = SqlxVideoTaskRepository::new(build_pool()); diff --git a/crates/aether-data/contracts/src/repository/usage/policy.rs b/crates/aether-data/contracts/src/repository/usage/policy.rs index ee2b46a2b..d3dadbdf8 100644 --- a/crates/aether-data/contracts/src/repository/usage/policy.rs +++ b/crates/aether-data/contracts/src/repository/usage/policy.rs @@ -268,7 +268,7 @@ pub fn strip_deprecated_usage_display_fields(mut usage: UpsertUsageRecord) -> Up usage } -pub fn sanitize_usage_for_persistence(mut usage: UpsertUsageRecord) -> UpsertUsageRecord { +fn sanitize_usage_record_metadata(mut usage: UpsertUsageRecord) -> UpsertUsageRecord { usage = strip_deprecated_usage_display_fields(usage); sanitize_usage_routing_fields(&mut usage, None); usage.error_message = None; @@ -280,6 +280,11 @@ pub fn sanitize_usage_for_persistence(mut usage: UpsertUsageRecord) -> UpsertUsa .map(str::to_string); } usage.request_metadata = super::sanitize_usage_request_metadata(usage.request_metadata); + usage +} + +pub fn sanitize_usage_for_persistence(usage: UpsertUsageRecord) -> UpsertUsageRecord { + let mut usage = sanitize_usage_record_metadata(usage); usage.request_headers = None; usage.request_body = None; usage.request_body_ref = None; @@ -299,40 +304,96 @@ pub fn sanitize_usage_for_persistence(mut usage: UpsertUsageRecord) -> UpsertUsa usage } -/// Project an event onto the non-content controls accepted by auxiliary usage storage. -/// -/// Explicit `none` states are retained only as tombstones for removing historical captures. -/// Every header, body, reference, and non-clear capture state is discarded. pub fn sanitize_usage_capture_controls_for_persistence( mut usage: UpsertUsageRecord, ) -> UpsertUsageRecord { - // Routing facts are allowed in the transient event metadata for compatibility with older - // writers. Project only the known scalar fields into typed slots before the general metadata - // sanitizer drops unknown keys. This keeps snapshots useful without re-persisting arbitrary - // metadata (or any body/header material). let metadata = usage .request_metadata .as_ref() .and_then(Value::as_object) .cloned(); sanitize_usage_routing_fields(&mut usage, metadata.as_ref()); - let clear_request_body = usage.request_body_state == Some(super::UsageBodyCaptureState::None); - let clear_provider_request_body = - usage.provider_request_body_state == Some(super::UsageBodyCaptureState::None); - let clear_response_body = usage.response_body_state == Some(super::UsageBodyCaptureState::None); - let clear_client_response_body = - usage.client_response_body_state == Some(super::UsageBodyCaptureState::None); - - let mut usage = sanitize_usage_for_persistence(usage); - usage.request_body_state = clear_request_body.then_some(super::UsageBodyCaptureState::None); - usage.provider_request_body_state = - clear_provider_request_body.then_some(super::UsageBodyCaptureState::None); - usage.response_body_state = clear_response_body.then_some(super::UsageBodyCaptureState::None); - usage.client_response_body_state = - clear_client_response_body.then_some(super::UsageBodyCaptureState::None); + let mut usage = sanitize_usage_record_metadata(usage); + for headers in [ + &mut usage.request_headers, + &mut usage.provider_request_headers, + &mut usage.response_headers, + &mut usage.client_response_headers, + ] { + *headers = sanitize_usage_headers_for_persistence(headers.take()); + } + for (field, body, body_ref, state) in [ + ( + super::UsageBodyField::RequestBody, + &mut usage.request_body, + &mut usage.request_body_ref, + usage.request_body_state, + ), + ( + super::UsageBodyField::ProviderRequestBody, + &mut usage.provider_request_body, + &mut usage.provider_request_body_ref, + usage.provider_request_body_state, + ), + ( + super::UsageBodyField::ResponseBody, + &mut usage.response_body, + &mut usage.response_body_ref, + usage.response_body_state, + ), + ( + super::UsageBodyField::ClientResponseBody, + &mut usage.client_response_body, + &mut usage.client_response_body_ref, + usage.client_response_body_state, + ), + ] { + if matches!( + state, + Some( + super::UsageBodyCaptureState::None + | super::UsageBodyCaptureState::Disabled + | super::UsageBodyCaptureState::Unavailable + ) + ) { + *body = None; + *body_ref = None; + } else { + *body_ref = body_ref.as_deref().and_then(|value| { + super::canonical_usage_body_ref_for(value, &usage.request_id, field) + }); + } + } usage } +pub fn usage_header_value_is_sensitive(name: &str) -> bool { + ![ + "accept", + "accept-encoding", + "content-encoding", + "content-length", + "content-type", + "transfer-encoding", + "x-request-id", + "x-trace-id", + ] + .iter() + .any(|candidate| name.trim().eq_ignore_ascii_case(candidate)) +} + +pub fn sanitize_usage_headers_for_persistence(value: Option) -> Option { + let Value::Object(mut headers) = value? else { + return None; + }; + for (name, value) in &mut headers { + if usage_header_value_is_sensitive(name) && !value.is_null() { + *value = Value::String("[redacted]".to_string()); + } + } + Some(Value::Object(headers)) +} + fn sanitize_usage_routing_fields( usage: &mut UpsertUsageRecord, metadata: Option<&Map>, @@ -773,20 +834,79 @@ mod tests { } #[test] - fn auxiliary_capture_projection_keeps_only_explicit_clear_tombstones() { + fn auxiliary_capture_projection_preserves_captures_and_honors_disabled_states() { let mut input = usage_with_http_capture(); input.request_body_state = Some(UsageBodyCaptureState::None); input.response_body_state = Some(UsageBodyCaptureState::Disabled); let usage = sanitize_usage_capture_controls_for_persistence(input); - assert!(usage.request_headers.is_none()); + assert_eq!( + usage.request_headers, + Some(json!({"authorization": "[redacted]"})) + ); assert!(usage.request_body.is_none()); assert!(usage.request_body_ref.is_none()); assert_eq!(usage.request_body_state, Some(UsageBodyCaptureState::None)); - assert!(usage.provider_request_body_state.is_none()); - assert!(usage.response_body_state.is_none()); - assert!(usage.client_response_body_state.is_none()); + assert_eq!( + usage.provider_request_body, + Some(json!({"prompt": "private"})) + ); + assert_eq!( + usage.provider_request_body_state, + Some(UsageBodyCaptureState::Inline) + ); + assert!(usage.response_body.is_none()); + assert!(usage.response_body_ref.is_none()); + assert_eq!( + usage.response_body_state, + Some(UsageBodyCaptureState::Disabled) + ); + assert!(usage.client_response_body.is_none()); + assert_eq!( + usage.client_response_body_state, + Some(UsageBodyCaptureState::Disabled) + ); + } + + #[test] + fn auxiliary_capture_projection_preserves_all_body_directions_and_scopes_references() { + let mut input = usage_with_http_capture(); + input.request_headers = + Some(json!({"Content-Type": "application/json", "Authorization": "Bearer secret"})); + input.request_body_state = Some(UsageBodyCaptureState::Inline); + input.client_response_body_state = Some(UsageBodyCaptureState::Inline); + input.request_body_ref = Some(super::super::usage_body_ref( + &input.request_id, + super::super::UsageBodyField::RequestBody, + )); + input.provider_request_body_ref = input.request_body_ref.clone(); + input.response_body_ref = Some(super::super::usage_body_ref( + "another-request", + super::super::UsageBodyField::ResponseBody, + )); + let captured = sanitize_usage_capture_controls_for_persistence(input.clone()); + assert_eq!(captured.request_body, input.request_body); + assert_eq!(captured.provider_request_body, input.provider_request_body); + assert_eq!(captured.response_body, input.response_body); + assert_eq!(captured.client_response_body, input.client_response_body); + assert_eq!(captured.request_body_ref, input.request_body_ref); + assert!(captured.provider_request_body_ref.is_none()); + assert!(captured.response_body_ref.is_none()); + assert!(captured.client_response_body_ref.is_none()); + assert_eq!( + captured.request_headers, + Some(json!({"Content-Type": "application/json", "Authorization": "[redacted]"})) + ); + assert_eq!( + captured.provider_request_headers, + Some(json!({"x-api-key": "[redacted]"})) + ); + assert_eq!( + captured.response_headers, + Some(json!({"set-cookie": "[redacted]"})) + ); + assert!(captured.error_message.is_none()); } #[test] diff --git a/crates/aether-data/contracts/src/repository/video_tasks/types.rs b/crates/aether-data/contracts/src/repository/video_tasks/types.rs index a2d5d2007..412a950ae 100644 --- a/crates/aether-data/contracts/src/repository/video_tasks/types.rs +++ b/crates/aether-data/contracts/src/repository/video_tasks/types.rs @@ -1,8 +1,6 @@ use async_trait::async_trait; use serde_json::Value; -const SAFE_VIDEO_URL_QUERY_KEYS: &[(&str, &str)] = &[("alt", "media")]; - #[derive( Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize, )] @@ -221,16 +219,11 @@ impl StoredVideoTask { } fn sanitize_persisted_diagnostics(&mut self) { - self.prompt = None; self.original_request_body = None; self.progress_message = None; self.error_code = sanitize_video_task_error_code(self.error_code.take()); self.error_message = None; - self.video_url = sanitize_video_task_url( - self.client_api_format.as_deref(), - self.provider_api_format.as_deref(), - self.video_url.take(), - ); + self.video_url = sanitize_video_task_url(self.video_url.take()); self.request_metadata = None; } @@ -339,18 +332,11 @@ pub struct UpsertVideoTask { impl UpsertVideoTask { pub fn sanitize_for_persistence(&mut self) { - self.username = None; - self.api_key_name = None; - self.prompt = None; self.original_request_body = None; self.progress_message = None; self.error_code = sanitize_video_task_error_code(self.error_code.take()); self.error_message = None; - self.video_url = sanitize_video_task_url( - self.client_api_format.as_deref(), - self.provider_api_format.as_deref(), - self.video_url.take(), - ); + self.video_url = sanitize_video_task_url(self.video_url.take()); self.request_metadata = None; } @@ -421,16 +407,7 @@ fn sanitize_video_task_error_code(value: Option) -> Option { }) } -fn sanitize_video_task_url( - client_api_format: Option<&str>, - provider_api_format: Option<&str>, - value: Option, -) -> Option { - if effective_video_task_api_format(client_api_format, provider_api_format) - != Some("gemini:video") - { - return None; - } +fn sanitize_video_task_url(value: Option) -> Option { let mut url = url::Url::parse(value?.trim()).ok()?; if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() @@ -440,15 +417,6 @@ fn sanitize_video_task_url( return None; } - let query = url - .query_pairs() - .filter(|(key, value)| SAFE_VIDEO_URL_QUERY_KEYS.contains(&(key.as_ref(), value.as_ref()))) - .map(|(key, value)| (key.into_owned(), value.into_owned())) - .collect::>(); - url.set_query(None); - if !query.is_empty() { - url.query_pairs_mut().extend_pairs(query); - } url.set_fragment(None); Some(url.into()) } @@ -940,20 +908,23 @@ mod tests { assert_eq!(task.user_id.as_deref(), Some("user-1")); assert_eq!(task.api_key_id.as_deref(), Some("api-key-1")); - assert_eq!(task.username, None); - assert_eq!(task.api_key_name, None); + assert_eq!(task.username.as_deref(), Some("private-user-name")); + assert_eq!(task.api_key_name.as_deref(), Some("private-key-name")); assert_eq!(task.original_request_body, None); assert_eq!(task.progress_message, None); assert_eq!(task.error_message, None); assert_eq!(task.error_code.as_deref(), Some("provider_error")); - assert_eq!(task.video_url, None); + assert_eq!( + task.video_url.as_deref(), + Some("https://cdn.example.test/video.mp4?token=secret") + ); assert_eq!(task.request_metadata, None); - assert_eq!(task.prompt, None); + assert_eq!(task.prompt.as_deref(), Some("prompt")); assert_eq!(task.duration_seconds, Some(4)); } #[test] - fn upsert_sanitization_keeps_only_noncredential_video_urls() { + fn stored_task_preserves_prompt_and_signed_download_url() { let mut args = base_new_args(); args.12 = Some("gemini:video".to_string()); args.15 = Some("private prompt".to_string()); @@ -968,15 +939,15 @@ mod tests { args.28, args.29, args.30, args.31, args.32, args.33, args.34, args.35, args.36, ) .expect("stored task should build"); - assert_eq!(task.prompt, None); + assert_eq!(task.prompt.as_deref(), Some("private prompt")); assert_eq!( task.video_url.as_deref(), - Some("https://cdn.example.test/video.mp4?alt=media") + Some("https://cdn.example.test/video.mp4?key=secret&alt=media&signature=private") ); } #[test] - fn upsert_sanitization_uses_client_format_when_legacy_provider_format_is_blank() { + fn stored_task_preserves_download_url_when_legacy_provider_format_is_blank() { let mut args = base_new_args(); args.11 = Some("gemini:video".to_string()); args.12 = Some(" ".to_string()); @@ -992,7 +963,32 @@ mod tests { assert_eq!( task.video_url.as_deref(), - Some("https://cdn.example.test/video.mp4?alt=media") + Some("https://cdn.example.test/video.mp4?key=secret&alt=media") ); } + + #[test] + fn video_url_sanitization_preserves_signed_query_encoding_and_order() { + let video_url = + "https://cdn.example.test/video.mp4?signature=a%2Fb%2Bc%3D&part=2&part=1&name=a%20b"; + assert_eq!( + super::sanitize_video_task_url(Some(format!("{video_url}#fragment"))).as_deref(), + Some(video_url) + ); + } + + #[test] + fn video_url_sanitization_rejects_invalid_schemes_and_embedded_credentials() { + for video_url in [ + "file:///etc/passwd", + "javascript:alert(1)", + "data:video/mp4;base64,AAAA", + "https://user:password@cdn.example.test/video.mp4", + "https://user@cdn.example.test/video.mp4", + "/relative/video.mp4", + "not a url", + ] { + assert!(super::sanitize_video_task_url(Some(video_url.to_string())).is_none()); + } + } } diff --git a/crates/aether-data/runtime/src/lifecycle/backfill/tests.rs b/crates/aether-data/runtime/src/lifecycle/backfill/tests.rs index dbecf72dd..ae4c36eb8 100644 --- a/crates/aether-data/runtime/src/lifecycle/backfill/tests.rs +++ b/crates/aether-data/runtime/src/lifecycle/backfill/tests.rs @@ -1,13 +1,8 @@ -use std::{ - path::{Path, PathBuf}, - process::{Child, Command, Stdio}, - time::{Duration, Instant}, -}; - -use sqlx::{query, query_scalar, Connection, PgConnection, PgPool}; +use sqlx::{query, query_scalar, PgPool}; use super::{pending_backfills, pending_backfills_from_applied, run_backfills, AppliedBackfill}; use crate::lifecycle::migrate::prepare_database_for_startup; +use crate::lifecycle::postgres_test_support::ManagedPostgresServer; const LEGACY_SYNC_ENABLED_ACTIVE_FLAGS_VERSION: i64 = 20260517012000; const LEGACY_SYNC_ENABLED_ACTIVE_FLAGS_SQL: &str = @@ -84,194 +79,6 @@ fn corrected_legacy_backfill_is_not_requeued_after_application() { assert!(!pending_versions.contains(&LEGACY_SYNC_ENABLED_ACTIVE_FLAGS_VERSION)); } -#[derive(Debug)] -struct ManagedPostgresServer { - child: Option, - workdir: PathBuf, - database_url: String, -} - -impl ManagedPostgresServer { - async fn try_start() -> Result, Box> { - let initdb_bin = std::env::var("AETHER_INITDB_BIN") - .ok() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "initdb".to_string()); - let postgres_bin = std::env::var("AETHER_POSTGRES_BIN") - .ok() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "postgres".to_string()); - - if !command_exists(&initdb_bin) || !command_exists(&postgres_bin) { - eprintln!( - "skipping postgres backfill test because required binaries are unavailable: initdb={}, postgres={}", - initdb_bin, postgres_bin - ); - return Ok(None); - } - - match Self::start(initdb_bin, postgres_bin).await { - Ok(server) => Ok(Some(server)), - Err(err) if postgres_local_startup_unavailable(err.to_string().as_str()) => { - eprintln!( - "skipping postgres backfill test because local postgres could not start in this environment: {err}" - ); - Ok(None) - } - Err(err) => Err(err), - } - } - - async fn start( - initdb_bin: String, - postgres_bin: String, - ) -> Result> { - let port = reserve_local_port()?; - let workdir = std::env::temp_dir().join(format!( - "aether-backfill-tests-{}-{}", - std::process::id(), - port - )); - let data_dir = workdir.join("data"); - std::fs::create_dir_all(&workdir)?; - - let init_output = Command::new(&initdb_bin) - .arg("-D") - .arg(&data_dir) - .arg("-U") - .arg("aether") - .arg("--auth=trust") - .arg("--encoding=UTF8") - .arg("--no-instructions") - .output()?; - if !init_output.status.success() { - return Err(std::io::Error::other(format!( - "initdb failed: {}", - String::from_utf8_lossy(&init_output.stderr) - )) - .into()); - } - - let database_url = format!("postgres://aether@127.0.0.1:{port}/postgres"); - let log_path = workdir.join("postgres.log"); - let stdout = std::fs::File::create(&log_path)?; - let stderr = stdout.try_clone()?; - let mut child = Command::new(&postgres_bin) - .arg("-D") - .arg(&data_dir) - .arg("-h") - .arg("127.0.0.1") - .arg("-p") - .arg(port.to_string()) - .arg("-F") - .arg("-c") - .arg("fsync=off") - .arg("-c") - .arg("synchronous_commit=off") - .arg("-c") - .arg("full_page_writes=off") - .arg("-c") - .arg("shared_buffers=8MB") - .arg("-c") - .arg("max_connections=8") - .arg("-c") - .arg("dynamic_shared_memory_type=mmap") - .arg("-c") - .arg("autovacuum=off") - .stdout(Stdio::from(stdout)) - .stderr(Stdio::from(stderr)) - .spawn()?; - - if let Err(err) = wait_for_postgres(&database_url).await { - let _ = child.kill(); - let _ = child.wait(); - return Err(err); - } - - Ok(Self { - child: Some(child), - workdir, - database_url, - }) - } - - fn database_url(&self) -> &str { - &self.database_url - } - - fn stop(&mut self) { - if let Some(mut child) = self.child.take() { - let _ = child.kill(); - let _ = child.wait(); - } - } -} - -impl Drop for ManagedPostgresServer { - fn drop(&mut self) { - self.stop(); - let _ = std::fs::remove_dir_all(&self.workdir); - } -} - -fn command_exists(bin: &str) -> bool { - if bin.contains(std::path::MAIN_SEPARATOR) { - return Path::new(bin).exists(); - } - - let Some(paths) = std::env::var_os("PATH") else { - return false; - }; - - std::env::split_paths(&paths).any(|path| path.join(bin).exists()) -} - -fn reserve_local_port() -> Result { - let listener = std::net::TcpListener::bind("127.0.0.1:0")?; - let port = listener.local_addr()?.port(); - drop(listener); - Ok(port) -} - -fn postgres_shared_memory_unavailable(message: &str) -> bool { - let message = message.to_ascii_lowercase(); - message.contains("shared memory") - && (message.contains("could not create shared memory segment") - || message.contains("shmget") - || message.contains("no space left on device")) -} - -fn postgres_local_startup_unavailable(message: &str) -> bool { - let message = message.to_ascii_lowercase(); - postgres_shared_memory_unavailable(&message) - || (message.contains("timed out waiting for local postgres") - && (message.contains("connection refused") - || message.contains("os error 61") - || message.contains("os error 111"))) -} - -async fn wait_for_postgres(database_url: &str) -> Result<(), Box> { - let deadline = Instant::now() + Duration::from_secs(10); - loop { - match PgConnection::connect(database_url).await { - Ok(connection) => { - connection.close().await?; - return Ok(()); - } - Err(_) if Instant::now() < deadline => { - tokio::time::sleep(Duration::from_millis(50)).await - } - Err(err) => { - return Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - format!("timed out waiting for local postgres: {err}"), - ) - .into()) - } - } - } -} - #[tokio::test] async fn run_backfills_rebuilds_stats_and_records_execution() { let Some(server) = ManagedPostgresServer::try_start() diff --git a/crates/aether-data/runtime/src/lifecycle/export.rs b/crates/aether-data/runtime/src/lifecycle/export.rs index e9523e36b..f16c93afb 100644 --- a/crates/aether-data/runtime/src/lifecycle/export.rs +++ b/crates/aether-data/runtime/src/lifecycle/export.rs @@ -538,9 +538,15 @@ fn imported_payload_ids( .collect() } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct DataImportOptions { + pub preserve_credentials: bool, +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct DataCopyOptions { pub omit_request_body_details: bool, + pub preserve_credentials: bool, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -572,6 +578,25 @@ fn set_supported_import_value( } } +fn apply_import_credential_policy( + table_name: &str, + object: &mut serde_json::Map, + target_has_column: impl Fn(&str) -> bool, + options: DataImportOptions, +) { + let normalized_table = table_name + .rsplit('.') + .next() + .unwrap_or(table_name) + .trim_matches(|character| matches!(character, '"' | '`')); + if options.preserve_credentials + && matches!(normalized_table, "users" | "api_keys" | "management_tokens") + { + return; + } + deactivate_imported_credentials(table_name, object, target_has_column); +} + fn deactivate_imported_credentials( table_name: &str, object: &mut serde_json::Map, @@ -1085,6 +1110,14 @@ pub async fn export_database_jsonl( pub async fn import_database_jsonl( database: SqlDatabaseConfig, input: &str, +) -> Result { + import_database_jsonl_with_options(database, input, DataImportOptions::default()).await +} + +pub async fn import_database_jsonl_with_options( + database: SqlDatabaseConfig, + input: &str, + options: DataImportOptions, ) -> Result { match database.driver { #[cfg(feature = "postgres")] @@ -1092,7 +1125,7 @@ pub async fn import_database_jsonl( let pool = crate::driver::postgres::PostgresPoolFactory::new(database.to_postgres_config()?)? .connect_lazy()?; - import_postgres_jsonl(&pool, input).await + postgres::import_postgres_jsonl_with_options(&pool, input, options).await } #[cfg(not(feature = "postgres"))] DatabaseDriver::Postgres => Err(DataLayerError::InvalidInput( @@ -1113,7 +1146,14 @@ pub async fn copy_database_records( if options.omit_request_body_details { omit_request_body_details_from_records(&mut records); } - import_database_jsonl(target, &encode_jsonl(&records)?).await + import_database_jsonl_with_options( + target, + &encode_jsonl(&records)?, + DataImportOptions { + preserve_credentials: options.preserve_credentials, + }, + ) + .await } fn omit_request_body_details_from_records(records: &mut Vec) { diff --git a/crates/aether-data/runtime/src/lifecycle/export/postgres.rs b/crates/aether-data/runtime/src/lifecycle/export/postgres.rs index 956c9e38a..32e455f8b 100644 --- a/crates/aether-data/runtime/src/lifecycle/export/postgres.rs +++ b/crates/aether-data/runtime/src/lifecycle/export/postgres.rs @@ -58,14 +58,30 @@ pub async fn export_postgres_jsonl( pub async fn import_postgres_jsonl( pool: &crate::driver::postgres::PostgresPool, input: &str, +) -> Result { + import_postgres_jsonl_with_options(pool, input, DataImportOptions::default()).await +} + +pub(super) async fn import_postgres_jsonl_with_options( + pool: &crate::driver::postgres::PostgresPool, + input: &str, + options: DataImportOptions, ) -> Result { let plan = build_import_plan(input)?; - import_postgres_plan(pool, &plan).await + import_postgres_plan_with_options(pool, &plan, options).await } pub async fn import_postgres_plan( pool: &crate::driver::postgres::PostgresPool, plan: &DataImportPlan, +) -> Result { + import_postgres_plan_with_options(pool, plan, DataImportOptions::default()).await +} + +async fn import_postgres_plan_with_options( + pool: &crate::driver::postgres::PostgresPool, + plan: &DataImportPlan, + options: DataImportOptions, ) -> Result { let identity_scope = IdentityImportScope::from_plan(plan)?; let mut tx = pool.begin().await.map_sql_err()?; @@ -75,7 +91,7 @@ pub async fn import_postgres_plan( for domain in &plan.manifest.domains { if *domain == ExportDomain::Auxiliary { for row in plan.rows(*domain) { - import_postgres_auxiliary_row(&mut tx, row, &mut column_cache).await?; + import_postgres_auxiliary_row(&mut tx, row, &mut column_cache, options).await?; imported = imported.saturating_add(1); } continue; @@ -110,6 +126,7 @@ pub async fn import_postgres_plan( *domain, row, &target_columns, + options, ) .await?; imported = imported.saturating_add(1); @@ -587,11 +604,15 @@ async fn import_postgres_row( domain: ExportDomain, row: &ExportRow, target_columns: &PostgresImportColumns, + options: DataImportOptions, ) -> Result<(), DataLayerError> { let mut object = normalize_postgres_import_payload(table_name, domain, row, target_columns)?; - deactivate_imported_credentials(table_name, &mut object, |column_name| { - target_columns.contains_key(column_name) - }); + apply_import_credential_policy( + table_name, + &mut object, + |column_name| target_columns.contains_key(column_name), + options, + ); let columns = object.keys().map(String::as_str).collect::>(); let column_sql = columns @@ -839,6 +860,7 @@ async fn import_postgres_billing_row( payload, }, &target_columns, + DataImportOptions::default(), ) .await } @@ -847,6 +869,7 @@ async fn import_postgres_auxiliary_row( tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, row: &ExportRow, column_cache: &mut BTreeMap, + options: DataImportOptions, ) -> Result<(), DataLayerError> { let (table_name, payload) = domain_payload_table(row, "auxiliary", None)?; let table = auxiliary_table(&table_name)?; @@ -862,6 +885,7 @@ async fn import_postgres_auxiliary_row( payload, }, &target_columns, + options, ) .await } @@ -897,6 +921,7 @@ async fn import_postgres_wallet_row( payload, }, &target_columns, + DataImportOptions::default(), ) .await } diff --git a/crates/aether-data/runtime/src/lifecycle/export/tests.rs b/crates/aether-data/runtime/src/lifecycle/export/tests.rs index 48686a98a..bb55b0bc3 100644 --- a/crates/aether-data/runtime/src/lifecycle/export/tests.rs +++ b/crates/aether-data/runtime/src/lifecycle/export/tests.rs @@ -3,11 +3,12 @@ use std::collections::{BTreeMap, BTreeSet}; use serde_json::{json, Value}; use super::{ - build_import_plan, deactivate_imported_credentials, decode_jsonl, decode_jsonl_with_limits, - encode_jsonl, export_postgres_core_jsonl, normalize_imported_binary, - normalize_imported_integer_timestamp, normalize_postgres_import_payload, - postgres_bytea_json_value, postgres_core_export_domains, DataExportManifest, DataExportRecord, - ExportDomain, ExportRow, PostgresImportColumn, + apply_import_credential_policy, build_import_plan, deactivate_imported_credentials, + decode_jsonl, decode_jsonl_with_limits, encode_jsonl, export_postgres_core_jsonl, + normalize_imported_binary, normalize_imported_integer_timestamp, + normalize_postgres_import_payload, postgres_bytea_json_value, postgres_core_export_domains, + DataExportManifest, DataExportRecord, DataImportOptions, ExportDomain, ExportRow, + PostgresImportColumn, }; use crate::driver::postgres::{PostgresPoolConfig, PostgresPoolFactory}; use crate::lifecycle::migrate::run_migrations as run_postgres_migrations; @@ -416,6 +417,159 @@ fn imported_proxy_nodes_receive_a_new_offline_tunnel_generation() { ); } +#[test] +fn trusted_import_preserves_stable_credentials_only_when_explicitly_requested() { + assert!(!DataImportOptions::default().preserve_credentials); + for (table, payload) in [ + ("users", json!({"password_hash": "$2b$12$trusted-hash"})), + ( + "public.\"api_keys\"", + json!({ + "key_hash": "trusted-key-hash", "key_encrypted": "trusted-ciphertext", + "status": "active", "is_active": true, "is_locked": false, + }), + ), + ( + "management_tokens", + json!({"token_hash": "trusted-token-hash", "is_active": true}), + ), + ] { + for preserve_credentials in [false, true] { + let mut object = payload.as_object().unwrap().clone(); + apply_import_credential_policy( + table, + &mut object, + |_| true, + DataImportOptions { + preserve_credentials, + }, + ); + if preserve_credentials { + assert_eq!(&object, payload.as_object().unwrap()); + } else { + assert_ne!(&object, payload.as_object().unwrap()); + } + } + } +} + +#[test] +fn trusted_import_still_revokes_imported_sessions_and_live_tunnels() { + let options = DataImportOptions { + preserve_credentials: true, + }; + let mut session = json!({ + "refresh_token_hash": "old-session", "prev_refresh_token_hash": "older-session", + "revoked_at": null, "revoke_reason": null, + }) + .as_object() + .unwrap() + .clone(); + apply_import_credential_policy("public.user_sessions", &mut session, |_| true, options); + assert_ne!(session["refresh_token_hash"], json!("old-session")); + assert_eq!(session["prev_refresh_token_hash"], Value::Null); + assert_eq!( + session["revoke_reason"], + json!("imported_credentials_revoked") + ); + + let mut node = json!({ + "tunnel_generation": "old-generation", "tunnel_connected": true, + "status": "online", "active_connections": 10, + }) + .as_object() + .unwrap() + .clone(); + apply_import_credential_policy("proxy_nodes", &mut node, |_| true, options); + assert_ne!(node["tunnel_generation"], json!("old-generation")); + assert_eq!(node["tunnel_connected"], json!(false)); + assert_eq!(node["status"], json!("offline")); + assert_eq!(node["active_connections"], json!(0)); +} + +#[tokio::test] +#[ignore = "requires AETHER_TEST_POSTGRES_URL and PostgreSQL migrations"] +async fn live_import_credential_policy_round_trips_through_postgres() { + let pool = PostgresPoolFactory::new(PostgresPoolConfig { + database_url: std::env::var("AETHER_TEST_POSTGRES_URL").unwrap(), + ..Default::default() + }) + .unwrap() + .connect_lazy() + .unwrap(); + run_postgres_migrations(&pool).await.unwrap(); + for preserve_credentials in [false, true] { + let user_id = uuid::Uuid::new_v4().to_string(); + let key_id = uuid::Uuid::new_v4().to_string(); + let password_hash = "$2b$12$trusted-import-hash"; + let key_hash = format!("trusted-{key_id}"); + sqlx::query("INSERT INTO users (id, username, password_hash, auth_source, email_verified) VALUES ($1, $2, $3, 'local', FALSE)") + .bind(&user_id).bind(format!("import-{}", &user_id[..8])).bind(password_hash) + .execute(&pool).await.unwrap(); + sqlx::query("INSERT INTO api_keys (id, user_id, key_hash, key_encrypted, name) VALUES ($1, $2, $3, 'trusted-ciphertext', 'Import probe')") + .bind(&key_id).bind(&user_id).bind(&key_hash).execute(&pool).await.unwrap(); + let user: Value = sqlx::query_scalar("SELECT to_jsonb(users) FROM users WHERE id = $1") + .bind(&user_id) + .fetch_one(&pool) + .await + .unwrap(); + let key: Value = + sqlx::query_scalar("SELECT to_jsonb(api_keys) FROM api_keys WHERE id = $1") + .bind(&key_id) + .fetch_one(&pool) + .await + .unwrap(); + let input = encode_jsonl(&[ + DataExportRecord::manifest(DataExportManifest::new( + 1_788_739_200, + Some(DatabaseDriver::Postgres), + vec![ExportDomain::Users, ExportDomain::ApiKeys], + )), + DataExportRecord::row(ExportDomain::Users, &user_id, user), + DataExportRecord::row(ExportDomain::ApiKeys, &key_id, key), + ]) + .unwrap(); + super::postgres::import_postgres_jsonl_with_options( + &pool, + &input, + DataImportOptions { + preserve_credentials, + }, + ) + .await + .unwrap(); + let imported_password: String = + sqlx::query_scalar("SELECT password_hash FROM users WHERE id = $1") + .bind(&user_id) + .fetch_one(&pool) + .await + .unwrap(); + let imported_key: (String, Option, bool) = + sqlx::query_as("SELECT key_hash, key_encrypted, is_active FROM api_keys WHERE id = $1") + .bind(&key_id) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(imported_password == password_hash, preserve_credentials); + assert_eq!(imported_key.0 == key_hash, preserve_credentials); + assert_eq!( + imported_key.1.as_deref(), + preserve_credentials.then_some("trusted-ciphertext") + ); + assert_eq!(imported_key.2, preserve_credentials); + sqlx::query("DELETE FROM api_keys WHERE id = $1") + .bind(&key_id) + .execute(&pool) + .await + .unwrap(); + sqlx::query("DELETE FROM users WHERE id = $1") + .bind(&user_id) + .execute(&pool) + .await + .unwrap(); + } +} + fn postgres_column(data_type: &str, udt_name: &str) -> PostgresImportColumn { PostgresImportColumn { data_type: data_type.to_ascii_lowercase(), diff --git a/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs b/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs index 90a64c6fb..47f575674 100644 --- a/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs +++ b/crates/aether-data/runtime/src/lifecycle/migrate/tests.rs @@ -1,9 +1,7 @@ use std::borrow::Cow; use std::collections::BTreeSet; use std::fs; -use std::path::{Path, PathBuf}; -use std::process::{Child, Command, Stdio}; -use std::time::{Duration, Instant}; +use std::path::PathBuf; use sqlx::{ migrate::{AppliedMigration, Migrate}, @@ -18,6 +16,8 @@ use aether_data_contracts::repository::{ }, }; +use crate::lifecycle::postgres_test_support::ManagedPostgresServer; + use super::{ postgres::{all_up_migrations, pending_migrations_from_applied, POSTGRES_MIGRATOR}, prepare_database_for_startup, @@ -27,146 +27,6 @@ use crate::lifecycle::bootstrap::postgres::{ EMPTY_DATABASE_SNAPSHOT_CUTOFF_VERSION, EMPTY_DATABASE_SNAPSHOT_SQL, }; -#[derive(Debug)] -struct ManagedPostgresServer { - child: Option, - workdir: PathBuf, - database_url: String, -} - -impl ManagedPostgresServer { - async fn try_start() -> Result, Box> { - let required = local_postgres_tests_required(); - let initdb_bin = std::env::var("AETHER_INITDB_BIN") - .ok() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "initdb".to_string()); - let postgres_bin = std::env::var("AETHER_POSTGRES_BIN") - .ok() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| "postgres".to_string()); - - if !command_exists(&initdb_bin) || !command_exists(&postgres_bin) { - let message = format!( - "required postgres integration test binaries are unavailable: initdb={initdb_bin}, postgres={postgres_bin}" - ); - if required { - return Err(std::io::Error::new(std::io::ErrorKind::NotFound, message).into()); - } - eprintln!("skipping postgres integration test because {message}"); - return Ok(None); - } - - match Self::start(initdb_bin, postgres_bin).await { - Ok(server) => Ok(Some(server)), - Err(err) - if !required && postgres_local_startup_unavailable(err.to_string().as_str()) => - { - eprintln!( - "skipping postgres integration test because local postgres could not start in this environment: {err}" - ); - Ok(None) - } - Err(err) => Err(err), - } - } - - async fn start( - initdb_bin: String, - postgres_bin: String, - ) -> Result> { - let port = reserve_local_port()?; - let workdir = std::env::temp_dir().join(format!( - "aether-migrate-tests-{}-{}", - std::process::id(), - port - )); - let data_dir = workdir.join("data"); - std::fs::create_dir_all(&workdir)?; - - let init_output = Command::new(&initdb_bin) - .arg("-D") - .arg(&data_dir) - .arg("-U") - .arg("aether") - .arg("--auth=trust") - .arg("--encoding=UTF8") - .arg("--no-instructions") - .output()?; - if !init_output.status.success() { - return Err(std::io::Error::other(format!( - "initdb failed: {}", - String::from_utf8_lossy(&init_output.stderr) - )) - .into()); - } - - let database_url = format!("postgres://aether@127.0.0.1:{port}/postgres"); - let log_path = workdir.join("postgres.log"); - let stdout = std::fs::File::create(&log_path)?; - let stderr = stdout.try_clone()?; - let mut child = Command::new(&postgres_bin) - .arg("-D") - .arg(&data_dir) - .arg("-h") - .arg("127.0.0.1") - .arg("-p") - .arg(port.to_string()) - .arg("-k") - .arg(&workdir) - .arg("-F") - .arg("-c") - .arg("fsync=off") - .arg("-c") - .arg("synchronous_commit=off") - .arg("-c") - .arg("full_page_writes=off") - .arg("-c") - .arg("shared_buffers=8MB") - .arg("-c") - .arg("max_connections=8") - .arg("-c") - .arg("dynamic_shared_memory_type=mmap") - .arg("-c") - .arg("autovacuum=off") - .stdout(Stdio::from(stdout)) - .stderr(Stdio::from(stderr)) - .spawn()?; - - if let Err(err) = wait_for_postgres(&database_url).await { - let _ = child.kill(); - let exit_status = child - .wait() - .map(|status| status.to_string()) - .unwrap_or_else(|wait_err| format!("unavailable ({wait_err})")); - let logs = fs::read_to_string(&log_path) - .unwrap_or_else(|read_err| format!("")); - return Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - format!("{err}; postgres exit status: {exit_status}; logs:\n{logs}"), - ) - .into()); - } - - Ok(Self { - child: Some(child), - workdir, - database_url, - }) - } - - fn database_url(&self) -> &str { - &self.database_url - } - - fn stop(&mut self) { - if let Some(mut child) = self.child.take() { - let _ = child.kill(); - let _ = child.wait(); - } - } -} - /// A clean PostgreSQL database is bootstrapped from the schema snapshot first; /// migrations after the privacy/security frontier are intentionally left /// pending so their data-preserving changes still execute. Exercise the same @@ -191,83 +51,6 @@ async fn prepare_and_apply_clean_postgres_database(pool: &PgPool) { ); } -fn local_postgres_tests_required() -> bool { - // CI can opt into failing when the isolated local PostgreSQL fixture is unavailable. - std::env::var("AETHER_REQUIRE_LOCAL_POSTGRES_TESTS") - .ok() - .is_some_and(|value| { - matches!( - value.trim().to_ascii_lowercase().as_str(), - "1" | "true" | "yes" | "on" - ) - }) -} - -impl Drop for ManagedPostgresServer { - fn drop(&mut self) { - self.stop(); - let _ = std::fs::remove_dir_all(&self.workdir); - } -} - -fn command_exists(bin: &str) -> bool { - if bin.contains(std::path::MAIN_SEPARATOR) { - return Path::new(bin).exists(); - } - - let Some(paths) = std::env::var_os("PATH") else { - return false; - }; - - std::env::split_paths(&paths).any(|path| path.join(bin).exists()) -} - -fn reserve_local_port() -> Result { - let listener = std::net::TcpListener::bind("127.0.0.1:0")?; - let port = listener.local_addr()?.port(); - drop(listener); - Ok(port) -} - -fn postgres_shared_memory_unavailable(message: &str) -> bool { - let message = message.to_ascii_lowercase(); - message.contains("shared memory") - && (message.contains("could not create shared memory segment") - || message.contains("shmget") - || message.contains("no space left on device")) -} - -fn postgres_local_startup_unavailable(message: &str) -> bool { - let message = message.to_ascii_lowercase(); - postgres_shared_memory_unavailable(&message) - || (message.contains("timed out waiting for local postgres") - && (message.contains("connection refused") - || message.contains("os error 61") - || message.contains("os error 111"))) -} - -async fn wait_for_postgres(database_url: &str) -> Result<(), Box> { - let deadline = Instant::now() + Duration::from_secs(10); - loop { - match PgConnection::connect(database_url).await { - Ok(connection) => { - connection.close().await?; - return Ok(()); - } - Err(_) if Instant::now() < deadline => { - tokio::time::sleep(Duration::from_millis(50)).await - } - Err(err) => { - return Err(std::io::Error::new( - std::io::ErrorKind::TimedOut, - format!("timed out waiting for local postgres: {err}"), - ) - .into()) - } - } - } -} - async fn table_exists(pool: &PgPool, table_name: &str) -> Result { query_scalar::<_, bool>("SELECT to_regclass($1) IS NOT NULL") .bind(format!("public.{table_name}")) diff --git a/crates/aether-data/runtime/src/lifecycle/mod.rs b/crates/aether-data/runtime/src/lifecycle/mod.rs index 109c3295d..99cf48c8c 100644 --- a/crates/aether-data/runtime/src/lifecycle/mod.rs +++ b/crates/aether-data/runtime/src/lifecycle/mod.rs @@ -8,3 +8,5 @@ pub mod backfill; pub(crate) mod bootstrap; pub mod export; pub mod migrate; +#[cfg(all(test, feature = "postgres"))] +mod postgres_test_support; diff --git a/crates/aether-data/runtime/src/lifecycle/postgres_test_support.rs b/crates/aether-data/runtime/src/lifecycle/postgres_test_support.rs new file mode 100644 index 000000000..4e1a3f157 --- /dev/null +++ b/crates/aether-data/runtime/src/lifecycle/postgres_test_support.rs @@ -0,0 +1,276 @@ +use std::{ + path::{Path, PathBuf}, + process::{Child, Command, Stdio}, + time::{Duration, Instant}, +}; + +use sqlx::{Connection, PgConnection}; + +#[derive(Debug)] +pub(super) struct ManagedPostgresServer { + child: Option, + pg_ctl_bin: PathBuf, + workdir: PathBuf, + data_dir: PathBuf, + database_url: String, +} + +impl ManagedPostgresServer { + pub(super) async fn try_start() -> Result, Box> { + let required = local_postgres_tests_required(); + let initdb_bin = configured_binary("AETHER_INITDB_BIN", "initdb"); + let postgres_bin = configured_binary("AETHER_POSTGRES_BIN", "postgres"); + let pg_ctl_bin = std::env::var("AETHER_PG_CTL_BIN") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(&postgres_bin).with_file_name(if cfg!(windows) { + "pg_ctl.exe" + } else { + "pg_ctl" + }) + }); + + if !command_exists(Path::new(&initdb_bin)) + || !command_exists(Path::new(&postgres_bin)) + || !command_exists(&pg_ctl_bin) + { + let message = format!( + "required postgres integration test binaries are unavailable: initdb={initdb_bin}, postgres={postgres_bin}, pg_ctl={}", + pg_ctl_bin.display() + ); + if required { + return Err(std::io::Error::new(std::io::ErrorKind::NotFound, message).into()); + } + eprintln!("skipping postgres integration test because {message}"); + return Ok(None); + } + + match Self::start(initdb_bin, postgres_bin, pg_ctl_bin).await { + Ok(server) => Ok(Some(server)), + Err(error) if !required && postgres_local_startup_unavailable(&error.to_string()) => { + eprintln!( + "skipping postgres integration test because local postgres could not start in this environment: {error}" + ); + Ok(None) + } + Err(error) => Err(error), + } + } + + async fn start( + initdb_bin: String, + postgres_bin: String, + pg_ctl_bin: PathBuf, + ) -> Result> { + let listener = std::net::TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + drop(listener); + let workdir = std::env::temp_dir().join(format!( + "aether-lifecycle-tests-{}-{port}", + std::process::id() + )); + std::fs::create_dir(&workdir)?; + let mut server = Self { + child: None, + pg_ctl_bin, + data_dir: workdir.join("data"), + workdir, + database_url: format!("postgres://aether@127.0.0.1:{port}/postgres"), + }; + + let init_output = Command::new(&initdb_bin) + .arg("-D") + .arg(&server.data_dir) + .args([ + "-U", + "aether", + "--auth=trust", + "--encoding=UTF8", + "--no-instructions", + ]) + .output()?; + if !init_output.status.success() { + return Err(std::io::Error::other(format!( + "initdb failed: {}", + String::from_utf8_lossy(&init_output.stderr) + )) + .into()); + } + + let log_path = server.workdir.join("postgres.log"); + let stdout = std::fs::File::create(&log_path)?; + let stderr = stdout.try_clone()?; + server.child = Some( + Command::new(&postgres_bin) + .arg("-D") + .arg(&server.data_dir) + .args(["-h", "127.0.0.1", "-p"]) + .arg(port.to_string()) + .arg("-F") + .args(["-c", "unix_socket_directories="]) + .args(["-c", "fsync=off"]) + .args(["-c", "synchronous_commit=off"]) + .args(["-c", "full_page_writes=off"]) + .args(["-c", "shared_buffers=8MB"]) + .args(["-c", "max_connections=8"]) + .args(["-c", "dynamic_shared_memory_type=mmap"]) + .args(["-c", "autovacuum=off"]) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)) + .spawn()?, + ); + + if let Err(error) = wait_for_postgres(&server.database_url).await { + let logs = std::fs::read_to_string(&log_path).unwrap_or_default(); + server.stop()?; + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("{error}; postgres logs:\n{logs}"), + ) + .into()); + } + Ok(server) + } + + pub(super) fn database_url(&self) -> &str { + &self.database_url + } + + fn stop(&mut self) -> Result<(), std::io::Error> { + let Some(child) = self.child.as_mut() else { + return Ok(()); + }; + if child.try_wait()?.is_some() { + self.child = None; + return Ok(()); + } + let output = Command::new(&self.pg_ctl_bin) + .arg("-D") + .arg(&self.data_dir) + .args(["stop", "-m", "fast", "-w", "-t", "10"]) + .output()?; + if !output.status.success() && child.try_wait()?.is_none() { + return Err(std::io::Error::other(format!( + "pg_ctl stop failed for {}: {}{}", + self.data_dir.display(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ))); + } + child.wait()?; + self.child = None; + Ok(()) + } +} + +impl Drop for ManagedPostgresServer { + fn drop(&mut self) { + match self.stop() { + Ok(()) => { + let _ = std::fs::remove_dir_all(&self.workdir); + } + Err(error) => { + eprintln!( + "failed to stop managed postgres; preserving {}: {error}", + self.workdir.display(), + ); + } + } + } +} + +fn configured_binary(variable: &str, default: &str) -> String { + std::env::var(variable) + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| default.to_string()) +} + +fn command_exists(binary: &Path) -> bool { + if binary.is_absolute() || binary.components().count() > 1 { + return binary.is_file(); + } + std::env::var_os("PATH") + .is_some_and(|paths| std::env::split_paths(&paths).any(|path| path.join(binary).is_file())) +} + +fn local_postgres_tests_required() -> bool { + std::env::var("AETHER_REQUIRE_LOCAL_POSTGRES_TESTS") + .ok() + .is_some_and(|value| { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + +fn postgres_local_startup_unavailable(message: &str) -> bool { + let message = message.to_ascii_lowercase(); + (message.contains("shared memory") + && (message.contains("could not create shared memory segment") + || message.contains("shmget") + || message.contains("no space left on device"))) + || (message.contains("timed out waiting for local postgres") + && (message.contains("connection refused") + || message.contains("os error 61") + || message.contains("os error 111"))) +} + +async fn wait_for_postgres(database_url: &str) -> Result<(), Box> { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + match PgConnection::connect(database_url).await { + Ok(connection) => { + connection.close().await?; + return Ok(()); + } + Err(_) if Instant::now() < deadline => { + tokio::time::sleep(Duration::from_millis(50)).await; + } + Err(error) => { + return Err(std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("timed out waiting for local postgres: {error}"), + ) + .into()); + } + } + } +} + +#[tokio::test] +async fn managed_postgres_stops_cleanly_with_open_connections() { + let Some(mut server) = ManagedPostgresServer::try_start().await.unwrap() else { + return; + }; + let connection = PgConnection::connect(server.database_url()).await.unwrap(); + let workdir = server.workdir.clone(); + assert!(server.data_dir.join("postmaster.pid").exists()); + server.stop().unwrap(); + assert!(server.child.is_none()); + assert!(!server.data_dir.join("postmaster.pid").exists()); + server.stop().unwrap(); + drop(connection); + drop(server); + assert!(!workdir.exists()); +} + +#[tokio::test] +async fn failed_postgres_stop_retains_ownership_for_retry() { + let Some(mut server) = ManagedPostgresServer::try_start().await.unwrap() else { + return; + }; + let pg_ctl_bin = server.pg_ctl_bin.clone(); + let workdir = server.workdir.clone(); + server.pg_ctl_bin = workdir.join("missing-pg-ctl"); + assert!(server.stop().is_err()); + assert!(server.child.as_mut().unwrap().try_wait().unwrap().is_none()); + assert!(server.data_dir.exists()); + server.pg_ctl_bin = pg_ctl_bin; + server.stop().unwrap(); + drop(server); + assert!(!workdir.exists()); +} diff --git a/crates/aether-data/runtime/src/repository/usage/memory.rs b/crates/aether-data/runtime/src/repository/usage/memory.rs index be1aa4fbd..af0fa9b63 100644 --- a/crates/aether-data/runtime/src/repository/usage/memory.rs +++ b/crates/aether-data/runtime/src/repository/usage/memory.rs @@ -2752,6 +2752,40 @@ fn hydrate_client_family(item: &mut StoredRequestUsageAudit) { } } +fn merge_usage_body_capture( + incoming_body: Option, + incoming_ref: Option, + incoming_state: Option, + existing: Option<&StoredRequestUsageAudit>, + field: UsageBodyField, +) -> (Option, Option, Option) { + if matches!( + incoming_state, + Some( + UsageBodyCaptureState::None + | UsageBodyCaptureState::Disabled + | UsageBodyCaptureState::Unavailable + ) + ) { + return (None, None, incoming_state); + } + if incoming_body.is_some() { + return ( + incoming_body, + None, + incoming_state.or(Some(UsageBodyCaptureState::Inline)), + ); + } + if incoming_ref.is_some() { + return (None, incoming_ref, Some(UsageBodyCaptureState::Reference)); + } + ( + existing.and_then(|item| item.body_value(field).cloned()), + existing.and_then(|item| item.body_ref(field).map(ToOwned::to_owned)), + incoming_state.or_else(|| existing.and_then(|item| item.body_state(field))), + ) +} + fn request_body_capture_replaces_derived_facts( request_body: Option<&Value>, request_body_state: Option, @@ -2883,36 +2917,47 @@ impl UsageWriteRepository for InMemoryUsageReadRepository { return Ok(existing.clone()); } } - let capture_usage = sanitize_usage_capture_controls_for_persistence(capture_usage); + let mut capture_usage = sanitize_usage_capture_controls_for_persistence(capture_usage); if let Some(existing) = by_request_id.get_mut(&usage.request_id) { - existing.request_headers = None; - existing.request_body = None; - existing.request_body_ref = None; - existing.request_body_state = None; - existing.provider_request_headers = None; - existing.provider_request_body = None; - existing.provider_request_body_ref = None; - existing.provider_request_body_state = None; - existing.response_headers = None; - existing.response_body = None; - existing.response_body_ref = None; - existing.response_body_state = None; - existing.client_response_headers = None; - existing.client_response_body = None; - existing.client_response_body_ref = None; - existing.client_response_body_state = None; existing.request_metadata = sanitize_usage_request_metadata(existing.request_metadata.take()); } { let mut detached_bodies = self.detached_bodies.write().expect("usage repository lock"); - for field in [ - UsageBodyField::RequestBody, - UsageBodyField::ProviderRequestBody, - UsageBodyField::ResponseBody, - UsageBodyField::ClientResponseBody, + for (field, state, body) in [ + ( + UsageBodyField::RequestBody, + capture_usage.request_body_state, + &capture_usage.request_body, + ), + ( + UsageBodyField::ProviderRequestBody, + capture_usage.provider_request_body_state, + &capture_usage.provider_request_body, + ), + ( + UsageBodyField::ResponseBody, + capture_usage.response_body_state, + &capture_usage.response_body, + ), + ( + UsageBodyField::ClientResponseBody, + capture_usage.client_response_body_state, + &capture_usage.client_response_body, + ), ] { - detached_bodies.remove(&usage_body_ref(&usage.request_id, field)); + if body.is_some() + || matches!( + state, + Some( + UsageBodyCaptureState::None + | UsageBodyCaptureState::Disabled + | UsageBodyCaptureState::Unavailable + ) + ) + { + detached_bodies.remove(&usage_body_ref(&usage.request_id, field)); + } } } @@ -2977,10 +3022,36 @@ impl UsageWriteRepository for InMemoryUsageReadRepository { } }); let request_metadata = sanitize_memory_request_metadata(request_metadata); - let request_body_ref = None; - let provider_request_body_ref = None; - let response_body_ref = None; - let client_response_body_ref = None; + let (request_body, request_body_ref, request_body_state) = merge_usage_body_capture( + capture_usage.request_body.take(), + capture_usage.request_body_ref.take(), + capture_usage.request_body_state, + existing.as_ref(), + UsageBodyField::RequestBody, + ); + let (provider_request_body, provider_request_body_ref, provider_request_body_state) = + merge_usage_body_capture( + capture_usage.provider_request_body.take(), + capture_usage.provider_request_body_ref.take(), + capture_usage.provider_request_body_state, + existing.as_ref(), + UsageBodyField::ProviderRequestBody, + ); + let (response_body, response_body_ref, response_body_state) = merge_usage_body_capture( + capture_usage.response_body.take(), + capture_usage.response_body_ref.take(), + capture_usage.response_body_state, + existing.as_ref(), + UsageBodyField::ResponseBody, + ); + let (client_response_body, client_response_body_ref, client_response_body_state) = + merge_usage_body_capture( + capture_usage.client_response_body.take(), + capture_usage.client_response_body_ref.take(), + capture_usage.client_response_body_state, + existing.as_ref(), + UsageBodyField::ClientResponseBody, + ); let stored = StoredRequestUsageAudit { id: existing .as_ref() @@ -3089,22 +3160,38 @@ impl UsageWriteRepository for InMemoryUsageReadRepository { ), status: usage.status, billing_status: usage.billing_status, - request_headers: None, - request_body: None, + request_headers: capture_usage.request_headers.or_else(|| { + existing + .as_ref() + .and_then(|item| item.request_headers.clone()) + }), + request_body, request_body_ref, - request_body_state: capture_usage.request_body_state, - provider_request_headers: None, - provider_request_body: None, + request_body_state, + provider_request_headers: capture_usage.provider_request_headers.or_else(|| { + existing + .as_ref() + .and_then(|item| item.provider_request_headers.clone()) + }), + provider_request_body, provider_request_body_ref, - provider_request_body_state: capture_usage.provider_request_body_state, - response_headers: None, - response_body: None, + provider_request_body_state, + response_headers: capture_usage.response_headers.or_else(|| { + existing + .as_ref() + .and_then(|item| item.response_headers.clone()) + }), + response_body, response_body_ref, - response_body_state: capture_usage.response_body_state, - client_response_headers: None, - client_response_body: None, + response_body_state, + client_response_headers: capture_usage.client_response_headers.or_else(|| { + existing + .as_ref() + .and_then(|item| item.client_response_headers.clone()) + }), + client_response_body, client_response_body_ref, - client_response_body_state: capture_usage.client_response_body_state, + client_response_body_state, candidate_id: if replace_routing_snapshot { capture_usage.candidate_id } else { diff --git a/crates/aether-data/runtime/src/repository/usage/memory/tests.rs b/crates/aether-data/runtime/src/repository/usage/memory/tests.rs index 949340fe8..ca00f279f 100644 --- a/crates/aether-data/runtime/src/repository/usage/memory/tests.rs +++ b/crates/aether-data/runtime/src/repository/usage/memory/tests.rs @@ -136,6 +136,76 @@ fn sample_upsert_usage_record(request_id: &str) -> UpsertUsageRecord { } } +#[tokio::test] +async fn upsert_preserves_full_http_captures_across_lifecycle_updates() { + let repository = InMemoryUsageReadRepository::default(); + let mut pending = sample_upsert_usage_record("req-full-capture"); + pending.request_headers = + Some(json!({"content-type": "application/json", "authorization": "Bearer private"})); + pending.request_body = + Some(json!({"messages": [{"role": "user", "content": "original request"}]})); + pending.provider_request_body = Some(json!({"input": "provider request"})); + pending.request_body_state = Some(UsageBodyCaptureState::Inline); + pending.provider_request_body_state = Some(UsageBodyCaptureState::Inline); + let stored_pending = repository.upsert(pending.clone()).await.unwrap(); + assert_eq!(stored_pending.request_body, pending.request_body); + assert_eq!( + stored_pending.provider_request_body, + pending.provider_request_body + ); + assert_eq!( + stored_pending.request_headers, + Some(json!({"content-type": "application/json", "authorization": "[redacted]"})) + ); + + let mut streaming = sample_upsert_usage_record(&pending.request_id); + streaming.status = "streaming".to_string(); + streaming.updated_at_unix_secs += 1; + let stored_streaming = repository.upsert(streaming).await.unwrap(); + assert_eq!(stored_streaming.request_body, pending.request_body); + assert_eq!( + stored_streaming.provider_request_body, + pending.provider_request_body + ); + + let mut terminal = sample_upsert_usage_record(&pending.request_id); + terminal.status = "completed".to_string(); + terminal.updated_at_unix_secs += 2; + terminal.finalized_at_unix_secs = Some(terminal.updated_at_unix_secs); + terminal.response_headers = + Some(json!({"content-type": "text/event-stream", "set-cookie": "private"})); + terminal.response_body = Some(json!("data: upstream response\n\ndata: [DONE]\n\n")); + terminal.client_response_body = + Some(json!({"choices": [{"message": {"content": "client response"}}]})); + let stored_terminal = repository.upsert(terminal.clone()).await.unwrap(); + assert_eq!(stored_terminal.request_body, pending.request_body); + assert_eq!( + stored_terminal.provider_request_body, + pending.provider_request_body + ); + assert_eq!(stored_terminal.response_body, terminal.response_body); + assert_eq!( + stored_terminal.client_response_body, + terminal.client_response_body + ); + assert_eq!( + stored_terminal.response_headers, + Some(json!({"content-type": "text/event-stream", "set-cookie": "[redacted]"})) + ); + + let found = repository + .find_by_request_id(&pending.request_id) + .await + .unwrap() + .unwrap(); + assert_eq!(found.request_body, pending.request_body); + assert_eq!(found.response_body, terminal.response_body); + assert_eq!( + repository.upsert(pending).await.unwrap().response_body, + terminal.response_body + ); +} + #[tokio::test] async fn upsert_uses_typed_provider_capture_as_the_fast_fact_snapshot() { for (name, state, incoming_tier, expected_tier) in [ diff --git a/crates/aether-oauth/src/provider/providers/generic.rs b/crates/aether-oauth/src/provider/providers/generic.rs index e51be4454..d3f761573 100644 --- a/crates/aether-oauth/src/provider/providers/generic.rs +++ b/crates/aether-oauth/src/provider/providers/generic.rs @@ -244,9 +244,15 @@ impl GenericProviderOAuthAdapter { fn resolve_client_secret(&self, configured: Option) -> Result { let configured = configured.or_else(|| { - (self.template.provider_type == "antigravity" - && self.client_id() == self.template.client_id) - .then(|| "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".to_string()) + let default_template = template_for_provider_type(self.template.provider_type)?; + if self.client_id() != default_template.client_id { + return None; + } + match self.template.provider_type { + "gemini_cli" => Some("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".to_string()), + "antigravity" => Some("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".to_string()), + _ => None, + } }); required_client_secret( self.template @@ -973,54 +979,78 @@ mod tests { } #[tokio::test] - async fn antigravity_default_credentials_support_authorization_and_refresh() { - let mut template = template_for_provider_type("antigravity").expect("template"); - template.client_id_env = None; - template.client_secret_env = Some("AETHER_TEST_UNUSED_ANTIGRAVITY_SECRET"); - let adapter = GenericProviderOAuthAdapter::new(template); - let ctx = transport_context("antigravity"); - adapter - .build_authorize_url(&ctx, "state", None) - .expect("authorize"); - let seen_request = Arc::new(Mutex::new(None)); - let executor = StaticExecutor { - seen_request: Arc::clone(&seen_request), - response_payload: json!({"access_token": "new-token", "expires_in": 3600}), - }; - adapter - .refresh(&executor, &ctx, &oauth_account("antigravity")) - .await - .expect("refresh"); - let seen = seen_request.lock().expect("lock").clone().expect("request"); - let body = seen.body_bytes.expect("body"); - let fields = url::form_urlencoded::parse(&body) - .into_owned() - .collect::>(); - assert_eq!(fields["client_id"], template.client_id); - assert_eq!( - fields["client_secret"], - adapter.resolve_client_secret(None).expect("default") - ); - assert_eq!(fields["refresh_token"], "old-refresh-token"); - assert!(!format!("{adapter:?}").contains(&fields["client_secret"])); + async fn google_default_credentials_support_authorization_and_refresh() { + for provider_type in ["gemini_cli", "antigravity"] { + let mut template = template_for_provider_type(provider_type).expect("template"); + template.client_id_env = None; + template.client_secret_env = Some("AETHER_TEST_UNUSED_ANTIGRAVITY_SECRET"); + let adapter = GenericProviderOAuthAdapter::new(template); + let ctx = transport_context(provider_type); + adapter + .build_authorize_url(&ctx, "state", None) + .expect("authorize"); + let seen_request = Arc::new(Mutex::new(None)); + let executor = StaticExecutor { + seen_request: Arc::clone(&seen_request), + response_payload: json!({"access_token": "new-token", "expires_in": 3600}), + }; + adapter + .refresh(&executor, &ctx, &oauth_account(provider_type)) + .await + .expect("refresh"); + let seen = seen_request.lock().expect("lock").clone().expect("request"); + let body = seen.body_bytes.expect("body"); + let fields = url::form_urlencoded::parse(&body) + .into_owned() + .collect::>(); + assert_eq!(fields["client_id"], template.client_id); + assert_eq!( + fields["client_secret"], + adapter.resolve_client_secret(None).expect("default") + ); + assert_eq!(fields["refresh_token"], "old-refresh-token"); + assert!(!format!("{adapter:?}").contains(&fields["client_secret"])); + } } #[test] - fn antigravity_custom_client_requires_its_own_secret() { - let adapter = GenericProviderOAuthAdapter::for_provider_type("antigravity") - .expect("adapter") - .with_oauth_credentials_for_tests("custom-client", "custom-secret"); - assert!(adapter.resolve_client_secret(None).is_err()); - assert_eq!( - adapter - .resolve_client_secret(Some("custom-secret".to_string())) - .expect("configured secret"), - "custom-secret" - ); - assert_eq!( - adapter.client_secret().expect("override"), - Some("custom-secret".to_string()) - ); + fn google_custom_client_requires_its_own_secret() { + for provider_type in ["gemini_cli", "antigravity"] { + let adapter = GenericProviderOAuthAdapter::for_provider_type(provider_type) + .expect("adapter") + .with_oauth_credentials_for_tests("custom-client", "custom-secret"); + assert!(adapter.resolve_client_secret(None).is_err()); + assert_eq!( + adapter + .resolve_client_secret(Some("custom-secret".to_string())) + .expect("configured secret"), + "custom-secret" + ); + assert_eq!( + adapter.client_secret().expect("override"), + Some("custom-secret".to_string()) + ); + } + } + + #[test] + fn google_configured_secret_overrides_the_native_app_default() { + for provider_type in ["gemini_cli", "antigravity"] { + let adapter = + GenericProviderOAuthAdapter::for_provider_type(provider_type).expect("adapter"); + assert_eq!( + adapter + .resolve_client_secret(Some("configured-secret".to_string())) + .unwrap(), + "configured-secret" + ); + let mut template = template_for_provider_type(provider_type).expect("template"); + template.client_id = "custom-template-client"; + template.client_id_env = None; + assert!(GenericProviderOAuthAdapter::new(template) + .resolve_client_secret(None) + .is_err()); + } } #[test] diff --git a/crates/aether-testing/testkit/src/postgres.rs b/crates/aether-testing/testkit/src/postgres.rs index 58bf8f033..957301f6f 100644 --- a/crates/aether-testing/testkit/src/postgres.rs +++ b/crates/aether-testing/testkit/src/postgres.rs @@ -11,6 +11,7 @@ use crate::wait_until; pub struct ManagedPostgresServer { child: Option, postgres_bin: String, + pg_ctl_bin: PathBuf, port: u16, workdir: PathBuf, data_dir: PathBuf, @@ -26,7 +27,7 @@ impl ManagedPostgresServer { port )); let data_dir = workdir.join("data"); - std::fs::create_dir_all(&workdir)?; + std::fs::create_dir(&workdir)?; let initdb_bin = std::env::var("AETHER_INITDB_BIN") .ok() @@ -36,10 +37,31 @@ impl ManagedPostgresServer { .ok() .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| "postgres".to_string()); + let pg_ctl_bin = std::env::var("AETHER_PG_CTL_BIN") + .ok() + .filter(|value| !value.trim().is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| { + PathBuf::from(&postgres_bin).with_file_name(if cfg!(windows) { + "pg_ctl.exe" + } else { + "pg_ctl" + }) + }); + let database_url = format!("postgres://aether@127.0.0.1:{port}/postgres"); + let mut server = Self { + child: None, + postgres_bin, + pg_ctl_bin, + port, + workdir, + data_dir, + database_url, + }; let init_output = Command::new(&initdb_bin) .arg("-D") - .arg(&data_dir) + .arg(&server.data_dir) .arg("-U") .arg("aether") .arg("--auth=trust") @@ -54,15 +76,6 @@ impl ManagedPostgresServer { .into()); } - let database_url = format!("postgres://aether@127.0.0.1:{port}/postgres"); - let mut server = Self { - child: None, - postgres_bin, - port, - workdir, - data_dir, - database_url, - }; server.restart().await?; Ok(server) } @@ -76,10 +89,29 @@ impl ManagedPostgresServer { } pub fn stop(&mut self) -> Result<(), std::io::Error> { - if let Some(mut child) = self.child.take() { - let _ = child.kill(); - let _ = child.wait(); + let Some(child) = self.child.as_mut() else { + return Ok(()); + }; + if child.try_wait()?.is_some() { + self.child = None; + return Ok(()); } + + let output = Command::new(&self.pg_ctl_bin) + .arg("-D") + .arg(&self.data_dir) + .args(["stop", "-m", "fast", "-w", "-t", "10"]) + .output()?; + if !output.status.success() && child.try_wait()?.is_none() { + return Err(std::io::Error::other(format!( + "pg_ctl stop failed for {}: {}{}", + self.data_dir.display(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ))); + } + child.wait()?; + self.child = None; Ok(()) } @@ -139,8 +171,17 @@ impl ManagedPostgresServer { impl Drop for ManagedPostgresServer { fn drop(&mut self) { - let _ = self.stop(); - let _ = std::fs::remove_dir_all(&self.workdir); + match self.stop() { + Ok(()) => { + let _ = std::fs::remove_dir_all(&self.workdir); + } + Err(error) => { + eprintln!( + "failed to stop managed postgres; preserving {}: {error}", + self.workdir.display(), + ); + } + } } } @@ -170,3 +211,57 @@ fn reserve_local_port() -> Result { drop(listener); Ok(port) } + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + #[ignore = "requires local initdb, postgres, and pg_ctl binaries"] + async fn live_managed_postgres_restarts_cleanly_with_open_connections() { + let mut server = ManagedPostgresServer::start().await.unwrap(); + let workdir = server.workdir.clone(); + let mut connection = PgConnection::connect(server.database_url()).await.unwrap(); + sqlx::query("CREATE TABLE restart_probe (value INTEGER NOT NULL)") + .execute(&mut connection) + .await + .unwrap(); + sqlx::query("INSERT INTO restart_probe VALUES (42)") + .execute(&mut connection) + .await + .unwrap(); + + for _iteration in 0..4 { + server.stop().unwrap(); + server.stop().unwrap(); + assert!(server.child.is_none()); + assert!(!server.data_dir.join("postmaster.pid").exists()); + assert!(server.data_dir.exists()); + server.restart().await.unwrap(); + connection = PgConnection::connect(server.database_url()).await.unwrap(); + let value: i32 = sqlx::query_scalar("SELECT value FROM restart_probe") + .fetch_one(&mut connection) + .await + .unwrap(); + assert_eq!(value, 42); + } + + drop(server); + assert!(!workdir.exists()); + } + + #[tokio::test] + #[ignore = "requires local initdb, postgres, and pg_ctl binaries"] + async fn live_failed_postgres_stop_can_be_retried_without_losing_ownership() { + let mut server = ManagedPostgresServer::start().await.unwrap(); + let pg_ctl_bin = server.pg_ctl_bin.clone(); + server.pg_ctl_bin = server.workdir.join("missing-pg-ctl"); + assert!(server.stop().is_err()); + assert!(server.child.as_mut().unwrap().try_wait().unwrap().is_none()); + assert!(server.data_dir.exists()); + server.pg_ctl_bin = pg_ctl_bin; + server.stop().unwrap(); + assert!(server.child.is_none()); + assert!(!server.data_dir.join("postmaster.pid").exists()); + } +} diff --git a/crates/aether-usage/runtime/src/write.rs b/crates/aether-usage/runtime/src/write.rs index 058a103f6..11ae333e9 100644 --- a/crates/aether-usage/runtime/src/write.rs +++ b/crates/aether-usage/runtime/src/write.rs @@ -2726,25 +2726,8 @@ fn headers_to_json(headers: &BTreeMap) -> Option { const REDACTED_USAGE_VALUE: &str = "[redacted]"; -/// Only headers whose values are protocol metadata are persisted verbatim. -/// Unknown headers are treated as credentials because providers commonly use -/// custom `X-*` names for authentication. -const SAFE_USAGE_HEADER_VALUE_NAMES: &[&str] = &[ - "accept", - "accept-encoding", - "content-encoding", - "content-length", - "content-type", - "transfer-encoding", - "x-request-id", - "x-trace-id", -]; - fn is_sensitive_header(name: &str) -> bool { - let trimmed = name.trim(); - !SAFE_USAGE_HEADER_VALUE_NAMES - .iter() - .any(|candidate| trimmed.eq_ignore_ascii_case(candidate)) + aether_data_contracts::repository::usage::usage_header_value_is_sensitive(name) } fn mask_header_value(name: &str, value: &str) -> String { @@ -2761,25 +2744,7 @@ fn mask_sensitive_header_value(_value: &str) -> String { /// Non-object values cannot be established as a valid header map and are /// discarded instead of being persisted verbatim. fn mask_sensitive_headers_in_json_value(value: Option) -> Option { - let mut value = value?; - let Value::Object(map) = &mut value else { - return None; - }; - for (key, val) in map.iter_mut() { - if !is_sensitive_header(key) { - continue; - } - match val { - Value::String(text) => { - *text = mask_sensitive_header_value(text); - } - Value::Null => {} - other => { - *other = Value::String(mask_sensitive_header_value(&other.to_string())); - } - } - } - Some(value) + aether_data_contracts::repository::usage::sanitize_usage_headers_for_persistence(value) } fn mask_sensitive_body_fields(mut value: Value) -> Value { diff --git a/crates/aether-video-tasks-core/src/gemini.rs b/crates/aether-video-tasks-core/src/gemini.rs index 8dd40da3e..8e48a5319 100644 --- a/crates/aether-video-tasks-core/src/gemini.rs +++ b/crates/aether-video-tasks-core/src/gemini.rs @@ -4,6 +4,7 @@ use aether_data_contracts::repository::video_tasks::{ }; use serde_json::{json, Map, Value}; +use crate::transport::{gemini_response_video_url, gemini_video_metadata}; use crate::types::sanitize_video_task_error_code; use crate::{ build_video_follow_up_report_context, current_unix_timestamp_secs, gemini_metadata_video_url, @@ -112,7 +113,16 @@ impl GeminiVideoTaskSeed { self.error_code = None; self.error_message = None; } - self.metadata = json!({}); + self.metadata = if error.is_none() { + gemini_video_metadata( + provider_body + .get("response") + .and_then(gemini_response_video_url) + .as_deref(), + ) + } else { + json!({}) + }; return; } @@ -365,8 +375,8 @@ mod tests { use serde_json::json; use crate::{ - GeminiVideoTaskSeed, LocalVideoTaskPersistence, LocalVideoTaskStatus, - LocalVideoTaskTransport, + GeminiVideoTaskSeed, LocalVideoTaskPersistence, LocalVideoTaskSnapshot, + LocalVideoTaskStatus, LocalVideoTaskTransport, }; use super::map_gemini_stored_task_to_read_response; @@ -484,7 +494,54 @@ mod tests { assert!(record.request_metadata.is_none()); assert_eq!( record.video_url.as_deref(), - Some("https://files.example/video.mp4?alt=media") + Some("https://files.example/video.mp4?alt=media&token=sensitive") ); + assert_eq!(record.prompt.as_deref(), Some("business prompt")); + + let transport = seed.transport.clone(); + let mut snapshot = LocalVideoTaskSnapshot::Gemini(seed); + snapshot.apply_provider_body(json!({ + "done": true, + "debug": "private-provider-debug", + "response": { + "provider_token": "private-provider-token", + "generateVideoResponse": { + "generatedSamples": [{ + "video": { + "uri": "https://files.example/video.mp4?alt=media&token=signed%2Bvalue", + "debug": "private-video-debug" + } + }] + } + } + }).as_object().expect("provider body")); + assert!(!serde_json::to_string(&snapshot) + .expect("serialize snapshot") + .contains("private-")); + snapshot.sanitize_persisted_diagnostics(); + let stored = snapshot.to_upsert_record().into_stored(); + assert_eq!(stored.status, VideoTaskStatus::Completed); + assert_eq!(stored.prompt.as_deref(), Some("business prompt")); + assert_eq!( + stored.video_url.as_deref(), + Some("https://files.example/video.mp4?alt=media&token=signed%2Bvalue") + ); + assert!(stored.request_metadata.is_none()); + assert!(stored.original_request_body.is_none()); + + let mut restored = + LocalVideoTaskSnapshot::from_stored_task_with_transport(&stored, transport) + .expect("stored Gemini task should reconstruct"); + restored.sanitize_persisted_diagnostics(); + let restored_record = restored.to_upsert_record(); + assert_eq!(restored_record.video_url, stored.video_url); + assert_eq!(restored_record.prompt, stored.prompt); + let response = restored.read_response(); + assert_eq!(response.body_json["done"], true); + assert!(response + .body_json + .to_string() + .contains("/v1beta/files/aev_")); + assert!(!response.body_json.to_string().contains("signed%2Bvalue")); } } diff --git a/crates/aether-video-tasks-core/src/openai.rs b/crates/aether-video-tasks-core/src/openai.rs index 4dc632642..8b0d657cb 100644 --- a/crates/aether-video-tasks-core/src/openai.rs +++ b/crates/aether-video-tasks-core/src/openai.rs @@ -610,7 +610,7 @@ impl OpenAiVideoTaskSeed { updated_at_unix_secs: self.completed_at_unix_secs.unwrap_or(now_unix_secs), error_code: self.error_code.clone(), error_message: None, - video_url: None, + video_url: self.video_url.clone(), request_metadata: None, }; record.sanitize_for_persistence(); @@ -626,8 +626,8 @@ mod tests { use serde_json::json; use crate::{ - LocalVideoTaskPersistence, LocalVideoTaskStatus, LocalVideoTaskTransport, - OpenAiVideoTaskSeed, + LocalVideoTaskContentAction, LocalVideoTaskPersistence, LocalVideoTaskSnapshot, + LocalVideoTaskStatus, LocalVideoTaskTransport, OpenAiVideoTaskSeed, }; use super::map_openai_stored_task_to_read_response; @@ -751,9 +751,33 @@ mod tests { assert!(record.original_request_body.is_none()); assert!(record.progress_message.is_none()); assert!(record.error_message.is_none()); - assert!(record.video_url.is_none()); + assert_eq!(record.video_url, seed.video_url); + assert_eq!(record.prompt, seed.prompt); assert!(record.request_metadata.is_none()); assert_eq!(record.duration_seconds, Some(4)); assert_eq!(record.size.as_deref(), Some("1280x720")); + + let mut stored = record.into_stored(); + stored.status = VideoTaskStatus::Completed; + let snapshot = + LocalVideoTaskSnapshot::from_stored_task_with_transport(&stored, seed.transport) + .expect("stored task should reconstruct with current transport"); + let LocalVideoTaskSnapshot::OpenAi(restored) = snapshot else { + panic!("expected OpenAI snapshot"); + }; + assert_eq!(restored.prompt, stored.prompt); + assert_eq!(restored.to_upsert_record().video_url, stored.video_url); + let Some(LocalVideoTaskContentAction::StreamPlan(plan)) = + restored.build_content_stream_action(None, "trace-download") + else { + panic!("completed stored task should stream content"); + }; + assert_eq!(Some(plan.url.as_str()), stored.video_url.as_deref()); + assert!(plan.headers.is_empty()); + let response = map_openai_stored_task_to_read_response(stored.clone()); + assert_eq!( + response.body_json["video_url"].as_str(), + stored.video_url.as_deref() + ); } } diff --git a/crates/aether-video-tasks-core/src/snapshot.rs b/crates/aether-video-tasks-core/src/snapshot.rs index fa56551c3..6aea1cb21 100644 --- a/crates/aether-video-tasks-core/src/snapshot.rs +++ b/crates/aether-video-tasks-core/src/snapshot.rs @@ -1,6 +1,7 @@ use aether_data_contracts::repository::video_tasks::{StoredVideoTask, UpsertVideoTask}; use serde_json::{json, Map, Value}; +use crate::transport::{gemini_metadata_video_url, gemini_video_metadata}; use crate::types::sanitize_video_task_error_code; use crate::{ local_status_from_stored, non_empty_owned, request_body_string, GeminiVideoTaskSeed, @@ -104,7 +105,7 @@ impl LocalVideoTaskSnapshot { progress_percent: task.progress_percent, error_code: task.error_code.clone(), error_message: task.error_message.clone(), - metadata: Value::Object(Map::new()), + metadata: gemini_video_metadata(task.video_url.as_deref()), persistence, transport, })) @@ -128,7 +129,8 @@ impl LocalVideoTaskSnapshot { let previous_error_code = seed.error_code.clone(); let error_code = sanitized_error_code_for_status(seed.status, seed.error_code.take()); - let safe_metadata = Value::Object(Map::new()); + let safe_metadata = + gemini_video_metadata(gemini_metadata_video_url(&seed.metadata).as_deref()); let changed = previous_error_code != error_code || seed.error_message.is_some() || seed.metadata != safe_metadata; diff --git a/crates/aether-video-tasks-core/src/store_backend.rs b/crates/aether-video-tasks-core/src/store_backend.rs index f2b94e5a1..6a76e12a6 100644 --- a/crates/aether-video-tasks-core/src/store_backend.rs +++ b/crates/aether-video-tasks-core/src/store_backend.rs @@ -450,6 +450,49 @@ mod tests { }) } + #[test] + fn completed_gemini_download_survives_encrypted_file_reload() { + let path = temp_store_path("completed-download"); + let video_url = "https://cdn.example.test/video.mp4?signature=a%2Fb%2Bc%3D"; + let mut snapshot = sensitive_gemini_snapshot(); + snapshot.apply_provider_body( + json!({ + "done": true, + "debug": "private-debug", + "response": { + "generateVideoResponse": { + "generatedSamples": [{"video": {"uri": video_url}}] + } + } + }) + .as_object() + .expect("provider body"), + ); + let store = FileVideoTaskStore::new(&path, DEVELOPMENT_ENCRYPTION_KEY).expect("store"); + store.insert(snapshot); + drop(store); + let bytes = std::fs::read_to_string(&path).expect("encrypted store file"); + assert!(bytes.starts_with(ENCRYPTED_VIDEO_TASK_STORE_PREFIX)); + assert!(!bytes.contains(video_url)); + assert!(!bytes.contains("transport-key-required-for-resume")); + let restored = + FileVideoTaskStore::new(&path, DEVELOPMENT_ENCRYPTION_KEY).expect("restored store"); + let task = restored + .clone_gemini("task-sensitive") + .expect("completed Gemini task"); + let record = task.to_upsert_record(); + assert_eq!( + record.status, + aether_data_contracts::repository::video_tasks::VideoTaskStatus::Completed + ); + assert_eq!(record.video_url.as_deref(), Some(video_url)); + assert_eq!(record.prompt.as_deref(), Some("create a video")); + assert!(!task.metadata.to_string().contains("private-debug")); + assert!(record.request_metadata.is_none()); + drop(restored); + cleanup_store_path(&path); + } + #[test] fn loading_encrypted_store_rewrites_legacy_provider_diagnostics() { let path = temp_store_path("diagnostic-migration"); diff --git a/crates/aether-video-tasks-core/src/transport.rs b/crates/aether-video-tasks-core/src/transport.rs index 761e8967f..f0803f962 100644 --- a/crates/aether-video-tasks-core/src/transport.rs +++ b/crates/aether-video-tasks-core/src/transport.rs @@ -1,4 +1,4 @@ -use serde_json::Value; +use serde_json::{json, Value}; use crate::LocalVideoTaskStatus; @@ -20,9 +20,12 @@ pub fn parse_video_content_variant(query_string: Option<&str>) -> Option<&'stati } pub fn gemini_metadata_video_url(metadata: &Value) -> Option { - metadata - .get("response") - .and_then(|value| value.get("generateVideoResponse")) + metadata.get("response").and_then(gemini_response_video_url) +} + +pub(crate) fn gemini_response_video_url(response: &Value) -> Option { + response + .get("generateVideoResponse") .and_then(|value| value.get("generatedSamples")) .and_then(Value::as_array) .and_then(|value| value.first()) @@ -32,6 +35,19 @@ pub fn gemini_metadata_video_url(metadata: &Value) -> Option { .map(str::to_string) } +pub(crate) fn gemini_video_metadata(video_url: Option<&str>) -> Value { + match video_url { + Some(video_url) => json!({ + "response": { + "generateVideoResponse": { + "generatedSamples": [{"video": {"uri": video_url}}] + } + } + }), + None => json!({}), + } +} + pub fn map_openai_task_status(status: LocalVideoTaskStatus) -> &'static str { match status { LocalVideoTaskStatus::Submitted | LocalVideoTaskStatus::Queued => "queued", diff --git a/crates/aether-video-tasks-core/src/transport_domain.rs b/crates/aether-video-tasks-core/src/transport_domain.rs index 5bbd5c411..9503e0087 100644 --- a/crates/aether-video-tasks-core/src/transport_domain.rs +++ b/crates/aether-video-tasks-core/src/transport_domain.rs @@ -98,10 +98,15 @@ impl LocalVideoTaskPersistence { api_key_name: task.api_key_name.clone(), client_api_format, provider_api_format, - original_request_body: task - .original_request_body - .clone() - .unwrap_or_else(|| Value::Object(Map::new())), + original_request_body: task.original_request_body.clone().unwrap_or_else(|| { + serde_json::json!({ + "prompt": task.prompt, + "seconds": task.duration_seconds, + "resolution": task.resolution, + "aspect_ratio": task.aspect_ratio, + "size": task.size, + }) + }), format_converted: task.format_converted, }) } diff --git a/docs/operations/security-hardening-audit-manifest.tsv b/docs/operations/security-hardening-audit-manifest.tsv new file mode 100644 index 000000000..e1f967f44 --- /dev/null +++ b/docs/operations/security-hardening-audit-manifest.tsv @@ -0,0 +1,1020 @@ +path subsystem hardening_change lines_added lines_removed current_path post_hardening_HEAD heuristic_added_risk_lines +.env.example root-config M 39 7 present changed_after_hardening 0 +.github/workflows/build-tunnel.yml .github M 34 10 present unchanged_at_HEAD 0 +.github/workflows/deploy-pages.yml .github M 8 7 present unchanged_at_HEAD 0 +.github/workflows/nightly.yml .github M 24 24 present changed_after_hardening 0 +.github/workflows/release.yml .github M 75 27 present unchanged_at_HEAD 0 +.github/workflows/rust-ci.yml .github M 112 56 present changed_after_hardening 0 +Cargo.lock root-config M 591 342 present changed_after_hardening 0 +Cargo.toml root-config M 3 1 present changed_after_hardening 0 +Dockerfile.app root-config M 9 4 present unchanged_at_HEAD 0 +README.md root-config M 54 9 present changed_after_hardening 0 +apps/aether-gateway/Cargo.toml apps M 3 2 present changed_after_hardening 0 +apps/aether-gateway/examples/execution_runtime_harness.rs apps M 1 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/admin_api.rs apps/aether-gateway/src/admin_api.rs M 9 8 present unchanged_at_HEAD 0 +apps/aether-gateway/src/ai_serving/adaptation/private_envelope/sync.rs apps/aether-gateway/src/ai_serving M 3 3 present unchanged_at_HEAD 0 +apps/aether-gateway/src/ai_serving/planner/candidate_materialization.rs apps/aether-gateway/src/ai_serving M 13 30 present unchanged_at_HEAD 0 +apps/aether-gateway/src/ai_serving/planner/candidate_metadata.rs apps/aether-gateway/src/ai_serving M 10 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/ai_serving/planner/candidate_ranking.rs apps/aether-gateway/src/ai_serving M 32 20 present unchanged_at_HEAD 1 +apps/aether-gateway/src/ai_serving/planner/candidate_source.rs apps/aether-gateway/src/ai_serving M 17 3 present unchanged_at_HEAD 1 +apps/aether-gateway/src/ai_serving/planner/decision/stream.rs apps/aether-gateway/src/ai_serving M 31 9 present unchanged_at_HEAD 0 +apps/aether-gateway/src/ai_serving/planner/decision/sync.rs apps/aether-gateway/src/ai_serving M 19 9 present unchanged_at_HEAD 0 +apps/aether-gateway/src/ai_serving/planner/decision_input.rs apps/aether-gateway/src/ai_serving M 76 14 present changed_after_hardening 8 +apps/aether-gateway/src/ai_serving/planner/report_context.rs apps/aether-gateway/src/ai_serving M 48 8 present unchanged_at_HEAD 3 +apps/aether-gateway/src/ai_serving/planner/specialized/files/request.rs apps/aether-gateway/src/ai_serving M 85 1 present unchanged_at_HEAD 3 +apps/aether-gateway/src/ai_serving/planner/standard/family/request.rs apps/aether-gateway/src/ai_serving M 1 1 present changed_after_hardening 0 +apps/aether-gateway/src/ai_serving/planner/standard/openai/plan_builders/stream.rs apps/aether-gateway/src/ai_serving M 12 27 present unchanged_at_HEAD 1 +apps/aether-gateway/src/ai_serving/planner/standard/openai/plan_builders/sync.rs apps/aether-gateway/src/ai_serving M 12 27 present unchanged_at_HEAD 1 +apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/payload.rs apps/aether-gateway/src/ai_serving M 5 5 present unchanged_at_HEAD 1 +apps/aether-gateway/src/ai_serving/planner/standard/openai/responses/decision/request.rs apps/aether-gateway/src/ai_serving M 7 15 present unchanged_at_HEAD 1 +apps/aether-gateway/src/ai_serving/response_history.rs apps/aether-gateway/src/ai_serving M 160 8 present unchanged_at_HEAD 1 +apps/aether-gateway/src/api/core.rs apps/aether-gateway/src/api M 1 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/api/ops.rs apps/aether-gateway/src/api M 254 3 present unchanged_at_HEAD 11 +apps/aether-gateway/src/api/response.rs apps/aether-gateway/src/api M 301 13 present unchanged_at_HEAD 1 +apps/aether-gateway/src/async_task/http.rs apps/aether-gateway/src/async_task M 331 42 present unchanged_at_HEAD 6 +apps/aether-gateway/src/async_task/http/cancel.rs apps/aether-gateway/src/async_task M 189 108 present unchanged_at_HEAD 9 +apps/aether-gateway/src/async_task/mod.rs apps/aether-gateway/src/async_task M 6 5 present unchanged_at_HEAD 0 +apps/aether-gateway/src/async_task/query.rs apps/aether-gateway/src/async_task M 263 5 present unchanged_at_HEAD 12 +apps/aether-gateway/src/async_task/runtime.rs apps/aether-gateway/src/async_task M 79 164 present unchanged_at_HEAD 13 +apps/aether-gateway/src/audit/admin.rs apps/aether-gateway/src/audit M 48 4 present unchanged_at_HEAD 15 +apps/aether-gateway/src/audit/http.rs apps/aether-gateway/src/audit M 8 2 present unchanged_at_HEAD 2 +apps/aether-gateway/src/backup/config.rs apps/aether-gateway/src/backup M 137 3 present unchanged_at_HEAD 2 +apps/aether-gateway/src/backup/executor.rs apps/aether-gateway/src/backup M 1086 29 present unchanged_at_HEAD 4 +apps/aether-gateway/src/backup/mod.rs apps/aether-gateway/src/backup M 162 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/backup/scopes.rs apps/aether-gateway/src/backup M 153 15 present unchanged_at_HEAD 2 +apps/aether-gateway/src/backup/store.rs apps/aether-gateway/src/backup M 219 27 present unchanged_at_HEAD 0 +apps/aether-gateway/src/backup/task.rs apps/aether-gateway/src/backup M 410 55 present unchanged_at_HEAD 2 +apps/aether-gateway/src/backup/worker.rs apps/aether-gateway/src/backup M 20 8 present unchanged_at_HEAD 0 +apps/aether-gateway/src/bark_push.rs apps/aether-gateway/src/bark_push.rs M 321 41 present changed_after_hardening 2 +apps/aether-gateway/src/bin/aether-backup-restore.rs apps/aether-gateway/src/bin A 1047 0 present unchanged_at_HEAD 3 +apps/aether-gateway/src/bin/support/responses_ws_probe.rs apps/aether-gateway/src/bin M 9 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/cache/auth_context.rs apps/aether-gateway/src/cache M 1 0 present unchanged_at_HEAD 1 +apps/aether-gateway/src/cache/system_config.rs apps/aether-gateway/src/cache M 9 0 present unchanged_at_HEAD 1 +apps/aether-gateway/src/constants.rs apps/aether-gateway/src/constants.rs M 1 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/control/auth/credentials.rs apps/aether-gateway/src/control M 158 20 present unchanged_at_HEAD 0 +apps/aether-gateway/src/control/auth/gate.rs apps/aether-gateway/src/control M 2 1 present unchanged_at_HEAD 1 +apps/aether-gateway/src/control/auth/mod.rs apps/aether-gateway/src/control M 8 5 present unchanged_at_HEAD 0 +apps/aether-gateway/src/control/auth/resolution.rs apps/aether-gateway/src/control M 337 120 present changed_after_hardening 19 +apps/aether-gateway/src/control/auth/types.rs apps/aether-gateway/src/control M 91 3 present unchanged_at_HEAD 14 +apps/aether-gateway/src/control/management_token_permissions.rs apps/aether-gateway/src/control M 1210 28 present unchanged_at_HEAD 4 +apps/aether-gateway/src/control/mod.rs apps/aether-gateway/src/control M 10 7 present unchanged_at_HEAD 0 +apps/aether-gateway/src/control/public.rs apps/aether-gateway/src/control M 41 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/control/route/internal.rs apps/aether-gateway/src/control M 15 13 present unchanged_at_HEAD 0 +apps/aether-gateway/src/control/route/mod.rs apps/aether-gateway/src/control M 22 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/control/route/oauth.rs apps/aether-gateway/src/control M 3 3 present unchanged_at_HEAD 0 +apps/aether-gateway/src/control/route/public_support.rs apps/aether-gateway/src/control M 7 3 present unchanged_at_HEAD 0 +apps/aether-gateway/src/control/tests/admin_core.rs apps/aether-gateway/src/control M 2 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/control/tests/admin_oauth.rs apps/aether-gateway/src/control M 16 16 present unchanged_at_HEAD 0 +apps/aether-gateway/src/control/tests/public_support.rs apps/aether-gateway/src/control M 28 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/data/decision_trace.rs apps/aether-gateway/src/data M 1 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/data/state/auth.rs apps/aether-gateway/src/data M 1345 79 present changed_after_hardening 17 +apps/aether-gateway/src/data/state/catalog.rs apps/aether-gateway/src/data M 275 47 present changed_after_hardening 18 +apps/aether-gateway/src/data/state/core.rs apps/aether-gateway/src/data M 38 0 present changed_after_hardening 0 +apps/aether-gateway/src/data/state/integrations.rs apps/aether-gateway/src/data M 89 13 present unchanged_at_HEAD 3 +apps/aether-gateway/src/data/state/mod.rs apps/aether-gateway/src/data M 35 25 present unchanged_at_HEAD 0 +apps/aether-gateway/src/data/state/referrals.rs apps/aether-gateway/src/data M 12 3 present unchanged_at_HEAD 0 +apps/aether-gateway/src/data/state/runtime.rs apps/aether-gateway/src/data M 257 19 present unchanged_at_HEAD 0 +apps/aether-gateway/src/data/state/testing/mod.rs apps/aether-gateway/src/data M 42 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/data/state/testkit.rs apps/aether-gateway/src/data M 103 0 present unchanged_at_HEAD 2 +apps/aether-gateway/src/data/tests.rs apps/aether-gateway/src/data M 1 1 present changed_after_hardening 0 +apps/aether-gateway/src/dispatch/pool_scheduler.rs apps/aether-gateway/src/dispatch M 22 2 present unchanged_at_HEAD 1 +apps/aether-gateway/src/email_delivery.rs apps/aether-gateway/src/email_delivery.rs M 528 45 present changed_after_hardening 22 +apps/aether-gateway/src/error.rs apps/aether-gateway/src/error.rs M 97 6 present unchanged_at_HEAD 0 +apps/aether-gateway/src/execution_runtime/attempt_lifecycle.rs apps/aether-gateway/src/execution_runtime M 36 11 present changed_after_hardening 1 +apps/aether-gateway/src/execution_runtime/chatgpt_web_image.rs apps/aether-gateway/src/execution_runtime M 1622 232 present changed_after_hardening 19 +apps/aether-gateway/src/execution_runtime/constants.rs apps/aether-gateway/src/execution_runtime M 10 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/execution_runtime/fallback.rs apps/aether-gateway/src/execution_runtime M 12 2 present changed_after_hardening 1 +apps/aether-gateway/src/execution_runtime/grok.rs apps/aether-gateway/src/execution_runtime M 1358 319 present unchanged_at_HEAD 20 +apps/aether-gateway/src/execution_runtime/kiro_web_search.rs apps/aether-gateway/src/execution_runtime M 114 44 present unchanged_at_HEAD 8 +apps/aether-gateway/src/execution_runtime/mod.rs apps/aether-gateway/src/execution_runtime M 2 1 present changed_after_hardening 0 +apps/aether-gateway/src/execution_runtime/remote_compat.rs apps/aether-gateway/src/execution_runtime M 75 11 present unchanged_at_HEAD 0 +apps/aether-gateway/src/execution_runtime/server.rs apps/aether-gateway/src/execution_runtime M 933 37 present changed_after_hardening 1 +apps/aether-gateway/src/execution_runtime/stream/error.rs apps/aether-gateway/src/execution_runtime M 76 11 present unchanged_at_HEAD 2 +apps/aether-gateway/src/execution_runtime/stream/execution.rs apps/aether-gateway/src/execution_runtime M 451 199 present changed_after_hardening 3 +apps/aether-gateway/src/execution_runtime/stream/execution_failures.rs apps/aether-gateway/src/execution_runtime M 142 18 present unchanged_at_HEAD 1 +apps/aether-gateway/src/execution_runtime/stream_pump.rs apps/aether-gateway/src/execution_runtime M 192 100 present unchanged_at_HEAD 1 +apps/aether-gateway/src/execution_runtime/submission.rs apps/aether-gateway/src/execution_runtime M 7 10 present unchanged_at_HEAD 0 +apps/aether-gateway/src/execution_runtime/sync/execution.rs apps/aether-gateway/src/execution_runtime M 156 26 present unchanged_at_HEAD 3 +apps/aether-gateway/src/execution_runtime/sync/execution/policy.rs apps/aether-gateway/src/execution_runtime M 55 8 present unchanged_at_HEAD 2 +apps/aether-gateway/src/execution_runtime/sync/execution/response.rs apps/aether-gateway/src/execution_runtime M 78 40 present unchanged_at_HEAD 0 +apps/aether-gateway/src/execution_runtime/transport.rs apps/aether-gateway/src/execution_runtime M 2053 238 present changed_after_hardening 84 +apps/aether-gateway/src/execution_runtime/windsurf.rs apps/aether-gateway/src/execution_runtime M 767 217 present unchanged_at_HEAD 16 +apps/aether-gateway/src/executor/candidate_loop.rs apps/aether-gateway/src/executor M 784 45 present changed_after_hardening 26 +apps/aether-gateway/src/executor/mod.rs apps/aether-gateway/src/executor M 5 4 present unchanged_at_HEAD 0 +apps/aether-gateway/src/executor/orchestration.rs apps/aether-gateway/src/executor M 74 10 present unchanged_at_HEAD 0 +apps/aether-gateway/src/executor/outcome.rs apps/aether-gateway/src/executor M 79 118 present unchanged_at_HEAD 3 +apps/aether-gateway/src/executor/stream_path.rs apps/aether-gateway/src/executor M 56 11 present unchanged_at_HEAD 0 +apps/aether-gateway/src/executor/sync_path.rs apps/aether-gateway/src/executor M 81 17 present unchanged_at_HEAD 0 +apps/aether-gateway/src/frontdoor_loop_guard.rs apps/aether-gateway/src/frontdoor_loop_guard.rs M 25 4 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/admin/auth/api_keys/install_routes.rs apps/aether-gateway/src/handlers/admin/auth/api_keys M 15 32 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/auth/api_keys/mod.rs apps/aether-gateway/src/handlers/admin/auth/api_keys M 3 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/auth/api_keys/mutation_routes.rs apps/aether-gateway/src/handlers/admin/auth/api_keys M 246 33 present unchanged_at_HEAD 13 +apps/aether-gateway/src/handlers/admin/auth/api_keys/read_routes.rs apps/aether-gateway/src/handlers/admin/auth/api_keys M 22 15 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/auth/api_keys/routes.rs apps/aether-gateway/src/handlers/admin/auth/api_keys M 2 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/auth/api_keys/shared.rs apps/aether-gateway/src/handlers/admin/auth/api_keys M 7 4 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/auth/ldap/builders.rs apps/aether-gateway/src/handlers/admin/auth/ldap M 141 102 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/admin/auth/ldap/routes.rs apps/aether-gateway/src/handlers/admin/auth/ldap M 14 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/auth/ldap/shared.rs apps/aether-gateway/src/handlers/admin/auth/ldap M 13 9 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/auth/oauth_config.rs apps/aether-gateway/src/handlers/admin/auth/oauth_config.rs M 121 64 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/auth/oauth_routes.rs apps/aether-gateway/src/handlers/admin/auth/oauth_routes.rs M 265 83 present changed_after_hardening 9 +apps/aether-gateway/src/handlers/admin/auth/routes.rs apps/aether-gateway/src/handlers/admin/auth/routes.rs M 1 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/billing/collectors/support.rs apps/aether-gateway/src/handlers/admin/billing/collectors M 13 12 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/billing/mod.rs apps/aether-gateway/src/handlers/admin/billing/mod.rs M 1 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/billing/payments/gateways.rs apps/aether-gateway/src/handlers/admin/billing/payments M 446 100 present unchanged_at_HEAD 4 +apps/aether-gateway/src/handlers/admin/billing/payments/mod.rs apps/aether-gateway/src/handlers/admin/billing/payments M 3 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/billing/payments/orders.rs apps/aether-gateway/src/handlers/admin/billing/payments M 8 3 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/billing/payments/redeem_codes.rs apps/aether-gateway/src/handlers/admin/billing/payments M 3 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/billing/payments/shared.rs apps/aether-gateway/src/handlers/admin/billing/payments M 388 13 present unchanged_at_HEAD 18 +apps/aether-gateway/src/handlers/admin/billing/plans.rs apps/aether-gateway/src/handlers/admin/billing/plans.rs M 15 4 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/billing/rules.rs apps/aether-gateway/src/handlers/admin/billing/rules.rs M 2 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/billing/wallets/mutations/adjust.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 2 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/billing/wallets/mutations/complete_refund.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 252 13 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/billing/wallets/mutations/fail_refund.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 2 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/billing/wallets/mutations/process_refund.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 2 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/billing/wallets/mutations/recharge.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 2 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/billing/wallets/reads/ledger.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 5 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/billing/wallets/reads/list.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 5 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/billing/wallets/reads/refund_requests.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 9 4 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/billing/wallets/reads/transactions.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 5 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/billing/wallets/shared/normalizers.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 5 4 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/billing/wallets/shared/payloads.rs apps/aether-gateway/src/handlers/admin/billing/wallets M 189 5 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/admin/features/background_tasks/routes.rs apps/aether-gateway/src/handlers/admin/features/background_tasks M 92 35 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/admin/features/gemini_files/read_routes.rs apps/aether-gateway/src/handlers/admin/features/gemini_files M 1 0 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/admin/features/gemini_files/upload/request.rs apps/aether-gateway/src/handlers/admin/features/gemini_files M 434 48 present unchanged_at_HEAD 12 +apps/aether-gateway/src/handlers/admin/features/gemini_files/upload/stage.rs apps/aether-gateway/src/handlers/admin/features/gemini_files M 144 13 present unchanged_at_HEAD 10 +apps/aether-gateway/src/handlers/admin/features/video_tasks/builders.rs apps/aether-gateway/src/handlers/admin/features/video_tasks M 77 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/features/video_tasks/routes.rs apps/aether-gateway/src/handlers/admin/features/video_tasks M 8 4 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/mod.rs apps/aether-gateway/src/handlers/admin/mod.rs M 6 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/model/external_cache.rs apps/aether-gateway/src/handlers/admin/model/external_cache.rs M 259 26 present changed_after_hardening 2 +apps/aether-gateway/src/handlers/admin/model/global_models/routes/core/writes.rs apps/aether-gateway/src/handlers/admin/model/global_models M 84 6 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/observability/monitoring/cache_affinity_reads.rs apps/aether-gateway/src/handlers/admin/observability/monitoring M 2 3 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/observability/monitoring/cache_payloads.rs apps/aether-gateway/src/handlers/admin/observability/monitoring M 51 66 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/observability/monitoring/resilience/snapshot.rs apps/aether-gateway/src/handlers/admin/observability/monitoring M 6 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/observability/monitoring/tests/mod.rs apps/aether-gateway/src/handlers/admin/observability/monitoring M 19 9 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/observability/monitoring/tests/trace.rs apps/aether-gateway/src/handlers/admin/observability/monitoring M 36 56 present changed_after_hardening 0 +apps/aether-gateway/src/handlers/admin/observability/monitoring/trace.rs apps/aether-gateway/src/handlers/admin/observability/monitoring M 23 36 present unchanged_at_HEAD 6 +apps/aether-gateway/src/handlers/admin/observability/usage/replay.rs apps/aether-gateway/src/handlers/admin/observability/usage M 8 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/observability/usage/summary_routes.rs apps/aether-gateway/src/handlers/admin/observability/usage M 33 9 present unchanged_at_HEAD 2 +apps/aether-gateway/src/handlers/admin/provider/delete_task.rs apps/aether-gateway/src/handlers/admin/provider/delete_task.rs M 8 19 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/endpoint_keys/reads.rs apps/aether-gateway/src/handlers/admin/provider/endpoint_keys M 7 5 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/models/batch.rs apps/aether-gateway/src/handlers/admin/provider/models M 75 15 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/execution.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 1 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/kiro_import.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 2 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/parse.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 8 24 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/batch/task.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 14 15 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/complete/key.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 59 11 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/complete/provider.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 43 6 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/complete/shared.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 84 0 present unchanged_at_HEAD 5 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/device/authorize.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 26 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/device/lease.rs apps/aether-gateway/src/handlers/admin/provider/oauth A 277 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/device/mod.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 1 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/device/poll.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 527 375 present unchanged_at_HEAD 35 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/device/session.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 1 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/import.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 5 10 present changed_after_hardening 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/kiro.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 43 14 present unchanged_at_HEAD 14 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/refresh/execution.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 11 15 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/refresh/helpers.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 8 6 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/refresh/request.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 2 3 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/start.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 49 10 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/dispatch/token_import.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 34 6 present unchanged_at_HEAD 3 +apps/aether-gateway/src/handlers/admin/provider/oauth/duplicates.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 3 6 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/errors.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 12 23 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/provisioning.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 24 14 present changed_after_hardening 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/quota/antigravity.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 14 21 present changed_after_hardening 1 +apps/aether-gateway/src/handlers/admin/provider/oauth/quota/chatgpt_web.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 94 29 present unchanged_at_HEAD 11 +apps/aether-gateway/src/handlers/admin/provider/oauth/quota/codex/mod.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 121 43 present unchanged_at_HEAD 3 +apps/aether-gateway/src/handlers/admin/provider/oauth/quota/gemini_cli.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 12 18 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/admin/provider/oauth/quota/grok.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 88 25 present unchanged_at_HEAD 11 +apps/aether-gateway/src/handlers/admin/provider/oauth/quota/kiro/mod.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 90 81 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/admin/provider/oauth/quota/shared.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 529 202 present changed_after_hardening 20 +apps/aether-gateway/src/handlers/admin/provider/oauth/quota/windsurf.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 25 99 present unchanged_at_HEAD 3 +apps/aether-gateway/src/handlers/admin/provider/oauth/state/exchange.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 19 6 present changed_after_hardening 0 +apps/aether-gateway/src/handlers/admin/provider/oauth/state/storage.rs apps/aether-gateway/src/handlers/admin/provider/oauth M 7 36 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/actions/checkin/probe.rs apps/aether-gateway/src/handlers/admin/provider/ops M 18 25 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/actions/checkin/run.rs apps/aether-gateway/src/handlers/admin/provider/ops M 16 24 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/actions/checkin/shared.rs apps/aether-gateway/src/handlers/admin/provider/ops M 41 26 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/actions/mod.rs apps/aether-gateway/src/handlers/admin/provider/ops M 18 16 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/actions/query_balance/mod.rs apps/aether-gateway/src/handlers/admin/provider/ops M 12 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/actions/query_balance/sub2api.rs apps/aether-gateway/src/handlers/admin/provider/ops M 23 6 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/actions/query_balance/yescode.rs apps/aether-gateway/src/handlers/admin/provider/ops M 41 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/actions/support.rs apps/aether-gateway/src/handlers/admin/provider/ops M 7 6 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/balance_cache.rs apps/aether-gateway/src/handlers/admin/provider/ops M 349 4 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/config.rs apps/aether-gateway/src/handlers/admin/provider/ops M 502 130 present unchanged_at_HEAD 8 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/mod.rs apps/aether-gateway/src/handlers/admin/provider/ops M 1 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/routes/batch.rs apps/aether-gateway/src/handlers/admin/provider/ops M 44 8 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/routes/config.rs apps/aether-gateway/src/handlers/admin/provider/ops M 104 42 present unchanged_at_HEAD 3 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/routes/connect.rs apps/aether-gateway/src/handlers/admin/provider/ops M 6 10 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/routes/read.rs apps/aether-gateway/src/handlers/admin/provider/ops M 1 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/routes/verify.rs apps/aether-gateway/src/handlers/admin/provider/ops M 94 24 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/support.rs apps/aether-gateway/src/handlers/admin/provider/ops M 6 18 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/verify/mod.rs apps/aether-gateway/src/handlers/admin/provider/ops M 12 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/verify/request.rs apps/aether-gateway/src/handlers/admin/provider/ops M 43 45 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/ops/providers/verify/sub2api.rs apps/aether-gateway/src/handlers/admin/provider/ops M 15 21 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/pool/runtime/keys.rs apps/aether-gateway/src/handlers/admin/provider/pool M 27 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/pool_admin/batch_routes/action.rs apps/aether-gateway/src/handlers/admin/provider/pool_admin M 26 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/pool_admin/batch_routes/update.rs apps/aether-gateway/src/handlers/admin/provider/pool_admin M 8 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/pool_admin/payloads.rs apps/aether-gateway/src/handlers/admin/provider/pool_admin M 39 9 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/admin/provider/pool_admin/read_routes/scores.rs apps/aether-gateway/src/handlers/admin/provider/pool_admin M 5 1 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/admin/provider/pool_admin/selection.rs apps/aether-gateway/src/handlers/admin/provider/pool_admin M 5 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/query/models/mod.rs apps/aether-gateway/src/handlers/admin/provider/query M 40 3 present changed_after_hardening 0 +apps/aether-gateway/src/handlers/admin/provider/query/models/model_test.rs apps/aether-gateway/src/handlers/admin/provider/query M 17 6 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/summary.rs apps/aether-gateway/src/handlers/admin/provider/query M 503 13 present unchanged_at_HEAD 28 +apps/aether-gateway/src/handlers/admin/provider/query/models/model_test/tests.rs apps/aether-gateway/src/handlers/admin/provider/query M 1 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/shared/payloads.rs apps/aether-gateway/src/handlers/admin/provider/shared M 100 3 present unchanged_at_HEAD 10 +apps/aether-gateway/src/handlers/admin/provider/summary/health.rs apps/aether-gateway/src/handlers/admin/provider/summary M 3 3 present unchanged_at_HEAD 2 +apps/aether-gateway/src/handlers/admin/provider/summary/list.rs apps/aether-gateway/src/handlers/admin/provider/summary M 4 1 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/admin/provider/summary/value.rs apps/aether-gateway/src/handlers/admin/provider/summary M 6 5 present changed_after_hardening 2 +apps/aether-gateway/src/handlers/admin/provider/write/keys/create.rs apps/aether-gateway/src/handlers/admin/provider/write M 16 11 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/provider/write/keys/update.rs apps/aether-gateway/src/handlers/admin/provider/write M 46 20 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/admin/provider/write/provider/update.rs apps/aether-gateway/src/handlers/admin/provider/write M 27 1 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/admin/provider/write/reveal.rs apps/aether-gateway/src/handlers/admin/provider/write M 19 8 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/request/auth.rs apps/aether-gateway/src/handlers/admin/request/auth.rs M 44 7 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/request/capabilities.rs apps/aether-gateway/src/handlers/admin/request/capabilities.rs M 12 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/request/mod.rs apps/aether-gateway/src/handlers/admin/request/mod.rs M 1 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/request/models.rs apps/aether-gateway/src/handlers/admin/request/models.rs M 25 11 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/request/provider/catalog.rs apps/aether-gateway/src/handlers/admin/request/provider M 9 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/request/provider/oauth.rs apps/aether-gateway/src/handlers/admin/request/provider M 593 93 present unchanged_at_HEAD 16 +apps/aether-gateway/src/handlers/admin/request/provider/tasks.rs apps/aether-gateway/src/handlers/admin/request/provider M 76 19 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/admin/request/provider/transport.rs apps/aether-gateway/src/handlers/admin/request/provider M 96 30 present unchanged_at_HEAD 2 +apps/aether-gateway/src/handlers/admin/request/route_request.rs apps/aether-gateway/src/handlers/admin/request/route_request.rs M 8 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/request/system/export.rs apps/aether-gateway/src/handlers/admin/request/system M 596 101 present unchanged_at_HEAD 21 +apps/aether-gateway/src/handlers/admin/request/system/import.rs apps/aether-gateway/src/handlers/admin/request/system M 5994 529 present changed_after_hardening 107 +apps/aether-gateway/src/handlers/admin/request/system/mod.rs apps/aether-gateway/src/handlers/admin/request/system M 5 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/request/users.rs apps/aether-gateway/src/handlers/admin/request/users.rs M 153 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/shared/mod.rs apps/aether-gateway/src/handlers/admin/shared/mod.rs M 7 6 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/system/core/system_routes.rs apps/aether-gateway/src/handlers/admin/system/core M 207 73 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/system/import_lock.rs apps/aether-gateway/src/handlers/admin/system/import_lock.rs A 325 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/system/management_tokens.rs apps/aether-gateway/src/handlers/admin/system/management_tokens.rs M 93 44 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/system/mod.rs apps/aether-gateway/src/handlers/admin/system/mod.rs M 5 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/system/proxy_nodes.rs apps/aether-gateway/src/handlers/admin/system/proxy_nodes.rs M 398 131 present changed_after_hardening 12 +apps/aether-gateway/src/handlers/admin/system/routes.rs apps/aether-gateway/src/handlers/admin/system/routes.rs M 1 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/system/shared/configs.rs apps/aether-gateway/src/handlers/admin/system/shared M 63 9 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/system/shared/export/mod.rs apps/aether-gateway/src/handlers/admin/system/shared M 3 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/system/shared/export/providers.rs apps/aether-gateway/src/handlers/admin/system/shared M 245 179 present unchanged_at_HEAD 3 +apps/aether-gateway/src/handlers/admin/system/shared/export/support.rs apps/aether-gateway/src/handlers/admin/system/shared M 107 15 present unchanged_at_HEAD 3 +apps/aether-gateway/src/handlers/admin/system/shared/modules.rs apps/aether-gateway/src/handlers/admin/system/shared M 1 1 present changed_after_hardening 0 +apps/aether-gateway/src/handlers/admin/system/shared/settings.rs apps/aether-gateway/src/handlers/admin/system/shared M 13 8 present unchanged_at_HEAD 2 +apps/aether-gateway/src/handlers/admin/system/shared/smtp.rs apps/aether-gateway/src/handlers/admin/system/shared M 68 38 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/system/shared/update.rs apps/aether-gateway/src/handlers/admin/system/shared M 1731 183 present changed_after_hardening 34 +apps/aether-gateway/src/handlers/admin/system/shared/update_client.rs apps/aether-gateway/src/handlers/admin/system/shared M 149 3 present changed_after_hardening 2 +apps/aether-gateway/src/handlers/admin/users/api_keys/helpers.rs apps/aether-gateway/src/handlers/admin/users/api_keys M 7 12 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/users/api_keys/responses/create.rs apps/aether-gateway/src/handlers/admin/users/api_keys M 38 55 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/users/api_keys/responses/list.rs apps/aether-gateway/src/handlers/admin/users/api_keys M 1 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/users/api_keys/responses/reveal.rs apps/aether-gateway/src/handlers/admin/users/api_keys M 19 12 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/users/api_keys/responses/update.rs apps/aether-gateway/src/handlers/admin/users/api_keys M 9 8 present unchanged_at_HEAD 2 +apps/aether-gateway/src/handlers/admin/users/batch.rs apps/aether-gateway/src/handlers/admin/users/batch.rs M 21 6 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/admin/users/billing.rs apps/aether-gateway/src/handlers/admin/users/billing.rs M 9 4 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/users/lifecycle/create.rs apps/aether-gateway/src/handlers/admin/users/lifecycle M 151 16 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/admin/users/lifecycle/delete.rs apps/aether-gateway/src/handlers/admin/users/lifecycle M 17 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/users/lifecycle/update.rs apps/aether-gateway/src/handlers/admin/users/lifecycle M 60 12 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/admin/users/mod.rs apps/aether-gateway/src/handlers/admin/users/mod.rs M 4 3 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/admin/users/sessions.rs apps/aether-gateway/src/handlers/admin/users/sessions.rs M 20 3 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/admin/users/shared.rs apps/aether-gateway/src/handlers/admin/users/shared.rs M 37 2 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/internal/gateway.rs apps/aether-gateway/src/handlers M 341 106 present unchanged_at_HEAD 7 +apps/aether-gateway/src/handlers/internal/gateway_helpers.rs apps/aether-gateway/src/handlers M 35 5 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/proxy/body_buffer.rs apps/aether-gateway/src/handlers M 91 10 present unchanged_at_HEAD 6 +apps/aether-gateway/src/handlers/proxy/finalize.rs apps/aether-gateway/src/handlers M 139 1 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/proxy/local.rs apps/aether-gateway/src/handlers M 4 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/proxy/mod.rs apps/aether-gateway/src/handlers M 806 238 present unchanged_at_HEAD 16 +apps/aether-gateway/src/handlers/proxy/websocket/live/audit.rs apps/aether-gateway/src/handlers M 2 0 present unchanged_at_HEAD 2 +apps/aether-gateway/src/handlers/proxy/websocket/live/http.rs apps/aether-gateway/src/handlers M 27 5 present changed_after_hardening 3 +apps/aether-gateway/src/handlers/proxy/websocket/live/session.rs apps/aether-gateway/src/handlers M 20 0 present changed_after_hardening 1 +apps/aether-gateway/src/handlers/proxy/websocket/realtime/session.rs apps/aether-gateway/src/handlers M 7 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/proxy/websocket/responses/client.rs apps/aether-gateway/src/handlers M 178 60 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/proxy/websocket/responses/connection.rs apps/aether-gateway/src/handlers M 65 27 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/proxy/websocket/responses/lifecycle.rs apps/aether-gateway/src/handlers M 52 3 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/proxy/websocket/responses/mod.rs apps/aether-gateway/src/handlers M 1 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/proxy/websocket/responses/ownership.rs apps/aether-gateway/src/handlers M 3 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/proxy/websocket/responses/plan_admission.rs apps/aether-gateway/src/handlers A 232 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/proxy/websocket/responses/quota.rs apps/aether-gateway/src/handlers M 56 15 present unchanged_at_HEAD 10 +apps/aether-gateway/src/handlers/proxy/websocket/responses/redaction.rs apps/aether-gateway/src/handlers M 67 11 present unchanged_at_HEAD 11 +apps/aether-gateway/src/handlers/proxy/websocket/responses/session.rs apps/aether-gateway/src/handlers M 105 31 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/proxy/websocket/responses/settlement.rs apps/aether-gateway/src/handlers M 24 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/proxy/websocket/responses/turn.rs apps/aether-gateway/src/handlers M 433 14 present unchanged_at_HEAD 2 +apps/aether-gateway/src/handlers/proxy/websocket/responses/turn_state.rs apps/aether-gateway/src/handlers M 28 1 present unchanged_at_HEAD 2 +apps/aether-gateway/src/handlers/proxy/websocket/responses/upstream.rs apps/aether-gateway/src/handlers M 308 26 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/proxy/websocket/transport.rs apps/aether-gateway/src/handlers M 243 25 present changed_after_hardening 2 +apps/aether-gateway/src/handlers/public/ai_public.rs apps/aether-gateway/src/handlers M 821 81 present unchanged_at_HEAD 10 +apps/aether-gateway/src/handlers/public/catalog_helpers.rs apps/aether-gateway/src/handlers M 502 48 present unchanged_at_HEAD 16 +apps/aether-gateway/src/handlers/public/mod.rs apps/aether-gateway/src/handlers M 3 2 present unchanged_at_HEAD 2 +apps/aether-gateway/src/handlers/public/support.rs apps/aether-gateway/src/handlers M 71 36 present unchanged_at_HEAD 5 +apps/aether-gateway/src/handlers/public/support/announcements/public_routes.rs apps/aether-gateway/src/handlers M 10 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/announcements/shared.rs apps/aether-gateway/src/handlers M 15 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/announcements/user_routes.rs apps/aether-gateway/src/handlers M 4 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/auth.rs apps/aether-gateway/src/handlers M 172 82 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/auth_email.rs apps/aether-gateway/src/handlers M 237 22 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/public/support/auth_helpers.rs apps/aether-gateway/src/handlers M 236 61 present unchanged_at_HEAD 3 +apps/aether-gateway/src/handlers/public/support/auth_ldap.rs apps/aether-gateway/src/handlers M 11 31 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/auth_rate_limit.rs apps/aether-gateway/src/handlers A 318 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/auth_registration.rs apps/aether-gateway/src/handlers M 405 157 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/auth_session.rs apps/aether-gateway/src/handlers M 276 237 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/auth_turnstile.rs apps/aether-gateway/src/handlers M 29 20 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/billing.rs apps/aether-gateway/src/handlers M 1352 151 present changed_after_hardening 8 +apps/aether-gateway/src/handlers/public/support/install.rs apps/aether-gateway/src/handlers M 796 93 present changed_after_hardening 8 +apps/aether-gateway/src/handlers/public/support/monitoring/audit_logs.rs apps/aether-gateway/src/handlers M 2 6 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/oauth.rs apps/aether-gateway/src/handlers M 586 220 present changed_after_hardening 2 +apps/aether-gateway/src/handlers/public/support/payment.rs apps/aether-gateway/src/handlers M 39 4 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/public/support/payment/alipay.rs apps/aether-gateway/src/handlers M 29 22 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/payment/epay.rs apps/aether-gateway/src/handlers M 322 109 present unchanged_at_HEAD 10 +apps/aether-gateway/src/handlers/public/support/payment/repository.rs apps/aether-gateway/src/handlers M 141 89 present unchanged_at_HEAD 4 +apps/aether-gateway/src/handlers/public/support/payment/route.rs apps/aether-gateway/src/handlers M 35 11 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/payment/shared.rs apps/aether-gateway/src/handlers M 266 50 present unchanged_at_HEAD 10 +apps/aether-gateway/src/handlers/public/support/payment/stripe.rs apps/aether-gateway/src/handlers M 113 55 present unchanged_at_HEAD 2 +apps/aether-gateway/src/handlers/public/support/payment/test_support.rs apps/aether-gateway/src/handlers M 14 86 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/public/support/payment/wxpay.rs apps/aether-gateway/src/handlers M 39 23 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/test_connection.rs apps/aether-gateway/src/handlers M 20 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/test_connection/route.rs apps/aether-gateway/src/handlers M 290 13 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/user_me.rs apps/aether-gateway/src/handlers M 6 4 present unchanged_at_HEAD 2 +apps/aether-gateway/src/handlers/public/support/user_me_api_keys.rs apps/aether-gateway/src/handlers M 124 110 present unchanged_at_HEAD 3 +apps/aether-gateway/src/handlers/public/support/user_me_catalog.rs apps/aether-gateway/src/handlers M 15 6 present unchanged_at_HEAD 5 +apps/aether-gateway/src/handlers/public/support/user_me_management_tokens.rs apps/aether-gateway/src/handlers M 99 29 present unchanged_at_HEAD 7 +apps/aether-gateway/src/handlers/public/support/user_me_profile.rs apps/aether-gateway/src/handlers M 129 65 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/user_me_routes.rs apps/aether-gateway/src/handlers M 26 10 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/user_me_sessions.rs apps/aether-gateway/src/handlers M 9 6 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/user_me_usage.rs apps/aether-gateway/src/handlers M 117 15 present changed_after_hardening 1 +apps/aether-gateway/src/handlers/public/support/wallet.rs apps/aether-gateway/src/handlers M 14 14 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/public/support/wallet/reads.rs apps/aether-gateway/src/handlers M 5 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/wallet/recharge.rs apps/aether-gateway/src/handlers M 2874 190 present unchanged_at_HEAD 41 +apps/aether-gateway/src/handlers/public/support/wallet/redeem.rs apps/aether-gateway/src/handlers M 3 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/support/wallet/refunds.rs apps/aether-gateway/src/handlers M 97 9 present unchanged_at_HEAD 6 +apps/aether-gateway/src/handlers/public/support/wallet/test_support.rs apps/aether-gateway/src/handlers M 37 4 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/public/system_modules_helpers/modules.rs apps/aether-gateway/src/handlers M 1 12 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/shared/admin_proxy.rs apps/aether-gateway/src/handlers M 36 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/shared/api_keys.rs apps/aether-gateway/src/handlers M 46 10 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/shared/auth_api_key_secret.rs apps/aether-gateway/src/handlers A 359 0 present unchanged_at_HEAD 15 +apps/aether-gateway/src/handlers/shared/catalog.rs apps/aether-gateway/src/handlers M 210 40 present unchanged_at_HEAD 6 +apps/aether-gateway/src/handlers/shared/email_templates.rs apps/aether-gateway/src/handlers M 90 6 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/shared/identity_oauth_provider_secret.rs apps/aether-gateway/src/handlers A 634 0 present unchanged_at_HEAD 7 +apps/aether-gateway/src/handlers/shared/mod.rs apps/aether-gateway/src/handlers M 73 14 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/shared/multipart.rs apps/aether-gateway/src/handlers A 350 0 present unchanged_at_HEAD 11 +apps/aether-gateway/src/handlers/shared/normalize.rs apps/aether-gateway/src/handlers M 22 2 present unchanged_at_HEAD 4 +apps/aether-gateway/src/handlers/shared/payloads.rs apps/aether-gateway/src/handlers M 1 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/shared/payment_currency.rs apps/aether-gateway/src/handlers A 101 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/shared/payment_direct.rs apps/aether-gateway/src/handlers M 1256 311 present changed_after_hardening 10 +apps/aether-gateway/src/handlers/shared/payment_gateway_config.rs apps/aether-gateway/src/handlers M 77 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/handlers/shared/payment_gateway_secret.rs apps/aether-gateway/src/handlers A 428 0 present unchanged_at_HEAD 5 +apps/aether-gateway/src/handlers/shared/payment_order_stripe_secret.rs apps/aether-gateway/src/handlers A 278 0 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/shared/provider_catalog_credential.rs apps/aether-gateway/src/handlers A 281 0 present unchanged_at_HEAD 2 +apps/aether-gateway/src/handlers/shared/provider_ops_credential.rs apps/aether-gateway/src/handlers A 381 0 present unchanged_at_HEAD 1 +apps/aether-gateway/src/handlers/shared/request_utils.rs apps/aether-gateway/src/handlers M 82 3 present unchanged_at_HEAD 14 +apps/aether-gateway/src/handlers/shared/runtime_secret.rs apps/aether-gateway/src/handlers A 130 0 present unchanged_at_HEAD 2 +apps/aether-gateway/src/handlers/shared/system_config_values.rs apps/aether-gateway/src/handlers M 1226 0 present changed_after_hardening 9 +apps/aether-gateway/src/headers.rs apps/aether-gateway/src/headers.rs M 415 82 present unchanged_at_HEAD 8 +apps/aether-gateway/src/important_notification.rs apps/aether-gateway/src/important_notification.rs M 66 5 present changed_after_hardening 0 +apps/aether-gateway/src/internal_gateway_auth.rs apps/aether-gateway/src/internal_gateway_auth.rs A 234 0 present unchanged_at_HEAD 8 +apps/aether-gateway/src/lib.rs apps/aether-gateway/src/lib.rs M 78 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/local_auth_token.rs apps/aether-gateway/src/local_auth_token.rs A 457 0 present unchanged_at_HEAD 12 +apps/aether-gateway/src/main.rs apps/aether-gateway/src/main.rs M 1292 26 present changed_after_hardening 3 +apps/aether-gateway/src/maintenance/mod.rs apps/aether-gateway/src/maintenance M 1 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/maintenance/runtime.rs apps/aether-gateway/src/maintenance M 7 6 present unchanged_at_HEAD 0 +apps/aether-gateway/src/maintenance/runtime/account_self_check.rs apps/aether-gateway/src/maintenance M 101 49 present unchanged_at_HEAD 0 +apps/aether-gateway/src/maintenance/runtime/cleanup_runs.rs apps/aether-gateway/src/maintenance M 125 3 present unchanged_at_HEAD 0 +apps/aether-gateway/src/maintenance/runtime/fixed_provider_reconciliation.rs apps/aether-gateway/src/maintenance M 2 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/maintenance/runtime/oauth_token_refresh.rs apps/aether-gateway/src/maintenance M 1 2 present changed_after_hardening 0 +apps/aether-gateway/src/maintenance/runtime/pool_quota_probe.rs apps/aether-gateway/src/maintenance M 42 10 present unchanged_at_HEAD 0 +apps/aether-gateway/src/maintenance/runtime/provider_quota_alert.rs apps/aether-gateway/src/maintenance M 8 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/maintenance/runtime/proxy_node_staleness.rs apps/aether-gateway/src/maintenance M 1 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/maintenance/runtime/proxy_upgrade_rollout.rs apps/aether-gateway/src/maintenance M 86 28 present unchanged_at_HEAD 12 +apps/aether-gateway/src/maintenance/runtime/runners.rs apps/aether-gateway/src/maintenance M 69 17 present unchanged_at_HEAD 0 +apps/aether-gateway/src/maintenance/runtime/tests.rs apps/aether-gateway/src/maintenance M 156 14 present unchanged_at_HEAD 0 +apps/aether-gateway/src/maintenance/runtime/usage_cleanup.rs apps/aether-gateway/src/maintenance M 43 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/maintenance/runtime/workers.rs apps/aether-gateway/src/maintenance M 5 5 present unchanged_at_HEAD 0 +apps/aether-gateway/src/management_token_auth.rs apps/aether-gateway/src/management_token_auth.rs A 255 0 present unchanged_at_HEAD 3 +apps/aether-gateway/src/middleware/mod.rs apps/aether-gateway/src/middleware M 1 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/model_fetch/mod.rs apps/aether-gateway/src/model_fetch M 1 1 present changed_after_hardening 0 +apps/aether-gateway/src/model_fetch/runtime.rs apps/aether-gateway/src/model_fetch M 231 9 present changed_after_hardening 1 +apps/aether-gateway/src/oauth/http_executor.rs apps/aether-gateway/src/oauth M 703 63 present changed_after_hardening 13 +apps/aether-gateway/src/oauth/identity_repo.rs apps/aether-gateway/src/oauth M 175 68 present changed_after_hardening 2 +apps/aether-gateway/src/oauth/mod.rs apps/aether-gateway/src/oauth M 4 4 present unchanged_at_HEAD 0 +apps/aether-gateway/src/oauth/provider_repo.rs apps/aether-gateway/src/oauth M 0 18 present unchanged_at_HEAD 0 +apps/aether-gateway/src/oauth/proxy.rs apps/aether-gateway/src/oauth M 11 4 present unchanged_at_HEAD 0 +apps/aether-gateway/src/oauth/state_store.rs apps/aether-gateway/src/oauth M 277 8 present unchanged_at_HEAD 9 +apps/aether-gateway/src/orchestration/effects.rs apps/aether-gateway/src/orchestration M 34 17 present unchanged_at_HEAD 1 +apps/aether-gateway/src/orchestration/mod.rs apps/aether-gateway/src/orchestration M 31 1 present unchanged_at_HEAD 3 +apps/aether-gateway/src/orchestration/report_effects.rs apps/aether-gateway/src/orchestration M 56 16 present unchanged_at_HEAD 0 +apps/aether-gateway/src/plan_usage_policy.rs apps/aether-gateway/src/plan_usage_policy.rs A 1487 0 present unchanged_at_HEAD 17 +apps/aether-gateway/src/privacy/mod.rs apps/aether-gateway/src/privacy M 720 189 present unchanged_at_HEAD 52 +apps/aether-gateway/src/rate_limit.rs apps/aether-gateway/src/rate_limit.rs M 15 1 present unchanged_at_HEAD 5 +apps/aether-gateway/src/request_candidate_runtime.rs apps/aether-gateway/src/request_candidate_runtime.rs M 4 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/router.rs apps/aether-gateway/src/router.rs M 143 8 present unchanged_at_HEAD 0 +apps/aether-gateway/src/routing/mutations.rs apps/aether-gateway/src/routing M 72 8 present unchanged_at_HEAD 0 +apps/aether-gateway/src/routing/resolver.rs apps/aether-gateway/src/routing M 97 25 present unchanged_at_HEAD 1 +apps/aether-gateway/src/scheduler/candidate/tests/selection.rs apps/aether-gateway/src/scheduler M 31 10 present unchanged_at_HEAD 0 +apps/aether-gateway/src/server_chan_push.rs apps/aether-gateway/src/server_chan_push.rs M 436 47 present changed_after_hardening 4 +apps/aether-gateway/src/state/admin_types.rs apps/aether-gateway/src/state M 3 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/state/app.rs apps/aether-gateway/src/state M 74 5 present unchanged_at_HEAD 1 +apps/aether-gateway/src/state/bootstrap_admin.rs apps/aether-gateway/src/state M 20 1 present unchanged_at_HEAD 3 +apps/aether-gateway/src/state/catalog.rs apps/aether-gateway/src/state M 189 34 present changed_after_hardening 0 +apps/aether-gateway/src/state/catalog_credentials.rs apps/aether-gateway/src/state A 395 0 present changed_after_hardening 1 +apps/aether-gateway/src/state/catalog_proxy.rs apps/aether-gateway/src/state A 1860 0 present unchanged_at_HEAD 30 +apps/aether-gateway/src/state/core.rs apps/aether-gateway/src/state M 127 23 present changed_after_hardening 2 +apps/aether-gateway/src/state/integrations.rs apps/aether-gateway/src/state M 47 27 present changed_after_hardening 0 +apps/aether-gateway/src/state/mod.rs apps/aether-gateway/src/state M 9 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/state/oauth.rs apps/aether-gateway/src/state M 382 279 present changed_after_hardening 12 +apps/aether-gateway/src/state/proxy.rs apps/aether-gateway/src/state M 1558 39 present unchanged_at_HEAD 72 +apps/aether-gateway/src/state/runtime/api_key_exports.rs apps/aether-gateway/src/state M 338 0 present unchanged_at_HEAD 10 +apps/aether-gateway/src/state/runtime/auth/sessions.rs apps/aether-gateway/src/state M 125 6 present unchanged_at_HEAD 4 +apps/aether-gateway/src/state/runtime/auth/user_lifecycle.rs apps/aether-gateway/src/state M 465 11 present unchanged_at_HEAD 13 +apps/aether-gateway/src/state/runtime/auth/user_provisioning.rs apps/aether-gateway/src/state M 1190 98 present unchanged_at_HEAD 28 +apps/aether-gateway/src/state/runtime/billing/admin.rs apps/aether-gateway/src/state M 131 3 present unchanged_at_HEAD 2 +apps/aether-gateway/src/state/runtime/billing/finance_queries.rs apps/aether-gateway/src/state M 1 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/state/runtime/billing/mod.rs apps/aether-gateway/src/state M 2 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/state/runtime/candidate_queries.rs apps/aether-gateway/src/state M 6 3 present unchanged_at_HEAD 3 +apps/aether-gateway/src/state/runtime/gemini_files.rs apps/aether-gateway/src/state M 80 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/state/runtime/monitoring.rs apps/aether-gateway/src/state M 42 3 present unchanged_at_HEAD 5 +apps/aether-gateway/src/state/runtime/payments.rs apps/aether-gateway/src/state M 1 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/state/runtime/referrals.rs apps/aether-gateway/src/state M 77 5 present changed_after_hardening 0 +apps/aether-gateway/src/state/runtime/wallet/mutations.rs apps/aether-gateway/src/state M 68 4 present unchanged_at_HEAD 0 +apps/aether-gateway/src/state/runtime/wallet/reads.rs apps/aether-gateway/src/state M 34 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/state/runtime/wallet/refund_lifecycle.rs apps/aether-gateway/src/state M 291 26 present unchanged_at_HEAD 6 +apps/aether-gateway/src/state/testing.rs apps/aether-gateway/src/state M 168 42 present unchanged_at_HEAD 2 +apps/aether-gateway/src/state/types.rs apps/aether-gateway/src/state M 86 3 present unchanged_at_HEAD 4 +apps/aether-gateway/src/state/video.rs apps/aether-gateway/src/state M 124 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/task_runtime/mod.rs apps/aether-gateway/src/task_runtime M 26 47 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/ai_execute/control_execute.rs apps/aether-gateway/src/tests M 50 6 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/ai_execute/fallback.rs apps/aether-gateway/src/tests M 45 3 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/cross_format.rs apps/aether-gateway/src/tests M 10 5 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/ai_execute/finalize_local_cli/direct.rs apps/aether-gateway/src/tests M 5 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/ai_execute/finalize_local_provider/gemini.rs apps/aether-gateway/src/tests M 10 5 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/ai_execute/mod.rs apps/aether-gateway/src/tests M 84 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/ai_execute/stream/decision.rs apps/aether-gateway/src/tests M 9 13 present changed_after_hardening 0 +apps/aether-gateway/src/tests/ai_execute/stream_cli/compact.rs apps/aether-gateway/src/tests M 5 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/ai_execute/stream_provider.rs apps/aether-gateway/src/tests M 20 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_chat.rs apps/aether-gateway/src/tests M 5 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/ai_execute/stream_provider_gemini/local_cli.rs apps/aether-gateway/src/tests M 30 10 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/ai_execute/sync/chat/failover.rs apps/aether-gateway/src/tests M 3 8 present changed_after_hardening 0 +apps/aether-gateway/src/tests/ai_execute/sync/chat/local_decision.rs apps/aether-gateway/src/tests M 51 11 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/ai_execute/sync/claude/claude_code.rs apps/aether-gateway/src/tests M 5 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/ai_execute/sync/claude/kiro.rs apps/aether-gateway/src/tests M 10 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/ai_execute/sync/claude/local_chat.rs apps/aether-gateway/src/tests M 11 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/ai_execute/sync/claude/local_cli.rs apps/aether-gateway/src/tests M 11 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/ai_execute/sync/cli.rs apps/aether-gateway/src/tests M 65 11 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/ai_execute/sync/gemini/cli.rs apps/aether-gateway/src/tests M 42 12 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/ai_execute/sync/gemini/local_chat.rs apps/aether-gateway/src/tests M 17 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/architecture/admin_provider.rs apps/aether-gateway/src/tests M 26 2 present changed_after_hardening 0 +apps/aether-gateway/src/tests/architecture/admin_shared.rs apps/aether-gateway/src/tests M 2 3 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/architecture/ai_serving.rs apps/aether-gateway/src/tests M 4 1 present changed_after_hardening 0 +apps/aether-gateway/src/tests/architecture/runtime_and_security.rs apps/aether-gateway/src/tests M 62 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/async_task.rs apps/aether-gateway/src/tests M 237 106 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/audit.rs apps/aether-gateway/src/tests M 28 21 present changed_after_hardening 0 +apps/aether-gateway/src/tests/concurrency.rs apps/aether-gateway/src/tests M 49 9 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/admin/api_keys.rs apps/aether-gateway/src/tests M 98 16 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/admin/endpoints/keys.rs apps/aether-gateway/src/tests M 128 142 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/admin/endpoints/quota.rs apps/aether-gateway/src/tests M 57 79 present changed_after_hardening 0 +apps/aether-gateway/src/tests/control/admin/endpoints/routes.rs apps/aether-gateway/src/tests M 6 5 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/admin/health_access.rs apps/aether-gateway/src/tests M 108 27 present changed_after_hardening 0 +apps/aether-gateway/src/tests/control/admin/ldap.rs apps/aether-gateway/src/tests M 66 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/admin/models/external.rs apps/aether-gateway/src/tests M 17 7 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/admin/models/global.rs apps/aether-gateway/src/tests M 33 11 present changed_after_hardening 0 +apps/aether-gateway/src/tests/control/admin/monitoring.rs apps/aether-gateway/src/tests M 4 3 present changed_after_hardening 0 +apps/aether-gateway/src/tests/control/admin/oauth.rs apps/aether-gateway/src/tests M 499 400 present changed_after_hardening 0 +apps/aether-gateway/src/tests/control/admin/payments.rs apps/aether-gateway/src/tests M 14 17 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/admin/pool.rs apps/aether-gateway/src/tests M 60 75 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/admin/provider_ops.rs apps/aether-gateway/src/tests M 78 31 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/admin/provider_query.rs apps/aether-gateway/src/tests M 124 138 present changed_after_hardening 0 +apps/aether-gateway/src/tests/control/admin/providers.rs apps/aether-gateway/src/tests M 13 22 present changed_after_hardening 0 +apps/aether-gateway/src/tests/control/admin/proxy_nodes.rs apps/aether-gateway/src/tests M 309 99 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/admin/security.rs apps/aether-gateway/src/tests M 56 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/admin/system.rs apps/aether-gateway/src/tests M 290 66 present changed_after_hardening 0 +apps/aether-gateway/src/tests/control/admin/system_import.rs apps/aether-gateway/src/tests M 1625 256 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/admin/usage.rs apps/aether-gateway/src/tests M 7 4 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/admin/users.rs apps/aether-gateway/src/tests M 327 21 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/admin/video_tasks.rs apps/aether-gateway/src/tests M 53 58 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/admin/wallets.rs apps/aether-gateway/src/tests M 123 5 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/helpers.rs apps/aether-gateway/src/tests M 154 3 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/internal.rs apps/aether-gateway/src/tests M 833 112 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/mod.rs apps/aether-gateway/src/tests M 3 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/proxy/embeddings.rs apps/aether-gateway/src/tests M 7 7 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/proxy/local_denials.rs apps/aether-gateway/src/tests M 235 52 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/control/proxy/rerank.rs apps/aether-gateway/src/tests M 2 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/files/mod.rs apps/aether-gateway/src/tests M 151 31 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/files/stream.rs apps/aether-gateway/src/tests M 12 4 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/files/sync.rs apps/aether-gateway/src/tests M 36 76 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/fixtures/admin_system/config_export_v22.json apps/aether-gateway/src/tests M 10 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/frontdoor/ai.rs apps/aether-gateway/src/tests M 334 10 present changed_after_hardening 0 +apps/aether-gateway/src/tests/frontdoor/internal.rs apps/aether-gateway/src/tests M 675 152 present changed_after_hardening 0 +apps/aether-gateway/src/tests/frontdoor/oauth.rs apps/aether-gateway/src/tests M 568 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/frontdoor/ops.rs apps/aether-gateway/src/tests M 1 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/frontdoor/public_support.rs apps/aether-gateway/src/tests M 1669 156 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/frontdoor/public_support/vscodex.rs apps/aether-gateway/src/tests M 5 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/mod.rs apps/aether-gateway/src/tests M 39 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/operational_auth.rs apps/aether-gateway/src/tests A 717 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/proxy.rs apps/aether-gateway/src/tests M 581 26 present changed_after_hardening 0 +apps/aether-gateway/src/tests/usage/local.rs apps/aether-gateway/src/tests M 37 137 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/video/data_read.rs apps/aether-gateway/src/tests M 174 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/video/gemini_sync_create.rs apps/aether-gateway/src/tests M 8 2 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/video/gemini_sync_task.rs apps/aether-gateway/src/tests M 91 106 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/video/mod.rs apps/aether-gateway/src/tests M 140 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/video/openai_sync_create.rs apps/aether-gateway/src/tests M 67 7 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/video/openai_sync_task.rs apps/aether-gateway/src/tests M 88 80 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/video/registry_poller.rs apps/aether-gateway/src/tests M 39 20 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tests/video/stream.rs apps/aether-gateway/src/tests M 96 85 present unchanged_at_HEAD 0 +apps/aether-gateway/src/tunnel/embedded/control_plane.rs apps/aether-gateway/src/tunnel M 456 72 present unchanged_at_HEAD 6 +apps/aether-gateway/src/tunnel/embedded/hub.rs apps/aether-gateway/src/tunnel M 494 76 present changed_after_hardening 8 +apps/aether-gateway/src/tunnel/embedded/local_relay.rs apps/aether-gateway/src/tunnel M 293 154 present changed_after_hardening 29 +apps/aether-gateway/src/tunnel/embedded/mod.rs apps/aether-gateway/src/tunnel M 2066 62 present changed_after_hardening 24 +apps/aether-gateway/src/tunnel/embedded/proxy_conn.rs apps/aether-gateway/src/tunnel M 325 14 present changed_after_hardening 1 +apps/aether-gateway/src/tunnel/mod.rs apps/aether-gateway/src/tunnel M 2336 207 present changed_after_hardening 15 +apps/aether-gateway/src/usage/http.rs apps/aether-gateway/src/usage M 6 1 present unchanged_at_HEAD 1 +apps/aether-gateway/src/usage/mod.rs apps/aether-gateway/src/usage M 1 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/usage/reporting/context.rs apps/aether-gateway/src/usage M 348 5 present changed_after_hardening 15 +apps/aether-gateway/src/usage/reporting/mod.rs apps/aether-gateway/src/usage M 564 52 present changed_after_hardening 5 +apps/aether-gateway/src/video_tasks/mod.rs apps/aether-gateway/src/video_tasks M 12 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/video_tasks/service.rs apps/aether-gateway/src/video_tasks M 39 1 present unchanged_at_HEAD 0 +apps/aether-gateway/src/video_tasks/tests/fixtures.rs apps/aether-gateway/src/video_tasks M 1 0 present unchanged_at_HEAD 0 +apps/aether-gateway/src/video_tasks/tests/plans.rs apps/aether-gateway/src/video_tasks M 113 6 present unchanged_at_HEAD 0 +apps/aether-gateway/src/video_tasks/tests/projection.rs apps/aether-gateway/src/video_tasks M 37 8 present unchanged_at_HEAD 0 +apps/aether-gateway/src/video_tasks/tests/sync.rs apps/aether-gateway/src/video_tasks M 7 0 present unchanged_at_HEAD 0 +apps/aether-tunnel/Cargo.toml apps M 3 2 present changed_after_hardening 0 +apps/aether-tunnel/README.md apps M 22 14 present changed_after_hardening 0 +apps/aether-tunnel/install.ps1 apps M 409 28 present unchanged_at_HEAD 0 +apps/aether-tunnel/install.sh apps M 428 30 present unchanged_at_HEAD 0 +apps/aether-tunnel/src/app.rs apps M 36 11 present unchanged_at_HEAD 0 +apps/aether-tunnel/src/config.rs apps M 595 13 present changed_after_hardening 17 +apps/aether-tunnel/src/egress_proxy.rs apps M 107 15 present unchanged_at_HEAD 5 +apps/aether-tunnel/src/main.rs apps M 2 2 present unchanged_at_HEAD 0 +apps/aether-tunnel/src/net.rs apps M 131 17 present unchanged_at_HEAD 0 +apps/aether-tunnel/src/registration/client.rs apps M 117 18 present unchanged_at_HEAD 0 +apps/aether-tunnel/src/safe_dns.rs apps D 0 66 absent unchanged_at_HEAD 0 +apps/aether-tunnel/src/setup/service.rs apps M 502 47 present changed_after_hardening 0 +apps/aether-tunnel/src/setup/tui.rs apps M 2 9 present unchanged_at_HEAD 1 +apps/aether-tunnel/src/setup/upgrade.rs apps M 967 105 present changed_after_hardening 8 +apps/aether-tunnel/src/state.rs apps M 2 0 present unchanged_at_HEAD 0 +apps/aether-tunnel/src/target_filter.rs apps M 170 114 present unchanged_at_HEAD 0 +apps/aether-tunnel/src/tunnel/client.rs apps M 136 20 present changed_after_hardening 0 +apps/aether-tunnel/src/tunnel/dispatcher.rs apps M 223 15 present changed_after_hardening 2 +apps/aether-tunnel/src/tunnel/heartbeat.rs apps M 27 3 present changed_after_hardening 0 +apps/aether-tunnel/src/tunnel/mod.rs apps M 76 4 present changed_after_hardening 0 +apps/aether-tunnel/src/tunnel/stream_handler.rs apps M 995 225 present changed_after_hardening 12 +apps/aether-tunnel/src/upstream_client.rs apps M 248 134 present unchanged_at_HEAD 3 +crates/aether-admin/src/observability/monitoring.rs crates/aether-admin M 42 101 present changed_after_hardening 28 +crates/aether-admin/src/observability/usage.rs crates/aether-admin M 208 32 present unchanged_at_HEAD 1 +crates/aether-admin/src/provider/endpoints.rs crates/aether-admin M 37 44 present unchanged_at_HEAD 1 +crates/aether-admin/src/provider/mod.rs crates/aether-admin M 1 0 present unchanged_at_HEAD 1 +crates/aether-admin/src/provider/ops/actions.rs crates/aether-admin M 60 17 present unchanged_at_HEAD 2 +crates/aether-admin/src/provider/ops/verify.rs crates/aether-admin M 84 35 present unchanged_at_HEAD 2 +crates/aether-admin/src/provider/pool.rs crates/aether-admin M 158 12 present unchanged_at_HEAD 12 +crates/aether-admin/src/provider/quota.rs crates/aether-admin M 123 108 present changed_after_hardening 1 +crates/aether-admin/src/provider/redaction.rs crates/aether-admin A 2550 0 present changed_after_hardening 98 +crates/aether-admin/src/provider/state.rs crates/aether-admin M 28 1 present unchanged_at_HEAD 0 +crates/aether-admin/src/provider/status.rs crates/aether-admin M 26 0 present unchanged_at_HEAD 2 +crates/aether-admin/src/system.rs crates/aether-admin M 994 54 present changed_after_hardening 40 +crates/aether-ai/formats/src/formats/openai/image/mod.rs crates/aether-ai M 93 0 present changed_after_hardening 3 +crates/aether-ai/formats/src/formats/openai/image/request.rs crates/aether-ai M 565 33 present unchanged_at_HEAD 18 +crates/aether-ai/formats/src/formats/openai/image/stream.rs crates/aether-ai M 604 70 present unchanged_at_HEAD 8 +crates/aether-ai/formats/src/formats/shared/image_bridge.rs crates/aether-ai M 316 57 present unchanged_at_HEAD 5 +crates/aether-ai/formats/src/formats/shared/mod.rs crates/aether-ai M 35 0 present unchanged_at_HEAD 1 +crates/aether-ai/formats/src/formats/shared/routing.rs crates/aether-ai M 36 1 present unchanged_at_HEAD 11 +crates/aether-ai/formats/src/formats/shared/stream_rewrite.rs crates/aether-ai M 79 2 present unchanged_at_HEAD 2 +crates/aether-ai/formats/src/formats/shared/sync_products.rs crates/aether-ai M 6 6 present changed_after_hardening 0 +crates/aether-ai/formats/src/formats/shared/sync_to_stream.rs crates/aether-ai M 157 42 present unchanged_at_HEAD 7 +crates/aether-ai/formats/src/protocol/canonical.rs crates/aether-ai M 456 19 present unchanged_at_HEAD 0 +crates/aether-ai/formats/src/provider_compat/private_envelope.rs crates/aether-ai M 33 0 present unchanged_at_HEAD 0 +crates/aether-ai/serving/src/attempt_plan.rs crates/aether-ai M 56 4 present unchanged_at_HEAD 3 +crates/aether-ai/serving/src/candidate_preparation.rs crates/aether-ai M 15 1 present unchanged_at_HEAD 0 +crates/aether-ai/serving/src/dto.rs crates/aether-ai M 138 5 present unchanged_at_HEAD 3 +crates/aether-contracts/Cargo.toml crates/aether-contracts M 1 0 present unchanged_at_HEAD 0 +crates/aether-contracts/src/internal_gateway.rs crates/aether-contracts A 141 0 present unchanged_at_HEAD 3 +crates/aether-contracts/src/lib.rs crates/aether-contracts M 6 4 present unchanged_at_HEAD 1 +crates/aether-contracts/src/plan.rs crates/aether-contracts M 186 6 present unchanged_at_HEAD 12 +crates/aether-contracts/src/result.rs crates/aether-contracts M 93 2 present unchanged_at_HEAD 6 +crates/aether-contracts/src/tunnel.rs crates/aether-contracts M 500 21 present changed_after_hardening 10 +crates/aether-contracts/src/tunnel_security.rs crates/aether-contracts M 547 4 present changed_after_hardening 8 +crates/aether-crypto/Cargo.toml crates/aether-crypto M 1 0 present unchanged_at_HEAD 0 +crates/aether-crypto/src/lib.rs crates/aether-crypto M 2 0 present unchanged_at_HEAD 0 +crates/aether-crypto/src/python_fernet.rs crates/aether-crypto M 154 20 present unchanged_at_HEAD 5 +crates/aether-crypto/src/rsa_pkcs1_sha256.rs crates/aether-crypto A 257 0 present unchanged_at_HEAD 1 +crates/aether-data/adapters/mysql/migrations/20260403000000_baseline.sql crates/aether-data/adapters M 1 1 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260814000000_add_usage_cost_reservations.sql crates/aether-data/adapters A 35 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260815000000_add_usage_request_admissions.sql crates/aether-data/adapters A 22 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260816000000_add_usage_cost_reservation_user_foreign_key.sql crates/aether-data/adapters A 12 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260817000000_add_usage_request_admission_user_foreign_key.sql crates/aether-data/adapters A 10 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260821120000_enforce_payment_gateway_order_uniqueness.sql crates/aether-data/adapters A 55 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260821130000_add_user_security_version.sql crates/aether-data/adapters A 5 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260822000000_purge_request_candidate_sensitive_diagnostics.sql crates/aether-data/adapters A 16 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260822010000_purge_usage_and_video_task_sensitive_diagnostics.sql crates/aether-data/adapters A 124 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260822020000_purge_background_task_sensitive_diagnostics.sql crates/aether-data/adapters A 43 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260827000000_purge_plaintext_stripe_client_secrets.sql crates/aether-data/adapters A 13 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260827010000_purge_legacy_payment_callback_payloads.sql crates/aether-data/adapters A 6 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260827020000_purge_video_task_prompts.sql crates/aether-data/adapters A 5 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260827030000_purge_identity_oauth_raw_userinfo.sql crates/aether-data/adapters A 6 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260827040000_expand_proxy_password_ciphertext.sql crates/aether-data/adapters A 3 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260827050000_anonymize_deleted_user_history.sql crates/aether-data/adapters A 377 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260829000000_harden_deleted_user_history_anonymization.sql crates/aether-data/adapters A 451 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260831000000_enforce_ldap_config_singleton.sql crates/aether-data/adapters A 13 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260831010000_add_proxy_node_tunnel_generation.sql crates/aether-data/adapters A 9 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260831020000_enforce_proxy_node_endpoint_uniqueness.sql crates/aether-data/adapters A 10 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260831030000_add_usage_counter_delta_tunnel_generation.sql crates/aether-data/adapters A 2 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/migrations/20260903010000_reset_legacy_oauth_email_verification.sql crates/aether-data/adapters A 8 0 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/auth.rs crates/aether-data/adapters M 555 109 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/auth_modules.rs crates/aether-data/adapters M 275 84 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/background_tasks.rs crates/aether-data/adapters M 12 6 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/billing.rs crates/aether-data/adapters M 154 2 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/candidate_selection.rs crates/aether-data/adapters M 101 5 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/candidates.rs crates/aether-data/adapters M 148 110 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/gemini_file_mappings.rs crates/aether-data/adapters M 202 2 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/management_tokens.rs crates/aether-data/adapters M 399 94 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/oauth_providers.rs crates/aether-data/adapters M 111 13 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/pool.rs crates/aether-data/adapters M 58 5 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/provider_catalog.rs crates/aether-data/adapters M 190 60 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/proxy_nodes.rs crates/aether-data/adapters M 1197 294 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/settlement.rs crates/aether-data/adapters M 752 14 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/usage.rs crates/aether-data/adapters M 49 49 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/usage/cleanup.rs crates/aether-data/adapters M 81 393 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/usage/counters.rs crates/aether-data/adapters M 69 12 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/usage/http_capture.rs crates/aether-data/adapters M 80 151 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/usage/tests.rs crates/aether-data/adapters M 146 32 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/users.rs crates/aether-data/adapters M 1342 125 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/video_tasks.rs crates/aether-data/adapters M 261 49 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/wallet.rs crates/aether-data/adapters M 2257 362 absent changed_after_hardening 0 +crates/aether-data/adapters/mysql/src/wallet/tests.rs crates/aether-data/adapters M 61 1 absent changed_after_hardening 0 +crates/aether-data/adapters/postgres/migrations/20260403000000_baseline.sql crates/aether-data/adapters M 1 1 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/migrations/20260814000000_add_usage_cost_reservations.sql crates/aether-data/adapters A 43 0 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/migrations/20260815000000_add_usage_request_admissions.sql crates/aether-data/adapters A 25 0 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/migrations/20260816000000_add_usage_policy_user_foreign_keys.sql crates/aether-data/adapters A 25 0 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/migrations/20260821120000_enforce_payment_gateway_order_uniqueness.sql crates/aether-data/adapters A 19 0 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/migrations/20260821130000_add_user_security_version.sql crates/aether-data/adapters A 5 0 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/migrations/20260822000000_purge_request_candidate_sensitive_diagnostics.sql crates/aether-data/adapters A 16 0 absent changed_after_hardening 0 +crates/aether-data/adapters/postgres/migrations/20260822010000_purge_usage_and_video_task_sensitive_diagnostics.sql crates/aether-data/adapters A 98 0 absent changed_after_hardening 0 +crates/aether-data/adapters/postgres/migrations/20260822020000_purge_background_task_sensitive_diagnostics.sql crates/aether-data/adapters A 43 0 absent changed_after_hardening 0 +crates/aether-data/adapters/postgres/migrations/20260827000000_purge_plaintext_stripe_client_secrets.sql crates/aether-data/adapters A 11 0 absent changed_after_hardening 0 +crates/aether-data/adapters/postgres/migrations/20260827010000_purge_legacy_payment_callback_payloads.sql crates/aether-data/adapters A 6 0 absent changed_after_hardening 0 +crates/aether-data/adapters/postgres/migrations/20260827020000_purge_video_task_prompts.sql crates/aether-data/adapters A 5 0 absent changed_after_hardening 0 +crates/aether-data/adapters/postgres/migrations/20260827030000_purge_identity_oauth_raw_userinfo.sql crates/aether-data/adapters A 6 0 absent changed_after_hardening 0 +crates/aether-data/adapters/postgres/migrations/20260827040000_expand_proxy_password_ciphertext.sql crates/aether-data/adapters A 3 0 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/migrations/20260827050000_anonymize_deleted_user_history.sql crates/aether-data/adapters A 362 0 present changed_after_hardening 38 +crates/aether-data/adapters/postgres/migrations/20260829000000_harden_deleted_user_history_anonymization.sql crates/aether-data/adapters A 444 0 absent changed_after_hardening 0 +crates/aether-data/adapters/postgres/migrations/20260831000000_enforce_ldap_config_singleton.sql crates/aether-data/adapters A 34 0 present changed_after_hardening 0 +crates/aether-data/adapters/postgres/migrations/20260831010000_add_proxy_node_tunnel_generation.sql crates/aether-data/adapters A 20 0 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/migrations/20260831030000_add_usage_counter_delta_tunnel_generation.sql crates/aether-data/adapters A 2 0 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/migrations/20260901000000_expand_gemini_file_mapping_metadata.sql crates/aether-data/adapters A 6 0 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/migrations/20260903010000_reset_legacy_oauth_email_verification.sql crates/aether-data/adapters A 10 0 absent changed_after_hardening 0 +crates/aether-data/adapters/postgres/src/auth.rs crates/aether-data/adapters M 588 96 present unchanged_at_HEAD 27 +crates/aether-data/adapters/postgres/src/auth_modules.rs crates/aether-data/adapters M 241 74 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/src/background_tasks.rs crates/aether-data/adapters M 12 6 present unchanged_at_HEAD 4 +crates/aether-data/adapters/postgres/src/billing.rs crates/aether-data/adapters M 117 2 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/src/candidate_selection.rs crates/aether-data/adapters M 101 5 present unchanged_at_HEAD 1 +crates/aether-data/adapters/postgres/src/candidates.rs crates/aether-data/adapters M 254 236 present changed_after_hardening 20 +crates/aether-data/adapters/postgres/src/gemini_file_mappings.rs crates/aether-data/adapters M 173 0 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/src/management_tokens.rs crates/aether-data/adapters M 364 109 present unchanged_at_HEAD 7 +crates/aether-data/adapters/postgres/src/migrations.rs crates/aether-data/adapters M 33 0 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/src/oauth_providers.rs crates/aether-data/adapters M 111 9 present unchanged_at_HEAD 3 +crates/aether-data/adapters/postgres/src/pool.rs crates/aether-data/adapters M 48 7 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/src/provider_catalog.rs crates/aether-data/adapters M 232 78 present unchanged_at_HEAD 2 +crates/aether-data/adapters/postgres/src/proxy_nodes.rs crates/aether-data/adapters M 550 134 present changed_after_hardening 20 +crates/aether-data/adapters/postgres/src/settlement.rs crates/aether-data/adapters M 677 7 present unchanged_at_HEAD 6 +crates/aether-data/adapters/postgres/src/usage/cleanup.rs crates/aether-data/adapters M 141 462 present unchanged_at_HEAD 1 +crates/aether-data/adapters/postgres/src/usage/mod.rs crates/aether-data/adapters M 296 212 present unchanged_at_HEAD 39 +crates/aether-data/adapters/postgres/src/usage/queries/find_by_id_sql.sql crates/aether-data/adapters M 7 5 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/src/usage/queries/find_by_request_id_sql.sql crates/aether-data/adapters M 7 5 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/src/usage/queries/list_recent_usage_audits_prefix.sql crates/aether-data/adapters M 7 5 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/src/usage/queries/list_usage_audits_prefix.sql crates/aether-data/adapters M 7 5 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/src/usage/queries/upsert_first_byte_sql.sql crates/aether-data/adapters M 4 0 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/src/usage/tests.rs crates/aether-data/adapters M 233 28 present unchanged_at_HEAD 0 +crates/aether-data/adapters/postgres/src/users.rs crates/aether-data/adapters M 1503 61 present changed_after_hardening 67 +crates/aether-data/adapters/postgres/src/video_tasks.rs crates/aether-data/adapters M 157 7 present unchanged_at_HEAD 2 +crates/aether-data/adapters/postgres/src/wallet.rs crates/aether-data/adapters M 2525 391 present unchanged_at_HEAD 26 +crates/aether-data/adapters/sqlite/migrations/20260814000000_add_usage_cost_reservations.sql crates/aether-data/adapters A 34 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260815000000_add_usage_request_admissions.sql crates/aether-data/adapters A 21 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260816000000_add_usage_policy_user_foreign_keys.sql crates/aether-data/adapters A 117 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260821120000_enforce_payment_gateway_order_uniqueness.sql crates/aether-data/adapters A 19 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260821130000_add_user_security_version.sql crates/aether-data/adapters A 5 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260822000000_purge_request_candidate_sensitive_diagnostics.sql crates/aether-data/adapters A 16 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260822010000_purge_usage_and_video_task_sensitive_diagnostics.sql crates/aether-data/adapters A 121 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260822020000_purge_background_task_sensitive_diagnostics.sql crates/aether-data/adapters A 43 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260827000000_purge_plaintext_stripe_client_secrets.sql crates/aether-data/adapters A 13 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260827010000_purge_legacy_payment_callback_payloads.sql crates/aether-data/adapters A 6 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260827020000_purge_video_task_prompts.sql crates/aether-data/adapters A 5 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260827030000_purge_identity_oauth_raw_userinfo.sql crates/aether-data/adapters A 6 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260827040000_expand_proxy_password_ciphertext.sql crates/aether-data/adapters A 2 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260827050000_anonymize_deleted_user_history.sql crates/aether-data/adapters A 444 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260829000000_harden_deleted_user_history_anonymization.sql crates/aether-data/adapters A 449 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260831000000_enforce_ldap_config_singleton.sql crates/aether-data/adapters A 11 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260831010000_add_proxy_node_tunnel_generation.sql crates/aether-data/adapters A 20 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260831020000_enforce_proxy_node_endpoint_uniqueness.sql crates/aether-data/adapters A 9 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260831030000_add_usage_counter_delta_tunnel_generation.sql crates/aether-data/adapters A 2 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/migrations/20260903010000_reset_legacy_oauth_email_verification.sql crates/aether-data/adapters A 8 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/auth.rs crates/aether-data/adapters M 957 108 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/auth_modules.rs crates/aether-data/adapters M 385 93 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/background_tasks.rs crates/aether-data/adapters M 26 9 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/billing.rs crates/aether-data/adapters M 215 3 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/candidate_selection.rs crates/aether-data/adapters M 103 7 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/candidates.rs crates/aether-data/adapters M 204 118 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/gemini_file_mappings.rs crates/aether-data/adapters M 236 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/management_tokens.rs crates/aether-data/adapters M 616 97 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/migrations.rs crates/aether-data/adapters M 127 0 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/oauth_providers.rs crates/aether-data/adapters M 188 26 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/provider_catalog.rs crates/aether-data/adapters M 391 72 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/proxy_nodes.rs crates/aether-data/adapters M 1487 308 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/settlement.rs crates/aether-data/adapters M 849 14 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/usage.rs crates/aether-data/adapters M 71 61 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/usage/cleanup.rs crates/aether-data/adapters M 73 374 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/usage/counters.rs crates/aether-data/adapters M 333 13 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/usage/http_capture.rs crates/aether-data/adapters M 76 147 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/usage/tests.rs crates/aether-data/adapters M 355 96 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/users.rs crates/aether-data/adapters M 2946 198 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/video_tasks.rs crates/aether-data/adapters M 263 9 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/wallet.rs crates/aether-data/adapters M 2331 389 absent changed_after_hardening 0 +crates/aether-data/adapters/sqlite/src/wallet/tests.rs crates/aether-data/adapters M 4336 363 absent changed_after_hardening 0 +crates/aether-data/contracts/Cargo.toml crates/aether-data/contracts M 6 0 present unchanged_at_HEAD 0 +crates/aether-data/contracts/src/repository/auth.rs crates/aether-data/contracts M 339 13 present unchanged_at_HEAD 13 +crates/aether-data/contracts/src/repository/auth_modules.rs crates/aether-data/contracts M 148 5 present changed_after_hardening 14 +crates/aether-data/contracts/src/repository/background_tasks/types.rs crates/aether-data/contracts M 421 2 present unchanged_at_HEAD 61 +crates/aether-data/contracts/src/repository/billing/mod.rs crates/aether-data/contracts M 20 2 present unchanged_at_HEAD 0 +crates/aether-data/contracts/src/repository/billing/replacement.rs crates/aether-data/contracts A 200 0 present unchanged_at_HEAD 2 +crates/aether-data/contracts/src/repository/billing/types.rs crates/aether-data/contracts M 200 2 present unchanged_at_HEAD 14 +crates/aether-data/contracts/src/repository/billing/usage_policy.rs crates/aether-data/contracts A 1059 0 present unchanged_at_HEAD 4 +crates/aether-data/contracts/src/repository/candidates/mod.rs crates/aether-data/contracts M 8 5 present changed_after_hardening 3 +crates/aether-data/contracts/src/repository/candidates/types.rs crates/aether-data/contracts M 1655 20 present changed_after_hardening 213 +crates/aether-data/contracts/src/repository/gemini_file_mappings.rs crates/aether-data/contracts M 145 0 present unchanged_at_HEAD 1 +crates/aether-data/contracts/src/repository/management_tokens.rs crates/aether-data/contracts M 434 2 present unchanged_at_HEAD 33 +crates/aether-data/contracts/src/repository/oauth_providers.rs crates/aether-data/contracts M 536 5 present changed_after_hardening 16 +crates/aether-data/contracts/src/repository/provider_catalog/mod.rs crates/aether-data/contracts M 4 3 present unchanged_at_HEAD 0 +crates/aether-data/contracts/src/repository/provider_catalog/types.rs crates/aether-data/contracts M 691 24 present unchanged_at_HEAD 61 +crates/aether-data/contracts/src/repository/proxy_nodes.rs crates/aether-data/contracts M 486 8 present unchanged_at_HEAD 32 +crates/aether-data/contracts/src/repository/settlement/mod.rs crates/aether-data/contracts M 7 2 present unchanged_at_HEAD 0 +crates/aether-data/contracts/src/repository/settlement/types.rs crates/aether-data/contracts M 504 1 present unchanged_at_HEAD 0 +crates/aether-data/contracts/src/repository/usage/compression.rs crates/aether-data/contracts A 58 0 present unchanged_at_HEAD 0 +crates/aether-data/contracts/src/repository/usage/metadata_policy.rs crates/aether-data/contracts A 1541 0 present unchanged_at_HEAD 86 +crates/aether-data/contracts/src/repository/usage/mod.rs crates/aether-data/contracts M 12 7 present unchanged_at_HEAD 0 +crates/aether-data/contracts/src/repository/usage/policy.rs crates/aether-data/contracts M 642 0 present unchanged_at_HEAD 83 +crates/aether-data/contracts/src/repository/usage/types.rs crates/aether-data/contracts M 129 32 present unchanged_at_HEAD 6 +crates/aether-data/contracts/src/repository/users.rs crates/aether-data/contracts M 659 15 present changed_after_hardening 16 +crates/aether-data/contracts/src/repository/video_tasks/types.rs crates/aether-data/contracts M 384 4 present unchanged_at_HEAD 36 +crates/aether-data/contracts/src/repository/wallet/snapshot.rs crates/aether-data/contracts M 71 2 present unchanged_at_HEAD 8 +crates/aether-data/contracts/src/repository/wallet/types.rs crates/aether-data/contracts M 2900 26 present unchanged_at_HEAD 76 +crates/aether-data/runtime/schema/bootstrap/postgres/001_types_and_tables.sql crates/aether-data/runtime M 9 4 present unchanged_at_HEAD 0 +crates/aether-data/runtime/schema/bootstrap/postgres/003_constraints.sql crates/aether-data/runtime M 30 0 present unchanged_at_HEAD 0 +crates/aether-data/runtime/schema/bootstrap/postgres/004_indexes.sql crates/aether-data/runtime M 8 0 present unchanged_at_HEAD 0 +crates/aether-data/runtime/schema/bootstrap/postgres/005_foreign_keys.sql crates/aether-data/runtime M 0 60 present unchanged_at_HEAD 0 +crates/aether-data/runtime/schema/bootstrap/postgres/100_usage_capture.sql crates/aether-data/runtime M 78 0 present unchanged_at_HEAD 0 +crates/aether-data/runtime/schema/drivers/mysql/baseline/004_proxy_nodes.sql crates/aether-data/runtime M 1 1 absent changed_after_hardening 0 +crates/aether-data/runtime/schema/drivers/postgres/baseline/001_types_and_tables.sql crates/aether-data/runtime M 1 1 present unchanged_at_HEAD 0 +crates/aether-data/runtime/schema/generated/mysql/baseline/001_identity.sql crates/aether-data/runtime M 2 0 absent changed_after_hardening 0 +crates/aether-data/runtime/schema/generated/mysql/baseline/003_auth_config.sql crates/aether-data/runtime M 3 1 absent changed_after_hardening 0 +crates/aether-data/runtime/schema/generated/mysql/baseline/004_proxy_nodes.sql crates/aether-data/runtime M 4 2 absent changed_after_hardening 0 +crates/aether-data/runtime/schema/generated/mysql/baseline/005_wallet_billing.sql crates/aether-data/runtime M 2 5 absent changed_after_hardening 0 +crates/aether-data/runtime/schema/generated/mysql/baseline/006_usage.sql crates/aether-data/runtime M 37 0 absent changed_after_hardening 0 +crates/aether-data/runtime/schema/generated/postgres/baseline/001_identity.sql crates/aether-data/runtime M 2 0 present unchanged_at_HEAD 0 +crates/aether-data/runtime/schema/generated/postgres/baseline/003_auth_config.sql crates/aether-data/runtime M 2 0 present unchanged_at_HEAD 0 +crates/aether-data/runtime/schema/generated/postgres/baseline/004_proxy_nodes.sql crates/aether-data/runtime M 3 1 present unchanged_at_HEAD 0 +crates/aether-data/runtime/schema/generated/postgres/baseline/005_wallet_billing.sql crates/aether-data/runtime M 1 4 present unchanged_at_HEAD 0 +crates/aether-data/runtime/schema/generated/postgres/baseline/006_usage.sql crates/aether-data/runtime M 39 0 present unchanged_at_HEAD 0 +crates/aether-data/runtime/schema/generated/sqlite/baseline/001_identity.sql crates/aether-data/runtime M 2 0 absent changed_after_hardening 0 +crates/aether-data/runtime/schema/generated/sqlite/baseline/003_auth_config.sql crates/aether-data/runtime M 3 1 absent changed_after_hardening 0 +crates/aether-data/runtime/schema/generated/sqlite/baseline/004_proxy_nodes.sql crates/aether-data/runtime M 3 1 absent changed_after_hardening 0 +crates/aether-data/runtime/schema/generated/sqlite/baseline/005_wallet_billing.sql crates/aether-data/runtime M 1 4 absent changed_after_hardening 0 +crates/aether-data/runtime/schema/generated/sqlite/baseline/006_usage.sql crates/aether-data/runtime M 35 0 absent changed_after_hardening 0 +crates/aether-data/runtime/schema/logical/001_identity.toml crates/aether-data/runtime M 10 0 present unchanged_at_HEAD 0 +crates/aether-data/runtime/schema/logical/003_auth_config.toml crates/aether-data/runtime M 9 0 present unchanged_at_HEAD 0 +crates/aether-data/runtime/schema/logical/004_proxy_nodes.toml crates/aether-data/runtime M 9 1 present unchanged_at_HEAD 0 +crates/aether-data/runtime/schema/logical/005_wallet_billing.toml crates/aether-data/runtime M 8 28 present changed_after_hardening 0 +crates/aether-data/runtime/schema/logical/006_usage.toml crates/aether-data/runtime M 145 0 present unchanged_at_HEAD 0 +crates/aether-data/runtime/src/backend/maintenance.rs crates/aether-data/runtime M 86 0 present changed_after_hardening 21 +crates/aether-data/runtime/src/backend/mod.rs crates/aether-data/runtime M 8 3 present changed_after_hardening 1 +crates/aether-data/runtime/src/backend/referrals.rs crates/aether-data/runtime M 3699 349 present changed_after_hardening 23 +crates/aether-data/runtime/src/backend/sqlite.rs crates/aether-data/runtime M 51 2 absent changed_after_hardening 0 +crates/aether-data/runtime/src/backend/system.rs crates/aether-data/runtime M 4 3 present changed_after_hardening 0 +crates/aether-data/runtime/src/backend/system/mysql.rs crates/aether-data/runtime M 34 1 absent changed_after_hardening 0 +crates/aether-data/runtime/src/backend/system/postgres.rs crates/aether-data/runtime M 30 1 present unchanged_at_HEAD 0 +crates/aether-data/runtime/src/backend/system/sqlite.rs crates/aether-data/runtime M 34 1 absent changed_after_hardening 0 +crates/aether-data/runtime/src/lifecycle/bootstrap/postgres.rs crates/aether-data/runtime M 4 1 present changed_after_hardening 0 +crates/aether-data/runtime/src/lifecycle/export.rs crates/aether-data/runtime M 582 2 present changed_after_hardening 37 +crates/aether-data/runtime/src/lifecycle/export/mysql.rs crates/aether-data/runtime M 207 1 absent changed_after_hardening 0 +crates/aether-data/runtime/src/lifecycle/export/postgres.rs crates/aether-data/runtime M 215 2 present changed_after_hardening 2 +crates/aether-data/runtime/src/lifecycle/export/sqlite.rs crates/aether-data/runtime M 207 1 absent changed_after_hardening 0 +crates/aether-data/runtime/src/lifecycle/export/tests.rs crates/aether-data/runtime M 886 24 present changed_after_hardening 0 +crates/aether-data/runtime/src/lifecycle/migrate/tests.rs crates/aether-data/runtime M 754 8 present changed_after_hardening 0 +crates/aether-data/runtime/src/repository/auth/memory.rs crates/aether-data/runtime M 1235 105 present changed_after_hardening 106 +crates/aether-data/runtime/src/repository/auth/mod.rs crates/aether-data/runtime M 2 2 present changed_after_hardening 0 +crates/aether-data/runtime/src/repository/auth_modules/memory.rs crates/aether-data/runtime M 270 24 present unchanged_at_HEAD 7 +crates/aether-data/runtime/src/repository/auth_modules/mod.rs crates/aether-data/runtime M 2 2 present changed_after_hardening 0 +crates/aether-data/runtime/src/repository/background_tasks/memory.rs crates/aether-data/runtime M 6 3 present unchanged_at_HEAD 3 +crates/aether-data/runtime/src/repository/billing/memory.rs crates/aether-data/runtime M 178 3 present unchanged_at_HEAD 5 +crates/aether-data/runtime/src/repository/candidates/memory.rs crates/aether-data/runtime M 208 55 present changed_after_hardening 29 +crates/aether-data/runtime/src/repository/gemini_file_mappings/memory.rs crates/aether-data/runtime M 209 0 present unchanged_at_HEAD 8 +crates/aether-data/runtime/src/repository/management_tokens/memory.rs crates/aether-data/runtime M 344 103 present unchanged_at_HEAD 16 +crates/aether-data/runtime/src/repository/management_tokens/mod.rs crates/aether-data/runtime M 4 4 present changed_after_hardening 0 +crates/aether-data/runtime/src/repository/oauth_providers/memory.rs crates/aether-data/runtime M 150 23 present unchanged_at_HEAD 3 +crates/aether-data/runtime/src/repository/oauth_providers/mod.rs crates/aether-data/runtime M 4 2 present changed_after_hardening 0 +crates/aether-data/runtime/src/repository/provider_catalog/memory.rs crates/aether-data/runtime M 123 53 present unchanged_at_HEAD 10 +crates/aether-data/runtime/src/repository/provider_catalog/mod.rs crates/aether-data/runtime M 4 3 present changed_after_hardening 0 +crates/aether-data/runtime/src/repository/provider_oauth.rs crates/aether-data/runtime M 49 7 present unchanged_at_HEAD 0 +crates/aether-data/runtime/src/repository/proxy_nodes/memory.rs crates/aether-data/runtime M 366 29 present unchanged_at_HEAD 51 +crates/aether-data/runtime/src/repository/settlement/memory.rs crates/aether-data/runtime M 960 20 present unchanged_at_HEAD 12 +crates/aether-data/runtime/src/repository/system.rs crates/aether-data/runtime M 1 0 present unchanged_at_HEAD 0 +crates/aether-data/runtime/src/repository/usage/memory.rs crates/aether-data/runtime M 182 260 present unchanged_at_HEAD 38 +crates/aether-data/runtime/src/repository/usage/memory/tests.rs crates/aether-data/runtime M 76 6 present unchanged_at_HEAD 0 +crates/aether-data/runtime/src/repository/usage/mod.rs crates/aether-data/runtime M 29 27 present changed_after_hardening 2 +crates/aether-data/runtime/src/repository/users/memory.rs crates/aether-data/runtime M 2261 384 present unchanged_at_HEAD 58 +crates/aether-data/runtime/src/repository/users/mod.rs crates/aether-data/runtime M 6 2 present changed_after_hardening 0 +crates/aether-data/runtime/src/repository/video_tasks/memory.rs crates/aether-data/runtime M 265 46 present unchanged_at_HEAD 10 +crates/aether-data/runtime/src/repository/wallet/memory.rs crates/aether-data/runtime M 2448 175 present changed_after_hardening 78 +crates/aether-data/runtime/src/repository/wallet/mod.rs crates/aether-data/runtime M 30 14 present changed_after_hardening 0 +crates/aether-data/schema/src/lib.rs crates/aether-data/schema M 24 0 present changed_after_hardening 2 +crates/aether-gateway/frontdoor/src/body.rs crates/aether-gateway M 289 36 present unchanged_at_HEAD 5 +crates/aether-gateway/frontdoor/src/lib.rs crates/aether-gateway M 1 3 present unchanged_at_HEAD 0 +crates/aether-gateway/frontdoor/src/middleware/cf_headers.rs crates/aether-gateway M 2 17 present unchanged_at_HEAD 0 +crates/aether-gateway/tunnel/src/embedded/protocol.rs crates/aether-gateway M 9 8 present unchanged_at_HEAD 0 +crates/aether-gateway/tunnel/src/hub.rs crates/aether-gateway M 24 1 present unchanged_at_HEAD 0 +crates/aether-gateway/tunnel/src/relay.rs crates/aether-gateway M 4 0 present unchanged_at_HEAD 0 +crates/aether-http/Cargo.toml crates/aether-http M 2 0 present unchanged_at_HEAD 0 +crates/aether-http/src/client.rs crates/aether-http M 3 1 present unchanged_at_HEAD 0 +crates/aether-http/src/dns.rs crates/aether-http A 91 0 present changed_after_hardening 0 +crates/aether-http/src/header_security.rs crates/aether-http A 184 0 present changed_after_hardening 0 +crates/aether-http/src/lib.rs crates/aether-http M 9 0 present changed_after_hardening 0 +crates/aether-http/src/response_body.rs crates/aether-http A 146 0 present unchanged_at_HEAD 0 +crates/aether-model-fetch/Cargo.toml crates/aether-model-fetch M 3 2 present unchanged_at_HEAD 0 +crates/aether-model-fetch/src/logic.rs crates/aether-model-fetch M 41 2 present changed_after_hardening 0 +crates/aether-model-fetch/src/strategy.rs crates/aether-model-fetch M 632 75 present changed_after_hardening 41 +crates/aether-model-fetch/src/transport.rs crates/aether-model-fetch M 22 5 present changed_after_hardening 0 +crates/aether-oauth/src/core/error.rs crates/aether-oauth M 371 10 present unchanged_at_HEAD 38 +crates/aether-oauth/src/core/flow.rs crates/aether-oauth M 148 5 present unchanged_at_HEAD 17 +crates/aether-oauth/src/core/mod.rs crates/aether-oauth M 1 1 present unchanged_at_HEAD 1 +crates/aether-oauth/src/core/token.rs crates/aether-oauth M 37 1 present unchanged_at_HEAD 5 +crates/aether-oauth/src/identity/adapter.rs crates/aether-oauth M 247 10 present unchanged_at_HEAD 26 +crates/aether-oauth/src/identity/providers/custom_oidc.rs crates/aether-oauth M 148 4 present unchanged_at_HEAD 11 +crates/aether-oauth/src/identity/providers/linuxdo.rs crates/aether-oauth M 48 1 present unchanged_at_HEAD 6 +crates/aether-oauth/src/lib.rs crates/aether-oauth M 2 2 present unchanged_at_HEAD 1 +crates/aether-oauth/src/network/context.rs crates/aether-oauth M 34 1 present unchanged_at_HEAD 0 +crates/aether-oauth/src/network/executor.rs crates/aether-oauth M 82 7 present unchanged_at_HEAD 7 +crates/aether-oauth/src/provider/account.rs crates/aether-oauth M 195 6 present unchanged_at_HEAD 31 +crates/aether-oauth/src/provider/providers/generic.rs crates/aether-oauth M 276 24 present changed_after_hardening 18 +crates/aether-oauth/src/provider/providers/kiro.rs crates/aether-oauth M 219 13 present unchanged_at_HEAD 36 +crates/aether-oauth/src/provider/providers/mod.rs crates/aether-oauth M 5 3 present changed_after_hardening 0 +crates/aether-provider/pool/Cargo.toml crates/aether-provider M 1 0 present unchanged_at_HEAD 0 +crates/aether-provider/pool/src/lib.rs crates/aether-provider M 28 1 present unchanged_at_HEAD 2 +crates/aether-provider/pool/src/providers/antigravity.rs crates/aether-provider M 0 2 present unchanged_at_HEAD 0 +crates/aether-provider/pool/src/providers/chatgpt_web.rs crates/aether-provider M 0 1 present unchanged_at_HEAD 0 +crates/aether-provider/pool/src/providers/codex.rs crates/aether-provider M 0 3 present unchanged_at_HEAD 0 +crates/aether-provider/pool/src/providers/gemini_cli.rs crates/aether-provider M 0 1 present unchanged_at_HEAD 0 +crates/aether-provider/pool/src/providers/kiro.rs crates/aether-provider M 36 8 present unchanged_at_HEAD 4 +crates/aether-provider/pool/src/providers/windsurf.rs crates/aether-provider M 0 1 present unchanged_at_HEAD 0 +crates/aether-provider/pool/src/quota_refresh.rs crates/aether-provider M 29 2 present unchanged_at_HEAD 2 +crates/aether-provider/transport/Cargo.toml crates/aether-provider M 2 1 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/agent_identity.rs crates/aether-provider M 74 8 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/auth.rs crates/aether-provider M 107 11 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/diagnostics.rs crates/aether-provider M 140 21 present unchanged_at_HEAD 2 +crates/aether-provider/transport/src/gemini_cli/auth.rs crates/aether-provider M 12 1 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/gemini_cli/request.rs crates/aether-provider M 19 1 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/gemini_files/mod.rs crates/aether-provider M 66 2 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/generic_oauth/mod.rs crates/aether-provider M 37 2 present changed_after_hardening 0 +crates/aether-provider/transport/src/grok.rs crates/aether-provider M 41 1 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/headers.rs crates/aether-provider M 146 2 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/kiro/auth.rs crates/aether-provider M 60 2 present unchanged_at_HEAD 6 +crates/aether-provider/transport/src/kiro/credentials.rs crates/aether-provider M 1 0 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/kiro/headers.rs crates/aether-provider M 30 4 present unchanged_at_HEAD 12 +crates/aether-provider/transport/src/kiro/mod.rs crates/aether-provider M 4 1 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/kiro/request.rs crates/aether-provider M 42 2 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/kiro/url.rs crates/aether-provider M 25 3 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/network.rs crates/aether-provider M 83 2 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/oauth_refresh/mod.rs crates/aether-provider M 288 26 present unchanged_at_HEAD 15 +crates/aether-provider/transport/src/openai_image/mod.rs crates/aether-provider M 41 1 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/provider_types.rs crates/aether-provider M 0 7 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/request_url/mod.rs crates/aether-provider M 142 13 present unchanged_at_HEAD 12 +crates/aether-provider/transport/src/same_format_provider/mod.rs crates/aether-provider M 120 4 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/snapshot.rs crates/aether-provider M 196 14 present unchanged_at_HEAD 6 +crates/aether-provider/transport/src/snapshot_mapping.rs crates/aether-provider M 79 25 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/standard/mod.rs crates/aether-provider M 102 3 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/url.rs crates/aether-provider M 101 7 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/vertex/auth.rs crates/aether-provider M 220 49 present unchanged_at_HEAD 9 +crates/aether-provider/transport/src/vertex/context.rs crates/aether-provider M 72 4 present unchanged_at_HEAD 3 +crates/aether-provider/transport/src/vertex/mod.rs crates/aether-provider M 3 2 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/vertex/url.rs crates/aether-provider M 90 6 present unchanged_at_HEAD 2 +crates/aether-provider/transport/src/video/mod.rs crates/aether-provider M 39 1 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/windsurf.rs crates/aether-provider M 6 1 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/windsurf/cascade.rs crates/aether-provider M 74 4 present unchanged_at_HEAD 0 +crates/aether-provider/transport/src/windsurf/proto.rs crates/aether-provider M 26 3 present unchanged_at_HEAD 0 +crates/aether-runtime/base/Cargo.toml crates/aether-runtime M 1 0 present unchanged_at_HEAD 0 +crates/aether-runtime/base/src/admission.rs crates/aether-runtime M 53 19 present unchanged_at_HEAD 0 +crates/aether-runtime/base/src/tracing.rs crates/aether-runtime M 102 7 present unchanged_at_HEAD 0 +crates/aether-runtime/state/src/lib.rs crates/aether-runtime M 967 3 present changed_after_hardening 2 +crates/aether-runtime/state/src/memory.rs crates/aether-runtime M 426 0 present unchanged_at_HEAD 11 +crates/aether-runtime/state/src/redis/client.rs crates/aether-runtime M 54 1 present unchanged_at_HEAD 3 +crates/aether-runtime/state/src/redis/runtime.rs crates/aether-runtime M 175 0 present unchanged_at_HEAD 0 +crates/aether-testing/integration/src/bin/capacity_curve_baseline.rs crates/aether-testing M 7 8 present unchanged_at_HEAD 0 +crates/aether-testing/integration/src/bin/failure_recovery_baseline.rs crates/aether-testing M 5 8 present unchanged_at_HEAD 0 +crates/aether-testing/integration/src/bin/gateway_pressure_seed.rs crates/aether-testing M 152 15 present unchanged_at_HEAD 3 +crates/aether-testing/integration/src/bin/gateway_tunnel_stream_baseline.rs crates/aether-testing M 5 6 present unchanged_at_HEAD 0 +crates/aether-testing/integration/src/bin/llm_stream_stability_baseline.rs crates/aether-testing M 4 5 present unchanged_at_HEAD 0 +crates/aether-testing/integration/src/bin/multi_instance_admission_baseline.rs crates/aether-testing M 7 11 present unchanged_at_HEAD 0 +crates/aether-testing/integration/src/bin/multi_instance_owner_relay_baseline.rs crates/aether-testing M 99 6 present unchanged_at_HEAD 2 +crates/aether-testing/integration/src/bin/usage_aux_counter_hotspot_baseline.rs crates/aether-testing M 7 2 present unchanged_at_HEAD 0 +crates/aether-testing/integration/tests/responses_websocket_e2e.rs crates/aether-testing M 194 1 present changed_after_hardening 0 +crates/aether-testing/testkit/Cargo.toml crates/aether-testing M 2 0 present unchanged_at_HEAD 0 +crates/aether-testing/testkit/src/lib.rs crates/aether-testing M 4 1 present unchanged_at_HEAD 0 +crates/aether-testing/testkit/src/tunnel.rs crates/aether-testing M 28 0 present unchanged_at_HEAD 0 +crates/aether-usage/runtime/src/body_capture.rs crates/aether-usage M 216 6 present changed_after_hardening 15 +crates/aether-usage/runtime/src/lib.rs crates/aether-usage M 10 7 present changed_after_hardening 0 +crates/aether-usage/runtime/src/record.rs crates/aether-usage M 2 1 present unchanged_at_HEAD 0 +crates/aether-usage/runtime/src/report.rs crates/aether-usage M 129 9 present changed_after_hardening 5 +crates/aether-usage/runtime/src/request_metadata.rs crates/aether-usage M 35 97 present changed_after_hardening 7 +crates/aether-usage/runtime/src/runtime.rs crates/aether-usage M 19 7 present unchanged_at_HEAD 1 +crates/aether-usage/runtime/src/settlement.rs crates/aether-usage M 388 17 present unchanged_at_HEAD 1 +crates/aether-usage/runtime/src/worker.rs crates/aether-usage M 51 3 present unchanged_at_HEAD 0 +crates/aether-usage/runtime/src/write.rs crates/aether-usage M 71 107 present changed_after_hardening 28 +crates/aether-video-tasks-core/Cargo.toml crates/aether-video-tasks-core M 1 0 present unchanged_at_HEAD 0 +crates/aether-video-tasks-core/src/gemini.rs crates/aether-video-tasks-core M 99 32 present unchanged_at_HEAD 13 +crates/aether-video-tasks-core/src/lib.rs crates/aether-video-tasks-core M 4 1 present unchanged_at_HEAD 0 +crates/aether-video-tasks-core/src/openai.rs crates/aether-video-tasks-core M 101 38 present unchanged_at_HEAD 16 +crates/aether-video-tasks-core/src/read_side.rs crates/aether-video-tasks-core M 51 4 present unchanged_at_HEAD 1 +crates/aether-video-tasks-core/src/service.rs crates/aether-video-tasks-core M 186 37 present unchanged_at_HEAD 0 +crates/aether-video-tasks-core/src/snapshot.rs crates/aether-video-tasks-core M 70 2 present unchanged_at_HEAD 9 +crates/aether-video-tasks-core/src/store_backend.rs crates/aether-video-tasks-core M 518 24 present changed_after_hardening 11 +crates/aether-video-tasks-core/src/store_registry.rs crates/aether-video-tasks-core M 10 1 present unchanged_at_HEAD 4 +crates/aether-video-tasks-core/src/types.rs crates/aether-video-tasks-core M 70 2 present unchanged_at_HEAD 10 +deploy.sh root-config M 33 3 present unchanged_at_HEAD 0 +docker-compose.single-node.yml root-config M 8 1 present changed_after_hardening 0 +docker-compose.yml root-config M 21 11 present changed_after_hardening 0 +frontend/package-lock.json frontend/package-lock.json M 210 147 present changed_after_hardening 0 +frontend/package.json frontend/package.json M 6 6 present changed_after_hardening 0 +frontend/src/App.vue frontend/src M 25 32 present unchanged_at_HEAD 0 +frontend/src/api/__tests__/auth-turnstile.spec.ts frontend/src M 23 0 present unchanged_at_HEAD 0 +frontend/src/api/__tests__/client.spec.ts frontend/src M 101 4 present unchanged_at_HEAD 0 +frontend/src/api/admin-payments.ts frontend/src M 16 1 present unchanged_at_HEAD 1 +frontend/src/api/admin.ts frontend/src M 4 2 present unchanged_at_HEAD 0 +frontend/src/api/async-tasks.ts frontend/src M 8 0 present unchanged_at_HEAD 0 +frontend/src/api/auth.ts frontend/src M 16 5 present unchanged_at_HEAD 0 +frontend/src/api/billing.ts frontend/src M 30 0 present unchanged_at_HEAD 0 +frontend/src/api/client.ts frontend/src M 128 43 present changed_after_hardening 10 +frontend/src/api/endpoints/types/provider.ts frontend/src M 8 0 present changed_after_hardening 0 +frontend/src/api/me.ts frontend/src M 1 0 present unchanged_at_HEAD 0 +frontend/src/api/oauth.ts frontend/src M 3 3 present unchanged_at_HEAD 0 +frontend/src/api/proxy-nodes.ts frontend/src M 2 2 present unchanged_at_HEAD 0 +frontend/src/api/wallet.ts frontend/src M 3 4 present unchanged_at_HEAD 0 +frontend/src/components/common/UpdateDialog.vue frontend/src M 4 2 present unchanged_at_HEAD 0 +frontend/src/components/common/VersionButton.vue frontend/src M 4 2 present unchanged_at_HEAD 0 +frontend/src/features/auth/components/LoginDialog.vue frontend/src M 3 4 present unchanged_at_HEAD 0 +frontend/src/features/auth/components/RegisterDialog.vue frontend/src M 19 3 present unchanged_at_HEAD 0 +frontend/src/features/auth/components/__tests__/RegisterDialog.spec.ts frontend/src M 1 0 present unchanged_at_HEAD 0 +frontend/src/features/auth/components/__tests__/RegisterDialog.turnstile.spec.ts frontend/src M 1 0 present unchanged_at_HEAD 0 +frontend/src/features/auth/utils/__tests__/loginRedirect.spec.ts frontend/src M 10 0 present unchanged_at_HEAD 0 +frontend/src/features/auth/utils/loginRedirect.ts frontend/src M 5 3 present unchanged_at_HEAD 0 +frontend/src/features/pool/components/PoolAccountBatchDialog.vue frontend/src M 5 8 present unchanged_at_HEAD 0 +frontend/src/features/providers/components/EndpointConditionEditor.vue frontend/src M 1 0 present unchanged_at_HEAD 1 +frontend/src/features/providers/components/EndpointFormDialog.vue frontend/src M 68 16 present unchanged_at_HEAD 14 +frontend/src/features/providers/components/OAuthAccountDialog.vue frontend/src M 14 2 present unchanged_at_HEAD 0 +frontend/src/features/providers/components/ProviderBatchActionDialog.vue frontend/src M 2 2 present unchanged_at_HEAD 0 +frontend/src/features/providers/components/ProviderDetailHeader.vue frontend/src M 6 4 present unchanged_at_HEAD 0 +frontend/src/features/providers/components/ProviderMobileCard.vue frontend/src M 6 4 present changed_after_hardening 0 +frontend/src/features/providers/components/ProviderTableRow.vue frontend/src M 6 4 present changed_after_hardening 0 +frontend/src/features/providers/components/__tests__/endpoint-rule-condition.secret-markers.spec.ts frontend/src A 56 0 present unchanged_at_HEAD 0 +frontend/src/features/providers/components/__tests__/endpoint-secret-markers.spec.ts frontend/src A 21 0 present unchanged_at_HEAD 0 +frontend/src/features/providers/components/endpoint-rule-condition.ts frontend/src M 13 3 present unchanged_at_HEAD 2 +frontend/src/features/providers/components/endpoint-secret-markers.ts frontend/src A 20 0 present unchanged_at_HEAD 3 +frontend/src/features/usage/components/HorizontalRequestTimeline.vue frontend/src M 7 2 present changed_after_hardening 0 +frontend/src/features/usage/components/__tests__/HorizontalRequestTimeline.spec.ts frontend/src M 16 0 present changed_after_hardening 0 +frontend/src/features/users/components/UserPlanDialog.vue frontend/src M 4 4 present unchanged_at_HEAD 0 +frontend/src/features/wallet/components/WalletOpsDrawer.vue frontend/src M 7 3 present changed_after_hardening 0 +frontend/src/layouts/MainLayout.vue frontend/src M 4 10 present changed_after_hardening 0 +frontend/src/mocks/handler.ts frontend/src M 4 5 present unchanged_at_HEAD 1 +frontend/src/router/guards/__tests__/authGuard.spec.ts frontend/src A 37 0 present unchanged_at_HEAD 0 +frontend/src/router/guards/__tests__/homeGuard.spec.ts frontend/src M 7 0 present unchanged_at_HEAD 0 +frontend/src/router/guards/authGuard.ts frontend/src M 4 0 present unchanged_at_HEAD 0 +frontend/src/router/guards/homeGuard.ts frontend/src M 4 2 present unchanged_at_HEAD 0 +frontend/src/router/index.ts frontend/src M 7 4 present unchanged_at_HEAD 1 +frontend/src/stores/__tests__/auth.spec.ts frontend/src M 44 1 present changed_after_hardening 0 +frontend/src/stores/auth.ts frontend/src M 75 11 present changed_after_hardening 13 +frontend/src/utils/__tests__/billingEntitlements.spec.ts frontend/src A 158 0 present unchanged_at_HEAD 0 +frontend/src/utils/__tests__/crossTabRefresh.spec.ts frontend/src M 217 15 present changed_after_hardening 0 +frontend/src/utils/__tests__/navigationSecurity.spec.ts frontend/src A 67 0 present changed_after_hardening 0 +frontend/src/utils/__tests__/oauth-icons.spec.ts frontend/src A 33 0 present unchanged_at_HEAD 0 +frontend/src/utils/__tests__/paymentUrl.spec.ts frontend/src A 29 0 present unchanged_at_HEAD 0 +frontend/src/utils/__tests__/sanitize.spec.ts frontend/src M 14 0 present unchanged_at_HEAD 0 +frontend/src/utils/billingEntitlements.ts frontend/src A 115 0 present changed_after_hardening 2 +frontend/src/utils/crossTabRefresh.ts frontend/src M 158 16 present changed_after_hardening 5 +frontend/src/utils/navigationSecurity.ts frontend/src A 61 0 present changed_after_hardening 8 +frontend/src/utils/oauth-icons.ts frontend/src M 34 1 present unchanged_at_HEAD 4 +frontend/src/utils/paymentUrl.ts frontend/src A 12 0 present unchanged_at_HEAD 4 +frontend/src/utils/sanitize.ts frontend/src M 27 2 present unchanged_at_HEAD 1 +frontend/src/views/admin/AsyncTasks.vue frontend/src M 55 11 present changed_after_hardening 1 +frontend/src/views/admin/BillingPlansManagement.vue frontend/src M 878 46 present unchanged_at_HEAD 5 +frontend/src/views/admin/ProxyNodes.vue frontend/src M 2 1 present unchanged_at_HEAD 0 +frontend/src/views/admin/ReferralManagement.vue frontend/src M 0 1 present changed_after_hardening 0 +frontend/src/views/admin/Users.vue frontend/src M 30 3 present unchanged_at_HEAD 0 +frontend/src/views/admin/WalletsManagement.vue frontend/src M 8 4 present changed_after_hardening 0 +frontend/src/views/admin/system-settings/AggregateImportDialog.vue frontend/src M 1 1 present unchanged_at_HEAD 0 +frontend/src/views/admin/system-settings/BasicConfigSection.vue frontend/src M 1 0 present unchanged_at_HEAD 0 +frontend/src/views/admin/system-settings/ConfigImportDialog.vue frontend/src M 1 1 present unchanged_at_HEAD 0 +frontend/src/views/admin/system-settings/UsersImportDialog.vue frontend/src M 2 2 present unchanged_at_HEAD 0 +frontend/src/views/admin/system-settings/__tests__/useSystemConfig.spec.ts frontend/src M 1 1 present changed_after_hardening 0 +frontend/src/views/admin/system-settings/composables/useSystemConfig.ts frontend/src M 1 1 present unchanged_at_HEAD 0 +frontend/src/views/public/AuthCallback.vue frontend/src M 6 15 present unchanged_at_HEAD 0 +frontend/src/views/public/__tests__/AuthCallback.spec.ts frontend/src A 79 0 present unchanged_at_HEAD 0 +frontend/src/views/public/guide/components/MarkdownViewer.vue frontend/src M 2 2 present unchanged_at_HEAD 2 +frontend/src/views/user/BillingPlans.vue frontend/src M 48 23 present changed_after_hardening 0 +frontend/src/views/user/Settings.vue frontend/src M 17 16 present changed_after_hardening 1 +frontend/src/views/user/WalletCenter.vue frontend/src M 48 8 present changed_after_hardening 1 +generate_keys.sh root-config M 9 0 present changed_after_hardening 0 +install.sh root-config M 934 144 present changed_after_hardening 0 +tests/deploy_state_safety_test.sh tests A 59 0 present unchanged_at_HEAD 0 +tests/install_archive_safety_test.sh tests A 49 0 present unchanged_at_HEAD 0 +tests/install_container_runtime_security_test.sh tests A 203 0 present changed_after_hardening 0 +tests/install_current_release_link_test.sh tests A 193 0 present unchanged_at_HEAD 0 +tests/install_local_bundle_safety_test.sh tests A 68 0 present unchanged_at_HEAD 0 +tests/install_privileged_write_safety_test.sh tests A 180 0 present unchanged_at_HEAD 0 +tests/install_source_trust_test.sh tests A 36 0 present unchanged_at_HEAD 0 +tests/release_supply_chain_test.sh tests A 68 0 present changed_after_hardening 0 +tests/tunnel_installer_config_security_test.sh tests A 221 0 present unchanged_at_HEAD 6 +tests/update_compose_safety_test.sh tests A 134 0 present unchanged_at_HEAD 0 +update.sh root-config M 19 5 present unchanged_at_HEAD 0 diff --git a/docs/operations/security-hardening-compatibility-review.md b/docs/operations/security-hardening-compatibility-review.md new file mode 100644 index 000000000..38e56abf5 --- /dev/null +++ b/docs/operations/security-hardening-compatibility-review.md @@ -0,0 +1,98 @@ +# 安全加固兼容性复核(2026-09-07) + +后续扩展到全仓的检查范围、新发现、逐文件清单及限制见 `security-hardening-full-audit.md` 和 `security-hardening-audit-manifest.tsv`。 + +## 范围 + +本轮针对 `579f2c7cc`(2026-09-04)及其后续修复进行复核。该提交涉及 1019 个文件,混合了安全边界、订阅计费、数据持久化和运行时变更,不适合整体 revert。 + +本轮重点检查管理端脱敏与编辑往返、Provider OAuth 默认配置、已有 DNS/代理兼容性修复、管理员错误诊断、SMTP/LDAP/OAuth 配置、导入导出及请求大小限制。以下是已确认的问题及处理,不代表对全部文件逐行审计或对生产环境的完整验证。 + +## 新增:管理员按需查看规则原值 + +- 端点管理的请求/响应规则工具栏增加“查看原值”。只读弹窗展示服务端已保存的请求头、请求体和响应头规则,不覆盖未保存的草稿。 +- 新接口:`GET /api/admin/endpoints/{endpoint_id}/rules/reveal`。 +- 仅管理员可访问;管理令牌需要 `admin:endpoints_manage:admin`,只读和普通写权限不足。 +- 返回 `Cache-Control: no-store`、`Pragma: no-cache`,记录 `admin_endpoint_rules_revealed` 审计事件。响应仅包含三组规则,不附带端点的其他配置或凭据。 +- 关闭弹窗、切换端点或卸载组件时取消请求并清除明文;旧请求的迟到结果不会重新显示。 + +## 本轮修复的六类误伤 + +| 问题 | 影响 | 处理 | +| --- | --- | --- | +| 普通协议头也全部脱敏 | `Content-Type`、`User-Agent`、版本/客户端信息及相应条件无法正常查看;配置导出也受影响 | 恢复明确的常用非凭据头的显示;认证头、Cookie 和未知自定义头继续默认隐藏。旧版本返回的保留标记仍可正常保存 | +| 重复规则无法恢复原值 | 同一头或路径配置多条条件规则后,保存或重排可能将原值变成 `***` | 在相同操作和目标范围内按可区分的规则结构匹配;未修改的重复规则保留原顺序与原值。无法唯一定位的脱敏编辑明确报错,禁止静默写入占位符 | +| 嵌套条件组丢失原值 | 多层 `all`/`any` 在修改其他条件或重排后,内部脱敏值无法恢复 | 增加递归条件组匹配、未修改条件组往返保留和保留值校验 | +| URL 类型判断过宽 | `image_url` 等对象/数组被变成 `null`,内嵌 `data:` 图片 URL 也受影响,继续编辑可能破坏请求规则 | 保留结构化 URL 和内嵌数据;递归处理网络 URL,继续隐藏并在保存时恢复网络凭据 | +| 保存后的编辑状态没有同步 | 后台返回脱敏数据后,界面仍保留提交前明文,持续显示“未保存” | 使用保存响应重新建立编辑基准;保存期间的新编辑不被覆盖,关闭后清除草稿,忽略跨弹窗的旧响应 | +| Gemini CLI 默认 OAuth 被误移除 | 未额外配置环境变量时,原有默认客户端不能授权或刷新 | 恢复加固前内置 native-app 客户端的配套默认值;显式配置优先,自定义客户端 ID 仍必须提供自己的凭据,调试输出继续脱敏 | + +明确可直接显示的头包括 `Accept`/编码/语言、`Content-Type`/编码、`Cache-Control`、`User-Agent`、Anthropic/OpenAI 协议版本与 beta 标记,以及明确列出的 `x-stainless-*` 客户端运行时字段;不是按前缀放行任意自定义头。 + +## 补充修复:显式 `full` 请求记录被禁用 + +原则:安全处理应约束未授权访问、未配置时的默认行为和真实凭据泄露,不应静默覆盖管理员明确启用的功能。 + +本次确认不是单纯的前端隐藏,而是同一链路上的多重清空: + +- `request_record_level=full` 在运行时被强制解析为 `basic`。 +- 独立 HTTP 审计存储的输入投影清除了所有请求头、正文、正文引用和采集状态。 +- 内存仓库每次更新都丢弃正文与引用;PostgreSQL 的单条写入把正文 blob 同步改成删除,并拒绝 HTTP 审计内容。 + +处理:恢复显式 `full`(含旧配置名 `request_log_level`)的采集与独立存储,保留当前配置名优先;恢复请求、上游请求、上游响应和客户端响应各方向已有的采集内容,流转状态更新不再无条件删除它们。PostgreSQL 正文仍写入原有压缩 blob 表,HTTP 头与引用仍写入独立审计表,不重新塞回计费主表。 + +保留:缺失、无效配置和读取配置失败时默认 `basic`;`basic` 不记录正文;OAuth 令牌交换等内部凭据请求不采集正文;认证头及未知自定义头继续脱敏;正文引用仍校验所属请求和字段;管理端权限、审计及无缓存策略不变。普通同格式非流式响应仍只保存一份原有响应体,前端沿用回退显示,不制造重复的客户端副本。 + +新增回归覆盖配置解析、内存生命周期、流式/非流式管理端正文读取、旧正文大小配置不覆盖 `full`、PostgreSQL 单条/批量写入与正文读回、前端按需加载与无正文时的展示。真实数据库测试使用本机临时隔离 PostgreSQL,不访问现有业务数据库。 + +此修复只能恢复之后新采集的记录;此前未采集或已删除的正文无法由代码补回。运行时沿用原有的 30 秒采集策略缓存,刚切换记录级别时需等待缓存刷新。 + +扩大执行原有、默认忽略的 PostgreSQL 测试时,另发现 5 个旧用例的测试数据/类型与当前 schema 不兼容:4 个使用超过 `varchar(36)` 限制的 Provider Key ID,1 个直接按 `f64` 解码 `NUMERIC` 列。它们并非本次正文修复引入,也不作为本次已通过项;未修改相关业务逻辑或测试夹具。 + +## 继续复核:视频任务链路的四类回归 + +继续沿“业务字段被当作诊断数据清空”“读取投影与更新条件不一致”检查,另确认以下四类问题,均可定位到本次加固新增的清空或校验条件,不是用户配置错误: + +| 问题 | 实际影响 | 本轮修复 | +| --- | --- | --- | +| 任务业务信息被清空 | 提示词、用户名和客户端 Key 名称丢失,管理页显示空白或 `Unknown`;提示词被写成 `NULL` 还与 PostgreSQL 的非空约束冲突,可能直接阻止任务入库 | 保留这些明确用于任务展示的业务字段,不再作为敏感诊断一律删除 | +| 下载地址被清空或破坏签名 | OpenAI 任务的 `video_url` 被无条件丢弃;Gemini 地址仅保留 `alt=media`,使下载签名、有效期等参数失效 | 保留 HTTP(S) 产物地址及完整查询串,不重排重复参数、不重编码签名;继续拒绝非法协议和 URL userinfo,移除 fragment | +| Gemini 完成结果和重载丢失 | 完成轮询直接清空元数据;数据库重建与加密文件加载再次清空结果 URI,出现任务已完成但无法取得视频 | 仅保留下载所需的最小结果 URI,不保留上游完整响应;从数据库的业务字段重建必要的提示词、尺寸信息和结果 URI,保证再次保存不会丢失 | +| PostgreSQL 轮询更新条件自相矛盾 | 领取任务时不读出时长、分辨率、宽高比和尺寸,而加固后的更新要求这些不可变字段逐项相等,导致正常轮询更新被拒绝、任务停留在处理中 | 领取时读回必要业务字段,保留原有归属/身份校验和并发领取锁,不靠放宽更新条件绕过问题 | + +保留原有播放/下载接口与鉴权语义:签名产物链接是有权限用户访问任务结果所需的业务数据,不等同于可随意删除的调试凭据。OpenAI 直接下载不附带 Provider 认证头;Gemini 仍通过网关文件接口访问,并校验上游地址同源后才附加 Provider Key。私网/保留地址拦截、跨域凭据隔离、任务归属校验及管理操作审计不撤销。 + +任务数据库仍不保存完整请求体、原始错误消息或含认证头的传输快照;本地文件仍使用原有认证加密格式,调试输出继续脱敏。回归覆盖任务保存后重读、签名参数顺序与编码、加密文件重载、管理列表/详情、带审计的下载,以及轮询前后提示词和尺寸不丢失。 + +真实 PostgreSQL 回归使用临时 Unix socket 实例,并从正式迁移后的 schema 复制会话隔离的任务表。OpenAI/Gemini 两种任务均验证了入库、领取、正常完成更新、拒绝不匹配的尺寸更新和重新查询签名地址;不连接现有业务数据库。 + +这四类是本轮已经确认的新增回归,不代表对混合提交全部 1019 个文件给出“零遗漏”保证。原有用户策略字段停用早于本次加固,不据此回退;后台任务和缓存中的裁剪未发现足以确认本次功能回归的调用链,不做猜测性恢复。数据库仍保存的历史值可以重新读取;已经写空、未入库或已过期的资源不能凭本次源码修复重建。 + +## 已存在的后续修复:保留,不重复撤销 + +- `522b97905`:已移除普通 Provider 代理的 DNS 地址过滤和相关白名单设置。 +- `696273122`:已恢复 Antigravity 默认 native-app OAuth 配套凭据;本轮将同类遗漏补到 Gemini CLI。 +- `a26680f46`:已恢复旧 SMTP 密码迁移。 +- `062e111c0`:已恢复管理员查看上游错误诊断的能力,同时保留普通用户侧脱敏。 +- `14f96c9fa` 等:已修复脱敏后的密钥健康状态摘要,不将其退回旧实现。 + +## 保留的安全边界 + +- 管理员/普通用户与管理令牌的权限隔离;凭据查看接口的审计和无缓存要求。 +- 凭据加密存储及凭据与目标/身份绑定;未知头和真实敏感字段的默认脱敏。 +- 登录 OAuth、支付、隧道中继等独立网络边界,TLS 校验和隧道防重放。 +- 有界请求/响应缓冲、备份恢复模式、导入校验、计费与配额一致性;本轮没有因“安全加固”标签撤回这些功能。 + +## 回归覆盖与使用限制 + +回归用例覆盖规则投影/恢复、重复与嵌套规则、占位符拒绝、结构化 URL、保存期间的并发编辑、弹窗关闭/切换时的迟到响应、接口权限/审计/无缓存,以及 Gemini CLI 和 Antigravity 的默认配置与显式覆盖。 + +早期兼容性修复的阶段性验证记录(最终全量结果见 `security-hardening-full-audit.md`): + +- 视频数据契约 12 项、视频核心 33 项(含加密文件重载)、内存视频仓库 11 项、PostgreSQL 视频仓库 9 项、Provider 视频传输 9 项均通过。 +- 新增的真实 PostgreSQL 回归单独执行通过,同时覆盖 OpenAI 和 Gemini;临时数据库实例已停止并清理。 +- 网关 `video` 101 项、`async_task::` 20 项、`usage` 302 项、端点规则原值查看 3 项、管理令牌权限 28 项均通过;筛选结果可能重叠,不合计为独立用例总数。 +- 前端 199 个测试文件、1411 项测试通过;补齐保存测试的类型夹具后,该文件 4 项测试及 ESLint 再次通过;Rust 格式检查和 `git diff --check` 通过。 +- 阶段性检查曾发现 461 条既有类型诊断,与未修改 HEAD 的同依赖基线一致。2026-09-07 的“全部修复”续轮已修正这些诊断,并将 `npm run type-check` 接入 `vue-tsc -b --force --pretty false`;当前真实全量类型检查为 0 错误。最新全量测试、构建及剩余验证边界以 `security-hardening-full-audit.md` 为准,未关闭严格模式或排除测试。 + +上述修复与回归不涉及生产服务器部署或现有业务数据库更改;源码提交、推送不代表生产环境已经部署或完成验证。若历史版本已经把真实值覆盖为字面量 `***`,查看接口不能重建丢失的原值,需重新填写或从可靠备份恢复。 diff --git a/docs/operations/security-hardening-full-audit.md b/docs/operations/security-hardening-full-audit.md new file mode 100644 index 000000000..1e8a3a7fd --- /dev/null +++ b/docs/operations/security-hardening-full-audit.md @@ -0,0 +1,134 @@ +# 安全加固全仓兼容性复核(2026-09-07) + +## 范围与方法 + +- 加固基线:`579f2c7cc`(2026-09-04);复核时 HEAD:`a5c3699ae`。保留已有未提交的兼容性修复,不整体回退该混合提交,也不撤销后续修复。 +- 历史提交涉及 1019 个文件,其中 907 个路径仍存在,112 个已在后续重构中移除。逐路径清单见 `security-hardening-audit-manifest.tsv`;已移除的旧数据库适配器、未发布迁移等不凭历史清单重新引入。 +- 对复核时 Git 跟踪的 2843 个 Rust、TypeScript、Vue、JavaScript、Python、SQL 和 Shell 源文件进行清单、读取与模式扫描,并检查加固差异、后续修订及关键生产调用链。正文清空、无条件禁用、脱敏、URL 拒绝、权限与数据归属等是重点搜索模式。 +- 清单中的 `heuristic_added_risk_lines` 是启发式候选行数,含初始化代码和内联测试,**不是 Bug 数、漏洞数或逐行人工审计完成标记**。全仓扫描、模块复核、回归测试是不同的覆盖层次,不等于人工精读所有源文件,也不承诺零遗漏。 +- 判断标准:恢复管理员明确启用的功能和必要业务数据;保留未授权访问阻断、默认保护、凭据隔离和有界资源使用。不因一条旧测试要求“所有内容都为空”,就把正常功能重新禁用。 + +之前的规则、OAuth、`full` 记录及视频四类修复详见 `security-hardening-compatibility-review.md`。本报告只把本次扩展检查的新发现单独计数。 + +## 本次新增确认并修复的两类加固回归 + +### 1. 正文的“压缩保留”被替换成删除 + +涉及 `crates/aether-data/adapters/postgres/src/usage/cleanup.rs`。 + +原本分为详细正文、压缩正文、请求头、计费记录等独立保留周期。加固后,详细正文清理不再压缩迁移,而是删除正文、独立 blob 与审计引用;选择条件还包含已经独立存储的正文。因此采用默认 7/30 天设置时,明确启用 `full` 后保存的正文也会在约 7 天后提前失去,而不是按压缩正文的 30 天保留。 + +修复内容: + +- 详细正文到期只处理主表中的旧内联/压缩数据,将四个方向的正文迁入独立 gzip blob,并保留 HTTP 审计引用;不选择已经迁出的正文反复处理。 +- 旧 metadata 中的正文引用按当前请求和字段校验后迁入审计表,删除旧 metadata 键,但不删除实际正文。无效、跨请求及跨字段引用不恢复。 +- 每条迁移在事务中锁定并重读来源行。正文写入或引用更新失败时回滚,不先清空原值;其他 metadata 内容保留。 +- 预览统计与实际清理选择条件一致。压缩正文到期仍删除;明确选择“立即清理正文”的操作仍执行删除,未把隐私清理功能改成永远保留。 + +真实 PostgreSQL 回归使用正式迁移后的 schema 与会话隔离的临时表,覆盖四个方向的内联 JSON、旧 gzip、独立 blob、旧引用迁移、外部请求引用拒绝、7/30 天区间、过期删除、重复清理幂等、显式即时删除和中途写入失败后的事务回滚。不会读取或修改业务数据库。 + +同时修复了该迁移路径原有的 SQL 错误:`usage` 表没有 `updated_at` 列,旧更新语句却写入它,真实 schema 下会导致迁移失败。这个错误在加固前已经存在,**不混算成第三类加固新增回归**。 + +### 2. 合法支付会话链接的 fragment 被误拒绝 + +涉及 `frontend/src/utils/paymentUrl.ts`,调用方包括钱包充值和订阅购买。 + +加固把包含 `#fragment` 的 HTTPS 支付链接一律拒绝。支付会话链接可能依赖该片段,不能将其等同于脚本协议或 URL 内嵌凭据。现在保留原有片段、查询参数及编码,同时继续拒绝非 HTTPS、相对地址、反斜杠和 URL 用户名/密码。 + +对应工具函数新增会话片段和编码保留测试,危险协议及凭据注入用例继续执行。服务器端支付 API 基址的同源、TLS 和无片段限制不变;没有放宽 webhook 验签、支付金额、订单归属或实际支付状态校验。此次不进行真实扣款测试。 + +## 按模块的复核边界 + +| 模块 | 复核内容与处理 | +| --- | --- | +| 公共入口、认证、会话、管理令牌 | 核对路由分类、认证缓存刷新、跨节点撤权和令牌权限目录;保留身份头防伪、用户/管理员隔离及敏感动作的权限要求。 | +| 端点配置及编辑往返 | 保留前序规则原值查看、重复/嵌套规则恢复、占位符拒绝和并发编辑保护,避免只恢复显示而破坏再次保存。 | +| 普通 Provider 连接与代理 | 对照后续 DNS/FakeIP/SOCKS 修复,不重新引入已移除的普通 Provider 地址过滤;凭据专用连接与普通业务代理分开判断。 | +| OAuth、SMTP、LDAP 与密钥 | 检查默认客户端、显式覆盖、凭据迁移与目标绑定;保留 Gemini CLI/Antigravity 兼容性恢复及既有 SMTP 修复,不撤销传输凭据隔离。 | +| 请求记录、候选诊断及保留策略 | 核对配置读取、采集、投影、单条/批量写入、读取和清理链路;修复本报告的提前删除,保留显式 `full` 及必要诊断,不把原始正文重新塞回计费主表。 | +| 视频与异步任务 | 保留前序提示词/展示名、签名产物 URL、Gemini 结果 URI、数据库领取字段修复;身份不可变条件、任务归属、加密文件和下载凭据隔离不撤销。 | +| 模型、调度、配额和池状态 | 对照模型获取、手动模型、健康摘要、候选选择、额度预留及状态流转;不回退混入该提交的订阅/计费功能。 | +| 钱包、订阅、兑换与支付 | 检查业务 URL 到前端跳转链路并修复 fragment 误拦;交易归属、金额、并发更新、幂等及回调校验维持原边界。 | +| 流式、WebSocket、格式转换 | 检查请求限制、认证头转发、取消/重定向、会话延续和正文捕获之间的关系;修正两处与恢复后的日志语义冲突的旧断言,不退回禁用功能的实现。 | +| 系统导入、导出、备份和恢复 | 区分交互式导入、恢复备份、回滚模式;保留加密备份、用途绑定、导入锁和凭据保留规则,不把安全导出误当作完整灾备。 | +| 数据契约、PostgreSQL 和迁移 | 检查脱敏投影与业务字段消费者是否矛盾;旧驱动不复活,使用真实迁移 schema 验证正文及视频的关键写入链路。 | +| 管理前端与外链 | 复核脱敏状态、权限显示、URL/导航校验、支付调用方,并执行全部前端测试与真实项目类型检查。 | +| 安装、更新、容器和发布流程 | 执行归档、链接、目录、写入目标、来源信任、compose 参数及发布供应链的隔离 Shell 测试;不运行实际部署。 | + +## “全部修复”续轮完成项 + +以下问题已按根因处理,不再作为上一轮的未解决清单。历史类型问题、测试夹具与辅助器缺陷不统一归因为本次加固,功能恢复也不以撤销所有保护为代价。 + +### 1. 真实前端类型检查及相关运行时缺陷 + +- 修复原先 116 个文件中的 461 条类型诊断,将 `npm run type-check` 改为 `vue-tsc -b --force --pretty false`,确实检查引用的应用和工具项目,不再依赖顶层空项目的成功退出。 +- 按现有接口契约补齐响应泛型、可空字段、配置 schema、图表时间轴、用户角色、事件参数及测试夹具。保留严格检查、ES2021、未知数据边界与动态配置扩展字段,未通过 `any`、忽略诊断、排除测试或降低配置绕过错误。 +- 修复请求缓存和登录校验在 fetcher 同步抛错后不能正确清理 in-flight 状态的问题;失败后可按原有退避策略重试,仍隔离不同身份和旧请求。 +- 修复 Provider 余额重试的迟到响应覆盖新加载结果,以及卸载后更新状态的问题;新增回归同时覆盖合法的零余额与签到失败值,不因真假值判断隐藏正常数据。 +- 修复钱包模板中的刷新调用,并在用户密钥删除、路由配置保存等异步流程中固定操作目标,避免确认期间切换页面后作用到另一个对象。 + +### 2. 手动保留天数取了更激进的截止点 + +`usage_cleanup_window_with_override` 原来使用 `max` 合并截止时间,与界面“在策略内取更保守时间点”的设计相反:删除条件是记录时间早于截止点,选更晚的时间会扩大删除范围。 + +现改为每一保留层级分别取 `min`。详细正文、压缩正文、请求头、完整日志均不短于既有策略,也不短于本次手动指定天数。测试覆盖 0、5、30、180、400 天及“选中记录是原策略子集”的关系。显式立即清理模式不受此改动影响。 + +### 3. 可信原始数据库恢复可显式保留凭据 + +原始 JSONL 导入及数据库复制曾无条件替换密码哈希、API Key 和管理令牌,导致可信恢复/迁移也不能继续使用原凭据。新增 CLI `--preserve-credentials`,仅由操作者显式选择,不能由导入文件中的字段自行启用: + +```sh +aether-gateway --database-driver postgres --postgres-url "$TARGET_DATABASE_URL" import --input /path/to/trusted.jsonl --preserve-credentials +aether-gateway copy --source-driver postgres --source-url "$SOURCE_DATABASE_URL" --target-driver postgres --target-url "$TARGET_DATABASE_URL" --preserve-credentials +``` + +- 不加该参数时仍默认撤销导入的身份凭据,并在操作前给出警告;原有库函数入口也保持该默认值。 +- 显式保留时,只保留导入文件中用户密码、API Key 和管理令牌原有字段/状态;不会把原本停用的凭据重新启用。 +- 导入的登录会话仍撤销,代理隧道仍重置在线状态和代际。外键、身份归属、OAuth 绑定一致性、输入大小及事务校验不变。 +- 仅适用于可信且由操作者控制的备份/来源。目标实例须使用与来源兼容的加密配置;此开关不会解密、重新加密或重建已经丢失的值。复制链接的 TLS 默认保护不变。 +- 这是原始数据库工具的选项,不改变 HTTP 配置导入权限,也不替代已有的加密备份恢复模式。操作示例会写入指定目标,执行前须自行核对目标并备份;本轮仅在临时隔离库中验证。 + +真实 PostgreSQL 回归分别验证默认撤销与显式保留后的密码哈希、密钥哈希、密文和启用状态;单元回归另覆盖管理令牌、会话及隧道边界。 + +### 4. 数据库回归夹具与测试资源回收 + +- 修正历史测试的超长 ID、NUMERIC/f64 解码、依赖预填充数据库的统计夹具,以及使用不在持久化契约内的 metadata 字段。使用真实 UUID、显式 SQL 类型转换、自建自清理数据和合法 trace 字段,未放宽生产 schema 或脱敏规则来迁就测试。 +- 保留现有 usage 导出时间戳以秒计的兼容契约,未因旧字段名包含 `_ms` 就改变导入导出单位。 +- 临时 PostgreSQL 辅助器改为 `pg_ctl stop -m fast` 并等待子进程退出,再清理自有目录;关闭失败时保留进程与目录的所有权,允许重试,不强杀后直接删数据。 +- 新增两个真实 PostgreSQL 回归,覆盖打开连接下四次停止/重启、数据保留、重复停止、关闭失败重试及析构清理。专项运行前后共享内存段清单未新增残留;未清除其他业务或历史进程的 IPC 资源。 +- 续轮全量回归进一步定位到迁移、回填测试各自复制的旧辅助器,同样在强杀后泄漏资源,导致后续网关测试无法初始化数据库。两份实现已合并到仅测试使用的 `postgres_test_support.rs`,采用相同的正常关闭与失败重试规则,另补两个回归;没有只清理环境而保留泄漏代码。 +- 用 `AETHER_REQUIRE_LOCAL_POSTGRES_TESTS=1` 强制迁移、回填和共用辅助器的真实数据库用例执行,生命周期筛选结果为 63 通过、0 失败、1 忽略;该忽略项为已在隔离数据库中单独通过的导入测试。此次完整生命周期运行前后 IPC 清单一致。 +- 辅助器支持 `AETHER_PG_CTL_BIN`,默认查找 `AETHER_POSTGRES_BIN` 同目录下的 `pg_ctl`;初始化失败也回收本次拥有的工作目录。 + +## 验证结果 + +| 验证层 | 结果 | +| --- | --- | +| Rust 工作区所有 library 与 binary 测试目标 | 最终 `cargo test --workspace --lib --bins --locked -- --test-threads=4`:65 个目标,8944 通过、0 失败、19 默认忽略;其中 41 个 library 目标 8645 通过,24 个 binary 目标 299 通过。设置 `AETHER_REQUIRE_LOCAL_POSTGRES_TESTS=1`,迁移/回填测试不能因环境问题静默跳过。 | +| 最终网关全量 | 随工作区 feature 合并执行:5124 通过,0 失败,含此前因数据库初始化失败的两项跨节点认证回归。使用仓库既有的 `RUST_MIN_STACK=16777216` 测试配置;不直接运行遗漏该配置的产物,也不改生产配置规避问题。 | +| PostgreSQL 适配器全量含真实数据库回归 | `AETHER_TEST_DATABASE_URL=<隔离库> cargo test -p aether-data-postgres --lib --locked -- --include-ignored --test-threads=1`:232 通过,0 失败,0 忽略,包含原先默认忽略的 16 项。 | +| 原始数据库导入/导出 | 设置独立的 `AETHER_TEST_POSTGRES_URL` 并执行 `lifecycle::export::tests --include-ignored`:18 通过,0 失败,0 忽略,包含迁移后数据库读取及两种凭据策略的真实往返。 | +| 临时 PostgreSQL 关闭/重启 | `aether-testkit --features postgres` 的两个真实数据库专项回归均通过;四次打开连接下重启、关闭失败重试、目录与共享内存回收均已验证。 | +| WebSocket 真实网关集成 | 最终重新执行 11 通过、0 失败,含跨连接继续对话、连续计费、长连接撤权、认证头隔离、未知字段透传、断开结算、额度重试和 PII 恢复。使用临时 PostgreSQL 与模拟上游。 | +| 可执行入口 | 299 项通过中包括网关主入口 61、隧道 185、备份恢复 CLI 10、两种 WebSocket 探针 3/4,以及压力测试种子/探针、模拟上游等入口测试。没有启动这些工具的实际生产操作。 | +| 额外入口与身份隔离集成 | `aether-data --test public_entrypoints` 3 项、`aether-gateway --test admin_unsigned_identity_headers` 1 项均重新执行通过。 | +| 前端全量与构建 | 最终 `npm run test:run`:200 个测试文件、1419 项测试通过;`npm run build:with-typecheck` 构建通过。 | +| 类型与格式 | `npm run type-check` 实际运行 `vue-tsc -b --force --pretty false`:0 错误,原先 461 条诊断均已修正;142 个改动/新增前端源码文件 ESLint 为 0 错误、0 警告。`cargo fmt --all -- --check` 和 `git diff --check` 通过。 | +| 提交前 Clippy | 本地按 `.github/workflows/rust-ci.yml` 的 Gateway、Data、其余工作区三组范围执行,均使用 `--locked` 和 `-D warnings`,全部通过;未关闭或放宽 lint 规则。 | +| 安装/发布脚本 | 10 份隔离 Shell 测试全部通过;`python3 tests/compose_database_config_test.py` 通过,只渲染配置,不启动容器。未实际安装、升级或部署。 | + +工作区默认忽略的 19 项全部另行显式执行通过:PostgreSQL 适配器 16 项、真实导入 1 项、测试辅助器 2 项。默认命令中的忽略不计作通过;工作区 feature、单包测试及过滤测试的范围不同且存在重叠,上表不累加成独立用例总数。此前的规则、完整正文单条/批量持久化和视频真实数据库回归结果保留在兼容性复核报告中。 + +中间轮次曾因旧测试辅助器留下的 IPC 残留导致 2 项网关测试和随后 10 项 WebSocket 测试初始化失败;没有把这些命令记为成功。现已修正三处辅助器的关闭路径,并只清理本轮确认创建、无连接且创建进程已退出的 11 个段,未清理此前已有的 21 个段,也未修改内核共享内存限制。 + +最终工作区、WebSocket、公开入口和身份隔离测试顺序运行前后的 IPC 清单一致,均为此前已有的 21 个段,无新增残留。单独用于数据库适配器及导入往返的临时 PostgreSQL 已正常停止并删除本次自有目录。 + +脚本范围:`tests/deploy_state_safety_test.sh`、`tests/install_archive_safety_test.sh`、`tests/install_container_runtime_security_test.sh`、`tests/install_current_release_link_test.sh`、`tests/install_local_bundle_safety_test.sh`、`tests/install_privileged_write_safety_test.sh`、`tests/install_source_trust_test.sh`、`tests/release_supply_chain_test.sh`、`tests/tunnel_installer_config_security_test.sh`、`tests/update_compose_safety_test.sh`。 + +## 交付限制 + +本轮已确认、可复现且可在本仓库修复的剩余问题均已处理,未保留已确认却未修复的本轮源码问题;这一结论不等于“所有代码及所有部署环境绝无未知缺陷”。 + +这是本地源码、静态检查与隔离回归,不是对生产配置、第三方账户或所有部署组合的认证。上述验证未执行服务器部署或改动现有业务数据库;后续源码提交、推送不代表已经部署或完成生产环境验证。 + +已经被旧代码写空、删除、未采集的正文或视频字段不能由修复自动重建;仍在数据库中的旧内联/压缩正文可在修正后的保留链路中迁移。真实第三方 OAuth、支付、对象存储和远程隧道服务未使用生产凭据进行端到端验证。 diff --git a/frontend/package.json b/frontend/package.json index d7ce7c246..1409863f2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,7 @@ "test:ui": "node --experimental-require-module --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs --ui", "test:run": "node --experimental-require-module --disable-warning=ExperimentalWarning ./node_modules/vitest/vitest.mjs run", "lint": "eslint . --fix", - "type-check": "vue-tsc --noEmit", + "type-check": "vue-tsc -b --force --pretty false", "version": "git describe --tags --always" }, "dependencies": { diff --git a/frontend/src/api/__tests__/client.spec.ts b/frontend/src/api/__tests__/client.spec.ts index 9542e359f..50f4bc99b 100644 --- a/frontend/src/api/__tests__/client.spec.ts +++ b/frontend/src/api/__tests__/client.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { AxiosAdapter, AxiosInstance, InternalAxiosRequestConfig } from 'axios' +import type { AxiosAdapter, InternalAxiosRequestConfig } from 'axios' import apiClient, { AUTH_SESSION_SIGNAL_KEY, @@ -8,10 +8,6 @@ import apiClient, { } from '@/api/client' import { cache, cachedRequest } from '@/utils/cache' -type TestableApiClient = typeof apiClient & { - client: AxiosInstance -} - describe('apiClient auth state change event', () => { beforeEach(() => { localStorage.clear() @@ -55,10 +51,10 @@ describe('apiClient auth state change event', () => { }) it('restores a session through the refresh cookie and stores the result in memory only', async () => { - const rawClient = apiClient as TestableApiClient - const previousAdapter = rawClient.client.defaults.adapter + const rawClient = apiClient['client'] + const previousAdapter = rawClient.defaults.adapter - rawClient.client.defaults.adapter = (async (config: InternalAxiosRequestConfig) => ({ + rawClient.defaults.adapter = (async (config: InternalAxiosRequestConfig) => ({ data: { access_token: 'restored-access-token' }, status: 200, statusText: 'OK', @@ -72,16 +68,16 @@ describe('apiClient auth state change event', () => { expect(localStorage.getItem('access_token')).toBeNull() expect(sessionStorage.getItem('access_token')).toBeNull() } finally { - rawClient.client.defaults.adapter = previousAdapter + rawClient.defaults.adapter = previousAdapter } }) it('does not resurrect a session when logout wins an in-flight restore', async () => { - const rawClient = apiClient as TestableApiClient - const previousAdapter = rawClient.client.defaults.adapter + const rawClient = apiClient['client'] + const previousAdapter = rawClient.defaults.adapter let resolveRefresh!: (response: Awaited>) => void - rawClient.client.defaults.adapter = (() => new Promise((resolve) => { + rawClient.defaults.adapter = (() => new Promise((resolve) => { resolveRefresh = resolve })) as AxiosAdapter @@ -100,7 +96,7 @@ describe('apiClient auth state change event', () => { await expect(restore).rejects.toThrow('Auth state changed') expect(apiClient.getToken()).toBeNull() } finally { - rawClient.client.defaults.adapter = previousAdapter + rawClient.defaults.adapter = previousAdapter } }) @@ -141,11 +137,11 @@ describe('apiClient auth state change event', () => { }) it('sends auth refresh without a request body', async () => { - const rawClient = apiClient as TestableApiClient - const previousAdapter = rawClient.client.defaults.adapter + const rawClient = apiClient['client'] + const previousAdapter = rawClient.defaults.adapter const requests: InternalAxiosRequestConfig[] = [] - rawClient.client.defaults.adapter = (async (config: InternalAxiosRequestConfig) => { + rawClient.defaults.adapter = (async (config: InternalAxiosRequestConfig) => { requests.push(config) return { data: { access_token: 'new-access-token' }, @@ -165,16 +161,16 @@ describe('apiClient auth state change event', () => { expect(requests[0].method).toBe('post') expect(requests[0].data).toBeUndefined() } finally { - rawClient.client.defaults.adapter = previousAdapter + rawClient.defaults.adapter = previousAdapter } }) it('authenticates protected gateway operational requests', async () => { - const rawClient = apiClient as TestableApiClient - const previousAdapter = rawClient.client.defaults.adapter + const rawClient = apiClient['client'] + const previousAdapter = rawClient.defaults.adapter const requests: InternalAxiosRequestConfig[] = [] - rawClient.client.defaults.adapter = (async (config: InternalAxiosRequestConfig) => { + rawClient.defaults.adapter = (async (config: InternalAxiosRequestConfig) => { requests.push(config) return { data: '', @@ -193,7 +189,7 @@ describe('apiClient auth state change event', () => { expect(requests[0].headers.Authorization).toBe('Bearer operational-access-token') expect(requests[0].headers['X-Client-Device-Id']).toBeTruthy() } finally { - rawClient.client.defaults.adapter = previousAdapter + rawClient.defaults.adapter = previousAdapter } }) }) diff --git a/frontend/src/api/__tests__/endpoint-rules.spec.ts b/frontend/src/api/__tests__/endpoint-rules.spec.ts new file mode 100644 index 000000000..3280bb073 --- /dev/null +++ b/frontend/src/api/__tests__/endpoint-rules.spec.ts @@ -0,0 +1,26 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { revealEndpointRules } from '../endpoints/endpoints' + +const client = vi.hoisted(() => ({ get: vi.fn() })) +vi.mock('../client', () => ({ default: client })) + +beforeEach(() => { + client.get.mockReset().mockResolvedValue({ data: { header_rules: [], body_rules: [], response_header_rules: [] } }) +}) + +describe('endpoint rule reveal API', () => { + it('uses the scoped route and forwards cancellation', async () => { + const controller = new AbortController() + await revealEndpointRules('endpoint/with?reserved', controller.signal) + expect(client.get).toHaveBeenCalledWith( + '/api/admin/endpoints/endpoint%2Fwith%3Freserved/rules/reveal', + { signal: controller.signal }, + ) + }) + + it('fetches each reveal without retaining a cached response', async () => { + await revealEndpointRules('endpoint-1') + await revealEndpointRules('endpoint-1') + expect(client.get).toHaveBeenCalledTimes(2) + }) +}) diff --git a/frontend/src/api/admin-wallets.ts b/frontend/src/api/admin-wallets.ts index 3b556ebeb..e50d235a2 100644 --- a/frontend/src/api/admin-wallets.ts +++ b/frontend/src/api/admin-wallets.ts @@ -215,7 +215,18 @@ export const adminWalletApi = { credited_at: string | null } }> { - const response = await apiClient.post(`/api/admin/wallets/${walletId}/recharge`, payload) + const response = await apiClient.post<{ + wallet: AdminWallet + payment_order: { + id: string + order_no: string + amount_usd: number + payment_method: string + status: string + created_at: string + credited_at: string | null + } + }>(`/api/admin/wallets/${walletId}/recharge`, payload) return response.data }, @@ -223,7 +234,10 @@ export const adminWalletApi = { wallet: AdminWallet transaction: WalletTransaction }> { - const response = await apiClient.post(`/api/admin/wallets/${walletId}/adjust`, payload) + const response = await apiClient.post<{ + wallet: AdminWallet + transaction: WalletTransaction + }>(`/api/admin/wallets/${walletId}/adjust`, payload) return response.data }, @@ -232,7 +246,11 @@ export const adminWalletApi = { refund: RefundRequest transaction: WalletTransaction }> { - const response = await apiClient.post( + const response = await apiClient.post<{ + wallet: AdminWallet + refund: RefundRequest + transaction: WalletTransaction + }>( `/api/admin/wallets/${walletId}/refunds/${refundId}/process`, {} ) @@ -244,7 +262,11 @@ export const adminWalletApi = { refund: RefundRequest transaction: WalletTransaction | null }> { - const response = await apiClient.post( + const response = await apiClient.post<{ + wallet: AdminWallet + refund: RefundRequest + transaction: WalletTransaction | null + }>( `/api/admin/wallets/${walletId}/refunds/${refundId}/fail`, payload ) @@ -256,7 +278,7 @@ export const adminWalletApi = { refundId: string, payload: RefundCompleteRequest ): Promise<{ refund: RefundRequest }> { - const response = await apiClient.post( + const response = await apiClient.post<{ refund: RefundRequest }>( `/api/admin/wallets/${walletId}/refunds/${refundId}/complete`, payload ) diff --git a/frontend/src/api/admin.ts b/frontend/src/api/admin.ts index 2d3f32b5e..86e8dc382 100644 --- a/frontend/src/api/admin.ts +++ b/frontend/src/api/admin.ts @@ -8,6 +8,11 @@ import type { ApiKeyInstallSession, InstallSessionTargetSystem, InstallTargetCli const SYSTEM_DATA_IMPORT_TIMEOUT_MS = 10 * 60 * 1000 const ALL_SYSTEM_CONFIGS_CACHE_KEY = 'admin:system:configs' +export interface AdminTimeSeriesPoint extends Record { + date: string + total_cost: number +} + export interface AdminSystemConfigItem { key: string value: unknown @@ -1594,12 +1599,12 @@ export const adminApi = { provider_name?: string }, options?: AdminAnalyticsRequestOptions - ): Promise>> { + ): Promise { const cacheKey = buildCacheKey('admin:stats:time-series', params) return cachedRequest( cacheKey, async () => { - const response = await apiClient.get>>('/api/admin/stats/time-series', { params }) + const response = await apiClient.get('/api/admin/stats/time-series', { params }) return response.data }, options?.skipCache ? 0 : 20 * 1000 diff --git a/frontend/src/api/announcements.ts b/frontend/src/api/announcements.ts index 56bc5c921..d4cda6db7 100644 --- a/frontend/src/api/announcements.ts +++ b/frontend/src/api/announcements.ts @@ -57,61 +57,61 @@ export const announcementApi = { limit?: number offset?: number }): Promise { - const response = await apiClient.get('/api/announcements', { params }) + const response = await apiClient.get('/api/announcements', { params }) return response.data }, // 获取当前有效的公告 async getActiveAnnouncements(): Promise { - const response = await apiClient.get('/api/announcements/active') + const response = await apiClient.get('/api/announcements/active') return response.data }, // 获取单个公告 async getAnnouncement(id: string): Promise { - const response = await apiClient.get(`/api/announcements/${id}`) + const response = await apiClient.get(`/api/announcements/${id}`) return response.data }, // 标记公告为已读 async markAsRead(id: string): Promise<{ message: string }> { - const response = await apiClient.patch(`/api/announcements/${id}/read-status`) + const response = await apiClient.patch<{ message: string }>(`/api/announcements/${id}/read-status`) return response.data }, // 标记所有公告为已读 async markAllAsRead(): Promise<{ message: string }> { - const response = await apiClient.post('/api/announcements/read-all') + const response = await apiClient.post<{ message: string }>('/api/announcements/read-all') return response.data }, // 获取未读公告数量 async getUnreadCount(): Promise<{ unread_count: number }> { - const response = await apiClient.get('/api/announcements/users/me/unread-count') + const response = await apiClient.get<{ unread_count: number }>('/api/announcements/users/me/unread-count') return response.data }, async getRequiredUnreadAnnouncements(): Promise { - const response = await apiClient.get('/api/announcements/users/me/required-unread') + const response = await apiClient.get('/api/announcements/users/me/required-unread') return response.data }, // 管理员方法 // 创建公告 async createAnnouncement(data: CreateAnnouncementRequest): Promise<{ id: string; title: string; message: string }> { - const response = await apiClient.post('/api/announcements', data) + const response = await apiClient.post<{ id: string; title: string; message: string }>('/api/announcements', data) return response.data }, // 更新公告 async updateAnnouncement(id: string, data: UpdateAnnouncementRequest): Promise<{ message: string }> { - const response = await apiClient.put(`/api/announcements/${id}`, data) + const response = await apiClient.put<{ message: string }>(`/api/announcements/${id}`, data) return response.data }, // 删除公告 async deleteAnnouncement(id: string): Promise<{ message: string }> { - const response = await apiClient.delete(`/api/announcements/${id}`) + const response = await apiClient.delete<{ message: string }>(`/api/announcements/${id}`) return response.data } } diff --git a/frontend/src/api/async-tasks.ts b/frontend/src/api/async-tasks.ts index 2017cdb93..97662559f 100644 --- a/frontend/src/api/async-tasks.ts +++ b/frontend/src/api/async-tasks.ts @@ -180,27 +180,27 @@ export const asyncTasksApi = { const query = searchParams.toString() const url = query ? `/api/admin/tasks?${query}` : '/api/admin/tasks' - const response = await apiClient.get(url) + const response = await apiClient.get(url) return response.data }, async getStats(): Promise { - const response = await apiClient.get('/api/admin/tasks/stats') + const response = await apiClient.get('/api/admin/tasks/stats') return response.data }, async getDetail(taskId: string): Promise { - const response = await apiClient.get(`/api/admin/tasks/${taskId}`) + const response = await apiClient.get(`/api/admin/tasks/${taskId}`) return response.data }, async getEvents(taskId: string): Promise<{ items: AsyncTaskEvent[] }> { - const response = await apiClient.get(`/api/admin/tasks/${taskId}/events`) + const response = await apiClient.get<{ items: AsyncTaskEvent[] }>(`/api/admin/tasks/${taskId}/events`) return response.data }, async cancel(taskId: string): Promise<{ id: string; status: string; message: string }> { - const response = await apiClient.post(`/api/admin/tasks/${taskId}/cancel`) + const response = await apiClient.post<{ id: string; status: string; message: string }>(`/api/admin/tasks/${taskId}/cancel`) return response.data }, @@ -213,7 +213,7 @@ export const asyncTasksApi = { }, async trigger(taskKey: string, payload: Record = {}): Promise<{ run_id: string; status: string }> { - const response = await apiClient.post(`/api/admin/tasks/${taskKey}/trigger`, payload) + const response = await apiClient.post<{ run_id: string; status: string }>(`/api/admin/tasks/${taskKey}/trigger`, payload) return response.data }, } diff --git a/frontend/src/api/audit.ts b/frontend/src/api/audit.ts index 38acacf55..d4ebd1be0 100644 --- a/frontend/src/api/audit.ts +++ b/frontend/src/api/audit.ts @@ -57,13 +57,13 @@ export const auditApi = { limit?: number offset?: number }): Promise { - const response = await apiClient.get('/api/monitoring/my-audit-logs', { params: filters }) + const response = await apiClient.get>('/api/monitoring/my-audit-logs', { params: filters }) return normalizeAuditResponse(response.data) }, // 获取所有审计日志 (管理员) async getAuditLogs(filters?: AuditFilters): Promise { - const response = await apiClient.get('/api/admin/monitoring/audit-logs', { params: filters }) + const response = await apiClient.get>('/api/admin/monitoring/audit-logs', { params: filters }) return normalizeAuditResponse(response.data) }, @@ -72,7 +72,10 @@ export const auditApi = { activities: AuditLog[] count: number }> { - const response = await apiClient.get('/api/admin/monitoring/suspicious-activities', { + const response = await apiClient.get<{ + activities: AuditLog[] + count: number + }>('/api/admin/monitoring/suspicious-activities', { params: { hours, limit } }) return response.data @@ -83,7 +86,10 @@ export const auditApi = { analysis: Record recommendations: string[] }> { - const response = await apiClient.get(`/api/admin/monitoring/user-behavior/${userId}`, { + const response = await apiClient.get<{ + analysis: Record + recommendations: string[] + }>(`/api/admin/monitoring/user-behavior/${userId}`, { params: { days } }) return response.data diff --git a/frontend/src/api/cache.ts b/frontend/src/api/cache.ts index e6b1ea3dc..ea7f46801 100644 --- a/frontend/src/api/cache.ts +++ b/frontend/src/api/cache.ts @@ -89,7 +89,7 @@ export const cacheApi = { * 获取缓存统计信息 */ async getStats(): Promise { - const response = await api.get('/api/admin/monitoring/cache/stats') + const response = await api.get<{ data: CacheStats }>('/api/admin/monitoring/cache/stats') return response.data.data }, @@ -97,7 +97,7 @@ export const cacheApi = { * 获取缓存配置 */ async getConfig(): Promise { - const response = await api.get('/api/admin/monitoring/cache/config') + const response = await api.get<{ data: CacheConfig }>('/api/admin/monitoring/cache/config') return response.data.data }, @@ -107,7 +107,7 @@ export const cacheApi = { * @param userIdentifier 用户标识符,支持:用户名、邮箱、User UUID、API Key ID */ async getUserAffinity(userIdentifier: string): Promise { - const response = await api.get(`/api/admin/monitoring/cache/affinity/${userIdentifier}`) + const response = await api.get<{ status: string; affinities: UserAffinity[] }>(`/api/admin/monitoring/cache/affinity/${userIdentifier}`) if (response.data.status === 'not_found') { return null } @@ -151,7 +151,7 @@ export const cacheApi = { * 清除所有缓存 */ async clearAllCache(): Promise<{ count: number }> { - const response = await api.delete('/api/admin/monitoring/cache') + const response = await api.delete<{ count: number }>('/api/admin/monitoring/cache') return response.data }, @@ -159,7 +159,7 @@ export const cacheApi = { * 清除指定Provider的所有缓存 */ async clearProviderCache(providerId: string): Promise<{ count: number; provider_id: string }> { - const response = await api.delete(`/api/admin/monitoring/cache/providers/${providerId}`) + const response = await api.delete<{ count: number; provider_id: string }>(`/api/admin/monitoring/cache/providers/${providerId}`) return response.data }, @@ -167,7 +167,13 @@ export const cacheApi = { * 获取缓存亲和性列表 */ async listAffinities(keyword?: string): Promise { - const response = await api.get('/api/admin/monitoring/cache/affinities', { + const response = await api.get<{ + data?: { + items?: UserAffinity[] + meta?: { total?: number } + matched_user_id?: string | null + } + }>('/api/admin/monitoring/cache/affinities', { params: keyword ? { keyword } : undefined }) const data = response.data.data ?? {} @@ -212,7 +218,7 @@ export const redisCacheApi = { * 获取 Redis 缓存分类概览 */ async getCategories(): Promise { - const response = await api.get('/api/admin/monitoring/cache/redis-keys') + const response = await api.get<{ data: RedisCacheCategoriesResponse }>('/api/admin/monitoring/cache/redis-keys') return response.data.data }, @@ -220,7 +226,7 @@ export const redisCacheApi = { * 清除指定分类的 Redis 缓存 */ async clearCategory(category: string): Promise<{ status: string; message: string; category: string; deleted_count: number }> { - const response = await api.delete(`/api/admin/monitoring/cache/redis-keys/${category}`) + const response = await api.delete<{ status: string; message: string; category: string; deleted_count: number }>(`/api/admin/monitoring/cache/redis-keys/${category}`) return response.data } } @@ -308,7 +314,7 @@ export const cacheAnalysisApi = { api_key_id?: string hours?: number }): Promise { - const response = await api.get('/api/admin/usage/cache-affinity/ttl-analysis', { params }) + const response = await api.get('/api/admin/usage/cache-affinity/ttl-analysis', { params }) return response.data }, @@ -320,7 +326,7 @@ export const cacheAnalysisApi = { api_key_id?: string hours?: number }): Promise { - const response = await api.get('/api/admin/usage/cache-affinity/hit-analysis', { params }) + const response = await api.get('/api/admin/usage/cache-affinity/hit-analysis', { params }) return response.data }, @@ -339,7 +345,7 @@ export const cacheAnalysisApi = { return cachedRequest( cacheKey, async () => { - const response = await api.get('/api/admin/usage/cache-affinity/interval-timeline', { params }) + const response = await api.get('/api/admin/usage/cache-affinity/interval-timeline', { params }) return response.data }, 30000 @@ -408,7 +414,7 @@ export const modelMappingCacheApi = { * 获取模型映射缓存统计 */ async getStats(): Promise { - const response = await api.get('/api/admin/monitoring/cache/model-mapping/stats') + const response = await api.get<{ data: ModelMappingCacheStats }>('/api/admin/monitoring/cache/model-mapping/stats') return response.data.data }, @@ -416,7 +422,7 @@ export const modelMappingCacheApi = { * 清除所有模型映射缓存 */ async clearAll(): Promise { - const response = await api.delete('/api/admin/monitoring/cache/model-mapping') + const response = await api.delete('/api/admin/monitoring/cache/model-mapping') return response.data }, @@ -424,7 +430,7 @@ export const modelMappingCacheApi = { * 清除指定模型名称的映射缓存 */ async clearByName(modelName: string): Promise { - const response = await api.delete(`/api/admin/monitoring/cache/model-mapping/${encodeURIComponent(modelName)}`) + const response = await api.delete(`/api/admin/monitoring/cache/model-mapping/${encodeURIComponent(modelName)}`) return response.data }, @@ -432,7 +438,7 @@ export const modelMappingCacheApi = { * 清除指定 Provider 和 GlobalModel 的映射缓存 */ async clearProviderModel(providerId: string, globalModelId: string): Promise { - const response = await api.delete(`/api/admin/monitoring/cache/model-mapping/provider/${providerId}/${globalModelId}`) + const response = await api.delete(`/api/admin/monitoring/cache/model-mapping/provider/${providerId}/${globalModelId}`) return response.data } } diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f26ec8724..d50733e35 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -224,7 +224,7 @@ class ApiClient { /** * 处理响应错误 */ - private async handleResponseError(error: unknown): Promise { + private async handleResponseError(error: unknown): Promise { // 请求被取消 if (axios.isCancel(error)) { return Promise.reject(error) diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index b3fc97595..dc243d9bd 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -278,14 +278,14 @@ export interface RequestDetail { end_to_end_first_byte_time_ms?: number | null created_at: string updated_at?: string | null - request_headers?: Record - request_body?: Record - provider_request_headers?: Record - provider_request_body?: Record - response_headers?: Record - client_response_headers?: Record - response_body?: Record - client_response_body?: Record + request_headers?: Record | null + request_body?: Record | null + provider_request_headers?: Record | null + provider_request_body?: Record | null + response_headers?: Record | null + client_response_headers?: Record | null + response_body?: Record | null + client_response_body?: Record | null has_request_body?: boolean has_provider_request_body?: boolean has_response_body?: boolean diff --git a/frontend/src/api/endpoints/adaptive.ts b/frontend/src/api/endpoints/adaptive.ts index 15aa3813c..20d1c7b6d 100644 --- a/frontend/src/api/endpoints/adaptive.ts +++ b/frontend/src/api/endpoints/adaptive.ts @@ -17,7 +17,13 @@ export async function toggleAdaptiveMode( rpm_limit: number | null effective_limit: number | null }> { - const response = await client.patch(`/api/admin/adaptive/keys/${keyId}/mode`, data) + const response = await client.patch<{ + message: string + key_id: string + is_adaptive: boolean + rpm_limit: number | null + effective_limit: number | null +}>(`/api/admin/adaptive/keys/${keyId}/mode`, data) return response.data } @@ -34,7 +40,13 @@ export async function setRpmLimit( rpm_limit: number previous_mode: string }> { - const response = await client.patch(`/api/admin/adaptive/keys/${keyId}/limit`, null, { + const response = await client.patch<{ + message: string + key_id: string + is_adaptive: boolean + rpm_limit: number + previous_mode: string +}>(`/api/admin/adaptive/keys/${keyId}/limit`, null, { params: { limit } }) return response.data @@ -44,7 +56,7 @@ export async function setRpmLimit( * 获取 Key 的自适应统计 */ export async function getAdaptiveStats(keyId: string): Promise { - const response = await client.get(`/api/admin/adaptive/keys/${keyId}/stats`) + const response = await client.get(`/api/admin/adaptive/keys/${keyId}/stats`) return response.data } @@ -52,6 +64,6 @@ export async function getAdaptiveStats(keyId: string): Promise { - const response = await client.delete(`/api/admin/adaptive/keys/${keyId}/learning`) + const response = await client.delete<{ message: string; key_id: string }>(`/api/admin/adaptive/keys/${keyId}/learning`) return response.data } diff --git a/frontend/src/api/endpoints/endpoints.ts b/frontend/src/api/endpoints/endpoints.ts index 67f7a2dc5..74197a114 100644 --- a/frontend/src/api/endpoints/endpoints.ts +++ b/frontend/src/api/endpoints/endpoints.ts @@ -1,11 +1,25 @@ import client from '../client' import type { ProviderEndpoint, ProxyConfig, HeaderRule, BodyRule, FormatAcceptanceConfig } from './types' +export interface ProviderEndpointRules { + header_rules: HeaderRule[] + body_rules: BodyRule[] + response_header_rules: HeaderRule[] +} + +export async function revealEndpointRules(endpointId: string, signal?: AbortSignal): Promise { + const response = await client.get( + `/api/admin/endpoints/${encodeURIComponent(endpointId)}/rules/reveal`, + { signal }, + ) + return response.data +} + /** * 获取指定 Provider 的所有 Endpoints */ export async function getProviderEndpoints(providerId: string): Promise { - const response = await client.get(`/api/admin/endpoints/providers/${providerId}/endpoints`) + const response = await client.get(`/api/admin/endpoints/providers/${providerId}/endpoints`) return response.data } @@ -13,7 +27,7 @@ export async function getProviderEndpoints(providerId: string): Promise { - const response = await client.get(`/api/admin/endpoints/${endpointId}`) + const response = await client.get(`/api/admin/endpoints/${endpointId}`) return response.data } @@ -36,7 +50,7 @@ export async function createEndpoint( format_acceptance_config?: FormatAcceptanceConfig | null } ): Promise { - const response = await client.post(`/api/admin/endpoints/providers/${providerId}/endpoints`, data) + const response = await client.post(`/api/admin/endpoints/providers/${providerId}/endpoints`, data) return response.data } @@ -57,7 +71,7 @@ export async function updateEndpoint( format_acceptance_config: FormatAcceptanceConfig | null }> ): Promise { - const response = await client.put(`/api/admin/endpoints/${endpointId}`, data) + const response = await client.put(`/api/admin/endpoints/${endpointId}`, data) return response.data } @@ -65,7 +79,7 @@ export async function updateEndpoint( * 删除 Endpoint */ export async function deleteEndpoint(endpointId: string): Promise<{ message: string; affected_keys_count: number }> { - const response = await client.delete(`/api/admin/endpoints/${endpointId}`) + const response = await client.delete<{ message: string; affected_keys_count: number }>(`/api/admin/endpoints/${endpointId}`) return response.data } @@ -75,6 +89,6 @@ export async function deleteEndpoint(endpointId: string): Promise<{ message: str export async function getDefaultBodyRules(apiFormat: string, providerType?: string): Promise<{ api_format: string; body_rules: BodyRule[] }> { const params: Record = {} if (providerType) params.provider_type = providerType - const response = await client.get(`/api/admin/endpoints/defaults/${apiFormat}/body-rules`, { params }) + const response = await client.get<{ api_format: string; body_rules: BodyRule[] }>(`/api/admin/endpoints/defaults/${apiFormat}/body-rules`, { params }) return response.data } diff --git a/frontend/src/api/endpoints/global-models.ts b/frontend/src/api/endpoints/global-models.ts index b9512ac5f..930383775 100644 --- a/frontend/src/api/endpoints/global-models.ts +++ b/frontend/src/api/endpoints/global-models.ts @@ -12,6 +12,7 @@ import type { // 重新导出路由相关类型供外部使用 export type { + GlobalModelResponse, RoutingKeyInfo, RoutingEndpointInfo, RoutingModelMapping, @@ -37,7 +38,7 @@ export async function getGlobalModels(params?: { return cachedRequest( key, async () => { - const response = await client.get('/api/admin/models/global', { params }) + const response = await client.get('/api/admin/models/global', { params }) return response.data }, cacheTtlMs, @@ -49,7 +50,7 @@ export async function getGlobalModels(params?: { */ export async function getGlobalModel(id: string): Promise { return dedupedRequest(`global-models:detail:${id}`, async () => { - const response = await client.get(`/api/admin/models/global/${id}`) + const response = await client.get(`/api/admin/models/global/${id}`) return response.data }) } @@ -58,7 +59,7 @@ export async function getGlobalModel(id: string): Promise * 创建 GlobalModel */ export async function createGlobalModel(data: GlobalModelCreate): Promise { - const response = await client.post('/api/admin/models/global', data) + const response = await client.post('/api/admin/models/global', data) return response.data } @@ -69,7 +70,7 @@ export async function updateGlobalModel( id: string, data: GlobalModelUpdate ): Promise { - const response = await client.patch(`/api/admin/models/global/${id}`, data) + const response = await client.patch(`/api/admin/models/global/${id}`, data) return response.data } @@ -89,7 +90,7 @@ export async function deleteGlobalModel( export async function batchDeleteGlobalModels( ids: string[] ): Promise<{ success_count: number; failed: Array<{ id: string; error: string }> }> { - const response = await client.post('/api/admin/models/global/batch-delete', { ids }) + const response = await client.post<{ success_count: number; failed: Array<{ id: string; error: string }> }>('/api/admin/models/global/batch-delete', { ids }) return response.data } @@ -113,7 +114,17 @@ export async function batchAssignToProviders( error: string }> }> { - const response = await client.post( + const response = await client.post<{ + success: Array<{ + provider_id: string + provider_name: string + model_id?: string + }> + errors: Array<{ + provider_id: string + error: string + }> +}>( `/api/admin/models/global/${globalModelId}/assign-to-providers`, data ) @@ -128,7 +139,7 @@ export async function getGlobalModelProviders(globalModelId: string): Promise<{ total: number }> { return dedupedRequest(`global-models:providers:${globalModelId}`, async () => { - const response = await client.get( + const response = await client.get<{ providers: ModelCatalogProviderDetail[]; total: number }>( `/api/admin/models/global/${globalModelId}/providers` ) return response.data @@ -141,7 +152,7 @@ export async function getGlobalModelProviders(globalModelId: string): Promise<{ export async function getGlobalModelRoutingPreview( globalModelId: string ): Promise { - const response = await client.get( + const response = await client.get( `/api/admin/models/global/${globalModelId}/routing` ) return response.data diff --git a/frontend/src/api/endpoints/health.ts b/frontend/src/api/endpoints/health.ts index cda285319..328dcb664 100644 --- a/frontend/src/api/endpoints/health.ts +++ b/frontend/src/api/endpoints/health.ts @@ -14,7 +14,7 @@ import type { * 获取健康状态摘要 */ export async function getHealthSummary(): Promise { - const response = await client.get('/api/admin/endpoints/health/summary') + const response = await client.get('/api/admin/endpoints/health/summary') return response.data } @@ -22,7 +22,7 @@ export async function getHealthSummary(): Promise { * 获取 Endpoint 健康状态 */ export async function getEndpointHealth(endpointId: string): Promise { - const response = await client.get(`/api/admin/endpoints/health/endpoint/${endpointId}`) + const response = await client.get(`/api/admin/endpoints/health/endpoint/${endpointId}`) return response.data } @@ -30,7 +30,7 @@ export async function getEndpointHealth(endpointId: string): Promise { - const response = await client.get(`/api/admin/endpoints/health/key/${keyId}`) + const response = await client.get(`/api/admin/endpoints/health/key/${keyId}`) return response.data } @@ -48,7 +48,15 @@ export async function recoverKeyHealth(keyId: string, apiFormat?: string): Promi is_active: boolean } }> { - const response = await client.patch(`/api/admin/endpoints/health/keys/${keyId}`, null, { + const response = await client.patch<{ + message: string + details: { + api_format?: string + health_score: number + circuit_breaker_open: boolean + is_active: boolean + } +}>(`/api/admin/endpoints/health/keys/${keyId}`, null, { params: apiFormat ? { api_format: apiFormat } : undefined }) return response.data @@ -66,7 +74,15 @@ export async function recoverAllKeysHealth(): Promise<{ endpoint_id: string }> }> { - const response = await client.patch('/api/admin/endpoints/health/keys') + const response = await client.patch<{ + message: string + recovered_count: number + recovered_keys: Array<{ + key_id: string + key_name: string + endpoint_id: string + }> +}>('/api/admin/endpoints/health/keys') return response.data } @@ -77,7 +93,7 @@ export async function getEndpointStatusMonitor(params?: { lookback_hours?: number per_format_limit?: number }): Promise { - const response = await client.get('/api/admin/endpoints/health/api-formats', { + const response = await client.get('/api/admin/endpoints/health/api-formats', { params }) return response.data @@ -90,7 +106,7 @@ export async function getPublicEndpointStatusMonitor(params?: { lookback_hours?: number per_format_limit?: number }): Promise { - const response = await client.get('/api/public/health/api-formats', { + const response = await client.get('/api/public/health/api-formats', { params }) return response.data @@ -104,7 +120,7 @@ export async function getModelStatusMonitor(params?: { model_limit?: number per_model_limit?: number }): Promise { - const response = await client.get('/api/admin/endpoints/health/models', { + const response = await client.get('/api/admin/endpoints/health/models', { params }) return response.data @@ -118,7 +134,7 @@ export async function getPublicModelStatusMonitor(params?: { model_limit?: number per_model_limit?: number }): Promise { - const response = await client.get('/api/public/health/models', { + const response = await client.get('/api/public/health/models', { params }) return response.data @@ -133,7 +149,7 @@ export async function getProviderStatusMonitor(params?: { per_provider_model_limit?: number per_model_limit?: number }): Promise { - const response = await client.get('/api/admin/endpoints/health/providers', { + const response = await client.get('/api/admin/endpoints/health/providers', { params }) return response.data @@ -146,7 +162,7 @@ export async function getHealthRelatedMonitor(params: { related_limit?: number per_item_limit?: number }): Promise { - const response = await client.get('/api/admin/endpoints/health/related', { + const response = await client.get('/api/admin/endpoints/health/related', { params }) return response.data @@ -159,7 +175,7 @@ export async function getPublicHealthRelatedMonitor(params: { related_limit?: number per_item_limit?: number }): Promise { - const response = await client.get('/api/public/health/related', { + const response = await client.get('/api/public/health/related', { params }) return response.data diff --git a/frontend/src/api/endpoints/keys.ts b/frontend/src/api/endpoints/keys.ts index 796ca190a..d4ecd13c0 100644 --- a/frontend/src/api/endpoints/keys.ts +++ b/frontend/src/api/endpoints/keys.ts @@ -37,7 +37,7 @@ export interface ModelCapabilitiesResponse { * 获取所有能力定义 */ export async function getAllCapabilities(): Promise { - const response = await client.get('/api/capabilities') + const response = await client.get<{ capabilities: CapabilityDefinition[] }>('/api/capabilities') return response.data.capabilities } @@ -45,7 +45,7 @@ export async function getAllCapabilities(): Promise { * 获取用户可配置的能力列表 */ export async function getUserConfigurableCapabilities(): Promise { - const response = await client.get('/api/capabilities/user-configurable') + const response = await client.get<{ capabilities: CapabilityDefinition[] }>('/api/capabilities/user-configurable') return response.data.capabilities } @@ -53,7 +53,7 @@ export async function getUserConfigurableCapabilities(): Promise { - const response = await client.get(`/api/capabilities/model/${encodeURIComponent(modelName)}`) + const response = await client.get(`/api/capabilities/model/${encodeURIComponent(modelName)}`) return response.data } @@ -68,7 +68,7 @@ export interface RevealKeyResult { } export async function revealEndpointKey(keyId: string): Promise { - const response = await client.get(`/api/admin/endpoints/keys/${keyId}/reveal`) + const response = await client.get(`/api/admin/endpoints/keys/${keyId}/reveal`) return response.data } @@ -76,7 +76,7 @@ export async function revealEndpointKey(keyId: string): Promise * 导出 OAuth Key 凭据(扁平 JSON,用于跨实例迁移) */ export async function exportKey(keyId: string): Promise> { - const response = await client.get(`/api/admin/endpoints/keys/${keyId}/export`) + const response = await client.get>(`/api/admin/endpoints/keys/${keyId}/export`) return response.data } @@ -84,7 +84,7 @@ export async function exportKey(keyId: string): Promise> * 删除 Key */ export async function deleteEndpointKey(keyId: string): Promise<{ message: string }> { - const response = await client.delete(`/api/admin/endpoints/keys/${keyId}`) + const response = await client.delete<{ message: string }>(`/api/admin/endpoints/keys/${keyId}`) return response.data } @@ -98,7 +98,7 @@ export interface BatchDeleteKeysResult { } export async function batchDeleteEndpointKeys(ids: string[]): Promise { - const response = await client.post('/api/admin/endpoints/keys/batch-delete', { ids }) + const response = await client.post('/api/admin/endpoints/keys/batch-delete', { ids }) return response.data } @@ -215,7 +215,7 @@ export async function addProviderKey( model_exclude_patterns?: string[] // 模型排除规则 } ): Promise { - const response = await client.post(`/api/admin/endpoints/providers/${providerId}/keys`, data) + const response = await client.post(`/api/admin/endpoints/providers/${providerId}/keys`, data) return response.data } @@ -251,7 +251,7 @@ export async function updateProviderKey( }>, requestOptions?: KeyRequestOptions, ): Promise { - const response = await client.put( + const response = await client.put( `/api/admin/endpoints/keys/${keyId}`, data, requestOptions, @@ -263,7 +263,7 @@ export async function updateProviderKey( * 清除 Key 的 OAuth 失效标记 */ export async function clearOAuthInvalid(keyId: string): Promise<{ message: string }> { - const response = await client.post(`/api/admin/endpoints/keys/${keyId}/clear-oauth-invalid`) + const response = await client.post<{ message: string }>(`/api/admin/endpoints/keys/${keyId}/clear-oauth-invalid`) return response.data } @@ -275,7 +275,11 @@ export async function resetProviderKeyCycleStats(keyId: string): Promise<{ reset_at: number windows: number }> { - const response = await client.post(`/api/admin/endpoints/keys/${keyId}/reset-cycle-stats`) + const response = await client.post<{ + message: string + reset_at: number + windows: number +}>(`/api/admin/endpoints/keys/${keyId}/reset-cycle-stats`) return response.data } @@ -311,7 +315,7 @@ export async function refreshProviderQuota( keyIds?: string[], ): Promise { const body = keyIds && keyIds.length > 0 ? { key_ids: keyIds } : undefined - const response = await client.post( + const response = await client.post( `/api/admin/endpoints/providers/${providerId}/refresh-quota`, body, { timeout: 5 * 60 * 1000 }, @@ -350,7 +354,7 @@ export async function consumeCodexResetCredit( keyId: string, payload: ConsumeCodexResetCreditPayload, ): Promise { - const response = await client.post( + const response = await client.post( `/api/admin/endpoints/keys/${keyId}/codex-reset-credit/consume`, payload, { timeout: 5 * 60 * 1000 }, @@ -383,7 +387,7 @@ export async function batchImportOAuth( credentials: string, proxyNodeId?: string ): Promise { - const response = await client.post(`/api/admin/provider-oauth/providers/${providerId}/batch-import`, { + const response = await client.post(`/api/admin/provider-oauth/providers/${providerId}/batch-import`, { credentials, proxy_node_id: proxyNodeId || undefined, }) diff --git a/frontend/src/api/endpoints/models.ts b/frontend/src/api/endpoints/models.ts index 142967980..596dfb99a 100644 --- a/frontend/src/api/endpoints/models.ts +++ b/frontend/src/api/endpoints/models.ts @@ -19,7 +19,7 @@ export async function getProviderModels( limit?: number } ): Promise { - const response = await client.get(`/api/admin/providers/${providerId}/models`, { params }) + const response = await client.get(`/api/admin/providers/${providerId}/models`, { params }) return response.data } @@ -30,7 +30,7 @@ export async function createModel( providerId: string, data: ModelCreate ): Promise { - const response = await client.post(`/api/admin/providers/${providerId}/models`, data) + const response = await client.post(`/api/admin/providers/${providerId}/models`, data) return response.data } @@ -41,7 +41,7 @@ export async function getModel( providerId: string, modelId: string ): Promise { - const response = await client.get(`/api/admin/providers/${providerId}/models/${modelId}`) + const response = await client.get(`/api/admin/providers/${providerId}/models/${modelId}`) return response.data } @@ -53,7 +53,7 @@ export async function updateModel( modelId: string, data: ModelUpdate ): Promise { - const response = await client.patch(`/api/admin/providers/${providerId}/models/${modelId}`, data) + const response = await client.patch(`/api/admin/providers/${providerId}/models/${modelId}`, data) return response.data } @@ -64,7 +64,7 @@ export async function deleteModel( providerId: string, modelId: string ): Promise<{ message: string }> { - const response = await client.delete(`/api/admin/providers/${providerId}/models/${modelId}`) + const response = await client.delete<{ message: string }>(`/api/admin/providers/${providerId}/models/${modelId}`) return response.data } @@ -75,7 +75,7 @@ export async function batchCreateModels( providerId: string, modelsData: ModelCreate[] ): Promise { - const response = await client.post(`/api/admin/providers/${providerId}/models/batch`, modelsData) + const response = await client.post(`/api/admin/providers/${providerId}/models/batch`, modelsData) return response.data } @@ -83,7 +83,7 @@ export async function batchCreateModels( * 获取统一模型目录 */ export async function getModelCatalog(): Promise { - const response = await client.get('/api/admin/models/catalog') + const response = await client.get('/api/admin/models/catalog') return response.data } @@ -93,7 +93,7 @@ export async function getModelCatalog(): Promise { export async function getProviderAvailableSourceModels( providerId: string ): Promise { - const response = await client.get(`/api/admin/providers/${providerId}/available-source-models`) + const response = await client.get(`/api/admin/providers/${providerId}/available-source-models`) return response.data } @@ -114,7 +114,17 @@ export async function batchAssignModelsToProvider( error: string }> }> { - const response = await client.post( + const response = await client.post<{ + success: Array<{ + global_model_id: string + global_model_name: string + model_id: string + }> + errors: Array<{ + global_model_id: string + error: string + }> +}>( `/api/admin/providers/${providerId}/assign-global-models`, { global_model_ids: globalModelIds } ) @@ -137,7 +147,7 @@ export async function importModelsFromUpstream( price_per_request?: number } ): Promise { - const response = await client.post( + const response = await client.post( `/api/admin/providers/${providerId}/import-from-upstream`, { model_ids: modelIds, diff --git a/frontend/src/api/endpoints/pool.ts b/frontend/src/api/endpoints/pool.ts index 9557c6328..3c1dc7de7 100644 --- a/frontend/src/api/endpoints/pool.ts +++ b/frontend/src/api/endpoints/pool.ts @@ -2,6 +2,7 @@ import client from '../client' import { buildCacheKey, cachedRequest } from '@/utils/cache' import type { AllowedModels, + ProviderType, OAuthOrganizationInfo, ProxyConfig, UpstreamMetadata, @@ -77,7 +78,7 @@ export async function resetPoolCost( export interface PoolOverviewItem { provider_id: string provider_name: string - provider_type: string + provider_type: ProviderType total_keys: number active_keys: number cooldown_count: number @@ -508,7 +509,7 @@ export async function batchActionPoolKeys( providerId: string, body: PoolBatchAction, ): Promise<{ affected: number; message: string; task_id?: string }> { - const response = await client.post( + const response = await client.post<{ affected: number; message: string; task_id?: string }>( `/api/admin/pool/${providerId}/keys/batch-action`, body, { timeout: POOL_BATCH_ACTION_TIMEOUT_MS }, @@ -561,7 +562,7 @@ export async function getPoolBatchDeleteTask( export async function cleanupBannedPoolKeys( providerId: string, ): Promise<{ affected: number; message: string }> { - const response = await client.post( + const response = await client.post<{ affected: number; message: string }>( `/api/admin/pool/${providerId}/keys/cleanup-banned`, undefined, { timeout: POOL_BATCH_ACTION_TIMEOUT_MS }, diff --git a/frontend/src/api/endpoints/provider_oauth.ts b/frontend/src/api/endpoints/provider_oauth.ts index cce12ee9c..5b0c1c68e 100644 --- a/frontend/src/api/endpoints/provider_oauth.ts +++ b/frontend/src/api/endpoints/provider_oauth.ts @@ -230,14 +230,14 @@ export function normalizeBatchImportCredentials(text: string): BatchImportCreden } export async function refreshProviderOAuth(keyId: string): Promise { - const resp = await client.post(`/api/admin/provider-oauth/keys/${keyId}/refresh`) + const resp = await client.post(`/api/admin/provider-oauth/keys/${keyId}/refresh`) return resp.data } // Provider-level OAuth (不需要预先创建 key) export async function startProviderLevelOAuth(providerId: string): Promise { - const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/start`) + const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/start`) return resp.data } @@ -245,7 +245,7 @@ export async function completeProviderLevelOAuth( providerId: string, data: ProviderOAuthCompleteRequest ): Promise { - const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/complete`, data) + const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/complete`, data) return resp.data } @@ -253,7 +253,7 @@ export async function authorizeProviderWithCookie( providerId: string, data: ProviderCookieAuthorizeRequest ): Promise { - const resp = await client.post( + const resp = await client.post( `/api/admin/provider-oauth/providers/${providerId}/cookie-authorize`, data, { timeout: CLAUDE_COOKIE_AUTHORIZE_TIMEOUT_MS }, @@ -265,7 +265,7 @@ export async function startProviderCookieAuthorizeTask( providerId: string, data: ProviderCookieAuthorizeBatchTaskRequest, ): Promise { - const resp = await client.post( + const resp = await client.post( `/api/admin/provider-oauth/providers/${providerId}/cookie-authorize/tasks`, data, ) @@ -276,7 +276,7 @@ export async function getProviderCookieAuthorizeTaskStatus( providerId: string, taskId: string, ): Promise { - const resp = await client.get( + const resp = await client.get( `/api/admin/provider-oauth/providers/${providerId}/cookie-authorize/tasks/${taskId}`, ) return resp.data @@ -313,7 +313,7 @@ export async function importProviderRefreshToken( headers?: Record } ): Promise { - const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data) + const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data) return resp.data } @@ -325,7 +325,7 @@ export async function startBatchImportOAuthTask( const route = containsAgentIdentityImport(credentials) ? 'agent-identity-import/tasks' : 'batch-import/tasks' - const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/${route}`, { + const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/${route}`, { credentials, proxy_node_id: proxyNodeId || undefined, }) @@ -339,7 +339,7 @@ export async function getBatchImportOAuthTaskStatus( const route = taskId.startsWith('agent-identity-') ? 'agent-identity-import/tasks' : 'batch-import/tasks' - const resp = await client.get(`/api/admin/provider-oauth/providers/${providerId}/${route}/${taskId}`) + const resp = await client.get(`/api/admin/provider-oauth/providers/${providerId}/${route}/${taskId}`) return resp.data } @@ -412,7 +412,7 @@ export async function startDeviceAuthorize( providerId: string, data: DeviceAuthorizeRequest ): Promise { - const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/device-authorize`, data) + const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/device-authorize`, data) return resp.data } @@ -420,6 +420,6 @@ export async function pollDeviceAuthorize( providerId: string, data: DevicePollRequest ): Promise { - const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/device-poll`, data) + const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/device-poll`, data) return resp.data } diff --git a/frontend/src/api/endpoints/providers.ts b/frontend/src/api/endpoints/providers.ts index 4df9d509e..ab70f598b 100644 --- a/frontend/src/api/endpoints/providers.ts +++ b/frontend/src/api/endpoints/providers.ts @@ -14,6 +14,7 @@ import { normalizePoolAdvancedConfig as normalizePoolAdvanced, } from './types' +export type { ProviderWithEndpointsSummary } from './types' interface ProviderRequestOptions { timeout?: number } @@ -140,7 +141,7 @@ export async function updateProvider( }>, requestOptions?: ProviderRequestOptions, ): Promise { - const response = await client.patch(`/api/admin/providers/${providerId}`, data, requestOptions) + const response = await client.patch(`/api/admin/providers/${providerId}`, data, requestOptions) return normalizeProviderSummary(response.data) } @@ -175,7 +176,7 @@ export async function createProvider( config?: ProviderConfig | null } ): Promise<{ id: string; name: string; message?: string }> { - const response = await client.post('/api/admin/providers/', data) + const response = await client.post<{ id: string; name: string; message?: string }>('/api/admin/providers/', data) return response.data } @@ -261,7 +262,7 @@ export async function testModel( data: TestModelRequest, options: { signal?: AbortSignal } = {}, ): Promise { - const response = await client.post('/api/admin/provider-query/test-model', data, { + const response = await client.post('/api/admin/provider-query/test-model', data, { timeout: 10 * 60 * 1000, signal: options.signal, }) @@ -350,7 +351,7 @@ export async function testModelFailover( const failoverModels = Array.isArray(data.failover_models) && data.failover_models.length > 0 ? data.failover_models : (normalizedModelName ? [normalizedModelName] : undefined) - const response = await client.post('/api/admin/provider-query/test-model-failover', { + const response = await client.post('/api/admin/provider-query/test-model-failover', { ...data, ...(failoverModels ? { failover_models: failoverModels } : {}), }, { diff --git a/frontend/src/api/endpoints/types/model.ts b/frontend/src/api/endpoints/types/model.ts index 9d3b6caa7..4acaee124 100644 --- a/frontend/src/api/endpoints/types/model.ts +++ b/frontend/src/api/endpoints/types/model.ts @@ -1,5 +1,12 @@ import type { ProviderModelMapping } from './provider' +export interface ModelProviderReference { + id: string + model_id?: string | null + name: string + is_active: boolean +} + // ========== 阶梯计费类型 ========== /** 缓存时长定价配置 */ @@ -67,13 +74,26 @@ export interface ProviderTieredPricingConfig { [key: string]: unknown } +export interface ModelConfig extends Record { + description?: string + model_mappings?: string[] + api_formats?: string[] + billing?: { + video?: { + price_per_second_by_resolution?: Record + [key: string]: unknown + } + [key: string]: unknown + } +} + export interface Model { id: string provider_id: string global_model_id: string // 关联的 GlobalModel ID provider_model_name: string // Provider 侧的主模型名称 provider_model_mappings?: ProviderModelMapping[] | null // 模型名称映射列表(带优先级) - config?: Record | null // 额外配置(如 billing/video 等) + config?: ModelConfig | null // 额外配置(如 billing/video 等) // 原始配置值(可能为空,为空时使用 GlobalModel 默认值) price_per_request?: number | null // 按次计费价格 tiered_pricing?: ProviderTieredPricingConfig | null // Provider 原始覆盖,可仅包含 processing_tiers @@ -102,7 +122,7 @@ export interface Model { global_model_name?: string global_model_display_name?: string // 有效配置(合并 Model 和 GlobalModel 的 config) - effective_config?: Record | null + effective_config?: ModelConfig | null model_test_capabilities?: ModelTestCapabilities | null } @@ -120,7 +140,7 @@ export interface ModelCreate { supports_extended_thinking?: boolean supports_image_generation?: boolean is_active?: boolean - config?: Record + config?: ModelConfig } export interface ModelUpdate { @@ -136,7 +156,7 @@ export interface ModelUpdate { supports_image_generation?: boolean is_active?: boolean is_available?: boolean - config?: Record | null + config?: ModelConfig | null } export interface ModelCapabilities { @@ -243,7 +263,7 @@ export interface GlobalModelCreate { // Key 能力配置 - 模型支持的能力列表 supported_capabilities?: string[] // 模型配置(JSON格式)- 包含能力、规格、元信息等 - config?: Record + config?: ModelConfig is_active?: boolean } @@ -257,7 +277,7 @@ export interface GlobalModelUpdate { // Key 能力配置 - 模型支持的能力列表 supported_capabilities?: string[] | null // 模型配置(JSON格式)- 包含能力、规格、元信息等 - config?: Record | null + config?: ModelConfig | null } export interface GlobalModelResponse { @@ -273,7 +293,7 @@ export interface GlobalModelResponse { supported_capabilities?: string[] | null supports_embedding?: boolean | null // 模型配置(JSON格式) - config?: Record | null + config?: ModelConfig | null // 统计数据 provider_count?: number active_provider_count?: number diff --git a/frontend/src/api/gemini-files.ts b/frontend/src/api/gemini-files.ts index 16caee738..ccdc0e125 100644 --- a/frontend/src/api/gemini-files.ts +++ b/frontend/src/api/gemini-files.ts @@ -68,7 +68,7 @@ export const geminiFilesApi = { * 获取文件映射统计 */ async getStats(): Promise { - const response = await apiClient.get('/api/admin/gemini-files/stats') + const response = await apiClient.get('/api/admin/gemini-files/stats') return response.data }, @@ -76,7 +76,7 @@ export const geminiFilesApi = { * 列出文件映射 */ async listMappings(params?: ListMappingsParams): Promise { - const response = await apiClient.get('/api/admin/gemini-files/mappings', { params }) + const response = await apiClient.get('/api/admin/gemini-files/mappings', { params }) return response.data }, @@ -84,7 +84,7 @@ export const geminiFilesApi = { * 删除指定映射 */ async deleteMapping(mappingId: string): Promise<{ message: string; file_name: string }> { - const response = await apiClient.delete(`/api/admin/gemini-files/mappings/${mappingId}`) + const response = await apiClient.delete<{ message: string; file_name: string }>(`/api/admin/gemini-files/mappings/${mappingId}`) return response.data }, @@ -92,7 +92,7 @@ export const geminiFilesApi = { * 清理过期映射 */ async cleanupExpired(): Promise<{ message: string; deleted_count: number }> { - const response = await apiClient.delete('/api/admin/gemini-files/mappings') + const response = await apiClient.delete<{ message: string; deleted_count: number }>('/api/admin/gemini-files/mappings') return response.data }, @@ -100,7 +100,7 @@ export const geminiFilesApi = { * 获取可用的 Key 列表 */ async getCapableKeys(): Promise { - const response = await apiClient.get('/api/admin/gemini-files/capable-keys') + const response = await apiClient.get('/api/admin/gemini-files/capable-keys') return response.data }, @@ -110,7 +110,7 @@ export const geminiFilesApi = { async uploadFile(file: File, keyIds: string[]): Promise { const formData = new FormData() formData.append('file', file) - const response = await apiClient.post( + const response = await apiClient.post( `/api/admin/gemini-files/upload?key_ids=${keyIds.join(',')}`, formData, { diff --git a/frontend/src/api/me.ts b/frontend/src/api/me.ts index bb45d5ad7..a816c9875 100644 --- a/frontend/src/api/me.ts +++ b/frontend/src/api/me.ts @@ -1,4 +1,5 @@ import apiClient from './client' +import type { ImageProgress } from './requestTrace' import type { ActivityHeatmap } from '@/types/activity' import type { TieredPricingConfig } from './endpoints/types' import { cachedRequest, buildCacheKey } from '@/utils/cache' @@ -238,13 +239,13 @@ export const meApi = { username?: string feature_settings?: FeatureSettingsMap | null }): Promise<{ message: string }> { - const response = await apiClient.put('/api/users/me', data) + const response = await apiClient.put<{ message: string }>('/api/users/me', data) return response.data }, // 修改密码 async changePassword(data: ChangePasswordRequest): Promise<{ message: string }> { - const response = await apiClient.patch('/api/users/me/password', data) + const response = await apiClient.patch<{ message: string }>('/api/users/me/password', data) return response.data }, @@ -261,12 +262,12 @@ export const meApi = { }, async revokeSession(sessionId: string): Promise<{ message: string }> { - const response = await apiClient.delete(`/api/users/me/sessions/${sessionId}`) + const response = await apiClient.delete<{ message: string }>(`/api/users/me/sessions/${sessionId}`) return response.data }, async revokeOtherSessions(): Promise<{ message: string; revoked_count: number }> { - const response = await apiClient.delete('/api/users/me/sessions/others') + const response = await apiClient.delete<{ message: string; revoked_count: number }>('/api/users/me/sessions/others') return response.data }, @@ -303,7 +304,7 @@ export const meApi = { }, async deleteApiKey(keyId: string): Promise<{ message: string }> { - const response = await apiClient.delete(`/api/users/me/api-keys/${keyId}`) + const response = await apiClient.delete<{ message: string }>(`/api/users/me/api-keys/${keyId}`) return response.data }, @@ -396,16 +397,61 @@ export const meApi = { reasoning_effort?: string | null service_tier?: string | null actual_service_tier?: string | null + image_progress?: ImageProgress | null }> }> { const params = ids ? { ids } : {} - const response = await apiClient.get('/api/users/me/usage/active', { params }) + const response = await apiClient.get<{ + requests: Array<{ + id: string + status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled' + input_tokens: number + effective_input_tokens?: number | null + output_tokens: number + cache_creation_input_tokens?: number | null + cache_creation_ephemeral_5m_input_tokens?: number | null + cache_creation_ephemeral_1h_input_tokens?: number | null + cache_read_input_tokens?: number | null + cost: number + actual_cost?: number | null + rate_multiplier?: number | null + response_time_ms: number | null + first_byte_time_ms: number | null + end_to_end_time_ms?: number | null + end_to_end_first_byte_time_ms?: number | null + updated_at?: string | null + response_time_updated_at?: string | null + status_code?: number | null + error_message?: string | null + api_format?: string | null + endpoint_api_format?: string | null + is_stream?: boolean | null + is_websocket?: boolean | null + websocket_transport?: string | null + usage_available?: boolean | null + usage_pricing_available?: boolean | null + input_audio_tokens?: number | null + output_audio_tokens?: number | null + upstream_is_stream?: boolean | null + client_requested_stream?: boolean | null + client_is_stream?: boolean | null + has_format_conversion?: boolean | null + has_fallback?: boolean | null + target_model?: string | null + request_type?: string | null + requested_reasoning_effort?: string | null + reasoning_effort?: string | null + service_tier?: string | null + actual_service_tier?: string | null + image_progress?: ImageProgress | null + }> + }>('/api/users/me/usage/active', { params }) return response.data }, // 获取可用的提供商 async getAvailableProviders(): Promise>> { - const response = await apiClient.get('/api/users/me/providers') + const response = await apiClient.get>>('/api/users/me/providers') return response.data }, @@ -429,24 +475,38 @@ export const meApi = { }> total: number }> { - const response = await apiClient.get('/api/users/me/available-models', { params }) + const response = await apiClient.get<{ + models: Array<{ + id: string + name: string + display_name: string | null + is_active: boolean + default_price_per_request: number | null + default_tiered_pricing: TieredPricingConfig | null + supported_capabilities: string[] | null + supports_embedding?: boolean | null + config: Record | null + usage_count: number + }> + total: number + }>('/api/users/me/available-models', { params }) return response.data }, // 获取端点状态(不包含敏感信息) async getEndpointStatus(): Promise>> { - const response = await apiClient.get('/api/users/me/endpoint-status') + const response = await apiClient.get>>('/api/users/me/endpoint-status') return response.data }, // 偏好设置 async getPreferences(): Promise { - const response = await apiClient.get('/api/users/me/preferences') + const response = await apiClient.get('/api/users/me/preferences') return response.data }, async updatePreferences(data: Partial): Promise<{ message: string }> { - const response = await apiClient.put('/api/users/me/preferences', data) + const response = await apiClient.put<{ message: string }>('/api/users/me/preferences', data) return response.data }, @@ -456,7 +516,7 @@ export const meApi = { async updateApiKeyProviders(keyId: string, data: { allowed_providers?: ProviderConfig[] }): Promise<{ message: string }> { - const response = await apiClient.put(`/api/users/me/api-keys/${keyId}/providers`, data) + const response = await apiClient.put<{ message: string }>(`/api/users/me/api-keys/${keyId}/providers`, data) return response.data }, @@ -464,7 +524,7 @@ export const meApi = { async updateApiKeyCapabilities(keyId: string, data: { force_capabilities?: Record | null }): Promise<{ message: string; force_capabilities?: Record | null }> { - const response = await apiClient.put(`/api/users/me/api-keys/${keyId}/capabilities`, data) + const response = await apiClient.put<{ message: string; force_capabilities?: Record | null }>(`/api/users/me/api-keys/${keyId}/capabilities`, data) return response.data }, @@ -472,7 +532,9 @@ export const meApi = { async getModelCapabilitySettings(): Promise<{ model_capability_settings: Record> }> { - const response = await apiClient.get('/api/users/me/model-capabilities') + const response = await apiClient.get<{ + model_capability_settings: Record> + }>('/api/users/me/model-capabilities') return response.data }, @@ -482,7 +544,10 @@ export const meApi = { message: string model_capability_settings: Record> | null }> { - const response = await apiClient.put('/api/users/me/model-capabilities', data) + const response = await apiClient.put<{ + message: string + model_capability_settings: Record> | null + }>('/api/users/me/model-capabilities', data) return response.data }, @@ -500,7 +565,12 @@ export const meApi = { return cachedRequest( cacheKey, async () => { - const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params }) + const response = await apiClient.get<{ + analysis_period_hours: number + total_points: number + points: Array<{ x: string; y: number; model?: string }> + models?: string[] + }>('/api/users/me/usage/interval-timeline', { params }) return response.data }, 30000 diff --git a/frontend/src/api/providerCredentials.ts b/frontend/src/api/providerCredentials.ts new file mode 100644 index 000000000..33993abbd --- /dev/null +++ b/frontend/src/api/providerCredentials.ts @@ -0,0 +1,64 @@ +export interface CredentialsSchema { + type: 'object' + properties: Record + required?: string[] + 'x-field-groups'?: SchemaFieldGroup[] + 'x-auth-type'?: string + 'x-auth-method'?: string + 'x-validation'?: SchemaValidation[] + 'x-quota-divisor'?: number | null + 'x-currency'?: string + 'x-default-base-url'?: string + 'x-balance-extra-format'?: BalanceExtraFormat[] + 'x-field-hooks'?: Record +} + +export interface SchemaProperty { + type: string + title?: string + description?: string + 'x-sensitive'?: boolean + 'x-input-type'?: string + 'x-default-value'?: string + 'x-help'?: string +} + +export interface SchemaFieldGroup { + fields: string[] + layout?: 'inline' | 'vertical' + 'x-flex'?: Record + 'x-help'?: string +} + +interface SchemaValidation { + type: 'required' | 'any_required' | 'conditional_required' + fields?: string[] + message: string + /** conditional_required: 当此字段有值时 */ + if?: string + /** conditional_required: 除非此字段有值 */ + unless?: string + /** conditional_required: 则这些字段必填 */ + then?: string[] +} + +export interface BalanceExtraFormat { + label: string + type: 'window_limit' | 'daily_quota' | 'weekly_spent' | 'monthly_expiry' + /** window_limit: extra 中的字段名 */ + source?: string + /** window_limit: 单位除数 */ + unit_divisor?: number + /** daily_quota / weekly_spent: limit 字段名 */ + source_limit?: string + /** daily_quota: remaining 字段名 */ + source_remaining?: string + /** daily_quota: 每日重置基准时间字段名(计算下次重置时间) */ + source_start_date?: string + /** weekly_spent: spent 字段名 */ + source_spent?: string + /** weekly_spent: resets_at 字段名 */ + source_resets_at?: string + /** monthly_expiry: 到期日期字段名 */ + source_end_date?: string +} diff --git a/frontend/src/api/providerOps.ts b/frontend/src/api/providerOps.ts index 2df16528e..24f8b21ff 100644 --- a/frontend/src/api/providerOps.ts +++ b/frontend/src/api/providerOps.ts @@ -8,6 +8,7 @@ */ import client from './client' +import type { CredentialsSchema } from './providerCredentials' // ==================== Types ==================== @@ -46,11 +47,11 @@ export interface ArchitectureInfo { architecture_id: string display_name: string description: string - credentials_schema: Record + credentials_schema: CredentialsSchema supported_auth_types: Array<{ type: string display_name: string - credentials_schema?: Record + credentials_schema?: CredentialsSchema }> supported_actions: Array<{ type: string diff --git a/frontend/src/api/public-models.ts b/frontend/src/api/public-models.ts index d94f94ef4..a0be2e418 100644 --- a/frontend/src/api/public-models.ts +++ b/frontend/src/api/public-models.ts @@ -36,6 +36,6 @@ export async function getPublicGlobalModels(params?: { is_active?: boolean search?: string }): Promise { - const response = await client.get('/api/public/global-models', { params }) + const response = await client.get('/api/public/global-models', { params }) return response.data } diff --git a/frontend/src/api/referrals.ts b/frontend/src/api/referrals.ts index e1ce472ff..a91a42dc7 100644 --- a/frontend/src/api/referrals.ts +++ b/frontend/src/api/referrals.ts @@ -87,7 +87,7 @@ export const referralApi = { async getAdminReferrals( params: ReferralRelationshipQuery = {} ): Promise> { - const response = await apiClient.get('/api/admin/referrals', { + const response = await apiClient.get>('/api/admin/referrals', { params: cleanParams(params as Record) }) return response.data @@ -96,19 +96,19 @@ export const referralApi = { async getAdminReferralRewards( params: ReferralRewardQuery = {} ): Promise> { - const response = await apiClient.get('/api/admin/referral-rewards', { + const response = await apiClient.get>('/api/admin/referral-rewards', { params: cleanParams(params as Record) }) return response.data }, async retryReferralReward(id: string, note?: string): Promise<{ reward: ReferralRewardRecord }> { - const response = await apiClient.post(`/api/admin/referral-rewards/${id}/retry`, { note }) + const response = await apiClient.post<{ reward: ReferralRewardRecord }>(`/api/admin/referral-rewards/${id}/retry`, { note }) return response.data }, async voidReferralReward(id: string, note?: string): Promise<{ reward: ReferralRewardRecord }> { - const response = await apiClient.post(`/api/admin/referral-rewards/${id}/void`, { note }) + const response = await apiClient.post<{ reward: ReferralRewardRecord }>(`/api/admin/referral-rewards/${id}/void`, { note }) return response.data } } diff --git a/frontend/src/api/requestTrace.ts b/frontend/src/api/requestTrace.ts index 83934763b..c5ec0a0e6 100644 --- a/frontend/src/api/requestTrace.ts +++ b/frontend/src/api/requestTrace.ts @@ -31,6 +31,32 @@ export interface CandidateResponseBoundary { body_state?: string | null } +export interface CandidateProxyTiming { + body_read_ms?: number | null + decompress_ms?: number | null + wire_size?: number | null + body_size?: number | null + ttfb_ms?: number | null + upstream_ms?: number | null + response_wait_ms?: number | null + connection_acquire_ms?: number | null + upstream_processing_ms?: number | null + connect_ms?: number | null + tls_ms?: number | null + dns_ms?: number | null + total_ms?: number | null + connection_reused?: boolean | null +} + +export interface CandidateProxy { + node_name?: string | null + node_id?: string | null + url?: string | null + source?: string | null + ttfb_ms?: number | null + timing?: CandidateProxyTiming | null +} + export interface CandidateRecord { id: string request_id: string @@ -70,6 +96,22 @@ export interface CandidateRecord { ranking?: CandidateRankingMetadata | null image_progress?: ImageProgress | null extra_data?: Record & { + first_byte_time_ms?: number | null + needs_conversion?: boolean + provider_api_format?: string | null + proxy?: CandidateProxy | null + pool_selection?: { + reason: string + cost_soft_threshold?: number | boolean | null + cost_window_usage?: number | null + cost_limit?: number | null + } | null + pool_skip?: { + type: string + cooldown_reason?: string | null + cooldown_ttl?: number | null + cost_window_usage?: number | null + } | null upstream_response?: CandidateResponseBoundary image_progress?: ImageProgress | null } diff --git a/frontend/src/api/security.ts b/frontend/src/api/security.ts index 4d1b42b8f..81c7ca763 100644 --- a/frontend/src/api/security.ts +++ b/frontend/src/api/security.ts @@ -59,7 +59,7 @@ export const blacklistApi = { * 获取黑名单统计 */ async getStats(): Promise { - const response = await apiClient.get('/api/admin/security/ip/blacklist/stats') + const response = await apiClient.get('/api/admin/security/ip/blacklist/stats') return response.data }, @@ -67,7 +67,7 @@ export const blacklistApi = { * 获取黑名单列表 */ async getList(): Promise { - const response = await apiClient.get('/api/admin/security/ip/blacklist') + const response = await apiClient.get('/api/admin/security/ip/blacklist') return response.data } } @@ -96,7 +96,7 @@ export const whitelistApi = { * 获取白名单列表 */ async getList(): Promise { - const response = await apiClient.get('/api/admin/security/ip/whitelist') + const response = await apiClient.get('/api/admin/security/ip/whitelist') return response.data } } diff --git a/frontend/src/api/usage.ts b/frontend/src/api/usage.ts index fbd3b20ed..12c76cd9f 100644 --- a/frontend/src/api/usage.ts +++ b/frontend/src/api/usage.ts @@ -2,6 +2,7 @@ import apiClient from './client' import { cachedRequest, dedupedRequest, buildCacheKey } from '@/utils/cache' import type { ActivityHeatmap } from '@/types/activity' import type { ImageProgress } from './requestTrace' +import type { UsageRecord as UsageListRecord } from './usageRecords' const ACTIVITY_HEATMAP_CACHE_TTL_MS = 30 * 60 * 1000 const USAGE_ANALYTICS_CACHE_TTL_MS = 30 * 1000 @@ -58,6 +59,12 @@ export interface UsageStats { avg_response_time: number error_count?: number error_rate?: number + cache_stats?: { + cache_creation_tokens: number + cache_read_tokens: number + cache_creation_cost: number + cache_read_cost: number + } today?: { requests: number tokens: number @@ -67,6 +74,7 @@ export interface UsageStats { } export interface UsageByModel { + actual_cost?: number model: string request_count: number total_tokens: number @@ -511,7 +519,7 @@ export const usageApi = { limit?: number offset?: number }): Promise<{ - records: Array> + records: UsageListRecord[] total: number limit: number offset: number @@ -519,7 +527,13 @@ export const usageApi = { }> { const key = buildCacheKey('usage:records', params as Record | undefined) return dedupedRequest(key, async () => { - const response = await apiClient.get('/api/admin/usage/records', { params }) + const response = await apiClient.get<{ + records: UsageListRecord[] + total: number + limit: number + offset: number + total_is_estimated?: boolean + }>('/api/admin/usage/records', { params }) return response.data }) }, @@ -630,7 +644,54 @@ export const usageApi = { if (typeof timeRange?.tz_offset_minutes === 'number') { params.tz_offset_minutes = timeRange.tz_offset_minutes } - const response = await apiClient.get('/api/admin/usage/active', { params }) + const response = await apiClient.get<{ + requests: Array<{ + id: string + status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled' + input_tokens: number + effective_input_tokens?: number | null + output_tokens: number + cache_creation_input_tokens?: number | null + cache_creation_ephemeral_5m_input_tokens?: number | null + cache_creation_ephemeral_1h_input_tokens?: number | null + cache_read_input_tokens?: number | null + cost: number + actual_cost?: number | null + rate_multiplier?: number | null + response_time_ms: number | null + first_byte_time_ms: number | null + end_to_end_time_ms?: number | null + end_to_end_first_byte_time_ms?: number | null + updated_at?: string | null + response_time_updated_at?: string | null + status_code?: number | null + error_message?: string | null + provider?: string | null + api_key_name?: string | null + provider_key_name?: string | null + api_format?: string | null + endpoint_api_format?: string | null + is_stream?: boolean | null + is_websocket?: boolean | null + websocket_transport?: string | null + usage_available?: boolean | null + usage_pricing_available?: boolean | null + input_audio_tokens?: number | null + output_audio_tokens?: number | null + upstream_is_stream?: boolean | null + client_requested_stream?: boolean | null + client_is_stream?: boolean | null + has_format_conversion?: boolean | null + has_fallback?: boolean | null + target_model?: string | null + request_type?: string | null + requested_reasoning_effort?: string | null + reasoning_effort?: string | null + service_tier?: string | null + actual_service_tier?: string | null + image_progress?: ImageProgress | null + }> + }>('/api/admin/usage/active', { params }) return response.data }, diff --git a/frontend/src/api/usageRecords.ts b/frontend/src/api/usageRecords.ts new file mode 100644 index 000000000..8ef0b4150 --- /dev/null +++ b/frontend/src/api/usageRecords.ts @@ -0,0 +1,69 @@ +import type { ImageProgress } from './requestTrace' + +export type RequestStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled' + +export interface UsageRecord { + id: string + user_id?: string + username?: string + user_email?: string + api_key?: { + id: string | null + name: string | null + display: string | null + } | null + provider?: string // 仅管理员可见 + api_key_name?: string + provider_key_name?: string | null + rate_multiplier?: number + model: string + target_model?: string | null // 映射后的目标模型名(若无映射则为空) + model_version?: string | null // Provider 返回的实际模型版本(列表轻量字段) + request_type?: string | null // 由请求语义识别出的操作类型 + requested_reasoning_effort?: string | null // 用户请求侧 reasoning 级别,用于展示转换关系 + reasoning_effort?: string | null // 从发送给 Provider 的请求体提取的 reasoning 级别 + service_tier?: string | null // 从发送给 Provider 的请求体提取的服务层级 + actual_service_tier?: string | null // 响应侧审计事实,不用于 Fast 展示或计费 + api_format?: string + endpoint_api_format?: string // 端点原生格式 + has_format_conversion?: boolean // 是否发生了格式转换 + input_tokens: number + effective_input_tokens?: number + output_tokens: number + reasoning_tokens?: number + cache_creation_input_tokens?: number + cache_creation_ephemeral_5m_input_tokens?: number + cache_creation_ephemeral_1h_input_tokens?: number + cache_read_input_tokens?: number + total_tokens: number + cost: number + actual_cost?: number + response_time_ms?: number | null + first_byte_time_ms?: number | null // 首字时间 (TTFB) + end_to_end_time_ms?: number | null // 客户端从请求进入网关到完成的总耗时 + end_to_end_first_byte_time_ms?: number | null // 客户端从请求进入网关到首字节的耗时 + is_stream: boolean + is_websocket?: boolean + websocket_transport?: string | null + usage_available?: boolean + usage_pricing_available?: boolean + input_audio_tokens?: number | null + output_audio_tokens?: number | null + upstream_is_stream?: boolean + client_requested_stream?: boolean + client_is_stream?: boolean + client_family?: string | null + client_ip?: string | null + user_agent?: string | null + request_path?: string | null + request_path_and_query?: string | null + status_code?: number + error_message?: string + status?: RequestStatus // 请求状态: pending, streaming, completed, failed + created_at: string + updated_at?: string | null + response_time_updated_at?: string | null + has_fallback?: boolean + has_retry?: boolean + image_progress?: ImageProgress | null +} diff --git a/frontend/src/api/users.ts b/frontend/src/api/users.ts index fed901bdb..8a9b8d6a7 100644 --- a/frontend/src/api/users.ts +++ b/frontend/src/api/users.ts @@ -60,7 +60,7 @@ export interface User { export interface CreateUserRequest { username: string password: string - email: string + email?: string role?: UserRole initial_gift_usd?: number | null unlimited?: boolean diff --git a/frontend/src/api/wallet.ts b/frontend/src/api/wallet.ts index 9b2470aec..2f460513b 100644 --- a/frontend/src/api/wallet.ts +++ b/frontend/src/api/wallet.ts @@ -223,7 +223,10 @@ export const walletApi = { order: PaymentOrder payment_instructions: Record }> { - const response = await apiClient.post('/api/wallet/recharge', payload) + const response = await apiClient.post<{ + order: PaymentOrder + payment_instructions: Record + }>('/api/wallet/recharge', payload) return response.data }, @@ -238,12 +241,17 @@ export const walletApi = { limit: number offset: number }> { - const response = await apiClient.get('/api/wallet/recharge', { params }) + const response = await apiClient.get<{ + items: PaymentOrder[] + total: number + limit: number + offset: number + }>('/api/wallet/recharge', { params }) return response.data }, async getRechargeOrder(orderId: string): Promise<{ order: PaymentOrder }> { - const response = await apiClient.get(`/api/wallet/recharge/${orderId}`) + const response = await apiClient.get<{ order: PaymentOrder }>(`/api/wallet/recharge/${orderId}`) return response.data }, @@ -253,7 +261,12 @@ export const walletApi = { limit: number offset: number }> { - const response = await apiClient.get('/api/wallet/refunds', { params }) + const response = await apiClient.get<{ + items: RefundRequest[] + total: number + limit: number + offset: number + }>('/api/wallet/refunds', { params }) return response.data }, diff --git a/frontend/src/components/charts/ScatterChart.vue b/frontend/src/components/charts/ScatterChart.vue index 32759d1bb..b6e0872fb 100644 --- a/frontend/src/components/charts/ScatterChart.vue +++ b/frontend/src/components/charts/ScatterChart.vue @@ -46,6 +46,7 @@ diff --git a/frontend/src/components/ui/sortable-table-head.vue b/frontend/src/components/ui/sortable-table-head.vue index fd8a502cd..e8fcddaec 100644 --- a/frontend/src/components/ui/sortable-table-head.vue +++ b/frontend/src/components/ui/sortable-table-head.vue @@ -1,4 +1,5 @@ diff --git a/frontend/src/features/providers/components/HealthMonitorDetailDrawer.vue b/frontend/src/features/providers/components/HealthMonitorDetailDrawer.vue index 7ee55fb80..241dde61c 100644 --- a/frontend/src/features/providers/components/HealthMonitorDetailDrawer.vue +++ b/frontend/src/features/providers/components/HealthMonitorDetailDrawer.vue @@ -239,15 +239,15 @@ async function loadRelated() { related_limit: 8, per_item_limit: 100 } - if (!props.isAdmin && target.source.kind === 'provider') { + const dimension = target.source.kind + if (!props.isAdmin && dimension === 'provider') { throw new Error('公开健康监控不支持 provider 详情') } const data = props.isAdmin ? await getHealthRelatedMonitor(params) - : await getPublicHealthRelatedMonitor({ - ...params, - dimension: target.source.kind - }) + : dimension === 'provider' + ? null + : await getPublicHealthRelatedMonitor({ ...params, dimension }) if (seq === requestSeq) { related.value = data } diff --git a/frontend/src/features/providers/components/OAuthAccountDialog.vue b/frontend/src/features/providers/components/OAuthAccountDialog.vue index d2d782a87..9686c8c9c 100644 --- a/frontend/src/features/providers/components/OAuthAccountDialog.vue +++ b/frontend/src/features/providers/components/OAuthAccountDialog.vue @@ -117,7 +117,7 @@
@@ -612,7 +612,7 @@ v-if="isClaudeCodeProvider" class="flex flex-col gap-3 justify-center transition-opacity duration-150" :class="mode === 'cookie' ? 'opacity-100' : 'opacity-0 pointer-events-none'" - :inert="mode !== 'cookie' ? '' : undefined" + :inert="mode !== 'cookie' ? true : undefined" :aria-hidden="mode !== 'cookie'" >