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,541 @@
1
+ //! Node Credentials Management
2
+ //!
3
+ //! Stores node_id + node_secret encrypted in the node vault.
4
+ //! Used for headless engine startup without interactive login.
5
+ //!
6
+ //! ## Security Model
7
+ //!
8
+ //! - Credentials stored in node vault (AES-256-GCM encrypted)
9
+ //! - Device-bound key derivation (device secret + node_id + epoch)
10
+ //! - Credentials never logged
11
+ //! - No OS keychain prompts
12
+
13
+ use crate::bootstrap::{initialize_home, resolve_home_path};
14
+ use crate::node_vault_store::{
15
+ delete_node_secret, has_node_secret, read_node_secret, write_node_secret,
16
+ SECRET_ID_NODE_CREDENTIALS,
17
+ };
18
+ use chrono::{DateTime, Utc};
19
+ use serde::{Deserialize, Serialize};
20
+ use std::sync::RwLock;
21
+ use std::time::Duration;
22
+ use uuid::Uuid;
23
+
24
+ // =============================================================================
25
+ // Types
26
+ // =============================================================================
27
+
28
+ /// Node credentials stored in vault
29
+ #[derive(Debug, Clone, Serialize, Deserialize)]
30
+ pub struct NodeCredentials {
31
+ pub node_id: String,
32
+ pub node_secret: String,
33
+ }
34
+
35
+ /// Node authentication token received from server (role=node)
36
+ /// Stored in memory only - never persisted to disk
37
+ #[derive(Debug, Clone)]
38
+ pub struct NodeAuthToken {
39
+ pub token: String,
40
+ pub node_id: Uuid,
41
+ pub tenant_id: Uuid,
42
+ pub workspace_id: Uuid,
43
+ pub session_id: Uuid,
44
+ pub expires_at: DateTime<Utc>,
45
+ }
46
+
47
+ impl NodeAuthToken {
48
+ /// Check if token is expired (with 60s buffer)
49
+ pub fn is_expired(&self) -> bool {
50
+ Utc::now() + chrono::Duration::seconds(60) >= self.expires_at
51
+ }
52
+ }
53
+
54
+ /// Thread-safe holder for node auth token
55
+ pub struct NodeAuthTokenHolder {
56
+ inner: RwLock<Option<NodeAuthToken>>,
57
+ }
58
+
59
+ impl NodeAuthTokenHolder {
60
+ pub fn new() -> Self {
61
+ Self {
62
+ inner: RwLock::new(None),
63
+ }
64
+ }
65
+
66
+ pub fn get(&self) -> Option<NodeAuthToken> {
67
+ self.inner.read().ok()?.clone()
68
+ }
69
+
70
+ pub fn set(&self, token: NodeAuthToken) {
71
+ if let Ok(mut guard) = self.inner.write() {
72
+ *guard = Some(token);
73
+ }
74
+ }
75
+
76
+ #[allow(dead_code)]
77
+ pub fn clear(&self) {
78
+ if let Ok(mut guard) = self.inner.write() {
79
+ *guard = None;
80
+ }
81
+ }
82
+
83
+ /// Get valid token or None if missing/expired
84
+ pub fn get_valid(&self) -> Option<NodeAuthToken> {
85
+ let token = self.get()?;
86
+ if token.is_expired() {
87
+ None
88
+ } else {
89
+ Some(token)
90
+ }
91
+ }
92
+ }
93
+
94
+ impl Default for NodeAuthTokenHolder {
95
+ fn default() -> Self {
96
+ Self::new()
97
+ }
98
+ }
99
+
100
+ /// Result of loading credentials
101
+ #[derive(Debug, Clone, Serialize, Deserialize)]
102
+ #[serde(rename_all = "camelCase")]
103
+ pub struct CredentialsStatus {
104
+ pub has_credentials: bool,
105
+ pub node_id: Option<String>,
106
+ }
107
+
108
+ /// Error type for credential operations
109
+ #[derive(Debug)]
110
+ pub enum CredentialsError {
111
+ VaultError(String),
112
+ InvalidNodeId(String),
113
+ InvalidNodeSecret(String),
114
+ NotConfigured,
115
+ AuthFailed(u16, String),
116
+ HttpError(String),
117
+ }
118
+
119
+ impl std::fmt::Display for CredentialsError {
120
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121
+ match self {
122
+ CredentialsError::VaultError(msg) => write!(f, "Vault error: {}", msg),
123
+ CredentialsError::InvalidNodeId(msg) => write!(f, "Invalid node_id: {}", msg),
124
+ CredentialsError::InvalidNodeSecret(msg) => write!(f, "Invalid node_secret: {}", msg),
125
+ CredentialsError::NotConfigured => write!(f, "Node credentials not configured"),
126
+ CredentialsError::AuthFailed(status, msg) => {
127
+ write!(f, "Node auth failed ({}): {}", status, msg)
128
+ }
129
+ CredentialsError::HttpError(msg) => write!(f, "HTTP error: {}", msg),
130
+ }
131
+ }
132
+ }
133
+
134
+ impl std::error::Error for CredentialsError {}
135
+
136
+ // =============================================================================
137
+ // Helper: Get epoch for vault operations
138
+ // =============================================================================
139
+
140
+ /// Get the security epoch from environment
141
+ fn get_security_epoch() -> u32 {
142
+ std::env::var("EKKA_SECURITY_EPOCH")
143
+ .ok()
144
+ .and_then(|s| s.parse().ok())
145
+ .unwrap_or(1)
146
+ }
147
+
148
+ // =============================================================================
149
+ // Core Functions
150
+ // =============================================================================
151
+
152
+ /// Store node credentials in vault
153
+ ///
154
+ /// # Arguments
155
+ /// * `node_id` - UUID of the node
156
+ /// * `node_secret` - Secret key for the node (NEVER logged)
157
+ ///
158
+ /// # Returns
159
+ /// * `Ok(())` on success
160
+ /// * `Err(CredentialsError)` on failure
161
+ pub fn store_credentials(node_id: &Uuid, node_secret: &str) -> Result<(), CredentialsError> {
162
+ // Validate inputs
163
+ if node_secret.is_empty() {
164
+ return Err(CredentialsError::InvalidNodeSecret(
165
+ "node_secret cannot be empty".to_string(),
166
+ ));
167
+ }
168
+
169
+ // Initialize home if needed
170
+ let bootstrap = initialize_home().map_err(|e| CredentialsError::VaultError(e))?;
171
+ let home = bootstrap.home_path();
172
+ let epoch = get_security_epoch();
173
+
174
+ // Store node_id + node_secret as JSON
175
+ let creds = NodeCredentials {
176
+ node_id: node_id.to_string(),
177
+ node_secret: node_secret.to_string(),
178
+ };
179
+
180
+ let json = serde_json::to_vec(&creds)
181
+ .map_err(|e| CredentialsError::VaultError(format!("JSON encode error: {}", e)))?;
182
+
183
+ // Key derivation uses device_secret + epoch only (not node_id)
184
+ write_node_secret(home, epoch, SECRET_ID_NODE_CREDENTIALS, &json)
185
+ .map_err(|e| CredentialsError::VaultError(e.to_string()))?;
186
+
187
+ tracing::info!(
188
+ op = "node_credentials.stored",
189
+ storage = "vault",
190
+ node_id = %node_id,
191
+ "Node credentials stored in vault"
192
+ );
193
+
194
+ Ok(())
195
+ }
196
+
197
+ /// Load node credentials from vault
198
+ ///
199
+ /// # Returns
200
+ /// * `Ok((node_id, node_secret))` on success
201
+ /// * `Err(CredentialsError)` if not found or invalid
202
+ pub fn load_credentials() -> Result<(Uuid, String), CredentialsError> {
203
+ let home = resolve_home_path().map_err(|e| CredentialsError::VaultError(e))?;
204
+ let epoch = get_security_epoch();
205
+
206
+ // Key derivation uses device_secret + epoch only (not node_id)
207
+ let plaintext = read_node_secret(&home, epoch, SECRET_ID_NODE_CREDENTIALS)
208
+ .map_err(|e| CredentialsError::VaultError(e.to_string()))?
209
+ .ok_or(CredentialsError::NotConfigured)?;
210
+
211
+ let creds: NodeCredentials = serde_json::from_slice(&plaintext)
212
+ .map_err(|e| CredentialsError::VaultError(format!("JSON decode error: {}", e)))?;
213
+
214
+ let node_id = Uuid::parse_str(&creds.node_id)
215
+ .map_err(|e| CredentialsError::InvalidNodeId(format!("Invalid UUID: {}", e)))?;
216
+
217
+ tracing::info!(
218
+ op = "node_credentials.loaded",
219
+ storage = "vault",
220
+ ok = true,
221
+ "Node credentials loaded from vault"
222
+ );
223
+
224
+ Ok((node_id, creds.node_secret))
225
+ }
226
+
227
+ /// Check if credentials exist
228
+ pub fn has_credentials() -> bool {
229
+ let Ok(home) = resolve_home_path() else {
230
+ tracing::info!(
231
+ op = "desktop.node.credentials.check",
232
+ has_credentials = false,
233
+ reason = "no_home_path",
234
+ "Credentials check: no home path"
235
+ );
236
+ return false;
237
+ };
238
+
239
+ // Check if vault file exists
240
+ let has_creds = has_node_secret(&home, SECRET_ID_NODE_CREDENTIALS);
241
+
242
+ tracing::info!(
243
+ op = "desktop.node.credentials.check",
244
+ has_credentials = has_creds,
245
+ storage = "vault",
246
+ "Credentials check"
247
+ );
248
+
249
+ has_creds
250
+ }
251
+
252
+ /// Get credentials status (has credentials + node_id if present)
253
+ pub fn get_status() -> CredentialsStatus {
254
+ let node_id = load_credentials().ok().map(|(id, _)| id.to_string());
255
+ let has_creds = node_id.is_some();
256
+
257
+ CredentialsStatus {
258
+ has_credentials: has_creds,
259
+ node_id,
260
+ }
261
+ }
262
+
263
+ /// Clear node credentials from vault
264
+ pub fn clear_credentials() -> Result<(), CredentialsError> {
265
+ let home = resolve_home_path().map_err(|e| CredentialsError::VaultError(e))?;
266
+
267
+ delete_node_secret(&home, SECRET_ID_NODE_CREDENTIALS)
268
+ .map_err(|e| CredentialsError::VaultError(e.to_string()))?;
269
+
270
+ tracing::info!(
271
+ op = "desktop.node.credentials.cleared",
272
+ "Node credentials cleared from vault"
273
+ );
274
+
275
+ Ok(())
276
+ }
277
+
278
+ /// Validate node_id format (must be valid UUID)
279
+ pub fn validate_node_id(node_id_str: &str) -> Result<Uuid, CredentialsError> {
280
+ Uuid::parse_str(node_id_str)
281
+ .map_err(|e| CredentialsError::InvalidNodeId(format!("Invalid UUID format: {}", e)))
282
+ }
283
+
284
+ /// Validate node_secret (must be non-empty)
285
+ pub fn validate_node_secret(node_secret: &str) -> Result<(), CredentialsError> {
286
+ if node_secret.is_empty() {
287
+ return Err(CredentialsError::InvalidNodeSecret(
288
+ "node_secret cannot be empty".to_string(),
289
+ ));
290
+ }
291
+ if node_secret.len() < 16 {
292
+ return Err(CredentialsError::InvalidNodeSecret(
293
+ "node_secret must be at least 16 characters".to_string(),
294
+ ));
295
+ }
296
+ Ok(())
297
+ }
298
+
299
+ // =============================================================================
300
+ // Node Authentication
301
+ // =============================================================================
302
+
303
+ /// Load instance_id from marker file for session reuse
304
+ fn load_instance_id_from_marker() -> Option<String> {
305
+ let home = resolve_home_path().ok()?;
306
+ let marker_path = home.join(".ekka-marker.json");
307
+
308
+ let content = std::fs::read_to_string(&marker_path).ok()?;
309
+ let marker: serde_json::Value = serde_json::from_str(&content).ok()?;
310
+
311
+ marker
312
+ .get("instance_id")
313
+ .and_then(|v| v.as_str())
314
+ .map(|s| s.to_string())
315
+ }
316
+
317
+ /// Response from POST /engine/nodes/auth
318
+ /// Server returns: token, session_id, tenant_id, workspace_id, expires_in_seconds, expires_at_iso_utc
319
+ /// Note: node_id is NOT in response - we already know it from the request
320
+ #[derive(Debug, Deserialize)]
321
+ struct NodeAuthResponse {
322
+ token: String,
323
+ #[serde(alias = "tenantId")]
324
+ tenant_id: Option<String>,
325
+ #[serde(alias = "workspaceId")]
326
+ workspace_id: Option<String>,
327
+ #[serde(alias = "sessionId")]
328
+ session_id: Option<String>,
329
+ #[serde(alias = "expiresAtIsoUtc", alias = "expiresAt", alias = "expires_at")]
330
+ expires_at_iso_utc: Option<String>,
331
+ }
332
+
333
+ /// Error response from auth endpoints
334
+ #[derive(Debug, Deserialize)]
335
+ struct AuthErrorResponse {
336
+ error: Option<String>,
337
+ }
338
+
339
+ // Error codes that indicate secret is invalid or revoked
340
+ const ERROR_INVALID_SECRET: &str = "invalid_secret";
341
+ const ERROR_SECRET_REVOKED: &str = "secret_revoked";
342
+ const ERROR_INVALID_CREDENTIALS: &str = "invalid_credentials";
343
+
344
+ /// Check if an auth failure indicates the secret is invalid or revoked.
345
+ pub fn is_secret_error(status: u16, body: &str) -> bool {
346
+ if status == 401 || status == 403 {
347
+ if let Ok(err) = serde_json::from_str::<AuthErrorResponse>(body) {
348
+ if let Some(error) = err.error {
349
+ return error == ERROR_INVALID_SECRET
350
+ || error == ERROR_SECRET_REVOKED
351
+ || error == ERROR_INVALID_CREDENTIALS;
352
+ }
353
+ }
354
+ }
355
+ false
356
+ }
357
+
358
+ /// Authenticate node with server using node_id + node_secret
359
+ /// Includes instance_id from marker for session reuse (avoids session_limit on restarts)
360
+ pub fn authenticate_node(engine_url: &str) -> Result<NodeAuthToken, CredentialsError> {
361
+ // Load credentials from vault
362
+ let (node_id, node_secret) = load_credentials()?;
363
+
364
+ // Load instance_id from marker file (for session reuse)
365
+ let instance_id = load_instance_id_from_marker();
366
+
367
+ let client = reqwest::blocking::Client::builder()
368
+ .timeout(Duration::from_secs(30))
369
+ .build()
370
+ .map_err(|e| CredentialsError::HttpError(format!("Client build error: {}", e)))?;
371
+
372
+ let request_id = Uuid::new_v4().to_string();
373
+
374
+ // Build request body with optional instance_id
375
+ let mut body = serde_json::json!({
376
+ "node_id": node_id.to_string(),
377
+ "node_secret": node_secret
378
+ });
379
+
380
+ if let Some(ref iid) = instance_id {
381
+ body["instance_id"] = serde_json::Value::String(iid.clone());
382
+ tracing::info!(
383
+ op = "desktop.node.auth.request",
384
+ instance_id = %iid,
385
+ "Authenticating with instance_id for session reuse"
386
+ );
387
+ }
388
+
389
+ let response = client
390
+ .post(format!("{}/engine/nodes/auth", engine_url))
391
+ .header("Content-Type", "application/json")
392
+ .header("X-EKKA-PROOF-TYPE", "node_secret")
393
+ .header("X-REQUEST-ID", &request_id)
394
+ .header("X-EKKA-CORRELATION-ID", &request_id)
395
+ .header("X-EKKA-MODULE", "desktop.node_auth")
396
+ .header("X-EKKA-ACTION", "authenticate")
397
+ .header("X-EKKA-CLIENT", "ekka-desktop")
398
+ .header("X-EKKA-CLIENT-VERSION", "0.2.0")
399
+ .json(&body)
400
+ .send()
401
+ .map_err(|e| CredentialsError::HttpError(format!("Request failed: {}", e)))?;
402
+
403
+ let status = response.status();
404
+
405
+ if !status.is_success() {
406
+ let status_code = status.as_u16();
407
+ let body = response.text().unwrap_or_default();
408
+ tracing::warn!(
409
+ op = "desktop.node.auth.failed",
410
+ status = status_code,
411
+ body = %body,
412
+ "Node authentication failed"
413
+ );
414
+ return Err(CredentialsError::AuthFailed(status_code, body));
415
+ }
416
+
417
+ // Get response text first to log it for debugging
418
+ let response_text = response.text().map_err(|e| {
419
+ CredentialsError::HttpError(format!("Failed to read response: {}", e))
420
+ })?;
421
+
422
+ tracing::debug!(
423
+ op = "desktop.node.auth.response",
424
+ body = %response_text,
425
+ "Raw auth response"
426
+ );
427
+
428
+ let auth_response: NodeAuthResponse = serde_json::from_str(&response_text).map_err(|e| {
429
+ tracing::error!(
430
+ op = "desktop.node.auth.parse_error",
431
+ error = %e,
432
+ body = %response_text,
433
+ "Failed to parse auth response"
434
+ );
435
+ CredentialsError::HttpError(format!("Parse error: {}. Response: {}", e, &response_text[..response_text.len().min(200)]))
436
+ })?;
437
+
438
+ // Extract fields with helpful error messages
439
+ // Note: node_id is not in response - we use the one we sent in the request
440
+ let tenant_id_str = auth_response.tenant_id.ok_or_else(|| {
441
+ CredentialsError::HttpError("Response missing tenant_id/tenantId field".to_string())
442
+ })?;
443
+ let workspace_id_str = auth_response.workspace_id.ok_or_else(|| {
444
+ CredentialsError::HttpError("Response missing workspace_id/workspaceId field".to_string())
445
+ })?;
446
+ let session_id_str = auth_response.session_id.ok_or_else(|| {
447
+ CredentialsError::HttpError("Response missing session_id/sessionId field".to_string())
448
+ })?;
449
+ let expires_at_str = auth_response.expires_at_iso_utc.ok_or_else(|| {
450
+ CredentialsError::HttpError("Response missing expires_at field".to_string())
451
+ })?;
452
+
453
+ let token = NodeAuthToken {
454
+ token: auth_response.token,
455
+ node_id, // Use the node_id from load_credentials() - already a Uuid
456
+ tenant_id: Uuid::parse_str(&tenant_id_str)
457
+ .map_err(|e| CredentialsError::HttpError(format!("Invalid tenant_id: {}", e)))?,
458
+ workspace_id: Uuid::parse_str(&workspace_id_str)
459
+ .map_err(|e| CredentialsError::HttpError(format!("Invalid workspace_id: {}", e)))?,
460
+ session_id: Uuid::parse_str(&session_id_str)
461
+ .map_err(|e| CredentialsError::HttpError(format!("Invalid session_id: {}", e)))?,
462
+ expires_at: DateTime::parse_from_rfc3339(&expires_at_str)
463
+ .map_err(|e| CredentialsError::HttpError(format!("Invalid expires_at: {}", e)))?
464
+ .with_timezone(&Utc),
465
+ };
466
+
467
+ tracing::info!(
468
+ op = "desktop.node.auth.success",
469
+ keys = ?["node_id", "session_id"],
470
+ node_id = %token.node_id,
471
+ session_id = %token.session_id,
472
+ "Node authenticated successfully"
473
+ );
474
+
475
+ Ok(token)
476
+ }
477
+
478
+ #[cfg(test)]
479
+ mod tests {
480
+ use super::*;
481
+
482
+ #[test]
483
+ fn test_validate_node_id() {
484
+ // Valid UUID
485
+ let result = validate_node_id("550e8400-e29b-41d4-a716-446655440000");
486
+ assert!(result.is_ok());
487
+
488
+ // Invalid UUID
489
+ let result = validate_node_id("not-a-uuid");
490
+ assert!(result.is_err());
491
+
492
+ // Empty
493
+ let result = validate_node_id("");
494
+ assert!(result.is_err());
495
+ }
496
+
497
+ #[test]
498
+ fn test_validate_node_secret() {
499
+ // Valid secret
500
+ let result = validate_node_secret("a-sufficiently-long-secret-key");
501
+ assert!(result.is_ok());
502
+
503
+ // Too short
504
+ let result = validate_node_secret("short");
505
+ assert!(result.is_err());
506
+
507
+ // Empty
508
+ let result = validate_node_secret("");
509
+ assert!(result.is_err());
510
+ }
511
+
512
+ #[test]
513
+ fn test_is_secret_error() {
514
+ // 401 with invalid_secret
515
+ assert!(is_secret_error(401, r#"{"error":"invalid_secret"}"#));
516
+
517
+ // 401 with secret_revoked
518
+ assert!(is_secret_error(401, r#"{"error":"secret_revoked"}"#));
519
+
520
+ // 401 with invalid_credentials
521
+ assert!(is_secret_error(401, r#"{"error":"invalid_credentials"}"#));
522
+
523
+ // 403 with invalid_secret
524
+ assert!(is_secret_error(403, r#"{"error":"invalid_secret"}"#));
525
+
526
+ // 401 with different error
527
+ assert!(!is_secret_error(401, r#"{"error":"some_other_error"}"#));
528
+
529
+ // 200 with invalid_secret (should not trigger - wrong status)
530
+ assert!(!is_secret_error(200, r#"{"error":"invalid_secret"}"#));
531
+
532
+ // 401 with malformed JSON
533
+ assert!(!is_secret_error(401, "not json"));
534
+
535
+ // 401 with empty body
536
+ assert!(!is_secret_error(401, ""));
537
+
538
+ // 401 with no error field
539
+ assert!(!is_secret_error(401, r#"{"message":"error"}"#));
540
+ }
541
+ }