create-ekka-desktop-app 0.2.3 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +231 -50
- package/package.json +1 -1
- package/template/app.config.json +14 -0
- package/template/src-tauri/Cargo.toml +1 -1
- package/template/src-tauri/build.rs +56 -24
- package/template/src-tauri/src/bootstrap.rs +5 -4
- package/template/src-tauri/src/commands.rs +3 -2
- package/template/src-tauri/src/config.rs +35 -0
- package/template/src-tauri/src/grants.rs +16 -7
- package/template/src-tauri/src/main.rs +29 -10
- package/template/src-tauri/src/node_auth.rs +8 -6
- package/template/src-tauri/src/node_credentials.rs +2 -1
- package/template/src-tauri/src/node_runner.rs +3 -2
- package/template/src-tauri/src/state.rs +16 -0
- package/template/src-tauri/src/well_known.rs +77 -0
package/bin/cli.js
CHANGED
|
@@ -3,70 +3,251 @@
|
|
|
3
3
|
import { existsSync, mkdirSync, cpSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { resolve, join, dirname } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { createInterface } from 'node:readline';
|
|
6
7
|
|
|
7
8
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
8
9
|
const templateDir = resolve(__dirname, '..', 'template');
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
// Parse command line arguments
|
|
12
|
+
const args = process.argv.slice(2);
|
|
13
|
+
let projectName = null;
|
|
14
|
+
let configFile = null;
|
|
15
|
+
let appName = null;
|
|
16
|
+
let appSlug = null;
|
|
17
|
+
let engineUrl = null;
|
|
18
|
+
let orgPrefix = 'ai.ekka';
|
|
19
|
+
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
const arg = args[i];
|
|
22
|
+
if (arg === '--config' || arg === '-c') {
|
|
23
|
+
configFile = args[++i];
|
|
24
|
+
} else if (arg === '--name' || arg === '-n') {
|
|
25
|
+
appName = args[++i];
|
|
26
|
+
} else if (arg === '--slug' || arg === '-s') {
|
|
27
|
+
appSlug = args[++i];
|
|
28
|
+
} else if (arg === '--engine-url' || arg === '-e') {
|
|
29
|
+
engineUrl = args[++i];
|
|
30
|
+
} else if (arg === '--org' || arg === '-o') {
|
|
31
|
+
orgPrefix = args[++i];
|
|
32
|
+
} else if (arg === '--help' || arg === '-h') {
|
|
33
|
+
console.log(`
|
|
34
|
+
Usage: npx create-ekka-desktop-app [project-dir] [options]
|
|
35
|
+
|
|
36
|
+
Options:
|
|
37
|
+
-c, --config <file> Use config file (skips all prompts)
|
|
38
|
+
-n, --name <name> App display name
|
|
39
|
+
-s, --slug <slug> App identifier (lowercase, hyphens only)
|
|
40
|
+
-e, --engine-url <url> EKKA Engine URL (required)
|
|
41
|
+
-o, --org <prefix> Organization prefix (default: ai.ekka)
|
|
42
|
+
-h, --help Show this help
|
|
43
|
+
|
|
44
|
+
Examples:
|
|
45
|
+
npx create-ekka-desktop-app my-app
|
|
46
|
+
npx create-ekka-desktop-app my-app --engine-url https://api.ekka.ai
|
|
47
|
+
npx create-ekka-desktop-app my-app --config ./app.config.json
|
|
48
|
+
`);
|
|
49
|
+
process.exit(0);
|
|
50
|
+
} else if (!arg.startsWith('-') && !projectName) {
|
|
51
|
+
projectName = arg;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Helper to prompt for input
|
|
56
|
+
async function prompt(question, defaultValue = '') {
|
|
57
|
+
const rl = createInterface({
|
|
58
|
+
input: process.stdin,
|
|
59
|
+
output: process.stdout
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const displayDefault = defaultValue ? ` (${defaultValue})` : '';
|
|
64
|
+
rl.question(`${question}${displayDefault}: `, (answer) => {
|
|
65
|
+
rl.close();
|
|
66
|
+
resolve(answer.trim() || defaultValue);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
11
70
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
71
|
+
// Helper to convert to title case
|
|
72
|
+
function titleCase(str) {
|
|
73
|
+
return str
|
|
74
|
+
.split('-')
|
|
75
|
+
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
|
76
|
+
.join(' ');
|
|
15
77
|
}
|
|
16
78
|
|
|
17
|
-
|
|
79
|
+
// Helper to slugify
|
|
80
|
+
function slugify(str) {
|
|
81
|
+
return str
|
|
82
|
+
.toLowerCase()
|
|
83
|
+
.replace(/[^a-z0-9-]/g, '-')
|
|
84
|
+
.replace(/-+/g, '-')
|
|
85
|
+
.replace(/^-|-$/g, '');
|
|
86
|
+
}
|
|
18
87
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
88
|
+
// Validate slug
|
|
89
|
+
function isValidSlug(slug) {
|
|
90
|
+
return /^[a-z][a-z0-9-]*$/.test(slug);
|
|
22
91
|
}
|
|
23
92
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
93
|
+
// Main
|
|
94
|
+
async function main() {
|
|
95
|
+
console.log(`
|
|
96
|
+
╔══════════════════════════════════════════════════════════════╗
|
|
97
|
+
║ CREATE EKKA DESKTOP APP ║
|
|
98
|
+
╚══════════════════════════════════════════════════════════════╝
|
|
99
|
+
`);
|
|
100
|
+
|
|
101
|
+
// If config file provided, load it and skip prompts
|
|
102
|
+
let config = null;
|
|
103
|
+
if (configFile) {
|
|
104
|
+
if (!existsSync(configFile)) {
|
|
105
|
+
console.error(`Error: Config file not found: ${configFile}`);
|
|
106
|
+
process.exit(1);
|
|
107
|
+
}
|
|
108
|
+
try {
|
|
109
|
+
config = JSON.parse(readFileSync(configFile, 'utf8'));
|
|
110
|
+
console.log(`Using config from: ${configFile}\n`);
|
|
111
|
+
} catch (e) {
|
|
112
|
+
console.error(`Error: Invalid JSON in config file: ${e.message}`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Get project directory
|
|
118
|
+
if (!projectName) {
|
|
119
|
+
projectName = await prompt('Project directory', 'my-ekka-app');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const targetDir = resolve(process.cwd(), projectName);
|
|
123
|
+
|
|
124
|
+
if (existsSync(targetDir)) {
|
|
125
|
+
console.error(`\nError: Directory "${projectName}" already exists.`);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Build config from flags, prompts, or config file
|
|
130
|
+
if (!config) {
|
|
131
|
+
// Get app name
|
|
132
|
+
if (!appName) {
|
|
133
|
+
appName = await prompt('App display name', titleCase(projectName));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Get app slug
|
|
137
|
+
if (!appSlug) {
|
|
138
|
+
const defaultSlug = slugify(projectName);
|
|
139
|
+
appSlug = await prompt('App identifier (lowercase, hyphens)', defaultSlug);
|
|
140
|
+
while (!isValidSlug(appSlug)) {
|
|
141
|
+
console.log(' ⚠ Must be lowercase letters, numbers, and hyphens only');
|
|
142
|
+
appSlug = await prompt('App identifier (lowercase, hyphens)', defaultSlug);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Get engine URL
|
|
147
|
+
if (!engineUrl) {
|
|
148
|
+
engineUrl = await prompt('EKKA Engine URL', 'https://api.ekka.ai');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Get org prefix (only prompt if not all values provided via flags)
|
|
152
|
+
if (orgPrefix === 'ai.ekka' && !process.argv.includes('--engine-url') && !process.argv.includes('-e')) {
|
|
153
|
+
const customOrg = await prompt('Organization prefix', 'ai.ekka');
|
|
154
|
+
if (customOrg) orgPrefix = customOrg;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
config = {
|
|
158
|
+
app: {
|
|
159
|
+
name: appName,
|
|
160
|
+
slug: appSlug,
|
|
161
|
+
identifier: `${orgPrefix}.${appSlug.replace(/-/g, '')}`
|
|
162
|
+
},
|
|
163
|
+
storage: {
|
|
164
|
+
homeFolderName: `.${appSlug}`,
|
|
165
|
+
keychainService: `${orgPrefix}.${appSlug.replace(/-/g, '')}`
|
|
166
|
+
},
|
|
167
|
+
engine: {
|
|
168
|
+
url: engineUrl
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Validate config
|
|
174
|
+
if (!config.app?.slug) {
|
|
175
|
+
console.error('\nError: app.slug is required in config');
|
|
176
|
+
process.exit(1);
|
|
177
|
+
}
|
|
178
|
+
if (!config.engine?.url) {
|
|
179
|
+
console.error('\nError: engine.url is required in config');
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Derive missing values
|
|
184
|
+
config.app.name = config.app.name || titleCase(config.app.slug);
|
|
185
|
+
config.app.identifier = config.app.identifier || `ai.ekka.${config.app.slug.replace(/-/g, '')}`;
|
|
186
|
+
config.storage = config.storage || {};
|
|
187
|
+
config.storage.homeFolderName = config.storage.homeFolderName || `.${config.app.slug}`;
|
|
188
|
+
config.storage.keychainService = config.storage.keychainService || config.app.identifier;
|
|
189
|
+
|
|
190
|
+
console.log(`
|
|
191
|
+
Creating EKKA desktop app in ${targetDir}...
|
|
192
|
+
|
|
193
|
+
App Name: ${config.app.name}
|
|
194
|
+
App Slug: ${config.app.slug}
|
|
195
|
+
Identifier: ${config.app.identifier}
|
|
196
|
+
Home Folder: ~/${config.storage.homeFolderName}
|
|
197
|
+
Engine URL: ${config.engine.url}
|
|
198
|
+
`);
|
|
199
|
+
|
|
200
|
+
// Copy template
|
|
201
|
+
mkdirSync(targetDir, { recursive: true });
|
|
202
|
+
cpSync(templateDir, targetDir, { recursive: true });
|
|
203
|
+
|
|
204
|
+
// Write app.config.json
|
|
205
|
+
const appConfigPath = join(targetDir, 'app.config.json');
|
|
206
|
+
writeFileSync(appConfigPath, JSON.stringify(config, null, 2) + '\n');
|
|
207
|
+
|
|
208
|
+
// Update package.json with project name
|
|
209
|
+
const pkgPath = join(targetDir, 'package.json');
|
|
210
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
211
|
+
pkg.name = config.app.slug;
|
|
212
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
|
|
213
|
+
|
|
214
|
+
// Update branding/app.json
|
|
215
|
+
const brandingPath = join(targetDir, 'branding', 'app.json');
|
|
216
|
+
const branding = JSON.parse(readFileSync(brandingPath, 'utf8'));
|
|
217
|
+
branding.name = config.app.name;
|
|
218
|
+
branding.bundleId = config.app.identifier;
|
|
219
|
+
writeFileSync(brandingPath, JSON.stringify(branding, null, 2) + '\n');
|
|
220
|
+
|
|
221
|
+
// Update src-tauri/Cargo.toml crate name
|
|
222
|
+
const cargoPath = join(targetDir, 'src-tauri', 'Cargo.toml');
|
|
223
|
+
let cargoContent = readFileSync(cargoPath, 'utf8');
|
|
224
|
+
cargoContent = cargoContent.replace(/^name = ".*"$/m, `name = "${config.app.slug}"`);
|
|
225
|
+
cargoContent = cargoContent.replace(/^description = ".*"$/m, `description = "${config.app.name}"`);
|
|
226
|
+
writeFileSync(cargoPath, cargoContent);
|
|
227
|
+
|
|
228
|
+
// Update src-tauri/tauri.conf.json identifier
|
|
229
|
+
const tauriConfPath = join(targetDir, 'src-tauri', 'tauri.conf.json');
|
|
230
|
+
const tauriConf = JSON.parse(readFileSync(tauriConfPath, 'utf8'));
|
|
231
|
+
tauriConf.identifier = config.app.identifier;
|
|
232
|
+
writeFileSync(tauriConfPath, JSON.stringify(tauriConf, null, 2) + '\n');
|
|
233
|
+
|
|
234
|
+
console.log(`
|
|
235
|
+
╔══════════════════════════════════════════════════════════════╗
|
|
236
|
+
║ SUCCESS! Your app is ready. ║
|
|
237
|
+
╚══════════════════════════════════════════════════════════════╝
|
|
238
|
+
|
|
239
|
+
To get started:
|
|
59
240
|
|
|
60
241
|
cd ${projectName}
|
|
61
242
|
npm install
|
|
243
|
+
npm run tauri:dev
|
|
62
244
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
npm run tauri:dev # Desktop (native window)
|
|
245
|
+
Configuration:
|
|
246
|
+
Edit app.config.json to change app identity or engine URL.
|
|
66
247
|
|
|
67
248
|
Build:
|
|
68
|
-
npm run tauri:build
|
|
69
|
-
|
|
70
|
-
Edit src/app/App.tsx to build your UI.
|
|
71
|
-
Delete src/demo/ when ready.
|
|
249
|
+
npm run tauri:build
|
|
72
250
|
`);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
main().catch(console.error);
|
package/package.json
CHANGED
|
@@ -1,47 +1,79 @@
|
|
|
1
|
+
use std::fs;
|
|
2
|
+
|
|
1
3
|
fn main() {
|
|
2
|
-
// Rerun if
|
|
3
|
-
println!("cargo:rerun-if-changed
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
// Load env files: try src-tauri first, then project root
|
|
9
|
-
// .env.local takes precedence over .env
|
|
10
|
-
let _ = dotenvy::from_filename(".env.local");
|
|
11
|
-
let _ = dotenvy::from_filename(".env");
|
|
12
|
-
let _ = dotenvy::from_filename("../.env.local");
|
|
13
|
-
let _ = dotenvy::from_filename("../.env");
|
|
14
|
-
|
|
15
|
-
// EKKA_ENGINE_URL is required at build time
|
|
16
|
-
let engine_url = std::env::var("EKKA_ENGINE_URL").unwrap_or_else(|_| {
|
|
4
|
+
// Rerun if config changes
|
|
5
|
+
println!("cargo:rerun-if-changed=../app.config.json");
|
|
6
|
+
|
|
7
|
+
// 1. Read app.config.json
|
|
8
|
+
let config_path = "../app.config.json";
|
|
9
|
+
let config_str = fs::read_to_string(config_path).unwrap_or_else(|_| {
|
|
17
10
|
panic!(
|
|
18
11
|
"\n\n\
|
|
19
12
|
╔══════════════════════════════════════════════════════════════════╗\n\
|
|
20
|
-
║ BUILD ERROR:
|
|
13
|
+
║ BUILD ERROR: app.config.json not found ║\n\
|
|
21
14
|
╠══════════════════════════════════════════════════════════════════╣\n\
|
|
22
|
-
║
|
|
15
|
+
║ This file is required and should have been created by CDA. ║\n\
|
|
16
|
+
║ Regenerate your app with: ║\n\
|
|
23
17
|
║ ║\n\
|
|
24
|
-
║
|
|
18
|
+
║ npx create-ekka-desktop-app@latest my-app ║\n\
|
|
25
19
|
║ ║\n\
|
|
26
20
|
╚══════════════════════════════════════════════════════════════════╝\n\n"
|
|
27
21
|
)
|
|
28
22
|
});
|
|
29
23
|
|
|
30
|
-
|
|
24
|
+
let config: serde_json::Value = serde_json::from_str(&config_str).unwrap_or_else(|e| {
|
|
25
|
+
panic!("\n\nBUILD ERROR: Invalid app.config.json: {}\n\n", e)
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// 2. Extract and validate required fields
|
|
29
|
+
let app = config.get("app").expect("app.config.json missing 'app' section");
|
|
30
|
+
let storage = config.get("storage").expect("app.config.json missing 'storage' section");
|
|
31
|
+
let engine = config.get("engine").expect("app.config.json missing 'engine' section");
|
|
32
|
+
|
|
33
|
+
let app_name = app.get("name").and_then(|v| v.as_str())
|
|
34
|
+
.expect("app.config.json: app.name is required");
|
|
35
|
+
let app_slug = app.get("slug").and_then(|v| v.as_str())
|
|
36
|
+
.expect("app.config.json: app.slug is required");
|
|
37
|
+
let app_identifier = app.get("identifier").and_then(|v| v.as_str())
|
|
38
|
+
.expect("app.config.json: app.identifier is required");
|
|
39
|
+
|
|
40
|
+
let home_folder = storage.get("homeFolderName").and_then(|v| v.as_str())
|
|
41
|
+
.expect("app.config.json: storage.homeFolderName is required");
|
|
42
|
+
let keychain_service = storage.get("keychainService").and_then(|v| v.as_str())
|
|
43
|
+
.expect("app.config.json: storage.keychainService is required");
|
|
44
|
+
|
|
45
|
+
let engine_url = engine.get("url").and_then(|v| v.as_str())
|
|
46
|
+
.expect("app.config.json: engine.url is required");
|
|
47
|
+
|
|
48
|
+
// 3. Validate non-empty
|
|
49
|
+
if app_slug.is_empty() {
|
|
50
|
+
panic!("\n\nBUILD ERROR: app.slug cannot be empty in app.config.json\n\n");
|
|
51
|
+
}
|
|
52
|
+
if home_folder.is_empty() {
|
|
53
|
+
panic!("\n\nBUILD ERROR: storage.homeFolderName cannot be empty in app.config.json\n\n");
|
|
54
|
+
}
|
|
55
|
+
if engine_url.is_empty() {
|
|
31
56
|
panic!(
|
|
32
57
|
"\n\n\
|
|
33
58
|
╔══════════════════════════════════════════════════════════════════╗\n\
|
|
34
|
-
║ BUILD ERROR:
|
|
59
|
+
║ BUILD ERROR: engine.url is empty in app.config.json ║\n\
|
|
35
60
|
╠══════════════════════════════════════════════════════════════════╣\n\
|
|
36
|
-
║ Set
|
|
61
|
+
║ Set your EKKA Engine URL in app.config.json: ║\n\
|
|
37
62
|
║ ║\n\
|
|
38
|
-
║
|
|
63
|
+
║ \"engine\": {{ ║\n\
|
|
64
|
+
║ \"url\": \"https://api.ekka.ai\" ║\n\
|
|
65
|
+
║ }} ║\n\
|
|
39
66
|
║ ║\n\
|
|
40
67
|
╚══════════════════════════════════════════════════════════════════╝\n\n"
|
|
41
|
-
)
|
|
68
|
+
);
|
|
42
69
|
}
|
|
43
70
|
|
|
44
|
-
// Bake
|
|
71
|
+
// 4. Bake values into binary at compile time
|
|
72
|
+
println!("cargo:rustc-env=EKKA_APP_NAME={}", app_name);
|
|
73
|
+
println!("cargo:rustc-env=EKKA_APP_SLUG={}", app_slug);
|
|
74
|
+
println!("cargo:rustc-env=EKKA_APP_IDENTIFIER={}", app_identifier);
|
|
75
|
+
println!("cargo:rustc-env=EKKA_HOME_FOLDER={}", home_folder);
|
|
76
|
+
println!("cargo:rustc-env=EKKA_KEYCHAIN_SERVICE={}", keychain_service);
|
|
45
77
|
println!("cargo:rustc-env=EKKA_ENGINE_URL={}", engine_url);
|
|
46
78
|
|
|
47
79
|
// Continue with tauri build
|
|
@@ -2,19 +2,20 @@
|
|
|
2
2
|
//!
|
|
3
3
|
//! Handles initialization and resolution of the EKKA home directory.
|
|
4
4
|
|
|
5
|
+
use crate::config;
|
|
5
6
|
use ekka_sdk_core::ekka_home_bootstrap::{BootstrapConfig, EpochSource, HomeBootstrap, HomeStrategy};
|
|
6
7
|
use std::path::PathBuf;
|
|
7
8
|
|
|
8
|
-
/// Standard bootstrap configuration
|
|
9
|
+
/// Standard bootstrap configuration - all values from app.config.json (baked at build time)
|
|
9
10
|
pub fn bootstrap_config() -> BootstrapConfig {
|
|
10
11
|
BootstrapConfig {
|
|
11
|
-
app_name:
|
|
12
|
-
default_folder_name:
|
|
12
|
+
app_name: config::app_name().to_string(),
|
|
13
|
+
default_folder_name: config::home_folder().to_string(),
|
|
13
14
|
home_strategy: HomeStrategy::DataHome {
|
|
14
15
|
env_var: "EKKA_DATA_HOME".to_string(),
|
|
15
16
|
},
|
|
16
17
|
marker_filename: ".ekka-marker.json".to_string(),
|
|
17
|
-
keychain_service:
|
|
18
|
+
keychain_service: config::keychain_service().to_string(),
|
|
18
19
|
subdirs: vec!["vault".to_string(), "db".to_string(), "tmp".to_string()],
|
|
19
20
|
epoch_source: EpochSource::EnvVar("EKKA_SECURITY_EPOCH".to_string()),
|
|
20
21
|
storage_layout_version: "v1".to_string(),
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
//! Entry points for TypeScript → Rust communication.
|
|
4
4
|
|
|
5
5
|
use crate::bootstrap::initialize_home;
|
|
6
|
+
use crate::config;
|
|
6
7
|
use crate::engine_process;
|
|
7
8
|
use crate::grants::require_home_granted;
|
|
8
9
|
use crate::handlers;
|
|
@@ -541,7 +542,7 @@ fn handle_runner_task_stats(state: &EngineState) -> EngineResponse {
|
|
|
541
542
|
.header("X-EKKA-CORRELATION-ID", &request_id)
|
|
542
543
|
.header("X-EKKA-MODULE", "engine.runner_tasks")
|
|
543
544
|
.header("X-EKKA-ACTION", "stats")
|
|
544
|
-
.header("X-EKKA-CLIENT",
|
|
545
|
+
.header("X-EKKA-CLIENT", config::app_slug())
|
|
545
546
|
.header("X-EKKA-CLIENT-VERSION", "0.2.0")
|
|
546
547
|
.send();
|
|
547
548
|
|
|
@@ -902,7 +903,7 @@ fn build_security_headers(jwt: Option<&str>, module: &str, action: &str) -> Vec<
|
|
|
902
903
|
("X-EKKA-PROOF-TYPE".to_string(), if jwt.is_some() { "jwt" } else { "none" }.to_string()),
|
|
903
904
|
("X-EKKA-MODULE".to_string(), module.to_string()),
|
|
904
905
|
("X-EKKA-ACTION".to_string(), action.to_string()),
|
|
905
|
-
("X-EKKA-CLIENT".to_string(),
|
|
906
|
+
("X-EKKA-CLIENT".to_string(), config::app_slug().to_string()),
|
|
906
907
|
("X-EKKA-CLIENT-VERSION".to_string(), "0.2.0".to_string()),
|
|
907
908
|
];
|
|
908
909
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
//! Compile-time app configuration
|
|
2
|
+
//!
|
|
3
|
+
//! All values are baked at build time from app.config.json.
|
|
4
|
+
//! ZERO runtime configuration or hardcoding.
|
|
5
|
+
|
|
6
|
+
#![allow(dead_code)] // Not all config values are used yet
|
|
7
|
+
|
|
8
|
+
macro_rules! baked_config {
|
|
9
|
+
($name:ident, $env:literal) => {
|
|
10
|
+
pub fn $name() -> &'static str {
|
|
11
|
+
option_env!($env).expect(concat!(
|
|
12
|
+
$env,
|
|
13
|
+
" not baked at build time. Check build.rs and app.config.json"
|
|
14
|
+
))
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// App display name (e.g., "EKKA Studio")
|
|
20
|
+
baked_config!(app_name, "EKKA_APP_NAME");
|
|
21
|
+
|
|
22
|
+
// App slug for machine use (e.g., "ekka-studio")
|
|
23
|
+
baked_config!(app_slug, "EKKA_APP_SLUG");
|
|
24
|
+
|
|
25
|
+
// App identifier for OS (e.g., "ai.ekka.studio")
|
|
26
|
+
baked_config!(app_identifier, "EKKA_APP_IDENTIFIER");
|
|
27
|
+
|
|
28
|
+
// Home folder name (e.g., ".ekka-studio")
|
|
29
|
+
baked_config!(home_folder, "EKKA_HOME_FOLDER");
|
|
30
|
+
|
|
31
|
+
// Keychain service identifier (e.g., "ai.ekka.studio")
|
|
32
|
+
baked_config!(keychain_service, "EKKA_KEYCHAIN_SERVICE");
|
|
33
|
+
|
|
34
|
+
// EKKA Engine URL (e.g., "https://api.ekka.ai")
|
|
35
|
+
baked_config!(engine_url, "EKKA_ENGINE_URL");
|
|
@@ -10,7 +10,7 @@ use std::path::PathBuf;
|
|
|
10
10
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
11
11
|
|
|
12
12
|
/// Check if a valid HOME grant exists for the given auth context
|
|
13
|
-
pub fn check_home_grant(home_path: &PathBuf, auth: &AuthContext) -> Result<bool, String> {
|
|
13
|
+
pub fn check_home_grant(home_path: &PathBuf, auth: &AuthContext, verify_key: &str) -> Result<bool, String> {
|
|
14
14
|
let grants_path = home_path.join("grants.json");
|
|
15
15
|
|
|
16
16
|
// No grants file = no grant
|
|
@@ -18,11 +18,7 @@ pub fn check_home_grant(home_path: &PathBuf, auth: &AuthContext) -> Result<bool,
|
|
|
18
18
|
return Ok(false);
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
let key_b64 = match std::env::var("ENGINE_GRANT_VERIFY_KEY_B64") {
|
|
23
|
-
Ok(k) => k,
|
|
24
|
-
Err(_) => return Err("ENGINE_GRANT_VERIFY_KEY_B64 not set".to_string()),
|
|
25
|
-
};
|
|
21
|
+
let key_b64 = verify_key;
|
|
26
22
|
|
|
27
23
|
// Load and verify grants
|
|
28
24
|
let store = GrantStore::new(grants_path, &key_b64).map_err(|e| e.to_string())?;
|
|
@@ -101,8 +97,21 @@ pub fn get_home_status(state: &EngineState) -> (HomeState, PathBuf, bool, Option
|
|
|
101
97
|
None => return (HomeState::BootstrapPreLogin, home_path, false, None),
|
|
102
98
|
};
|
|
103
99
|
|
|
100
|
+
// Get verify key from state (fetched from well-known endpoint)
|
|
101
|
+
let verify_key = match state.get_grant_verify_key() {
|
|
102
|
+
Some(k) => k,
|
|
103
|
+
None => {
|
|
104
|
+
return (
|
|
105
|
+
HomeState::AuthenticatedNoHomeGrant,
|
|
106
|
+
home_path,
|
|
107
|
+
false,
|
|
108
|
+
Some("Grant verification key not loaded. Waiting for engine configuration.".to_string()),
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
104
113
|
// Check for valid HOME grant
|
|
105
|
-
match check_home_grant(&home_path, &auth) {
|
|
114
|
+
match check_home_grant(&home_path, &auth, &verify_key) {
|
|
106
115
|
Ok(true) => (HomeState::HomeGranted, home_path, true, None),
|
|
107
116
|
Ok(false) => (
|
|
108
117
|
HomeState::AuthenticatedNoHomeGrant,
|
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
|
|
8
8
|
mod bootstrap;
|
|
9
9
|
mod commands;
|
|
10
|
+
mod config;
|
|
10
11
|
mod device_secret;
|
|
11
12
|
mod engine_process;
|
|
12
13
|
mod grants;
|
|
14
|
+
mod well_known;
|
|
13
15
|
mod handlers;
|
|
14
16
|
mod node_auth;
|
|
15
17
|
mod node_credentials;
|
|
@@ -29,14 +31,11 @@ use tauri::Manager;
|
|
|
29
31
|
|
|
30
32
|
fn main() {
|
|
31
33
|
// Load .env.local for development (before anything else)
|
|
32
|
-
// This provides
|
|
33
|
-
|
|
34
|
+
// This provides EKKA_SECURITY_EPOCH and other dev-time overrides.
|
|
35
|
+
// Note: ENGINE_GRANT_VERIFY_KEY_B64 is now fetched from /.well-known/ekka-configuration
|
|
36
|
+
if let Err(_) = dotenvy::from_filename(".env.local") {
|
|
34
37
|
// Also try parent directory (when running from src-tauri)
|
|
35
38
|
let _ = dotenvy::from_filename("../.env.local");
|
|
36
|
-
// Silence error in production where .env.local may not exist
|
|
37
|
-
if std::env::var("ENGINE_GRANT_VERIFY_KEY_B64").is_err() {
|
|
38
|
-
eprintln!("Warning: .env.local not loaded and ENGINE_GRANT_VERIFY_KEY_B64 not set: {}", e);
|
|
39
|
-
}
|
|
40
39
|
}
|
|
41
40
|
|
|
42
41
|
// Initialize tracing for runner logs
|
|
@@ -64,14 +63,12 @@ fn main() {
|
|
|
64
63
|
// Attempt to spawn engine process
|
|
65
64
|
tracing::info!(op = "desktop.startup", "EKKA Desktop starting");
|
|
66
65
|
|
|
67
|
-
// Log
|
|
68
|
-
let grant_key_set = std::env::var("ENGINE_GRANT_VERIFY_KEY_B64").is_ok();
|
|
66
|
+
// Log security epoch status (still needed for home bootstrap)
|
|
69
67
|
let security_epoch_set = std::env::var("EKKA_SECURITY_EPOCH").is_ok();
|
|
70
68
|
tracing::info!(
|
|
71
69
|
op = "desktop.required_env.loaded",
|
|
72
|
-
ENGINE_GRANT_VERIFY_KEY_B64 = grant_key_set,
|
|
73
70
|
EKKA_SECURITY_EPOCH = security_epoch_set,
|
|
74
|
-
"
|
|
71
|
+
"Security epoch env var status"
|
|
75
72
|
);
|
|
76
73
|
|
|
77
74
|
// Log build-time baked engine URL presence (not the URL itself)
|
|
@@ -106,6 +103,28 @@ fn main() {
|
|
|
106
103
|
let node_auth_holder = state_handle.node_auth_token.clone();
|
|
107
104
|
let node_auth_state = state_handle.node_auth_state.clone();
|
|
108
105
|
|
|
106
|
+
// Fetch grant verification key from engine's well-known endpoint
|
|
107
|
+
// This runs async and caches the key in state for grant verification
|
|
108
|
+
let app_handle = app.app_handle().clone();
|
|
109
|
+
tauri::async_runtime::spawn(async move {
|
|
110
|
+
let state = app_handle.state::<EngineState>();
|
|
111
|
+
match well_known::fetch_and_cache_verify_key(&state).await {
|
|
112
|
+
Ok(()) => {
|
|
113
|
+
tracing::info!(
|
|
114
|
+
op = "desktop.well_known.loaded",
|
|
115
|
+
"Grant verification key loaded from engine"
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
Err(e) => {
|
|
119
|
+
tracing::warn!(
|
|
120
|
+
op = "desktop.well_known.failed",
|
|
121
|
+
error = %e,
|
|
122
|
+
"Failed to fetch grant verification key - grants will not be verified"
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
109
128
|
// Spawn engine in background thread to not block UI
|
|
110
129
|
let engine = engine_for_setup.clone();
|
|
111
130
|
std::thread::spawn(move || {
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
#![allow(dead_code)] // API types may not all be used yet
|
|
19
19
|
|
|
20
|
+
use crate::config;
|
|
20
21
|
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
|
21
22
|
use chrono::{DateTime, Utc};
|
|
22
23
|
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
|
|
@@ -240,7 +241,8 @@ pub fn get_private_key_vault_path(node_id: &Uuid) -> PathBuf {
|
|
|
240
241
|
fn derive_node_encryption_key(node_id: &Uuid, device_fingerprint: Option<&str>) -> KeyMaterial {
|
|
241
242
|
// Use node_id + device fingerprint for key derivation
|
|
242
243
|
// This binds the key to this specific node on this device
|
|
243
|
-
let
|
|
244
|
+
let default_device = format!("{}-default-device", config::app_slug());
|
|
245
|
+
let device_secret = device_fingerprint.unwrap_or(&default_device);
|
|
244
246
|
|
|
245
247
|
derive_key(
|
|
246
248
|
device_secret,
|
|
@@ -386,7 +388,7 @@ pub fn register_node(
|
|
|
386
388
|
"node_id": node_id.to_string(),
|
|
387
389
|
"public_key_b64": public_key_b64,
|
|
388
390
|
"default_workspace_id": default_workspace_id,
|
|
389
|
-
"display_name":
|
|
391
|
+
"display_name": config::app_name(),
|
|
390
392
|
"node_type": "desktop"
|
|
391
393
|
});
|
|
392
394
|
|
|
@@ -403,7 +405,7 @@ pub fn register_node(
|
|
|
403
405
|
.header("X-EKKA-CORRELATION-ID", &request_id)
|
|
404
406
|
.header("X-EKKA-MODULE", "desktop.node_auth")
|
|
405
407
|
.header("X-EKKA-ACTION", "register")
|
|
406
|
-
.header("X-EKKA-CLIENT",
|
|
408
|
+
.header("X-EKKA-CLIENT", config::app_slug())
|
|
407
409
|
.header("X-EKKA-CLIENT-VERSION", "0.2.0")
|
|
408
410
|
.json(&body)
|
|
409
411
|
.send()
|
|
@@ -459,7 +461,7 @@ pub fn get_challenge(engine_url: &str, node_id: &Uuid) -> Result<ChallengeRespon
|
|
|
459
461
|
.header("X-EKKA-CORRELATION-ID", &request_id)
|
|
460
462
|
.header("X-EKKA-MODULE", "desktop.node_auth")
|
|
461
463
|
.header("X-EKKA-ACTION", "challenge")
|
|
462
|
-
.header("X-EKKA-CLIENT",
|
|
464
|
+
.header("X-EKKA-CLIENT", config::app_slug())
|
|
463
465
|
.header("X-EKKA-CLIENT-VERSION", "0.2.0")
|
|
464
466
|
.json(&serde_json::json!({ "node_id": node_id.to_string() }))
|
|
465
467
|
.send()
|
|
@@ -503,7 +505,7 @@ pub fn create_session(
|
|
|
503
505
|
.header("X-EKKA-CORRELATION-ID", &request_id)
|
|
504
506
|
.header("X-EKKA-MODULE", "desktop.node_auth")
|
|
505
507
|
.header("X-EKKA-ACTION", "session")
|
|
506
|
-
.header("X-EKKA-CLIENT",
|
|
508
|
+
.header("X-EKKA-CLIENT", config::app_slug())
|
|
507
509
|
.header("X-EKKA-CLIENT-VERSION", "0.2.0")
|
|
508
510
|
.json(&serde_json::json!({
|
|
509
511
|
"node_id": node_id.to_string(),
|
|
@@ -798,7 +800,7 @@ impl NodeSessionRunnerConfig {
|
|
|
798
800
|
("X-REQUEST-ID", Uuid::new_v4().to_string()),
|
|
799
801
|
("X-EKKA-CORRELATION-ID", Uuid::new_v4().to_string()),
|
|
800
802
|
("X-EKKA-MODULE", "engine.runner_tasks".to_string()),
|
|
801
|
-
("X-EKKA-CLIENT",
|
|
803
|
+
("X-EKKA-CLIENT", config::app_slug().to_string()),
|
|
802
804
|
("X-EKKA-CLIENT-VERSION", "0.2.0".to_string()),
|
|
803
805
|
("X-EKKA-NODE-ID", self.node_id.to_string()),
|
|
804
806
|
]
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
//! - No OS keychain prompts
|
|
12
12
|
|
|
13
13
|
use crate::bootstrap::{initialize_home, resolve_home_path};
|
|
14
|
+
use crate::config;
|
|
14
15
|
use crate::node_vault_store::{
|
|
15
16
|
delete_node_secret, has_node_secret, read_node_secret, write_node_secret,
|
|
16
17
|
SECRET_ID_NODE_CREDENTIALS,
|
|
@@ -394,7 +395,7 @@ pub fn authenticate_node(engine_url: &str) -> Result<NodeAuthToken, CredentialsE
|
|
|
394
395
|
.header("X-EKKA-CORRELATION-ID", &request_id)
|
|
395
396
|
.header("X-EKKA-MODULE", "desktop.node_auth")
|
|
396
397
|
.header("X-EKKA-ACTION", "authenticate")
|
|
397
|
-
.header("X-EKKA-CLIENT",
|
|
398
|
+
.header("X-EKKA-CLIENT", config::app_slug())
|
|
398
399
|
.header("X-EKKA-CLIENT-VERSION", "0.2.0")
|
|
399
400
|
.json(&body)
|
|
400
401
|
.send()
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
#![allow(dead_code)] // API types and fields may not all be used yet
|
|
19
19
|
|
|
20
|
+
use crate::config;
|
|
20
21
|
use crate::node_auth::{
|
|
21
22
|
refresh_node_session, NodeSession, NodeSessionHolder, NodeSessionRunnerConfig,
|
|
22
23
|
};
|
|
@@ -236,7 +237,7 @@ impl NodeSessionRunner {
|
|
|
236
237
|
("X-REQUEST-ID", Uuid::new_v4().to_string()),
|
|
237
238
|
("X-EKKA-CORRELATION-ID", Uuid::new_v4().to_string()),
|
|
238
239
|
("X-EKKA-MODULE", "engine.runner_tasks".to_string()),
|
|
239
|
-
("X-EKKA-CLIENT",
|
|
240
|
+
("X-EKKA-CLIENT", config::app_slug().to_string()),
|
|
240
241
|
("X-EKKA-CLIENT-VERSION", "0.2.0".to_string()),
|
|
241
242
|
("X-EKKA-NODE-ID", self.node_id.to_string()),
|
|
242
243
|
])
|
|
@@ -700,7 +701,7 @@ impl NodeSessionRunnerHeartbeat {
|
|
|
700
701
|
.header("X-EKKA-CORRELATION-ID", Uuid::new_v4().to_string())
|
|
701
702
|
.header("X-EKKA-MODULE", "engine.runner_tasks")
|
|
702
703
|
.header("X-EKKA-ACTION", "heartbeat")
|
|
703
|
-
.header("X-EKKA-CLIENT",
|
|
704
|
+
.header("X-EKKA-CLIENT", config::app_slug())
|
|
704
705
|
.header("X-EKKA-CLIENT-VERSION", "0.2.0")
|
|
705
706
|
.header("X-EKKA-NODE-ID", self.node_id.to_string())
|
|
706
707
|
.json(&serde_json::json!({ "runner_id": self.runner_id }))
|
|
@@ -325,6 +325,8 @@ pub struct EngineState {
|
|
|
325
325
|
pub node_auth_state: Arc<NodeAuthStateHolder>,
|
|
326
326
|
/// External engine process (Phase 3A)
|
|
327
327
|
pub engine_process: Option<Arc<EngineProcess>>,
|
|
328
|
+
/// Cached grant verification key (fetched from /.well-known/ekka-configuration)
|
|
329
|
+
pub grant_verify_key: RwLock<Option<String>>,
|
|
328
330
|
}
|
|
329
331
|
|
|
330
332
|
impl Default for EngineState {
|
|
@@ -341,6 +343,7 @@ impl Default for EngineState {
|
|
|
341
343
|
node_auth_token: Arc::new(NodeAuthTokenHolder::new()),
|
|
342
344
|
node_auth_state: Arc::new(NodeAuthStateHolder::new()),
|
|
343
345
|
engine_process: None,
|
|
346
|
+
grant_verify_key: RwLock::new(None),
|
|
344
347
|
}
|
|
345
348
|
}
|
|
346
349
|
}
|
|
@@ -360,6 +363,7 @@ impl EngineState {
|
|
|
360
363
|
node_auth_token: Arc::new(NodeAuthTokenHolder::new()),
|
|
361
364
|
node_auth_state: Arc::new(NodeAuthStateHolder::new()),
|
|
362
365
|
engine_process: Some(engine),
|
|
366
|
+
grant_verify_key: RwLock::new(None),
|
|
363
367
|
}
|
|
364
368
|
}
|
|
365
369
|
|
|
@@ -431,6 +435,18 @@ impl EngineState {
|
|
|
431
435
|
pub fn clear_vault_cache(&self) {
|
|
432
436
|
self.vault_cache.clear();
|
|
433
437
|
}
|
|
438
|
+
|
|
439
|
+
/// Get cached grant verification key
|
|
440
|
+
pub fn get_grant_verify_key(&self) -> Option<String> {
|
|
441
|
+
self.grant_verify_key.read().ok()?.clone()
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/// Set grant verification key (fetched from well-known endpoint)
|
|
445
|
+
pub fn set_grant_verify_key(&self, key: String) {
|
|
446
|
+
if let Ok(mut guard) = self.grant_verify_key.write() {
|
|
447
|
+
*guard = Some(key);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
434
450
|
}
|
|
435
451
|
|
|
436
452
|
// =============================================================================
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
//! Well-Known Configuration Fetcher
|
|
2
|
+
//!
|
|
3
|
+
//! Fetches public configuration from the EKKA Engine's /.well-known/ekka-configuration endpoint.
|
|
4
|
+
//! This includes the grant verification key needed for cryptographic grant validation.
|
|
5
|
+
|
|
6
|
+
use crate::config;
|
|
7
|
+
use serde::Deserialize;
|
|
8
|
+
|
|
9
|
+
/// Response from /.well-known/ekka-configuration
|
|
10
|
+
#[derive(Debug, Deserialize)]
|
|
11
|
+
pub struct WellKnownConfig {
|
|
12
|
+
pub grant_verify_key_b64: String,
|
|
13
|
+
pub grant_signing_algorithm: String,
|
|
14
|
+
#[serde(default)]
|
|
15
|
+
pub api_version: Option<String>,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/// Fetch the well-known configuration from the engine.
|
|
19
|
+
/// Returns the grant verification key (base64).
|
|
20
|
+
pub async fn fetch_grant_verify_key() -> Result<String, String> {
|
|
21
|
+
let engine_url = config::engine_url();
|
|
22
|
+
let url = format!("{}/.well-known/ekka-configuration", engine_url.trim_end_matches('/'));
|
|
23
|
+
|
|
24
|
+
tracing::info!(
|
|
25
|
+
op = "well_known.fetch.start",
|
|
26
|
+
url = %url,
|
|
27
|
+
"Fetching grant verification key from engine"
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
let client = reqwest::Client::builder()
|
|
31
|
+
.timeout(std::time::Duration::from_secs(10))
|
|
32
|
+
.build()
|
|
33
|
+
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
|
34
|
+
|
|
35
|
+
let response = client
|
|
36
|
+
.get(&url)
|
|
37
|
+
.header("X-EKKA-CLIENT", config::app_slug())
|
|
38
|
+
.send()
|
|
39
|
+
.await
|
|
40
|
+
.map_err(|e| format!("Failed to fetch well-known config: {}", e))?;
|
|
41
|
+
|
|
42
|
+
if !response.status().is_success() {
|
|
43
|
+
return Err(format!(
|
|
44
|
+
"Engine returned error: {} {}",
|
|
45
|
+
response.status().as_u16(),
|
|
46
|
+
response.status().canonical_reason().unwrap_or("Unknown")
|
|
47
|
+
));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
let config: WellKnownConfig = response
|
|
51
|
+
.json()
|
|
52
|
+
.await
|
|
53
|
+
.map_err(|e| format!("Failed to parse well-known config: {}", e))?;
|
|
54
|
+
|
|
55
|
+
tracing::info!(
|
|
56
|
+
op = "well_known.fetch.success",
|
|
57
|
+
algorithm = %config.grant_signing_algorithm,
|
|
58
|
+
"Grant verification key fetched successfully"
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
Ok(config.grant_verify_key_b64)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/// Fetch and cache the grant verification key in state.
|
|
65
|
+
/// Also sets ENGINE_GRANT_VERIFY_KEY_B64 env var for SDK compatibility.
|
|
66
|
+
/// This is called on app startup.
|
|
67
|
+
pub async fn fetch_and_cache_verify_key(state: &crate::state::EngineState) -> Result<(), String> {
|
|
68
|
+
let key = fetch_grant_verify_key().await?;
|
|
69
|
+
|
|
70
|
+
// Cache in state
|
|
71
|
+
state.set_grant_verify_key(key.clone());
|
|
72
|
+
|
|
73
|
+
// Also set env var for SDK compatibility (ekka-ops, ekka-path-guard use it)
|
|
74
|
+
std::env::set_var("ENGINE_GRANT_VERIFY_KEY_B64", &key);
|
|
75
|
+
|
|
76
|
+
Ok(())
|
|
77
|
+
}
|