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.
Files changed (96) hide show
  1. package/README.md +137 -0
  2. package/bin/cli.js +72 -0
  3. package/package.json +23 -0
  4. package/template/branding/app.json +6 -0
  5. package/template/branding/icon.icns +0 -0
  6. package/template/eslint.config.js +98 -0
  7. package/template/index.html +29 -0
  8. package/template/package.json +40 -0
  9. package/template/src/app/App.tsx +24 -0
  10. package/template/src/demo/DemoApp.tsx +260 -0
  11. package/template/src/demo/components/Banner.tsx +82 -0
  12. package/template/src/demo/components/EmptyState.tsx +61 -0
  13. package/template/src/demo/components/InfoPopover.tsx +171 -0
  14. package/template/src/demo/components/InfoTooltip.tsx +76 -0
  15. package/template/src/demo/components/LearnMore.tsx +98 -0
  16. package/template/src/demo/components/NodeCredentialsOnboarding.tsx +219 -0
  17. package/template/src/demo/components/SetupWizard.tsx +48 -0
  18. package/template/src/demo/components/StatusBadge.tsx +83 -0
  19. package/template/src/demo/components/index.ts +10 -0
  20. package/template/src/demo/hooks/index.ts +6 -0
  21. package/template/src/demo/hooks/useAuditEvents.ts +30 -0
  22. package/template/src/demo/layout/Shell.tsx +110 -0
  23. package/template/src/demo/layout/Sidebar.tsx +192 -0
  24. package/template/src/demo/pages/AuditLogPage.tsx +235 -0
  25. package/template/src/demo/pages/DocGenPage.tsx +874 -0
  26. package/template/src/demo/pages/HomeSetupPage.tsx +182 -0
  27. package/template/src/demo/pages/LoginPage.tsx +192 -0
  28. package/template/src/demo/pages/PathPermissionsPage.tsx +873 -0
  29. package/template/src/demo/pages/RunnerPage.tsx +445 -0
  30. package/template/src/demo/pages/SystemPage.tsx +557 -0
  31. package/template/src/demo/pages/VaultPage.tsx +805 -0
  32. package/template/src/ekka/__tests__/demo-backend.test.ts +187 -0
  33. package/template/src/ekka/audit/index.ts +7 -0
  34. package/template/src/ekka/audit/store.ts +68 -0
  35. package/template/src/ekka/audit/types.ts +22 -0
  36. package/template/src/ekka/auth/client.ts +212 -0
  37. package/template/src/ekka/auth/index.ts +30 -0
  38. package/template/src/ekka/auth/storage.ts +114 -0
  39. package/template/src/ekka/auth/types.ts +67 -0
  40. package/template/src/ekka/backend/demo.ts +151 -0
  41. package/template/src/ekka/backend/interface.ts +36 -0
  42. package/template/src/ekka/config.ts +48 -0
  43. package/template/src/ekka/constants.ts +143 -0
  44. package/template/src/ekka/errors.ts +54 -0
  45. package/template/src/ekka/index.ts +516 -0
  46. package/template/src/ekka/internal/backend.ts +156 -0
  47. package/template/src/ekka/internal/index.ts +7 -0
  48. package/template/src/ekka/ops/auth.ts +29 -0
  49. package/template/src/ekka/ops/debug.ts +68 -0
  50. package/template/src/ekka/ops/home.ts +101 -0
  51. package/template/src/ekka/ops/index.ts +16 -0
  52. package/template/src/ekka/ops/nodeCredentials.ts +131 -0
  53. package/template/src/ekka/ops/nodeSession.ts +145 -0
  54. package/template/src/ekka/ops/paths.ts +183 -0
  55. package/template/src/ekka/ops/runner.ts +86 -0
  56. package/template/src/ekka/ops/runtime.ts +31 -0
  57. package/template/src/ekka/ops/setup.ts +47 -0
  58. package/template/src/ekka/ops/vault.ts +459 -0
  59. package/template/src/ekka/ops/workflowRuns.ts +116 -0
  60. package/template/src/ekka/types.ts +82 -0
  61. package/template/src/ekka/utils/idempotency.ts +14 -0
  62. package/template/src/ekka/utils/index.ts +7 -0
  63. package/template/src/ekka/utils/time.ts +77 -0
  64. package/template/src/main.tsx +12 -0
  65. package/template/src/vite-env.d.ts +12 -0
  66. package/template/src-tauri/Cargo.toml +41 -0
  67. package/template/src-tauri/build.rs +3 -0
  68. package/template/src-tauri/capabilities/default.json +11 -0
  69. package/template/src-tauri/icons/icon.icns +0 -0
  70. package/template/src-tauri/icons/icon.png +0 -0
  71. package/template/src-tauri/resources/ekka-engine-bootstrap +0 -0
  72. package/template/src-tauri/src/bootstrap.rs +37 -0
  73. package/template/src-tauri/src/commands.rs +1215 -0
  74. package/template/src-tauri/src/device_secret.rs +111 -0
  75. package/template/src-tauri/src/engine_process.rs +538 -0
  76. package/template/src-tauri/src/grants.rs +129 -0
  77. package/template/src-tauri/src/handlers/home.rs +65 -0
  78. package/template/src-tauri/src/handlers/mod.rs +7 -0
  79. package/template/src-tauri/src/handlers/paths.rs +128 -0
  80. package/template/src-tauri/src/handlers/vault.rs +680 -0
  81. package/template/src-tauri/src/main.rs +243 -0
  82. package/template/src-tauri/src/node_auth.rs +858 -0
  83. package/template/src-tauri/src/node_credentials.rs +541 -0
  84. package/template/src-tauri/src/node_runner.rs +882 -0
  85. package/template/src-tauri/src/node_vault_crypto.rs +113 -0
  86. package/template/src-tauri/src/node_vault_store.rs +267 -0
  87. package/template/src-tauri/src/ops/auth.rs +50 -0
  88. package/template/src-tauri/src/ops/home.rs +251 -0
  89. package/template/src-tauri/src/ops/mod.rs +7 -0
  90. package/template/src-tauri/src/ops/runtime.rs +21 -0
  91. package/template/src-tauri/src/state.rs +639 -0
  92. package/template/src-tauri/src/types.rs +84 -0
  93. package/template/src-tauri/tauri.conf.json +41 -0
  94. package/template/tsconfig.json +26 -0
  95. package/template/tsconfig.tsbuildinfo +1 -0
  96. 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
+ }