create-ekka-desktop-app 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +137 -0
- package/bin/cli.js +72 -0
- package/package.json +23 -0
- package/template/branding/app.json +6 -0
- package/template/branding/icon.icns +0 -0
- package/template/eslint.config.js +98 -0
- package/template/index.html +29 -0
- package/template/package.json +40 -0
- package/template/src/app/App.tsx +24 -0
- package/template/src/demo/DemoApp.tsx +260 -0
- package/template/src/demo/components/Banner.tsx +82 -0
- package/template/src/demo/components/EmptyState.tsx +61 -0
- package/template/src/demo/components/InfoPopover.tsx +171 -0
- package/template/src/demo/components/InfoTooltip.tsx +76 -0
- package/template/src/demo/components/LearnMore.tsx +98 -0
- package/template/src/demo/components/NodeCredentialsOnboarding.tsx +219 -0
- package/template/src/demo/components/SetupWizard.tsx +48 -0
- package/template/src/demo/components/StatusBadge.tsx +83 -0
- package/template/src/demo/components/index.ts +10 -0
- package/template/src/demo/hooks/index.ts +6 -0
- package/template/src/demo/hooks/useAuditEvents.ts +30 -0
- package/template/src/demo/layout/Shell.tsx +110 -0
- package/template/src/demo/layout/Sidebar.tsx +192 -0
- package/template/src/demo/pages/AuditLogPage.tsx +235 -0
- package/template/src/demo/pages/DocGenPage.tsx +874 -0
- package/template/src/demo/pages/HomeSetupPage.tsx +182 -0
- package/template/src/demo/pages/LoginPage.tsx +192 -0
- package/template/src/demo/pages/PathPermissionsPage.tsx +873 -0
- package/template/src/demo/pages/RunnerPage.tsx +445 -0
- package/template/src/demo/pages/SystemPage.tsx +557 -0
- package/template/src/demo/pages/VaultPage.tsx +805 -0
- package/template/src/ekka/__tests__/demo-backend.test.ts +187 -0
- package/template/src/ekka/audit/index.ts +7 -0
- package/template/src/ekka/audit/store.ts +68 -0
- package/template/src/ekka/audit/types.ts +22 -0
- package/template/src/ekka/auth/client.ts +212 -0
- package/template/src/ekka/auth/index.ts +30 -0
- package/template/src/ekka/auth/storage.ts +114 -0
- package/template/src/ekka/auth/types.ts +67 -0
- package/template/src/ekka/backend/demo.ts +151 -0
- package/template/src/ekka/backend/interface.ts +36 -0
- package/template/src/ekka/config.ts +48 -0
- package/template/src/ekka/constants.ts +143 -0
- package/template/src/ekka/errors.ts +54 -0
- package/template/src/ekka/index.ts +516 -0
- package/template/src/ekka/internal/backend.ts +156 -0
- package/template/src/ekka/internal/index.ts +7 -0
- package/template/src/ekka/ops/auth.ts +29 -0
- package/template/src/ekka/ops/debug.ts +68 -0
- package/template/src/ekka/ops/home.ts +101 -0
- package/template/src/ekka/ops/index.ts +16 -0
- package/template/src/ekka/ops/nodeCredentials.ts +131 -0
- package/template/src/ekka/ops/nodeSession.ts +145 -0
- package/template/src/ekka/ops/paths.ts +183 -0
- package/template/src/ekka/ops/runner.ts +86 -0
- package/template/src/ekka/ops/runtime.ts +31 -0
- package/template/src/ekka/ops/setup.ts +47 -0
- package/template/src/ekka/ops/vault.ts +459 -0
- package/template/src/ekka/ops/workflowRuns.ts +116 -0
- package/template/src/ekka/types.ts +82 -0
- package/template/src/ekka/utils/idempotency.ts +14 -0
- package/template/src/ekka/utils/index.ts +7 -0
- package/template/src/ekka/utils/time.ts +77 -0
- package/template/src/main.tsx +12 -0
- package/template/src/vite-env.d.ts +12 -0
- package/template/src-tauri/Cargo.toml +41 -0
- package/template/src-tauri/build.rs +3 -0
- package/template/src-tauri/capabilities/default.json +11 -0
- package/template/src-tauri/icons/icon.icns +0 -0
- package/template/src-tauri/icons/icon.png +0 -0
- package/template/src-tauri/resources/ekka-engine-bootstrap +0 -0
- package/template/src-tauri/src/bootstrap.rs +37 -0
- package/template/src-tauri/src/commands.rs +1215 -0
- package/template/src-tauri/src/device_secret.rs +111 -0
- package/template/src-tauri/src/engine_process.rs +538 -0
- package/template/src-tauri/src/grants.rs +129 -0
- package/template/src-tauri/src/handlers/home.rs +65 -0
- package/template/src-tauri/src/handlers/mod.rs +7 -0
- package/template/src-tauri/src/handlers/paths.rs +128 -0
- package/template/src-tauri/src/handlers/vault.rs +680 -0
- package/template/src-tauri/src/main.rs +243 -0
- package/template/src-tauri/src/node_auth.rs +858 -0
- package/template/src-tauri/src/node_credentials.rs +541 -0
- package/template/src-tauri/src/node_runner.rs +882 -0
- package/template/src-tauri/src/node_vault_crypto.rs +113 -0
- package/template/src-tauri/src/node_vault_store.rs +267 -0
- package/template/src-tauri/src/ops/auth.rs +50 -0
- package/template/src-tauri/src/ops/home.rs +251 -0
- package/template/src-tauri/src/ops/mod.rs +7 -0
- package/template/src-tauri/src/ops/runtime.rs +21 -0
- package/template/src-tauri/src/state.rs +639 -0
- package/template/src-tauri/src/types.rs +84 -0
- package/template/src-tauri/tauri.conf.json +41 -0
- package/template/tsconfig.json +26 -0
- package/template/tsconfig.tsbuildinfo +1 -0
- package/template/vite.config.ts +34 -0
|
@@ -0,0 +1,1215 @@
|
|
|
1
|
+
//! Tauri commands
|
|
2
|
+
//!
|
|
3
|
+
//! Entry points for TypeScript → Rust communication.
|
|
4
|
+
|
|
5
|
+
use crate::bootstrap::initialize_home;
|
|
6
|
+
use crate::engine_process;
|
|
7
|
+
use crate::grants::require_home_granted;
|
|
8
|
+
use crate::handlers;
|
|
9
|
+
use crate::node_auth;
|
|
10
|
+
use crate::node_credentials;
|
|
11
|
+
use crate::ops::{auth, runtime};
|
|
12
|
+
use crate::state::EngineState;
|
|
13
|
+
use crate::types::{EngineRequest, EngineResponse};
|
|
14
|
+
use serde_json::Value;
|
|
15
|
+
use tauri::State;
|
|
16
|
+
|
|
17
|
+
/// Initialize the SDK and store in state
|
|
18
|
+
#[tauri::command]
|
|
19
|
+
pub fn engine_connect(state: State<EngineState>) -> Result<(), String> {
|
|
20
|
+
let mut connected = state.connected.lock().map_err(|e| e.to_string())?;
|
|
21
|
+
|
|
22
|
+
if *connected {
|
|
23
|
+
return Ok(());
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Initialize home directory structure
|
|
27
|
+
let bootstrap = initialize_home()?;
|
|
28
|
+
|
|
29
|
+
// Store home path
|
|
30
|
+
if let Ok(mut hp) = state.home_path.lock() {
|
|
31
|
+
*hp = Some(bootstrap.home_path().to_path_buf());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Store instance_id from marker (used as node_id for grants)
|
|
35
|
+
let marker_path = bootstrap.home_path().join(".ekka-marker.json");
|
|
36
|
+
if let Ok(content) = std::fs::read_to_string(&marker_path) {
|
|
37
|
+
if let Ok(marker) = serde_json::from_str::<Value>(&content) {
|
|
38
|
+
if let Some(instance_id_str) = marker.get("instance_id").and_then(|v| v.as_str()) {
|
|
39
|
+
if let Ok(instance_id) = uuid::Uuid::parse_str(instance_id_str) {
|
|
40
|
+
if let Ok(mut nid) = state.node_id.lock() {
|
|
41
|
+
*nid = Some(instance_id);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
*connected = true;
|
|
49
|
+
Ok(())
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Clean up
|
|
53
|
+
#[tauri::command]
|
|
54
|
+
pub fn engine_disconnect(state: State<EngineState>) {
|
|
55
|
+
// Clear vault cache first (before clearing auth)
|
|
56
|
+
state.clear_vault_cache();
|
|
57
|
+
|
|
58
|
+
if let Ok(mut connected) = state.connected.lock() {
|
|
59
|
+
*connected = false;
|
|
60
|
+
}
|
|
61
|
+
if let Ok(mut auth) = state.auth.lock() {
|
|
62
|
+
*auth = None;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Main RPC dispatcher - routes all operations to handlers
|
|
67
|
+
///
|
|
68
|
+
/// ═══════════════════════════════════════════════════════════════════════════════════════════
|
|
69
|
+
/// DRIFT GUARD - ARCHITECTURE FREEZE
|
|
70
|
+
/// ═══════════════════════════════════════════════════════════════════════════════════════════
|
|
71
|
+
/// DO NOT extend this without revisiting the Desktop–Engine architecture decision.
|
|
72
|
+
///
|
|
73
|
+
/// The routing switch below (engine vs stub) is FROZEN as of Phase 3G.
|
|
74
|
+
/// Engine routing is one-way: once disabled for a session, it stays disabled.
|
|
75
|
+
/// The stub path handles all operations locally via SDK handlers.
|
|
76
|
+
///
|
|
77
|
+
/// Any changes to routing logic require explicit architecture review.
|
|
78
|
+
/// ═══════════════════════════════════════════════════════════════════════════════════════════
|
|
79
|
+
#[tauri::command]
|
|
80
|
+
pub fn engine_request(req: EngineRequest, state: State<EngineState>) -> EngineResponse {
|
|
81
|
+
// LOCAL-ONLY OPERATIONS: Never route to engine
|
|
82
|
+
// These are desktop-specific operations that must be handled locally
|
|
83
|
+
let local_only = matches!(
|
|
84
|
+
req.op.as_str(),
|
|
85
|
+
"setup.status" | "nodeCredentials.set" | "nodeCredentials.status" | "nodeCredentials.clear"
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// ROUTING SWITCH: If real engine is available and not local-only, route to it
|
|
89
|
+
if !local_only && state.is_engine_available() {
|
|
90
|
+
if let Some(response) = engine_process::route_to_engine(&req) {
|
|
91
|
+
return response;
|
|
92
|
+
}
|
|
93
|
+
// Engine failed - permanently disable for this session
|
|
94
|
+
state.disable_engine();
|
|
95
|
+
tracing::warn!(op = "engine.disabled.session", "Engine routing disabled for session, using stub");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// STUB PATH: Handle locally via SDK handlers
|
|
99
|
+
|
|
100
|
+
// Check connected (except for status operations and setup)
|
|
101
|
+
if !matches!(req.op.as_str(), "runtime.info" | "home.status" | "vault.status" | "setup.status" | "nodeCredentials.set" | "nodeCredentials.status" | "nodeCredentials.clear") {
|
|
102
|
+
let connected = match state.connected.lock() {
|
|
103
|
+
Ok(guard) => *guard,
|
|
104
|
+
Err(e) => return EngineResponse::err("INTERNAL_ERROR", &e.to_string()),
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
if !connected {
|
|
108
|
+
return EngineResponse::err(
|
|
109
|
+
"NOT_CONNECTED",
|
|
110
|
+
"Engine not connected. Call engine_connect first.",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Dispatch based on operation
|
|
116
|
+
match req.op.as_str() {
|
|
117
|
+
// Setup Status (pre-login, no connection required)
|
|
118
|
+
"setup.status" => handle_setup_status(&state),
|
|
119
|
+
|
|
120
|
+
// Auth
|
|
121
|
+
"auth.set" => auth::handle_set(&req.payload, &state),
|
|
122
|
+
|
|
123
|
+
// Node Credentials (keychain-stored node_id + node_secret)
|
|
124
|
+
"nodeCredentials.set" => handle_node_credentials_set(&req.payload),
|
|
125
|
+
"nodeCredentials.status" => handle_node_credentials_status(&state),
|
|
126
|
+
"nodeCredentials.clear" => handle_node_credentials_clear(),
|
|
127
|
+
|
|
128
|
+
// Node Session Authentication
|
|
129
|
+
"nodeSession.ensureIdentity" => handle_ensure_node_identity(&state),
|
|
130
|
+
"nodeSession.bootstrap" => handle_bootstrap_node_session(&req.payload, &state),
|
|
131
|
+
"nodeSession.status" => handle_node_session_status(&state),
|
|
132
|
+
|
|
133
|
+
// Home (use new SDK handlers)
|
|
134
|
+
"home.status" => handlers::home::handle_status(&state),
|
|
135
|
+
"home.grant" => handlers::home::handle_grant(&state),
|
|
136
|
+
|
|
137
|
+
// Paths (new SDK operations)
|
|
138
|
+
"paths.check" => handlers::paths::handle_check(&req.payload, &state),
|
|
139
|
+
"paths.list" => handlers::paths::handle_list(&req.payload, &state),
|
|
140
|
+
"paths.get" => handlers::paths::handle_get(&req.payload, &state),
|
|
141
|
+
"paths.request" => handlers::paths::handle_request(&req.payload, &state),
|
|
142
|
+
"paths.remove" => handlers::paths::handle_remove(&req.payload, &state),
|
|
143
|
+
|
|
144
|
+
// Runtime
|
|
145
|
+
"runtime.info" => runtime::handle_info(&state),
|
|
146
|
+
|
|
147
|
+
// Runner status (local runner loop status)
|
|
148
|
+
"runner.status" => {
|
|
149
|
+
let status = state.runner_state.get();
|
|
150
|
+
EngineResponse::ok(serde_json::json!(status))
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Runner task stats (proxied from engine API)
|
|
154
|
+
"runner.taskStats" => handle_runner_task_stats(&state),
|
|
155
|
+
|
|
156
|
+
// Workflow Runs (proxied from engine API)
|
|
157
|
+
"workflowRuns.create" => handle_workflow_runs_create(&req.payload),
|
|
158
|
+
"workflowRuns.get" => handle_workflow_runs_get(&req.payload),
|
|
159
|
+
|
|
160
|
+
// Auth (proxied from API)
|
|
161
|
+
"auth.login" => handle_auth_login(&req.payload),
|
|
162
|
+
"auth.refresh" => handle_auth_refresh(&req.payload),
|
|
163
|
+
"auth.logout" => handle_auth_logout(&req.payload),
|
|
164
|
+
|
|
165
|
+
// Engine status (read-only diagnostics)
|
|
166
|
+
"engine.status" => {
|
|
167
|
+
let status = state.get_engine_status();
|
|
168
|
+
EngineResponse::ok(serde_json::json!(status))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Database (require HOME_GRANTED)
|
|
172
|
+
"db.get" | "db.put" | "db.delete" => {
|
|
173
|
+
if let Err(e) = require_home_granted(&state) {
|
|
174
|
+
return e;
|
|
175
|
+
}
|
|
176
|
+
EngineResponse::err("NOT_IMPLEMENTED", &format!("{} not yet implemented", req.op))
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Queue (require HOME_GRANTED)
|
|
180
|
+
"queue.enqueue" | "queue.claim" | "queue.ack" | "queue.nack" | "queue.heartbeat" => {
|
|
181
|
+
if let Err(e) = require_home_granted(&state) {
|
|
182
|
+
return e;
|
|
183
|
+
}
|
|
184
|
+
EngineResponse::err("NOT_IMPLEMENTED", &format!("{} not yet implemented", req.op))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Pipeline (require HOME_GRANTED)
|
|
188
|
+
"pipeline.submit" | "pipeline.events" => {
|
|
189
|
+
if let Err(e) = require_home_granted(&state) {
|
|
190
|
+
return e;
|
|
191
|
+
}
|
|
192
|
+
EngineResponse::err("NOT_IMPLEMENTED", &format!("{} not yet implemented", req.op))
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Vault - Status/Capabilities (require HOME_GRANTED)
|
|
196
|
+
"vault.status" => {
|
|
197
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
198
|
+
handlers::vault::handle_status(&state)
|
|
199
|
+
}
|
|
200
|
+
"vault.capabilities" => {
|
|
201
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
202
|
+
handlers::vault::handle_capabilities(&state)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Vault - Secrets (require HOME_GRANTED)
|
|
206
|
+
"vault.secrets.list" => {
|
|
207
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
208
|
+
handlers::vault::handle_secrets_list(&req.payload, &state)
|
|
209
|
+
}
|
|
210
|
+
"vault.secrets.get" => {
|
|
211
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
212
|
+
handlers::vault::handle_secrets_get(&req.payload, &state)
|
|
213
|
+
}
|
|
214
|
+
"vault.secrets.create" => {
|
|
215
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
216
|
+
handlers::vault::handle_secrets_create(&req.payload, &state)
|
|
217
|
+
}
|
|
218
|
+
"vault.secrets.update" => {
|
|
219
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
220
|
+
handlers::vault::handle_secrets_update(&req.payload, &state)
|
|
221
|
+
}
|
|
222
|
+
"vault.secrets.delete" => {
|
|
223
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
224
|
+
handlers::vault::handle_secrets_delete(&req.payload, &state)
|
|
225
|
+
}
|
|
226
|
+
"vault.secrets.upsert" => {
|
|
227
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
228
|
+
handlers::vault::handle_secrets_upsert(&req.payload, &state)
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Vault - Bundles (require HOME_GRANTED)
|
|
232
|
+
"vault.bundles.list" => {
|
|
233
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
234
|
+
handlers::vault::handle_bundles_list(&req.payload, &state)
|
|
235
|
+
}
|
|
236
|
+
"vault.bundles.get" => {
|
|
237
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
238
|
+
handlers::vault::handle_bundles_get(&req.payload, &state)
|
|
239
|
+
}
|
|
240
|
+
"vault.bundles.create" => {
|
|
241
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
242
|
+
handlers::vault::handle_bundles_create(&req.payload, &state)
|
|
243
|
+
}
|
|
244
|
+
"vault.bundles.rename" => {
|
|
245
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
246
|
+
handlers::vault::handle_bundles_rename(&req.payload, &state)
|
|
247
|
+
}
|
|
248
|
+
"vault.bundles.delete" => {
|
|
249
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
250
|
+
handlers::vault::handle_bundles_delete(&req.payload, &state)
|
|
251
|
+
}
|
|
252
|
+
"vault.bundles.listSecrets" => {
|
|
253
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
254
|
+
handlers::vault::handle_bundles_list_secrets(&req.payload, &state)
|
|
255
|
+
}
|
|
256
|
+
"vault.bundles.addSecret" => {
|
|
257
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
258
|
+
handlers::vault::handle_bundles_add_secret(&req.payload, &state)
|
|
259
|
+
}
|
|
260
|
+
"vault.bundles.removeSecret" => {
|
|
261
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
262
|
+
handlers::vault::handle_bundles_remove_secret(&req.payload, &state)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Vault - Files (require HOME_GRANTED)
|
|
266
|
+
"vault.files.writeText" => {
|
|
267
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
268
|
+
handlers::vault::handle_files_write_text(&req.payload, &state)
|
|
269
|
+
}
|
|
270
|
+
"vault.files.writeBytes" => {
|
|
271
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
272
|
+
handlers::vault::handle_files_write_bytes(&req.payload, &state)
|
|
273
|
+
}
|
|
274
|
+
"vault.files.readText" => {
|
|
275
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
276
|
+
handlers::vault::handle_files_read_text(&req.payload, &state)
|
|
277
|
+
}
|
|
278
|
+
"vault.files.readBytes" => {
|
|
279
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
280
|
+
handlers::vault::handle_files_read_bytes(&req.payload, &state)
|
|
281
|
+
}
|
|
282
|
+
"vault.files.list" => {
|
|
283
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
284
|
+
handlers::vault::handle_files_list(&req.payload, &state)
|
|
285
|
+
}
|
|
286
|
+
"vault.files.exists" => {
|
|
287
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
288
|
+
handlers::vault::handle_files_exists(&req.payload, &state)
|
|
289
|
+
}
|
|
290
|
+
"vault.files.delete" => {
|
|
291
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
292
|
+
handlers::vault::handle_files_delete(&req.payload, &state)
|
|
293
|
+
}
|
|
294
|
+
"vault.files.mkdir" => {
|
|
295
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
296
|
+
handlers::vault::handle_files_mkdir(&req.payload, &state)
|
|
297
|
+
}
|
|
298
|
+
"vault.files.move" => {
|
|
299
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
300
|
+
handlers::vault::handle_files_move(&req.payload, &state)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Vault - Injection (require HOME_GRANTED) - DEFERRED
|
|
304
|
+
"vault.attachSecretsToConnector" => {
|
|
305
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
306
|
+
handlers::vault::handle_attach_secrets_to_connector(&req.payload, &state)
|
|
307
|
+
}
|
|
308
|
+
"vault.injectSecretsIntoRun" => {
|
|
309
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
310
|
+
handlers::vault::handle_inject_secrets_into_run(&req.payload, &state)
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Vault - Audit (require HOME_GRANTED)
|
|
314
|
+
"vault.audit.list" => {
|
|
315
|
+
if let Err(e) = require_home_granted(&state) { return e; }
|
|
316
|
+
handlers::vault::handle_audit_list(&req.payload, &state)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Legacy vault operations (deprecated)
|
|
320
|
+
"vault.init" | "vault.isInitialized" | "vault.install" | "vault.listBundles"
|
|
321
|
+
| "vault.showPolicy" | "vault.folders.list" | "vault.folders.get"
|
|
322
|
+
| "vault.folders.create" | "vault.folders.rename" | "vault.folders.delete" => {
|
|
323
|
+
if let Err(e) = require_home_granted(&state) {
|
|
324
|
+
return e;
|
|
325
|
+
}
|
|
326
|
+
EngineResponse::err("DEPRECATED", &format!("{} is deprecated, use vault.* API instead", req.op))
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Debug utilities (dev mode only)
|
|
330
|
+
"debug.isDevMode" => handle_is_dev_mode(),
|
|
331
|
+
"debug.openFolder" => handle_open_folder(&req.payload, &state),
|
|
332
|
+
"debug.resolveVaultPath" => handle_resolve_vault_path(&req.payload, &state),
|
|
333
|
+
|
|
334
|
+
// Unknown
|
|
335
|
+
_ => EngineResponse::err("INVALID_OP", &format!("Unknown operation: {}", req.op)),
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// =============================================================================
|
|
340
|
+
// Setup Status Handler
|
|
341
|
+
// =============================================================================
|
|
342
|
+
|
|
343
|
+
/// Get setup status for pre-login wizard
|
|
344
|
+
///
|
|
345
|
+
/// Returns status of:
|
|
346
|
+
/// - nodeIdentity: configured | not_configured
|
|
347
|
+
/// - setupComplete: true if node credentials are configured
|
|
348
|
+
///
|
|
349
|
+
/// This is called before login to determine if setup wizard is needed.
|
|
350
|
+
/// Home folder grant is handled post-login via HomeSetupPage.
|
|
351
|
+
fn handle_setup_status(_state: &EngineState) -> EngineResponse {
|
|
352
|
+
tracing::info!(op = "rust.local.op", opName = "setup.status", "Handling setup.status locally");
|
|
353
|
+
|
|
354
|
+
// Check node identity status (credentials in vault)
|
|
355
|
+
let node_configured = node_credentials::has_credentials();
|
|
356
|
+
|
|
357
|
+
// Setup is complete when node credentials are configured
|
|
358
|
+
// Home folder grant is handled post-login, not here
|
|
359
|
+
let setup_complete = node_configured;
|
|
360
|
+
|
|
361
|
+
tracing::info!(
|
|
362
|
+
op = "desktop.setup.status",
|
|
363
|
+
node_configured = node_configured,
|
|
364
|
+
setup_complete = setup_complete,
|
|
365
|
+
"Setup status checked"
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
EngineResponse::ok(serde_json::json!({
|
|
369
|
+
"nodeIdentity": if node_configured { "configured" } else { "not_configured" },
|
|
370
|
+
"setupComplete": setup_complete
|
|
371
|
+
}))
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// =============================================================================
|
|
375
|
+
// Node Credentials Handlers
|
|
376
|
+
// =============================================================================
|
|
377
|
+
|
|
378
|
+
/// Set node credentials (store in OS keychain)
|
|
379
|
+
///
|
|
380
|
+
/// Accepts node_id (UUID) and node_secret (string).
|
|
381
|
+
/// Validates both before storing.
|
|
382
|
+
fn handle_node_credentials_set(payload: &Value) -> EngineResponse {
|
|
383
|
+
tracing::info!(op = "rust.local.op", opName = "nodeCredentials.set", "Handling nodeCredentials.set locally");
|
|
384
|
+
|
|
385
|
+
// Extract node_id
|
|
386
|
+
let node_id_str = match payload.get("nodeId").and_then(|v| v.as_str()) {
|
|
387
|
+
Some(id) => id,
|
|
388
|
+
None => return EngineResponse::err("INVALID_PAYLOAD", "nodeId is required"),
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
// Validate node_id format
|
|
392
|
+
let node_id = match node_credentials::validate_node_id(node_id_str) {
|
|
393
|
+
Ok(id) => id,
|
|
394
|
+
Err(e) => return EngineResponse::err("INVALID_NODE_ID", &e.to_string()),
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
// Extract node_secret
|
|
398
|
+
let node_secret = match payload.get("nodeSecret").and_then(|v| v.as_str()) {
|
|
399
|
+
Some(secret) => secret,
|
|
400
|
+
None => return EngineResponse::err("INVALID_PAYLOAD", "nodeSecret is required"),
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
// Validate node_secret
|
|
404
|
+
if let Err(e) = node_credentials::validate_node_secret(node_secret) {
|
|
405
|
+
return EngineResponse::err("INVALID_NODE_SECRET", &e.to_string());
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Store credentials
|
|
409
|
+
match node_credentials::store_credentials(&node_id, node_secret) {
|
|
410
|
+
Ok(()) => EngineResponse::ok(serde_json::json!({
|
|
411
|
+
"ok": true,
|
|
412
|
+
"nodeId": node_id.to_string()
|
|
413
|
+
})),
|
|
414
|
+
Err(e) => EngineResponse::err("CREDENTIALS_STORE_ERROR", &e.to_string()),
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/// Get node credentials status (has credentials + node_id + auth status)
|
|
419
|
+
fn handle_node_credentials_status(state: &EngineState) -> EngineResponse {
|
|
420
|
+
tracing::info!(op = "rust.local.op", opName = "nodeCredentials.status", "Handling nodeCredentials.status locally");
|
|
421
|
+
let status = node_credentials::get_status();
|
|
422
|
+
let auth_token = state.get_node_auth_token();
|
|
423
|
+
|
|
424
|
+
EngineResponse::ok(serde_json::json!({
|
|
425
|
+
"hasCredentials": status.has_credentials,
|
|
426
|
+
"nodeId": status.node_id,
|
|
427
|
+
"isAuthenticated": auth_token.is_some(),
|
|
428
|
+
"authSession": auth_token.map(|t| serde_json::json!({
|
|
429
|
+
"sessionId": t.session_id.to_string(),
|
|
430
|
+
"tenantId": t.tenant_id.to_string(),
|
|
431
|
+
"workspaceId": t.workspace_id.to_string(),
|
|
432
|
+
"expiresAt": t.expires_at.to_rfc3339()
|
|
433
|
+
}))
|
|
434
|
+
}))
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/// Clear node credentials from OS keychain
|
|
438
|
+
fn handle_node_credentials_clear() -> EngineResponse {
|
|
439
|
+
tracing::info!(op = "rust.local.op", opName = "nodeCredentials.clear", "Handling nodeCredentials.clear locally");
|
|
440
|
+
match node_credentials::clear_credentials() {
|
|
441
|
+
Ok(()) => EngineResponse::ok(serde_json::json!({
|
|
442
|
+
"ok": true
|
|
443
|
+
})),
|
|
444
|
+
Err(e) => EngineResponse::err("CREDENTIALS_CLEAR_ERROR", &e.to_string()),
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// =============================================================================
|
|
449
|
+
// Runner Task Stats (Proxied HTTP)
|
|
450
|
+
// =============================================================================
|
|
451
|
+
|
|
452
|
+
/// Fetch runner task stats from engine API.
|
|
453
|
+
/// Proxies GET /engine/runner-tasks/stats through Rust to avoid CORS.
|
|
454
|
+
/// Auto-authenticates from keychain if token is missing (single-flight, no retry on failure).
|
|
455
|
+
fn handle_runner_task_stats(state: &EngineState) -> EngineResponse {
|
|
456
|
+
use crate::state::NodeAuthState;
|
|
457
|
+
|
|
458
|
+
// Get engine URL (needed for both auth and stats request)
|
|
459
|
+
let engine_url = std::env::var("EKKA_ENGINE_URL")
|
|
460
|
+
.unwrap_or_else(|_| "https://api.ekka.ai".to_string());
|
|
461
|
+
|
|
462
|
+
// Get node auth token for Authorization header
|
|
463
|
+
let node_token = match state.get_node_auth_token() {
|
|
464
|
+
Some(token) => token,
|
|
465
|
+
None => {
|
|
466
|
+
// Check auth state - don't retry if already failed
|
|
467
|
+
let auth_state = state.node_auth_state.get();
|
|
468
|
+
if auth_state == NodeAuthState::Failed {
|
|
469
|
+
let error = state.node_auth_state.get_last_error()
|
|
470
|
+
.unwrap_or_else(|| "Authentication previously failed".to_string());
|
|
471
|
+
return EngineResponse::err("NOT_AUTHENTICATED", &error);
|
|
472
|
+
}
|
|
473
|
+
if auth_state == NodeAuthState::Authenticating {
|
|
474
|
+
return EngineResponse::err("NOT_AUTHENTICATED", "Authentication in progress");
|
|
475
|
+
}
|
|
476
|
+
if auth_state == NodeAuthState::Authenticated {
|
|
477
|
+
// Token should exist but doesn't - inconsistent state
|
|
478
|
+
return EngineResponse::err("NOT_AUTHENTICATED", "Token expired, restart app");
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Token missing - try auto-auth from keychain (single-flight)
|
|
482
|
+
if !node_credentials::has_credentials() {
|
|
483
|
+
return EngineResponse::err(
|
|
484
|
+
"NOT_AUTHENTICATED",
|
|
485
|
+
"Node not authenticated. Complete setup first.",
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Try to acquire single-flight lock
|
|
490
|
+
if !state.node_auth_state.try_start() {
|
|
491
|
+
return EngineResponse::err("NOT_AUTHENTICATED", "Authentication in progress");
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// Attempt auto-auth (single attempt, no retry)
|
|
495
|
+
tracing::info!(
|
|
496
|
+
op = "desktop.node.auth.attempt",
|
|
497
|
+
reason = "runner.taskStats",
|
|
498
|
+
"Authenticating node from keychain"
|
|
499
|
+
);
|
|
500
|
+
|
|
501
|
+
match node_credentials::authenticate_node(&engine_url) {
|
|
502
|
+
Ok(token) => {
|
|
503
|
+
// Store token and mark authenticated
|
|
504
|
+
state.node_auth_token.set(token.clone());
|
|
505
|
+
state.node_auth_state.set_authenticated();
|
|
506
|
+
token
|
|
507
|
+
}
|
|
508
|
+
Err(e) => {
|
|
509
|
+
let error_msg = format!("Node authentication failed: {}", e);
|
|
510
|
+
tracing::warn!(
|
|
511
|
+
op = "desktop.node.auth.failed",
|
|
512
|
+
error = %e,
|
|
513
|
+
"Node authentication failed - will not retry"
|
|
514
|
+
);
|
|
515
|
+
state.node_auth_state.set_failed(error_msg.clone());
|
|
516
|
+
return EngineResponse::err("NOT_AUTHENTICATED", &error_msg);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
|
|
522
|
+
// Build HTTP client
|
|
523
|
+
let client = match reqwest::blocking::Client::builder()
|
|
524
|
+
.timeout(std::time::Duration::from_secs(30))
|
|
525
|
+
.build()
|
|
526
|
+
{
|
|
527
|
+
Ok(c) => c,
|
|
528
|
+
Err(e) => return EngineResponse::err("HTTP_CLIENT_ERROR", &e.to_string()),
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
let request_id = uuid::Uuid::new_v4().to_string();
|
|
532
|
+
|
|
533
|
+
// Make request with security envelope headers
|
|
534
|
+
let response = client
|
|
535
|
+
.get(format!("{}/engine/runner-tasks/stats", engine_url))
|
|
536
|
+
.header("Content-Type", "application/json")
|
|
537
|
+
.header("Authorization", format!("Bearer {}", node_token.token))
|
|
538
|
+
.header("X-EKKA-PROOF-TYPE", "jwt")
|
|
539
|
+
.header("X-REQUEST-ID", &request_id)
|
|
540
|
+
.header("X-EKKA-CORRELATION-ID", &request_id)
|
|
541
|
+
.header("X-EKKA-MODULE", "engine.runner_tasks")
|
|
542
|
+
.header("X-EKKA-ACTION", "stats")
|
|
543
|
+
.header("X-EKKA-CLIENT", "ekka-desktop")
|
|
544
|
+
.header("X-EKKA-CLIENT-VERSION", "0.2.0")
|
|
545
|
+
.send();
|
|
546
|
+
|
|
547
|
+
match response {
|
|
548
|
+
Ok(resp) => {
|
|
549
|
+
let status = resp.status();
|
|
550
|
+
if status.is_success() {
|
|
551
|
+
match resp.json::<serde_json::Value>() {
|
|
552
|
+
Ok(data) => EngineResponse::ok(data),
|
|
553
|
+
Err(e) => EngineResponse::err("PARSE_ERROR", &e.to_string()),
|
|
554
|
+
}
|
|
555
|
+
} else {
|
|
556
|
+
let status_code = status.as_u16();
|
|
557
|
+
let body = resp.text().unwrap_or_default();
|
|
558
|
+
EngineResponse::err(
|
|
559
|
+
"HTTP_ERROR",
|
|
560
|
+
&format!("HTTP {}: {}", status_code, body),
|
|
561
|
+
)
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
Err(e) => EngineResponse::err("REQUEST_FAILED", &e.to_string()),
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// =============================================================================
|
|
569
|
+
// Debug Handlers
|
|
570
|
+
// =============================================================================
|
|
571
|
+
|
|
572
|
+
/// Check if running in development mode (EKKA_ENV=development)
|
|
573
|
+
fn handle_is_dev_mode() -> EngineResponse {
|
|
574
|
+
let is_dev = std::env::var("EKKA_ENV")
|
|
575
|
+
.map(|v| v == "development")
|
|
576
|
+
.unwrap_or(false);
|
|
577
|
+
|
|
578
|
+
EngineResponse::ok(serde_json::json!({ "isDevMode": is_dev }))
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/// Open a folder in the system file browser
|
|
582
|
+
fn handle_open_folder(payload: &Value, state: &EngineState) -> EngineResponse {
|
|
583
|
+
// Only allow in dev mode
|
|
584
|
+
let is_dev = std::env::var("EKKA_ENV")
|
|
585
|
+
.map(|v| v == "development")
|
|
586
|
+
.unwrap_or(false);
|
|
587
|
+
|
|
588
|
+
if !is_dev {
|
|
589
|
+
return EngineResponse::err("DEV_MODE_ONLY", "debug.openFolder is only available in development mode");
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
let path = match payload.get("path").and_then(|v| v.as_str()) {
|
|
593
|
+
Some(p) => p,
|
|
594
|
+
None => return EngineResponse::err("INVALID_PAYLOAD", "path is required"),
|
|
595
|
+
};
|
|
596
|
+
|
|
597
|
+
// If path starts with vault://, resolve it to filesystem path
|
|
598
|
+
let resolved_path = if path.starts_with("vault://") {
|
|
599
|
+
// Get home path
|
|
600
|
+
let home_path = match state.home_path.lock() {
|
|
601
|
+
Ok(guard) => match guard.as_ref() {
|
|
602
|
+
Some(p) => p.clone(),
|
|
603
|
+
None => return EngineResponse::err("NOT_CONNECTED", "Home path not initialized"),
|
|
604
|
+
},
|
|
605
|
+
Err(e) => return EngineResponse::err("INTERNAL_ERROR", &e.to_string()),
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
// vault://tmp/... -> {EKKA_HOME}/vault/tmp/...
|
|
609
|
+
let vault_path = path.strip_prefix("vault://").unwrap_or(path);
|
|
610
|
+
home_path.join("vault").join(vault_path)
|
|
611
|
+
} else {
|
|
612
|
+
std::path::PathBuf::from(path)
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
// Check if path exists
|
|
616
|
+
if !resolved_path.exists() {
|
|
617
|
+
return EngineResponse::err("PATH_NOT_FOUND", &format!("Path does not exist: {}", resolved_path.display()));
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// Open folder using system default
|
|
621
|
+
#[cfg(target_os = "macos")]
|
|
622
|
+
{
|
|
623
|
+
std::process::Command::new("open")
|
|
624
|
+
.arg(&resolved_path)
|
|
625
|
+
.spawn()
|
|
626
|
+
.map_err(|e| e.to_string())
|
|
627
|
+
.ok();
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
#[cfg(target_os = "windows")]
|
|
631
|
+
{
|
|
632
|
+
std::process::Command::new("explorer")
|
|
633
|
+
.arg(&resolved_path)
|
|
634
|
+
.spawn()
|
|
635
|
+
.map_err(|e| e.to_string())
|
|
636
|
+
.ok();
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
#[cfg(target_os = "linux")]
|
|
640
|
+
{
|
|
641
|
+
std::process::Command::new("xdg-open")
|
|
642
|
+
.arg(&resolved_path)
|
|
643
|
+
.spawn()
|
|
644
|
+
.map_err(|e| e.to_string())
|
|
645
|
+
.ok();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
EngineResponse::ok(serde_json::json!({
|
|
649
|
+
"ok": true,
|
|
650
|
+
"path": resolved_path.display().to_string()
|
|
651
|
+
}))
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/// Resolve a vault:// path to filesystem path (dev mode only)
|
|
655
|
+
fn handle_resolve_vault_path(payload: &Value, state: &EngineState) -> EngineResponse {
|
|
656
|
+
// Only allow in dev mode
|
|
657
|
+
let is_dev = std::env::var("EKKA_ENV")
|
|
658
|
+
.map(|v| v == "development")
|
|
659
|
+
.unwrap_or(false);
|
|
660
|
+
|
|
661
|
+
if !is_dev {
|
|
662
|
+
return EngineResponse::err("DEV_MODE_ONLY", "debug.resolveVaultPath is only available in development mode");
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
let vault_uri = match payload.get("path").and_then(|v| v.as_str()) {
|
|
666
|
+
Some(p) => p,
|
|
667
|
+
None => return EngineResponse::err("INVALID_PAYLOAD", "path is required"),
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
if !vault_uri.starts_with("vault://") {
|
|
671
|
+
return EngineResponse::err("INVALID_PATH", "Path must start with vault://");
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Get home path
|
|
675
|
+
let home_path = match state.home_path.lock() {
|
|
676
|
+
Ok(guard) => match guard.as_ref() {
|
|
677
|
+
Some(p) => p.clone(),
|
|
678
|
+
None => return EngineResponse::err("NOT_CONNECTED", "Home path not initialized"),
|
|
679
|
+
},
|
|
680
|
+
Err(e) => return EngineResponse::err("INTERNAL_ERROR", &e.to_string()),
|
|
681
|
+
};
|
|
682
|
+
|
|
683
|
+
// vault://tmp/... -> {EKKA_HOME}/vault/tmp/...
|
|
684
|
+
let vault_path = vault_uri.strip_prefix("vault://").unwrap_or(vault_uri);
|
|
685
|
+
let resolved_path = home_path.join("vault").join(vault_path);
|
|
686
|
+
|
|
687
|
+
EngineResponse::ok(serde_json::json!({
|
|
688
|
+
"vaultUri": vault_uri,
|
|
689
|
+
"filesystemPath": resolved_path.display().to_string(),
|
|
690
|
+
"exists": resolved_path.exists()
|
|
691
|
+
}))
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// =============================================================================
|
|
695
|
+
// Node Session Handlers
|
|
696
|
+
// =============================================================================
|
|
697
|
+
|
|
698
|
+
/// Ensure node identity exists
|
|
699
|
+
///
|
|
700
|
+
/// Returns node identity from node auth token (obtained at startup via node_secret auth).
|
|
701
|
+
/// Does NOT use Ed25519 keypair generation.
|
|
702
|
+
fn handle_ensure_node_identity(state: &EngineState) -> EngineResponse {
|
|
703
|
+
// Use node auth token if available (from startup auth)
|
|
704
|
+
if let Some(node_token) = state.get_node_auth_token() {
|
|
705
|
+
tracing::info!(
|
|
706
|
+
op = "node_identity.from_token",
|
|
707
|
+
node_id = %node_token.node_id,
|
|
708
|
+
"Node identity from auth token"
|
|
709
|
+
);
|
|
710
|
+
return EngineResponse::ok(serde_json::json!({
|
|
711
|
+
"ok": true,
|
|
712
|
+
"node_id": node_token.node_id.to_string(),
|
|
713
|
+
"tenant_id": node_token.tenant_id.to_string(),
|
|
714
|
+
"workspace_id": node_token.workspace_id.to_string(),
|
|
715
|
+
"auth_method": "node_secret"
|
|
716
|
+
}));
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
// No node auth token - check for credentials
|
|
720
|
+
let creds_status = node_credentials::get_status();
|
|
721
|
+
if creds_status.has_credentials {
|
|
722
|
+
// Credentials exist but auth failed or not attempted
|
|
723
|
+
return EngineResponse::err(
|
|
724
|
+
"NODE_NOT_AUTHENTICATED",
|
|
725
|
+
"Node credentials exist but not authenticated. Restart app to authenticate.",
|
|
726
|
+
);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// No credentials configured
|
|
730
|
+
EngineResponse::err(
|
|
731
|
+
"NODE_CREDENTIALS_MISSING",
|
|
732
|
+
"Node credentials not configured. Use nodeCredentials.set to configure.",
|
|
733
|
+
)
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/// Bootstrap node session using node_id + node_secret auth
|
|
737
|
+
///
|
|
738
|
+
/// Uses node auth token (role=node) obtained at startup.
|
|
739
|
+
/// Requires local engine to be available (strict local engine mode).
|
|
740
|
+
/// Does NOT use Ed25519 register/challenge/session flow.
|
|
741
|
+
fn handle_bootstrap_node_session(payload: &Value, state: &EngineState) -> EngineResponse {
|
|
742
|
+
// STRICT LOCAL ENGINE MODE: Gate node_auth + node_runner behind engine availability
|
|
743
|
+
if !state.is_engine_available() {
|
|
744
|
+
tracing::warn!(
|
|
745
|
+
op = "node_runner.skipped.engine_unavailable",
|
|
746
|
+
engine_available = false,
|
|
747
|
+
"Node session bootstrap skipped: local engine not available"
|
|
748
|
+
);
|
|
749
|
+
return EngineResponse::err(
|
|
750
|
+
"ENGINE_UNAVAILABLE",
|
|
751
|
+
"Local engine not available. Node session requires local engine.",
|
|
752
|
+
);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
// Get home_path
|
|
756
|
+
let home_path = match state.home_path.lock() {
|
|
757
|
+
Ok(guard) => match guard.as_ref() {
|
|
758
|
+
Some(p) => p.clone(),
|
|
759
|
+
None => return EngineResponse::err("NOT_CONNECTED", "Home path not initialized"),
|
|
760
|
+
},
|
|
761
|
+
Err(e) => return EngineResponse::err("INTERNAL_ERROR", &e.to_string()),
|
|
762
|
+
};
|
|
763
|
+
|
|
764
|
+
// Get node_id from state
|
|
765
|
+
let node_id = match state.node_id.lock() {
|
|
766
|
+
Ok(guard) => match guard.as_ref() {
|
|
767
|
+
Some(id) => *id,
|
|
768
|
+
None => return EngineResponse::err("NOT_CONNECTED", "Node ID not initialized"),
|
|
769
|
+
},
|
|
770
|
+
Err(e) => return EngineResponse::err("INTERNAL_ERROR", &e.to_string()),
|
|
771
|
+
};
|
|
772
|
+
|
|
773
|
+
// REQUIRE node auth token (from startup auth via node_id + node_secret)
|
|
774
|
+
// Do NOT fall back to user auth or Ed25519 flow
|
|
775
|
+
let node_token = match state.get_node_auth_token() {
|
|
776
|
+
Some(token) => {
|
|
777
|
+
tracing::info!(
|
|
778
|
+
op = "node_session.using_node_token",
|
|
779
|
+
node_id = %token.node_id,
|
|
780
|
+
session_id = %token.session_id,
|
|
781
|
+
"Using node auth token for session"
|
|
782
|
+
);
|
|
783
|
+
token
|
|
784
|
+
}
|
|
785
|
+
None => {
|
|
786
|
+
tracing::error!(
|
|
787
|
+
op = "node_session.no_token",
|
|
788
|
+
"Node auth token not available - authenticate node at startup first"
|
|
789
|
+
);
|
|
790
|
+
return EngineResponse::err(
|
|
791
|
+
"NODE_NOT_AUTHENTICATED",
|
|
792
|
+
"Node not authenticated. Restart app with valid node credentials.",
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
// Get device fingerprint from marker
|
|
798
|
+
let marker_path = home_path.join(".ekka-marker.json");
|
|
799
|
+
let device_fingerprint = std::fs::read_to_string(&marker_path)
|
|
800
|
+
.ok()
|
|
801
|
+
.and_then(|content| serde_json::from_str::<Value>(&content).ok())
|
|
802
|
+
.and_then(|marker| marker.get("device_id_fingerprint").and_then(|v| v.as_str()).map(|s| s.to_string()));
|
|
803
|
+
|
|
804
|
+
// Create NodeSession from node auth token (no Ed25519 flow)
|
|
805
|
+
let node_session = node_auth::NodeSession {
|
|
806
|
+
token: node_token.token.clone(),
|
|
807
|
+
session_id: node_token.session_id,
|
|
808
|
+
tenant_id: node_token.tenant_id,
|
|
809
|
+
workspace_id: node_token.workspace_id,
|
|
810
|
+
expires_at: node_token.expires_at,
|
|
811
|
+
};
|
|
812
|
+
|
|
813
|
+
// Store session in state
|
|
814
|
+
state.node_session.set(node_session.clone());
|
|
815
|
+
|
|
816
|
+
// Check if we should start the runner
|
|
817
|
+
let start_runner = payload.get("startRunner").and_then(|v| v.as_bool()).unwrap_or(false);
|
|
818
|
+
|
|
819
|
+
if start_runner {
|
|
820
|
+
// Build runner config from node auth token
|
|
821
|
+
let runner_config = node_auth::NodeSessionRunnerConfig {
|
|
822
|
+
engine_url: std::env::var("EKKA_ENGINE_URL")
|
|
823
|
+
.or_else(|_| std::env::var("ENGINE_URL"))
|
|
824
|
+
.unwrap_or_default(),
|
|
825
|
+
node_url: std::env::var("NODE_URL").unwrap_or_else(|_| "http://127.0.0.1:7777".to_string()),
|
|
826
|
+
session_token: node_token.token.clone(),
|
|
827
|
+
tenant_id: node_token.tenant_id,
|
|
828
|
+
workspace_id: node_token.workspace_id,
|
|
829
|
+
node_id,
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
let runner_state = state.runner_state.clone();
|
|
833
|
+
let session_holder = state.node_session.clone();
|
|
834
|
+
let home_path_clone = home_path.clone();
|
|
835
|
+
let device_fp = device_fingerprint.clone();
|
|
836
|
+
|
|
837
|
+
// Spawn runner in background
|
|
838
|
+
tauri::async_runtime::spawn(async move {
|
|
839
|
+
let _ = crate::node_runner::start_node_runner(
|
|
840
|
+
runner_state,
|
|
841
|
+
session_holder,
|
|
842
|
+
runner_config,
|
|
843
|
+
home_path_clone,
|
|
844
|
+
device_fp,
|
|
845
|
+
None, // No user_sub with node auth
|
|
846
|
+
).await;
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
let session_info = serde_json::json!({
|
|
851
|
+
"session_id": node_session.session_id.to_string(),
|
|
852
|
+
"tenant_id": node_session.tenant_id.to_string(),
|
|
853
|
+
"workspace_id": node_session.workspace_id.to_string(),
|
|
854
|
+
"expires_at": node_session.expires_at.to_rfc3339()
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
EngineResponse::ok(serde_json::json!({
|
|
858
|
+
"ok": true,
|
|
859
|
+
"node_id": node_id.to_string(),
|
|
860
|
+
"auth_method": "node_secret",
|
|
861
|
+
"session": session_info
|
|
862
|
+
}))
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/// Get current node session status
|
|
866
|
+
fn handle_node_session_status(state: &EngineState) -> EngineResponse {
|
|
867
|
+
let identity = state.node_identity.lock().ok().and_then(|g| g.clone());
|
|
868
|
+
let session = state.node_session.get();
|
|
869
|
+
|
|
870
|
+
EngineResponse::ok(serde_json::json!({
|
|
871
|
+
"hasIdentity": identity.is_some(),
|
|
872
|
+
"hasSession": session.is_some(),
|
|
873
|
+
"sessionValid": state.node_session.get_valid().is_some(),
|
|
874
|
+
"identity": identity.map(|i| serde_json::json!({
|
|
875
|
+
"node_id": i.node_id.to_string(),
|
|
876
|
+
"public_key_b64": i.public_key_b64,
|
|
877
|
+
"created_at": i.created_at_iso_utc
|
|
878
|
+
})),
|
|
879
|
+
"session": session.map(|s| serde_json::json!({
|
|
880
|
+
"session_id": s.session_id.to_string(),
|
|
881
|
+
"tenant_id": s.tenant_id.to_string(),
|
|
882
|
+
"workspace_id": s.workspace_id.to_string(),
|
|
883
|
+
"expires_at": s.expires_at.to_rfc3339(),
|
|
884
|
+
"is_expired": s.is_expired()
|
|
885
|
+
}))
|
|
886
|
+
}))
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
// =============================================================================
|
|
890
|
+
// Workflow Runs Handlers (Proxied HTTP)
|
|
891
|
+
// =============================================================================
|
|
892
|
+
|
|
893
|
+
/// Build security envelope headers for proxied requests
|
|
894
|
+
fn build_security_headers(jwt: Option<&str>, module: &str, action: &str) -> Vec<(String, String)> {
|
|
895
|
+
let request_id = uuid::Uuid::new_v4().to_string();
|
|
896
|
+
let mut headers = vec![
|
|
897
|
+
("Content-Type".to_string(), "application/json".to_string()),
|
|
898
|
+
("X-REQUEST-ID".to_string(), request_id.clone()),
|
|
899
|
+
("X-EKKA-CORRELATION-ID".to_string(), request_id),
|
|
900
|
+
("X-EKKA-PROOF-TYPE".to_string(), if jwt.is_some() { "jwt" } else { "none" }.to_string()),
|
|
901
|
+
("X-EKKA-MODULE".to_string(), module.to_string()),
|
|
902
|
+
("X-EKKA-ACTION".to_string(), action.to_string()),
|
|
903
|
+
("X-EKKA-CLIENT".to_string(), "ekka-desktop".to_string()),
|
|
904
|
+
("X-EKKA-CLIENT-VERSION".to_string(), "0.2.0".to_string()),
|
|
905
|
+
];
|
|
906
|
+
|
|
907
|
+
if let Some(token) = jwt {
|
|
908
|
+
headers.push(("Authorization".to_string(), format!("Bearer {}", token)));
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
headers
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/// Create a workflow run (POST /engine/workflow-runs)
|
|
915
|
+
fn handle_workflow_runs_create(payload: &Value) -> EngineResponse {
|
|
916
|
+
let engine_url = std::env::var("EKKA_ENGINE_URL")
|
|
917
|
+
.unwrap_or_else(|_| "http://localhost:3200".to_string());
|
|
918
|
+
|
|
919
|
+
// Extract request body
|
|
920
|
+
let request = match payload.get("request") {
|
|
921
|
+
Some(r) => r.clone(),
|
|
922
|
+
None => return EngineResponse::err("INVALID_PAYLOAD", "request is required"),
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
// Extract optional JWT
|
|
926
|
+
let jwt = payload.get("jwt").and_then(|v| v.as_str());
|
|
927
|
+
|
|
928
|
+
// Build client
|
|
929
|
+
let client = match reqwest::blocking::Client::builder()
|
|
930
|
+
.timeout(std::time::Duration::from_secs(30))
|
|
931
|
+
.build()
|
|
932
|
+
{
|
|
933
|
+
Ok(c) => c,
|
|
934
|
+
Err(e) => return EngineResponse::err("HTTP_CLIENT_ERROR", &e.to_string()),
|
|
935
|
+
};
|
|
936
|
+
|
|
937
|
+
// Build request
|
|
938
|
+
let headers = build_security_headers(jwt, "desktop.docgen", "workflow.create");
|
|
939
|
+
let mut req_builder = client.post(format!("{}/engine/workflow-runs", engine_url));
|
|
940
|
+
|
|
941
|
+
for (key, value) in headers {
|
|
942
|
+
req_builder = req_builder.header(&key, &value);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
let response = req_builder.json(&request).send();
|
|
946
|
+
|
|
947
|
+
match response {
|
|
948
|
+
Ok(resp) => {
|
|
949
|
+
let status = resp.status();
|
|
950
|
+
if status.is_success() {
|
|
951
|
+
match resp.json::<serde_json::Value>() {
|
|
952
|
+
Ok(data) => EngineResponse::ok(data),
|
|
953
|
+
Err(e) => EngineResponse::err("PARSE_ERROR", &e.to_string()),
|
|
954
|
+
}
|
|
955
|
+
} else {
|
|
956
|
+
let status_code = status.as_u16();
|
|
957
|
+
let body = resp.text().unwrap_or_default();
|
|
958
|
+
let error_msg = serde_json::from_str::<Value>(&body)
|
|
959
|
+
.ok()
|
|
960
|
+
.and_then(|v| v.get("message").or(v.get("error")).and_then(|m| m.as_str()).map(|s| s.to_string()))
|
|
961
|
+
.unwrap_or_else(|| format!("HTTP {}", status_code));
|
|
962
|
+
EngineResponse::err_with_status("HTTP_ERROR", &error_msg, status_code)
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
Err(e) => {
|
|
966
|
+
if e.is_connect() {
|
|
967
|
+
EngineResponse::err("ENGINE_UNAVAILABLE", &format!("Cannot connect to engine at {}. Is the engine running?", engine_url))
|
|
968
|
+
} else {
|
|
969
|
+
EngineResponse::err("REQUEST_FAILED", &e.to_string())
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/// Get a workflow run (GET /engine/workflow-runs/{id})
|
|
976
|
+
fn handle_workflow_runs_get(payload: &Value) -> EngineResponse {
|
|
977
|
+
let engine_url = std::env::var("EKKA_ENGINE_URL")
|
|
978
|
+
.unwrap_or_else(|_| "http://localhost:3200".to_string());
|
|
979
|
+
|
|
980
|
+
// Extract workflow run ID
|
|
981
|
+
let id = match payload.get("id").and_then(|v| v.as_str()) {
|
|
982
|
+
Some(id) => id,
|
|
983
|
+
None => return EngineResponse::err("INVALID_PAYLOAD", "id is required"),
|
|
984
|
+
};
|
|
985
|
+
|
|
986
|
+
// Extract optional JWT
|
|
987
|
+
let jwt = payload.get("jwt").and_then(|v| v.as_str());
|
|
988
|
+
|
|
989
|
+
// Build client
|
|
990
|
+
let client = match reqwest::blocking::Client::builder()
|
|
991
|
+
.timeout(std::time::Duration::from_secs(30))
|
|
992
|
+
.build()
|
|
993
|
+
{
|
|
994
|
+
Ok(c) => c,
|
|
995
|
+
Err(e) => return EngineResponse::err("HTTP_CLIENT_ERROR", &e.to_string()),
|
|
996
|
+
};
|
|
997
|
+
|
|
998
|
+
// Build request
|
|
999
|
+
let headers = build_security_headers(jwt, "desktop.docgen", "workflow.get");
|
|
1000
|
+
let mut req_builder = client.get(format!("{}/engine/workflow-runs/{}", engine_url, id));
|
|
1001
|
+
|
|
1002
|
+
for (key, value) in headers {
|
|
1003
|
+
req_builder = req_builder.header(&key, &value);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
let response = req_builder.send();
|
|
1007
|
+
|
|
1008
|
+
match response {
|
|
1009
|
+
Ok(resp) => {
|
|
1010
|
+
let status = resp.status();
|
|
1011
|
+
if status.is_success() {
|
|
1012
|
+
match resp.json::<serde_json::Value>() {
|
|
1013
|
+
Ok(data) => EngineResponse::ok(data),
|
|
1014
|
+
Err(e) => EngineResponse::err("PARSE_ERROR", &e.to_string()),
|
|
1015
|
+
}
|
|
1016
|
+
} else {
|
|
1017
|
+
let status_code = status.as_u16();
|
|
1018
|
+
let body = resp.text().unwrap_or_default();
|
|
1019
|
+
let error_msg = serde_json::from_str::<Value>(&body)
|
|
1020
|
+
.ok()
|
|
1021
|
+
.and_then(|v| v.get("message").or(v.get("error")).and_then(|m| m.as_str()).map(|s| s.to_string()))
|
|
1022
|
+
.unwrap_or_else(|| format!("HTTP {}", status_code));
|
|
1023
|
+
EngineResponse::err_with_status("HTTP_ERROR", &error_msg, status_code)
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
Err(e) => {
|
|
1027
|
+
if e.is_connect() {
|
|
1028
|
+
EngineResponse::err("ENGINE_UNAVAILABLE", &format!("Cannot connect to engine at {}. Is the engine running?", engine_url))
|
|
1029
|
+
} else {
|
|
1030
|
+
EngineResponse::err("REQUEST_FAILED", &e.to_string())
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// =============================================================================
|
|
1037
|
+
// Auth Handlers (Proxied HTTP)
|
|
1038
|
+
// =============================================================================
|
|
1039
|
+
|
|
1040
|
+
/// Login (POST /auth/login)
|
|
1041
|
+
fn handle_auth_login(payload: &Value) -> EngineResponse {
|
|
1042
|
+
let api_url = std::env::var("EKKA_API_URL")
|
|
1043
|
+
.unwrap_or_else(|_| "https://api.ekka.ai".to_string());
|
|
1044
|
+
|
|
1045
|
+
// Extract credentials
|
|
1046
|
+
let identifier = match payload.get("identifier").and_then(|v| v.as_str()) {
|
|
1047
|
+
Some(id) => id,
|
|
1048
|
+
None => return EngineResponse::err("INVALID_PAYLOAD", "identifier is required"),
|
|
1049
|
+
};
|
|
1050
|
+
let password = match payload.get("password").and_then(|v| v.as_str()) {
|
|
1051
|
+
Some(p) => p,
|
|
1052
|
+
None => return EngineResponse::err("INVALID_PAYLOAD", "password is required"),
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
// Build client
|
|
1056
|
+
let client = match reqwest::blocking::Client::builder()
|
|
1057
|
+
.timeout(std::time::Duration::from_secs(30))
|
|
1058
|
+
.build()
|
|
1059
|
+
{
|
|
1060
|
+
Ok(c) => c,
|
|
1061
|
+
Err(e) => return EngineResponse::err("HTTP_CLIENT_ERROR", &e.to_string()),
|
|
1062
|
+
};
|
|
1063
|
+
|
|
1064
|
+
// Build request
|
|
1065
|
+
let headers = build_security_headers(None, "auth", "login");
|
|
1066
|
+
let mut req_builder = client.post(format!("{}/auth/login", api_url));
|
|
1067
|
+
|
|
1068
|
+
for (key, value) in headers {
|
|
1069
|
+
req_builder = req_builder.header(&key, &value);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
let body = serde_json::json!({
|
|
1073
|
+
"identifier": identifier,
|
|
1074
|
+
"password": password
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
let response = req_builder.json(&body).send();
|
|
1078
|
+
|
|
1079
|
+
match response {
|
|
1080
|
+
Ok(resp) => {
|
|
1081
|
+
let status = resp.status();
|
|
1082
|
+
if status.is_success() {
|
|
1083
|
+
match resp.json::<serde_json::Value>() {
|
|
1084
|
+
Ok(data) => EngineResponse::ok(data),
|
|
1085
|
+
Err(e) => EngineResponse::err("PARSE_ERROR", &e.to_string()),
|
|
1086
|
+
}
|
|
1087
|
+
} else {
|
|
1088
|
+
let status_code = status.as_u16();
|
|
1089
|
+
let body = resp.text().unwrap_or_default();
|
|
1090
|
+
let error_msg = serde_json::from_str::<Value>(&body)
|
|
1091
|
+
.ok()
|
|
1092
|
+
.and_then(|v| v.get("message").or(v.get("error")).and_then(|m| m.as_str()).map(|s| s.to_string()))
|
|
1093
|
+
.unwrap_or_else(|| format!("HTTP {}", status_code));
|
|
1094
|
+
EngineResponse::err_with_status("AUTH_ERROR", &error_msg, status_code)
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
Err(e) => EngineResponse::err("REQUEST_FAILED", &e.to_string()),
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
/// Refresh token (POST /auth/refresh)
|
|
1102
|
+
fn handle_auth_refresh(payload: &Value) -> EngineResponse {
|
|
1103
|
+
let api_url = std::env::var("EKKA_API_URL")
|
|
1104
|
+
.unwrap_or_else(|_| "https://api.ekka.ai".to_string());
|
|
1105
|
+
|
|
1106
|
+
// Extract refresh token
|
|
1107
|
+
let refresh_token = match payload.get("refresh_token").and_then(|v| v.as_str()) {
|
|
1108
|
+
Some(t) => t,
|
|
1109
|
+
None => return EngineResponse::err("INVALID_PAYLOAD", "refresh_token is required"),
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
// Extract optional current JWT
|
|
1113
|
+
let jwt = payload.get("jwt").and_then(|v| v.as_str());
|
|
1114
|
+
|
|
1115
|
+
// Build client
|
|
1116
|
+
let client = match reqwest::blocking::Client::builder()
|
|
1117
|
+
.timeout(std::time::Duration::from_secs(30))
|
|
1118
|
+
.build()
|
|
1119
|
+
{
|
|
1120
|
+
Ok(c) => c,
|
|
1121
|
+
Err(e) => return EngineResponse::err("HTTP_CLIENT_ERROR", &e.to_string()),
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
// Build request
|
|
1125
|
+
let headers = build_security_headers(jwt, "auth", "refresh_token");
|
|
1126
|
+
let mut req_builder = client.post(format!("{}/auth/refresh", api_url));
|
|
1127
|
+
|
|
1128
|
+
for (key, value) in headers {
|
|
1129
|
+
req_builder = req_builder.header(&key, &value);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
let body = serde_json::json!({
|
|
1133
|
+
"refresh_token": refresh_token
|
|
1134
|
+
});
|
|
1135
|
+
|
|
1136
|
+
let response = req_builder.json(&body).send();
|
|
1137
|
+
|
|
1138
|
+
match response {
|
|
1139
|
+
Ok(resp) => {
|
|
1140
|
+
let status = resp.status();
|
|
1141
|
+
if status.is_success() {
|
|
1142
|
+
match resp.json::<serde_json::Value>() {
|
|
1143
|
+
Ok(data) => EngineResponse::ok(data),
|
|
1144
|
+
Err(e) => EngineResponse::err("PARSE_ERROR", &e.to_string()),
|
|
1145
|
+
}
|
|
1146
|
+
} else {
|
|
1147
|
+
let status_code = status.as_u16();
|
|
1148
|
+
let body = resp.text().unwrap_or_default();
|
|
1149
|
+
let error_msg = serde_json::from_str::<Value>(&body)
|
|
1150
|
+
.ok()
|
|
1151
|
+
.and_then(|v| v.get("message").or(v.get("error")).and_then(|m| m.as_str()).map(|s| s.to_string()))
|
|
1152
|
+
.unwrap_or_else(|| format!("HTTP {}", status_code));
|
|
1153
|
+
EngineResponse::err_with_status("AUTH_ERROR", &error_msg, status_code)
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
Err(e) => EngineResponse::err("REQUEST_FAILED", &e.to_string()),
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
/// Logout (POST /auth/logout)
|
|
1161
|
+
fn handle_auth_logout(payload: &Value) -> EngineResponse {
|
|
1162
|
+
let api_url = std::env::var("EKKA_API_URL")
|
|
1163
|
+
.unwrap_or_else(|_| "https://api.ekka.ai".to_string());
|
|
1164
|
+
|
|
1165
|
+
// Extract refresh token
|
|
1166
|
+
let refresh_token = match payload.get("refresh_token").and_then(|v| v.as_str()) {
|
|
1167
|
+
Some(t) => t,
|
|
1168
|
+
None => return EngineResponse::err("INVALID_PAYLOAD", "refresh_token is required"),
|
|
1169
|
+
};
|
|
1170
|
+
|
|
1171
|
+
// Build client
|
|
1172
|
+
let client = match reqwest::blocking::Client::builder()
|
|
1173
|
+
.timeout(std::time::Duration::from_secs(30))
|
|
1174
|
+
.build()
|
|
1175
|
+
{
|
|
1176
|
+
Ok(c) => c,
|
|
1177
|
+
Err(e) => return EngineResponse::err("HTTP_CLIENT_ERROR", &e.to_string()),
|
|
1178
|
+
};
|
|
1179
|
+
|
|
1180
|
+
// Build request
|
|
1181
|
+
let headers = build_security_headers(None, "auth", "logout");
|
|
1182
|
+
let mut req_builder = client.post(format!("{}/auth/logout", api_url));
|
|
1183
|
+
|
|
1184
|
+
for (key, value) in headers {
|
|
1185
|
+
req_builder = req_builder.header(&key, &value);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
let body = serde_json::json!({
|
|
1189
|
+
"refresh_token": refresh_token
|
|
1190
|
+
});
|
|
1191
|
+
|
|
1192
|
+
let response = req_builder.json(&body).send();
|
|
1193
|
+
|
|
1194
|
+
match response {
|
|
1195
|
+
Ok(resp) => {
|
|
1196
|
+
let status = resp.status();
|
|
1197
|
+
if status.is_success() {
|
|
1198
|
+
match resp.json::<serde_json::Value>() {
|
|
1199
|
+
Ok(data) => EngineResponse::ok(data),
|
|
1200
|
+
Err(e) => EngineResponse::err("PARSE_ERROR", &e.to_string()),
|
|
1201
|
+
}
|
|
1202
|
+
} else {
|
|
1203
|
+
// Logout errors are typically ignored, but still return properly
|
|
1204
|
+
let status_code = status.as_u16();
|
|
1205
|
+
let body = resp.text().unwrap_or_default();
|
|
1206
|
+
let error_msg = serde_json::from_str::<Value>(&body)
|
|
1207
|
+
.ok()
|
|
1208
|
+
.and_then(|v| v.get("message").or(v.get("error")).and_then(|m| m.as_str()).map(|s| s.to_string()))
|
|
1209
|
+
.unwrap_or_else(|| format!("HTTP {}", status_code));
|
|
1210
|
+
EngineResponse::err_with_status("AUTH_ERROR", &error_msg, status_code)
|
|
1211
|
+
}
|
|
1212
|
+
}
|
|
1213
|
+
Err(e) => EngineResponse::err("REQUEST_FAILED", &e.to_string()),
|
|
1214
|
+
}
|
|
1215
|
+
}
|