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,113 @@
1
+ //! Node Vault Cryptography
2
+ //!
3
+ //! Provides key derivation and AES-256-GCM encryption for node-level secrets.
4
+ //! Uses the existing ekka-crypto primitives for consistency.
5
+
6
+ use crate::device_secret::load_or_create_device_secret;
7
+ use ekka_sdk_core::ekka_crypto::{self, KeyDerivationConfig, KeyMaterial};
8
+ use std::path::Path;
9
+
10
+ /// Purpose label for node vault key derivation
11
+ const NODE_VAULT_PURPOSE: &str = "node-vault";
12
+
13
+ /// Derive the encryption key for the node vault
14
+ ///
15
+ /// Key derivation inputs:
16
+ /// - device_secret: 32 bytes from device secret file (device-bound)
17
+ /// - security_epoch: Current security epoch (for key rotation)
18
+ ///
19
+ /// Note: node_id is NOT used in key derivation because it's a business
20
+ /// identifier stored inside the encrypted credentials, not a cryptographic input.
21
+ ///
22
+ /// Uses PBKDF2-SHA256 with 100k iterations (via ekka-crypto).
23
+ pub fn derive_node_vault_key(home: &Path, epoch: u32) -> anyhow::Result<KeyMaterial> {
24
+ let device_secret = load_or_create_device_secret(home)?;
25
+
26
+ // Convert device secret to hex string for derivation
27
+ let device_secret_hex = hex::encode(device_secret);
28
+
29
+ // User context is fixed for node vault (no user-specific data needed)
30
+ let user_context = "node-vault-context";
31
+
32
+ let config = KeyDerivationConfig::default();
33
+
34
+ let key = ekka_crypto::derive_key(
35
+ &device_secret_hex,
36
+ user_context,
37
+ epoch,
38
+ NODE_VAULT_PURPOSE,
39
+ &config,
40
+ );
41
+
42
+ Ok(key)
43
+ }
44
+
45
+ /// Encrypt plaintext bytes using AES-256-GCM
46
+ ///
47
+ /// Returns the encrypted envelope as bytes (version || nonce || ciphertext).
48
+ pub fn encrypt_node_value(key: &KeyMaterial, plaintext: &[u8]) -> anyhow::Result<Vec<u8>> {
49
+ ekka_crypto::encrypt(plaintext, key).map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))
50
+ }
51
+
52
+ /// Decrypt ciphertext bytes using AES-256-GCM
53
+ ///
54
+ /// Expects the encrypted envelope format (version || nonce || ciphertext).
55
+ pub fn decrypt_node_value(key: &KeyMaterial, ciphertext: &[u8]) -> anyhow::Result<Vec<u8>> {
56
+ ekka_crypto::decrypt(ciphertext, key).map_err(|e| anyhow::anyhow!("Decryption failed: {}", e))
57
+ }
58
+
59
+ #[cfg(test)]
60
+ mod tests {
61
+ use super::*;
62
+ use tempfile::TempDir;
63
+
64
+ #[test]
65
+ fn test_derive_node_vault_key_deterministic() {
66
+ let temp_dir = TempDir::new().unwrap();
67
+ let epoch = 1u32;
68
+
69
+ let key1 = derive_node_vault_key(temp_dir.path(), epoch).unwrap();
70
+ let key2 = derive_node_vault_key(temp_dir.path(), epoch).unwrap();
71
+
72
+ // Same inputs should produce same key
73
+ assert_eq!(key1.as_bytes(), key2.as_bytes());
74
+ }
75
+
76
+ #[test]
77
+ fn test_derive_node_vault_key_different_epoch() {
78
+ let temp_dir = TempDir::new().unwrap();
79
+
80
+ let key1 = derive_node_vault_key(temp_dir.path(), 1).unwrap();
81
+ let key2 = derive_node_vault_key(temp_dir.path(), 2).unwrap();
82
+
83
+ // Different epochs should produce different keys
84
+ assert_ne!(key1.as_bytes(), key2.as_bytes());
85
+ }
86
+
87
+ #[test]
88
+ fn test_encrypt_decrypt_roundtrip() {
89
+ let temp_dir = TempDir::new().unwrap();
90
+ let key = derive_node_vault_key(temp_dir.path(), 1).unwrap();
91
+
92
+ let plaintext = b"test secret data";
93
+ let ciphertext = encrypt_node_value(&key, plaintext).unwrap();
94
+ let decrypted = decrypt_node_value(&key, &ciphertext).unwrap();
95
+
96
+ assert_eq!(plaintext.as_slice(), decrypted.as_slice());
97
+ }
98
+
99
+ #[test]
100
+ fn test_decrypt_wrong_key_fails() {
101
+ let temp_dir = TempDir::new().unwrap();
102
+
103
+ let key1 = derive_node_vault_key(temp_dir.path(), 1).unwrap();
104
+ let key2 = derive_node_vault_key(temp_dir.path(), 2).unwrap();
105
+
106
+ let plaintext = b"secret";
107
+ let ciphertext = encrypt_node_value(&key1, plaintext).unwrap();
108
+
109
+ // Decrypting with wrong key should fail
110
+ let result = decrypt_node_value(&key2, &ciphertext);
111
+ assert!(result.is_err());
112
+ }
113
+ }
@@ -0,0 +1,267 @@
1
+ //! Node Vault Store
2
+ //!
3
+ //! Encrypted storage for node-level secrets that works before user authentication.
4
+ //! Uses AES-256-GCM with device-bound key derivation.
5
+ //!
6
+ //! Storage layout:
7
+ //! ```text
8
+ //! {EKKA_HOME}/vault/node/
9
+ //! values/
10
+ //! node_credentials.enc # Encrypted node credentials
11
+ //! ```
12
+
13
+ use crate::node_vault_crypto::{decrypt_node_value, derive_node_vault_key, encrypt_node_value};
14
+ use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
15
+ use serde::{Deserialize, Serialize};
16
+ use std::fs;
17
+ use std::io::Write;
18
+ use std::path::{Path, PathBuf};
19
+
20
+ #[cfg(unix)]
21
+ use std::os::unix::fs::PermissionsExt;
22
+
23
+ /// Secret ID for node credentials
24
+ pub const SECRET_ID_NODE_CREDENTIALS: &str = "node_credentials";
25
+
26
+ /// Encrypted value envelope stored on disk
27
+ #[derive(Debug, Serialize, Deserialize)]
28
+ struct EncryptedEnvelope {
29
+ /// Version for future format changes
30
+ v: u8,
31
+ /// Base64-encoded encrypted data (version || nonce || ciphertext)
32
+ data_b64: String,
33
+ }
34
+
35
+ impl EncryptedEnvelope {
36
+ const CURRENT_VERSION: u8 = 1;
37
+
38
+ fn new(encrypted_bytes: &[u8]) -> Self {
39
+ Self {
40
+ v: Self::CURRENT_VERSION,
41
+ data_b64: BASE64.encode(encrypted_bytes),
42
+ }
43
+ }
44
+
45
+ fn decode(&self) -> anyhow::Result<Vec<u8>> {
46
+ if self.v != Self::CURRENT_VERSION {
47
+ anyhow::bail!("Unsupported envelope version: {}", self.v);
48
+ }
49
+ BASE64
50
+ .decode(&self.data_b64)
51
+ .map_err(|e| anyhow::anyhow!("Base64 decode failed: {}", e))
52
+ }
53
+ }
54
+
55
+ /// Get the node vault directory path
56
+ pub fn node_vault_dir(home: &Path) -> PathBuf {
57
+ home.join("vault").join("node")
58
+ }
59
+
60
+ /// Get the node vault values directory path
61
+ pub fn node_vault_values_dir(home: &Path) -> PathBuf {
62
+ node_vault_dir(home).join("values")
63
+ }
64
+
65
+ /// Get the path for a specific secret
66
+ fn secret_path(home: &Path, secret_id: &str) -> PathBuf {
67
+ node_vault_values_dir(home).join(format!("{}.enc", secret_id))
68
+ }
69
+
70
+ /// Ensure the node vault directory structure exists
71
+ fn ensure_dirs(home: &Path) -> anyhow::Result<()> {
72
+ let values_dir = node_vault_values_dir(home);
73
+ if !values_dir.exists() {
74
+ fs::create_dir_all(&values_dir)?;
75
+
76
+ // Set secure permissions on Unix
77
+ #[cfg(unix)]
78
+ {
79
+ let perms = fs::Permissions::from_mode(0o700);
80
+ fs::set_permissions(&node_vault_dir(home), perms.clone())?;
81
+ fs::set_permissions(&values_dir, perms)?;
82
+ }
83
+ }
84
+ Ok(())
85
+ }
86
+
87
+ /// Write a secret to the node vault
88
+ ///
89
+ /// Encrypts the plaintext using the derived key and writes atomically.
90
+ pub fn write_node_secret(
91
+ home: &Path,
92
+ epoch: u32,
93
+ secret_id: &str,
94
+ plaintext: &[u8],
95
+ ) -> anyhow::Result<()> {
96
+ ensure_dirs(home)?;
97
+
98
+ // Derive key (device-bound, no node_id needed)
99
+ let key = derive_node_vault_key(home, epoch)?;
100
+
101
+ // Encrypt
102
+ let encrypted = encrypt_node_value(&key, plaintext)?;
103
+
104
+ // Create envelope
105
+ let envelope = EncryptedEnvelope::new(&encrypted);
106
+ let json = serde_json::to_string_pretty(&envelope)?;
107
+
108
+ // Atomic write: temp file + rename
109
+ let final_path = secret_path(home, secret_id);
110
+ let temp_path = final_path.with_extension("enc.tmp");
111
+
112
+ {
113
+ let mut file = fs::File::create(&temp_path)?;
114
+ file.write_all(json.as_bytes())?;
115
+ file.sync_all()?;
116
+
117
+ // Set secure permissions on Unix
118
+ #[cfg(unix)]
119
+ {
120
+ let perms = fs::Permissions::from_mode(0o600);
121
+ fs::set_permissions(&temp_path, perms)?;
122
+ }
123
+ }
124
+
125
+ fs::rename(&temp_path, &final_path)?;
126
+
127
+ tracing::info!(
128
+ op = "node_vault.write",
129
+ secret_id = secret_id,
130
+ "Node secret written"
131
+ );
132
+
133
+ Ok(())
134
+ }
135
+
136
+ /// Read a secret from the node vault
137
+ ///
138
+ /// Returns None if the secret doesn't exist.
139
+ pub fn read_node_secret(
140
+ home: &Path,
141
+ epoch: u32,
142
+ secret_id: &str,
143
+ ) -> anyhow::Result<Option<Vec<u8>>> {
144
+ let path = secret_path(home, secret_id);
145
+
146
+ if !path.exists() {
147
+ tracing::info!(
148
+ op = "node_vault.read",
149
+ secret_id = secret_id,
150
+ hit = false,
151
+ "Node secret not found"
152
+ );
153
+ return Ok(None);
154
+ }
155
+
156
+ // Read envelope
157
+ let content = fs::read_to_string(&path)?;
158
+ let envelope: EncryptedEnvelope = serde_json::from_str(&content)?;
159
+
160
+ // Decode base64
161
+ let encrypted = envelope.decode()?;
162
+
163
+ // Derive key (device-bound, no node_id needed)
164
+ let key = derive_node_vault_key(home, epoch)?;
165
+
166
+ // Decrypt
167
+ let plaintext = decrypt_node_value(&key, &encrypted)?;
168
+
169
+ tracing::info!(
170
+ op = "node_vault.read",
171
+ secret_id = secret_id,
172
+ hit = true,
173
+ "Node secret read"
174
+ );
175
+
176
+ Ok(Some(plaintext))
177
+ }
178
+
179
+ /// Delete a secret from the node vault
180
+ pub fn delete_node_secret(home: &Path, secret_id: &str) -> anyhow::Result<()> {
181
+ let path = secret_path(home, secret_id);
182
+
183
+ if path.exists() {
184
+ fs::remove_file(&path)?;
185
+ tracing::info!(
186
+ op = "node_vault.delete",
187
+ secret_id = secret_id,
188
+ "Node secret deleted"
189
+ );
190
+ }
191
+
192
+ Ok(())
193
+ }
194
+
195
+ /// Check if a secret exists in the node vault
196
+ pub fn has_node_secret(home: &Path, secret_id: &str) -> bool {
197
+ secret_path(home, secret_id).exists()
198
+ }
199
+
200
+ #[cfg(test)]
201
+ mod tests {
202
+ use super::*;
203
+ use tempfile::TempDir;
204
+
205
+ #[test]
206
+ fn test_write_read_roundtrip() {
207
+ let temp_dir = TempDir::new().unwrap();
208
+ let epoch = 1u32;
209
+ let secret_id = "test_secret";
210
+ let plaintext = b"my secret data";
211
+
212
+ // Write
213
+ write_node_secret(temp_dir.path(), epoch, secret_id, plaintext).unwrap();
214
+
215
+ // Read
216
+ let result = read_node_secret(temp_dir.path(), epoch, secret_id).unwrap();
217
+
218
+ assert!(result.is_some());
219
+ assert_eq!(result.unwrap(), plaintext.to_vec());
220
+ }
221
+
222
+ #[test]
223
+ fn test_read_nonexistent_returns_none() {
224
+ let temp_dir = TempDir::new().unwrap();
225
+ let result = read_node_secret(temp_dir.path(), 1, "nonexistent").unwrap();
226
+ assert!(result.is_none());
227
+ }
228
+
229
+ #[test]
230
+ fn test_delete_secret() {
231
+ let temp_dir = TempDir::new().unwrap();
232
+ let epoch = 1u32;
233
+ let secret_id = "to_delete";
234
+
235
+ // Write
236
+ write_node_secret(temp_dir.path(), epoch, secret_id, b"data").unwrap();
237
+ assert!(has_node_secret(temp_dir.path(), secret_id));
238
+
239
+ // Delete
240
+ delete_node_secret(temp_dir.path(), secret_id).unwrap();
241
+ assert!(!has_node_secret(temp_dir.path(), secret_id));
242
+ }
243
+
244
+ #[test]
245
+ fn test_has_node_secret() {
246
+ let temp_dir = TempDir::new().unwrap();
247
+
248
+ assert!(!has_node_secret(temp_dir.path(), "test"));
249
+
250
+ write_node_secret(temp_dir.path(), 1, "test", b"data").unwrap();
251
+
252
+ assert!(has_node_secret(temp_dir.path(), "test"));
253
+ }
254
+
255
+ #[test]
256
+ fn test_wrong_epoch_fails_decrypt() {
257
+ let temp_dir = TempDir::new().unwrap();
258
+ let secret_id = "epoch_test";
259
+
260
+ // Write with epoch 1
261
+ write_node_secret(temp_dir.path(), 1, secret_id, b"secret").unwrap();
262
+
263
+ // Read with epoch 2 should fail
264
+ let result = read_node_secret(temp_dir.path(), 2, secret_id);
265
+ assert!(result.is_err());
266
+ }
267
+ }
@@ -0,0 +1,50 @@
1
+ //! Auth operations
2
+ //!
3
+ //! Handles: auth.set
4
+
5
+ use crate::state::{AuthContext, EngineState};
6
+ use crate::types::EngineResponse;
7
+ use serde_json::{json, Value};
8
+
9
+ /// Handle auth.set operation
10
+ pub fn handle_set(payload: &Value, state: &EngineState) -> EngineResponse {
11
+ let tenant_id = match payload.get("tenantId").and_then(|v| v.as_str()) {
12
+ Some(t) if !t.is_empty() => t.to_string(),
13
+ _ => return EngineResponse::err("INVALID_PAYLOAD", "Missing or empty 'tenantId'"),
14
+ };
15
+
16
+ let sub = match payload.get("sub").and_then(|v| v.as_str()) {
17
+ Some(s) if !s.is_empty() => s.to_string(),
18
+ _ => return EngineResponse::err("INVALID_PAYLOAD", "Missing or empty 'sub'"),
19
+ };
20
+
21
+ let jwt = match payload.get("jwt").and_then(|v| v.as_str()) {
22
+ Some(j) if !j.is_empty() => j.to_string(),
23
+ _ => return EngineResponse::err("INVALID_PAYLOAD", "Missing or empty 'jwt'"),
24
+ };
25
+
26
+ // Workspace ID is optional - defaults to tenant_id if not provided
27
+ let workspace_id = payload
28
+ .get("workspaceId")
29
+ .and_then(|v| v.as_str())
30
+ .filter(|s| !s.is_empty())
31
+ .map(|s| s.to_string());
32
+
33
+ let auth_context = AuthContext {
34
+ tenant_id,
35
+ sub,
36
+ jwt,
37
+ workspace_id,
38
+ };
39
+
40
+ // Clear vault cache when auth changes (new tenant/user means new encryption key)
41
+ state.clear_vault_cache();
42
+
43
+ match state.auth.lock() {
44
+ Ok(mut guard) => {
45
+ *guard = Some(auth_context);
46
+ EngineResponse::ok(json!({ "ok": true }))
47
+ }
48
+ Err(e) => EngineResponse::err("INTERNAL_ERROR", &e.to_string()),
49
+ }
50
+ }
@@ -0,0 +1,251 @@
1
+ //! Home operations (DEPRECATED - use handlers/home.rs)
2
+ //!
3
+ //! Handles: home.status, home.grant
4
+ //!
5
+ //! This module is deprecated in favor of SDK-based handlers.
6
+
7
+ #![allow(dead_code)]
8
+
9
+ use crate::bootstrap::resolve_home_path;
10
+ use crate::grants::get_home_status;
11
+ use crate::state::EngineState;
12
+ use crate::types::EngineResponse;
13
+ use serde_json::{json, Value};
14
+
15
+ /// Handle home.status operation
16
+ pub fn handle_status(state: &EngineState) -> EngineResponse {
17
+ let (home_state, home_path, grant_present, reason) = get_home_status(state);
18
+
19
+ EngineResponse::ok(json!({
20
+ "state": home_state,
21
+ "homePath": home_path.to_string_lossy(),
22
+ "grantPresent": grant_present,
23
+ "reason": reason,
24
+ }))
25
+ }
26
+
27
+ /// Handle home.grant operation
28
+ pub fn handle_grant(state: &EngineState) -> EngineResponse {
29
+ // 1. Must have auth with JWT
30
+ let auth = match state.auth.lock() {
31
+ Ok(guard) => guard.clone(),
32
+ Err(e) => return EngineResponse::err("INTERNAL_ERROR", &e.to_string()),
33
+ };
34
+
35
+ let auth = match auth {
36
+ Some(a) => a,
37
+ None => {
38
+ return EngineResponse::err("NOT_AUTHENTICATED", "Must call auth.set before home.grant")
39
+ }
40
+ };
41
+
42
+ // 2. Get home path
43
+ let home_path = match state.home_path.lock() {
44
+ Ok(guard) => guard.clone(),
45
+ Err(e) => return EngineResponse::err("INTERNAL_ERROR", &e.to_string()),
46
+ };
47
+
48
+ let home_path = match home_path {
49
+ Some(p) => p,
50
+ None => match resolve_home_path() {
51
+ Ok(p) => p,
52
+ Err(e) => return EngineResponse::err("HOME_PATH_ERROR", &e),
53
+ },
54
+ };
55
+
56
+ // 3. Load marker to get instance_id (used as node_id in grants)
57
+ let marker_path = home_path.join(".ekka-marker.json");
58
+ let marker_content = match std::fs::read_to_string(&marker_path) {
59
+ Ok(c) => c,
60
+ Err(e) => {
61
+ return EngineResponse::err(
62
+ "MARKER_READ_ERROR",
63
+ &format!("Failed to read marker: {}", e),
64
+ )
65
+ }
66
+ };
67
+
68
+ let marker: Value = match serde_json::from_str(&marker_content) {
69
+ Ok(m) => m,
70
+ Err(e) => {
71
+ return EngineResponse::err(
72
+ "MARKER_PARSE_ERROR",
73
+ &format!("Failed to parse marker: {}", e),
74
+ )
75
+ }
76
+ };
77
+
78
+ let node_id = match marker.get("instance_id").and_then(|v| v.as_str()) {
79
+ Some(n) => n.to_string(),
80
+ None => return EngineResponse::err("MARKER_INVALID", "Marker missing instance_id"),
81
+ };
82
+
83
+ // 4. Get engine URL
84
+ let engine_url = match std::env::var("EKKA_ENGINE_URL") {
85
+ Ok(u) => u,
86
+ Err(_) => {
87
+ return EngineResponse::err(
88
+ "ENGINE_NOT_CONFIGURED",
89
+ "EKKA_ENGINE_URL not set. HOME grant requires online engine.",
90
+ )
91
+ }
92
+ };
93
+
94
+ // 5. Build grant request
95
+ let grant_request = json!({
96
+ "resource": {
97
+ "kind": "path",
98
+ "path_prefix": home_path.to_string_lossy(),
99
+ "attrs": {
100
+ "path_type": "HOME"
101
+ }
102
+ },
103
+ "permissions": {
104
+ "ops": ["read", "write", "delete"],
105
+ "access": "READ_WRITE"
106
+ },
107
+ "purpose": "home_bootstrap",
108
+ "expires_in_seconds": 31536000,
109
+ "node_id": node_id,
110
+ "consent": {
111
+ "mode": "user_click"
112
+ }
113
+ });
114
+
115
+ // 6. Make HTTP request to engine
116
+ let client = match reqwest::blocking::Client::builder()
117
+ .timeout(std::time::Duration::from_secs(30))
118
+ .build()
119
+ {
120
+ Ok(c) => c,
121
+ Err(e) => return EngineResponse::err("HTTP_CLIENT_ERROR", &e.to_string()),
122
+ };
123
+
124
+ let request_id = uuid::Uuid::new_v4().to_string();
125
+ let response = match client
126
+ .post(format!("{}/engine/grants/issue", engine_url))
127
+ .header("Authorization", format!("Bearer {}", auth.jwt))
128
+ .header("Content-Type", "application/json")
129
+ .header("X-EKKA-PROOF-TYPE", "jwt")
130
+ .header("X-REQUEST-ID", &request_id)
131
+ .header("X-EKKA-CORRELATION-ID", &request_id)
132
+ .header("X-EKKA-MODULE", "desktop.home")
133
+ .header("X-EKKA-ACTION", "grant")
134
+ .header("X-EKKA-CLIENT", "desktop")
135
+ .header("X-EKKA-CLIENT-VERSION", "0.2.0")
136
+ .json(&grant_request)
137
+ .send()
138
+ {
139
+ Ok(r) => r,
140
+ Err(e) => {
141
+ return EngineResponse::err(
142
+ "ENGINE_REQUEST_FAILED",
143
+ &format!("HTTP request failed: {}", e),
144
+ )
145
+ }
146
+ };
147
+
148
+ // 7. Check response status
149
+ if !response.status().is_success() {
150
+ let status = response.status();
151
+ let error_body = response.text().unwrap_or_else(|_| "No error body".to_string());
152
+ return EngineResponse::err(
153
+ "ENGINE_GRANT_DENIED",
154
+ &format!("Engine returned {}: {}", status, error_body),
155
+ );
156
+ }
157
+
158
+ // 8. Parse response
159
+ let grant_response: Value = match response.json() {
160
+ Ok(v) => v,
161
+ Err(e) => {
162
+ return EngineResponse::err(
163
+ "RESPONSE_PARSE_ERROR",
164
+ &format!("Failed to parse response: {}", e),
165
+ )
166
+ }
167
+ };
168
+
169
+ // 9. Extract signed grant
170
+ let signed_grant = match grant_response.get("signed_grant") {
171
+ Some(sg) => sg.clone(),
172
+ None => return EngineResponse::err("INVALID_RESPONSE", "Response missing signed_grant"),
173
+ };
174
+
175
+ let grant_id = signed_grant
176
+ .get("grant")
177
+ .and_then(|g| g.get("grant_id"))
178
+ .and_then(|id| id.as_str())
179
+ .unwrap_or("unknown")
180
+ .to_string();
181
+
182
+ let expires_at = grant_response
183
+ .get("expires_at")
184
+ .and_then(|e| e.as_str())
185
+ .map(|s| s.to_string());
186
+
187
+ // 10. Load or create grants.json
188
+ let grants_path = home_path.join("grants.json");
189
+ let mut grants_file: Value = if grants_path.exists() {
190
+ match std::fs::read_to_string(&grants_path) {
191
+ Ok(content) => serde_json::from_str(&content).unwrap_or(json!({
192
+ "schema_version": "1.0",
193
+ "grants": []
194
+ })),
195
+ Err(_) => json!({
196
+ "schema_version": "1.0",
197
+ "grants": []
198
+ }),
199
+ }
200
+ } else {
201
+ json!({
202
+ "schema_version": "1.0",
203
+ "grants": []
204
+ })
205
+ };
206
+
207
+ // 11. Add grant to grants array
208
+ let path_grant = json!({
209
+ "schema": signed_grant.get("schema"),
210
+ "canon_alg": signed_grant.get("canon_alg"),
211
+ "signing_alg": signed_grant.get("signing_alg"),
212
+ "grant": signed_grant.get("grant"),
213
+ "grant_canonical_b64": signed_grant.get("grant_canonical_b64"),
214
+ "signature_b64": signed_grant.get("signature_b64"),
215
+ "path_type": "HOME",
216
+ "path_access": "READ_WRITE"
217
+ });
218
+
219
+ if let Some(grants_array) = grants_file.get_mut("grants").and_then(|g| g.as_array_mut()) {
220
+ grants_array.push(path_grant);
221
+ }
222
+
223
+ // 12. Write grants.json atomically
224
+ let grants_json = match serde_json::to_string_pretty(&grants_file) {
225
+ Ok(j) => j,
226
+ Err(e) => {
227
+ return EngineResponse::err(
228
+ "SERIALIZE_ERROR",
229
+ &format!("Failed to serialize grants: {}", e),
230
+ )
231
+ }
232
+ };
233
+
234
+ let temp_path = grants_path.with_extension("json.tmp");
235
+ if let Err(e) = std::fs::write(&temp_path, &grants_json) {
236
+ return EngineResponse::err("WRITE_ERROR", &format!("Failed to write grants: {}", e));
237
+ }
238
+ if let Err(e) = std::fs::rename(&temp_path, &grants_path) {
239
+ return EngineResponse::err(
240
+ "RENAME_ERROR",
241
+ &format!("Failed to rename grants file: {}", e),
242
+ );
243
+ }
244
+
245
+ // 13. Return success
246
+ EngineResponse::ok(json!({
247
+ "success": true,
248
+ "grant_id": grant_id,
249
+ "expires_at": expires_at,
250
+ }))
251
+ }
@@ -0,0 +1,7 @@
1
+ //! Operation handlers
2
+ //!
3
+ //! Each module handles a group of related operations.
4
+
5
+ pub mod auth;
6
+ pub mod home;
7
+ pub mod runtime;