@openparachute/vault 0.1.0

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 (103) hide show
  1. package/.claude/settings.local.json +31 -0
  2. package/.dockerignore +8 -0
  3. package/.env.example +9 -0
  4. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
  5. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
  6. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
  7. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
  8. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
  10. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
  11. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
  12. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
  13. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
  14. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
  15. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
  16. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
  17. package/CLAUDE.md +115 -0
  18. package/Caddyfile +3 -0
  19. package/Dockerfile +22 -0
  20. package/LICENSE +661 -0
  21. package/README.md +356 -0
  22. package/bun.lock +219 -0
  23. package/bunfig.toml +2 -0
  24. package/core/package.json +7 -0
  25. package/core/src/core.test.ts +940 -0
  26. package/core/src/hooks.test.ts +361 -0
  27. package/core/src/hooks.ts +234 -0
  28. package/core/src/links.ts +352 -0
  29. package/core/src/mcp.ts +672 -0
  30. package/core/src/notes.ts +520 -0
  31. package/core/src/obsidian.test.ts +380 -0
  32. package/core/src/obsidian.ts +322 -0
  33. package/core/src/paths.test.ts +197 -0
  34. package/core/src/paths.ts +53 -0
  35. package/core/src/schema.ts +331 -0
  36. package/core/src/store.ts +303 -0
  37. package/core/src/tag-schemas.ts +104 -0
  38. package/core/src/test-preload.ts +8 -0
  39. package/core/src/types.ts +140 -0
  40. package/core/src/wikilinks.test.ts +277 -0
  41. package/core/src/wikilinks.ts +402 -0
  42. package/deploy/parachute-vault.service +20 -0
  43. package/docker-compose.yml +50 -0
  44. package/docs/HTTP_API.md +328 -0
  45. package/fly.toml +24 -0
  46. package/package.json +32 -0
  47. package/railway.json +14 -0
  48. package/religions-abrahamic-filter.png +0 -0
  49. package/religions-buddhism-v2.png +0 -0
  50. package/religions-buddhism.png +0 -0
  51. package/religions-final.png +0 -0
  52. package/religions-v1.png +0 -0
  53. package/religions-v2.png +0 -0
  54. package/religions-zen.png +0 -0
  55. package/scripts/migrate-audio-to-opus.test.ts +237 -0
  56. package/scripts/migrate-audio-to-opus.ts +499 -0
  57. package/src/auth.ts +170 -0
  58. package/src/cli.ts +1131 -0
  59. package/src/config-triggers.test.ts +83 -0
  60. package/src/config.test.ts +125 -0
  61. package/src/config.ts +716 -0
  62. package/src/db.ts +14 -0
  63. package/src/launchd.ts +109 -0
  64. package/src/mcp-http.ts +113 -0
  65. package/src/mcp-tools.ts +155 -0
  66. package/src/oauth.test.ts +1242 -0
  67. package/src/oauth.ts +729 -0
  68. package/src/owner-auth.ts +159 -0
  69. package/src/prompt.ts +141 -0
  70. package/src/published.test.ts +214 -0
  71. package/src/qrcode-terminal.d.ts +9 -0
  72. package/src/routes.ts +822 -0
  73. package/src/server.ts +450 -0
  74. package/src/systemd.ts +84 -0
  75. package/src/token-store.test.ts +174 -0
  76. package/src/token-store.ts +241 -0
  77. package/src/triggers.test.ts +397 -0
  78. package/src/triggers.ts +412 -0
  79. package/src/two-factor.test.ts +246 -0
  80. package/src/two-factor.ts +222 -0
  81. package/src/vault-store.ts +47 -0
  82. package/src/vault.test.ts +1309 -0
  83. package/tsconfig.json +29 -0
  84. package/web/README.md +73 -0
  85. package/web/bun.lock +827 -0
  86. package/web/eslint.config.js +23 -0
  87. package/web/index.html +15 -0
  88. package/web/package.json +36 -0
  89. package/web/public/favicon.svg +1 -0
  90. package/web/public/icons.svg +24 -0
  91. package/web/src/App.tsx +149 -0
  92. package/web/src/Graph.tsx +200 -0
  93. package/web/src/NoteView.tsx +155 -0
  94. package/web/src/Sidebar.tsx +186 -0
  95. package/web/src/api.ts +21 -0
  96. package/web/src/index.css +50 -0
  97. package/web/src/main.tsx +10 -0
  98. package/web/src/types.ts +37 -0
  99. package/web/src/utils.ts +107 -0
  100. package/web/tsconfig.app.json +25 -0
  101. package/web/tsconfig.json +7 -0
  102. package/web/tsconfig.node.json +24 -0
  103. package/web/vite.config.ts +15 -0
package/src/config.ts ADDED
@@ -0,0 +1,716 @@
1
+ /**
2
+ * Configuration management for Parachute Vault.
3
+ *
4
+ * Directory layout:
5
+ * ~/.parachute/
6
+ * config.yaml — global server config
7
+ * vault.log / vault.err — daemon logs
8
+ * vaults/
9
+ * {name}/
10
+ * vault.db — SQLite database
11
+ * vault.yaml — per-vault config (description, tool_hints, api_keys)
12
+ */
13
+
14
+ import { homedir } from "os";
15
+ import { join } from "path";
16
+ import { mkdir, readFile, writeFile } from "fs/promises";
17
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from "fs";
18
+ import crypto from "node:crypto";
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // Paths
22
+ // ---------------------------------------------------------------------------
23
+
24
+ export const CONFIG_DIR = process.env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
25
+ export const VAULTS_DIR = join(CONFIG_DIR, "vaults");
26
+ export const GLOBAL_CONFIG_PATH = join(CONFIG_DIR, "config.yaml");
27
+ export const ENV_PATH = join(CONFIG_DIR, ".env");
28
+ export const LOG_PATH = join(CONFIG_DIR, "vault.log");
29
+ export const ERR_PATH = join(CONFIG_DIR, "vault.err");
30
+ export const DEFAULT_PORT = 1940;
31
+ export const ASSETS_DIR = join(CONFIG_DIR, "assets");
32
+
33
+ export function vaultDir(name: string): string {
34
+ return join(VAULTS_DIR, name);
35
+ }
36
+
37
+ export function vaultDbPath(name: string): string {
38
+ return join(vaultDir(name), "vault.db");
39
+ }
40
+
41
+ export function vaultConfigPath(name: string): string {
42
+ return join(vaultDir(name), "vault.yaml");
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Types
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export type KeyScope = "read" | "write";
50
+
51
+ export interface StoredKey {
52
+ id: string;
53
+ label: string;
54
+ key_hash: string;
55
+ scope: KeyScope;
56
+ created_at: string;
57
+ last_used_at?: string;
58
+ }
59
+
60
+ export interface TagFieldSchema {
61
+ type: string;
62
+ description?: string;
63
+ enum?: string[];
64
+ }
65
+
66
+ export interface TagSchema {
67
+ description?: string;
68
+ fields?: Record<string, TagFieldSchema>;
69
+ }
70
+
71
+ export interface VaultConfig {
72
+ name: string;
73
+ description?: string;
74
+ api_keys: StoredKey[];
75
+ created_at: string;
76
+ tag_schemas?: Record<string, TagSchema>;
77
+ /** Tag name that marks a note as publicly viewable. Default: "published". */
78
+ published_tag?: string;
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Trigger configuration
83
+ // ---------------------------------------------------------------------------
84
+
85
+ export interface TriggerWhen {
86
+ /** Note must have ALL of these tags. */
87
+ tags?: string[];
88
+ /** If true, note.content must be non-empty. If false, must be empty. */
89
+ has_content?: boolean;
90
+ /** Note.metadata must NOT have any of these keys set (non-null). */
91
+ missing_metadata?: string[];
92
+ /** Note.metadata must have ALL of these keys set (non-null). */
93
+ has_metadata?: string[];
94
+ }
95
+
96
+ /**
97
+ * How the trigger sends data to the webhook.
98
+ *
99
+ * - `"json"` (default): POST `{ trigger, event, note }` as JSON. Response is
100
+ * the standard webhook response `{ content?, metadata?, attachments? }`.
101
+ * - `"attachment"`: Read the first audio attachment from the vault assets dir,
102
+ * POST it as multipart/form-data (`file` field). Response is `{ text }`.
103
+ * Used for Whisper-compatible transcription services.
104
+ * - `"content"`: POST `{ model?, voice?, input: note.content }` as JSON.
105
+ * Response is binary audio bytes. Used for OpenAI-compatible TTS services.
106
+ */
107
+ export type TriggerSendMode = "json" | "attachment" | "content";
108
+
109
+ export interface TriggerAction {
110
+ /** URL to POST the webhook payload to. */
111
+ webhook: string;
112
+ /** Timeout in ms for the webhook call. Default 60000. */
113
+ timeout?: number;
114
+ /** How to send data to the webhook. Default "json". */
115
+ send?: TriggerSendMode;
116
+ }
117
+
118
+ export interface TriggerConfig {
119
+ /** Human-readable name, also used as the metadata prefix for markers. */
120
+ name: string;
121
+ /** Which hook events to listen for. Default ["created", "updated"]. */
122
+ events?: Array<"created" | "updated">;
123
+ /** Predicate — all conditions must be true. */
124
+ when: TriggerWhen;
125
+ /** What to do when the predicate matches. */
126
+ action: TriggerAction;
127
+ }
128
+
129
+ export interface GlobalConfig {
130
+ port: number;
131
+ default_vault?: string;
132
+ api_keys?: StoredKey[];
133
+ triggers?: TriggerConfig[];
134
+ /** Bcrypt hash of the vault owner's password for OAuth consent. */
135
+ owner_password_hash?: string;
136
+ /** Base32-encoded TOTP secret for 2FA on OAuth consent. */
137
+ totp_secret?: string;
138
+ /** Bcrypt hashes of single-use backup codes for 2FA recovery. */
139
+ backup_codes?: string[];
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // YAML helpers (minimal, no deps)
144
+ // ---------------------------------------------------------------------------
145
+
146
+ function serializeVaultConfig(config: VaultConfig): string {
147
+ const lines: string[] = [];
148
+ lines.push(`name: ${config.name}`);
149
+ if (config.description) {
150
+ lines.push(`description: |`);
151
+ for (const line of config.description.split("\n")) {
152
+ lines.push(` ${line}`);
153
+ }
154
+ }
155
+ lines.push(`created_at: "${config.created_at}"`);
156
+ if (config.published_tag) {
157
+ lines.push(`published_tag: ${config.published_tag}`);
158
+ }
159
+
160
+ lines.push("api_keys:");
161
+ for (const key of config.api_keys) {
162
+ lines.push(` - id: ${key.id}`);
163
+ lines.push(` label: ${key.label}`);
164
+ lines.push(` scope: ${key.scope ?? "write"}`);
165
+ lines.push(` key_hash: ${key.key_hash}`);
166
+ lines.push(` created_at: "${key.created_at}"`);
167
+ if (key.last_used_at) {
168
+ lines.push(` last_used_at: "${key.last_used_at}"`);
169
+ }
170
+ }
171
+
172
+ if (config.tag_schemas && Object.keys(config.tag_schemas).length > 0) {
173
+ lines.push("tag_schemas:");
174
+ for (const [tag, schema] of Object.entries(config.tag_schemas)) {
175
+ lines.push(` ${tag}:`);
176
+ if (schema.description) {
177
+ lines.push(` description: "${schema.description}"`);
178
+ }
179
+ if (schema.fields && Object.keys(schema.fields).length > 0) {
180
+ lines.push(" fields:");
181
+ for (const [field, fieldSchema] of Object.entries(schema.fields)) {
182
+ lines.push(` ${field}:`);
183
+ lines.push(` type: ${fieldSchema.type}`);
184
+ if (fieldSchema.description) {
185
+ lines.push(` description: "${fieldSchema.description}"`);
186
+ }
187
+ if (fieldSchema.enum) {
188
+ lines.push(` enum: [${fieldSchema.enum.map((v) => `"${v}"`).join(", ")}]`);
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ return lines.join("\n") + "\n";
196
+ }
197
+
198
+ function parseVaultConfig(yaml: string, name: string): VaultConfig {
199
+ const config: VaultConfig = {
200
+ name,
201
+ api_keys: [],
202
+ created_at: new Date().toISOString(),
203
+ };
204
+
205
+ const nameMatch = yaml.match(/^name:\s*(.+)/m);
206
+ if (nameMatch) config.name = nameMatch[1].trim();
207
+
208
+ const createdMatch = yaml.match(/^created_at:\s*"?([^"\n]+)"?/m);
209
+ if (createdMatch) config.created_at = createdMatch[1];
210
+
211
+ const pubTagMatch = yaml.match(/^published_tag:\s*(\S+)/m);
212
+ if (pubTagMatch) config.published_tag = pubTagMatch[1];
213
+
214
+ // Parse description (block scalar)
215
+ const descMatch = yaml.match(/^description:\s*\|\s*\n((?:\s{2}.+\n?)+)/m);
216
+ if (descMatch) {
217
+ config.description = descMatch[1]
218
+ .split("\n")
219
+ .map((l) => l.replace(/^\s{2}/, ""))
220
+ .join("\n")
221
+ .trim();
222
+ } else {
223
+ const descSimple = yaml.match(/^description:\s*(.+)/m);
224
+ if (descSimple) config.description = descSimple[1].trim().replace(/^"(.*)"$/, "$1");
225
+ }
226
+
227
+ // Parse api_keys
228
+ const keyBlocks = yaml.split(/\n\s+-\s+id:\s+/).slice(1);
229
+ for (const block of keyBlocks) {
230
+ const idMatch = block.match(/^(\S+)/);
231
+ const labelMatch = block.match(/label:\s*(.+)/);
232
+ const scopeMatch = block.match(/scope:\s*(\S+)/);
233
+ const hashMatch = block.match(/key_hash:\s*(\S+)/);
234
+ const createdAtMatch = block.match(/created_at:\s*"?([^"\n]+)"?/);
235
+ const lastUsedMatch = block.match(/last_used_at:\s*"?([^"\n]+)"?/);
236
+
237
+ if (idMatch && hashMatch) {
238
+ config.api_keys.push({
239
+ id: idMatch[1],
240
+ label: (labelMatch?.[1] ?? "default").trim(),
241
+ scope: (scopeMatch?.[1] as KeyScope) ?? "write",
242
+ key_hash: hashMatch[1],
243
+ created_at: createdAtMatch?.[1] ?? new Date().toISOString(),
244
+ last_used_at: lastUsedMatch?.[1],
245
+ });
246
+ }
247
+ }
248
+
249
+ // Parse tag_schemas
250
+ config.tag_schemas = parseTagSchemas(yaml);
251
+
252
+ return config;
253
+ }
254
+
255
+ /**
256
+ * Parse the tag_schemas section from vault.yaml.
257
+ * Uses line-by-line indent tracking since the main parser is hand-rolled.
258
+ */
259
+ function parseTagSchemas(yaml: string): Record<string, TagSchema> | undefined {
260
+ const startMatch = yaml.match(/^tag_schemas:\s*$/m);
261
+ if (!startMatch) return undefined;
262
+
263
+ const startIdx = (startMatch.index ?? 0) + startMatch[0].length;
264
+ const lines = yaml.slice(startIdx).split("\n");
265
+
266
+ const schemas: Record<string, TagSchema> = {};
267
+ let currentTag: string | null = null;
268
+ let currentField: string | null = null;
269
+
270
+ for (const line of lines) {
271
+ // Stop at next top-level key (no indent)
272
+ if (line.match(/^\S/) && line.trim().length > 0) break;
273
+ if (line.trim().length === 0) continue;
274
+
275
+ const indent = line.match(/^(\s*)/)?.[1].length ?? 0;
276
+
277
+ if (indent === 2) {
278
+ // Tag name (e.g., " person:")
279
+ const tagMatch = line.match(/^\s{2}(\S+):\s*$/);
280
+ if (tagMatch) {
281
+ currentTag = tagMatch[1];
282
+ currentField = null;
283
+ schemas[currentTag] = {};
284
+ }
285
+ } else if (indent === 4 && currentTag) {
286
+ // Tag-level property (description, fields:)
287
+ const descMatch = line.match(/^\s{4}description:\s*"?([^"]*)"?\s*$/);
288
+ if (descMatch) {
289
+ schemas[currentTag].description = descMatch[1];
290
+ continue;
291
+ }
292
+ const fieldsMatch = line.match(/^\s{4}fields:\s*$/);
293
+ if (fieldsMatch) {
294
+ schemas[currentTag].fields = schemas[currentTag].fields ?? {};
295
+ currentField = null;
296
+ }
297
+ } else if (indent === 6 && currentTag && schemas[currentTag].fields !== undefined) {
298
+ // Field name (e.g., " first_appeared:")
299
+ const fieldMatch = line.match(/^\s{6}(\S+):\s*$/);
300
+ if (fieldMatch) {
301
+ currentField = fieldMatch[1];
302
+ schemas[currentTag].fields![currentField] = { type: "string" };
303
+ }
304
+ } else if (indent === 8 && currentTag && currentField && schemas[currentTag].fields) {
305
+ // Field property (type, description, enum)
306
+ const typeMatch = line.match(/^\s{8}type:\s*(\S+)/);
307
+ if (typeMatch) {
308
+ schemas[currentTag].fields![currentField].type = typeMatch[1];
309
+ continue;
310
+ }
311
+ const fdescMatch = line.match(/^\s{8}description:\s*"?([^"]*)"?\s*$/);
312
+ if (fdescMatch) {
313
+ schemas[currentTag].fields![currentField].description = fdescMatch[1];
314
+ continue;
315
+ }
316
+ const enumMatch = line.match(/^\s{8}enum:\s*\[([^\]]*)\]/);
317
+ if (enumMatch) {
318
+ schemas[currentTag].fields![currentField].enum = enumMatch[1]
319
+ .split(",")
320
+ .map((s) => s.trim().replace(/^"(.*)"$/, "$1"));
321
+ }
322
+ }
323
+ }
324
+
325
+ return Object.keys(schemas).length > 0 ? schemas : undefined;
326
+ }
327
+
328
+ // ---------------------------------------------------------------------------
329
+ // Trigger YAML parsing
330
+ // ---------------------------------------------------------------------------
331
+
332
+ function parseYamlList(val: string): string[] {
333
+ // Parse "[a, b, c]" → ["a", "b", "c"]
334
+ const inner = val.replace(/^\[/, "").replace(/\]$/, "");
335
+ return inner.split(",").map((s) => s.trim().replace(/^"(.*)"$/, "$1")).filter(Boolean);
336
+ }
337
+
338
+ function parseTriggers(yaml: string): TriggerConfig[] | undefined {
339
+ const startMatch = yaml.match(/^triggers:\s*$/m);
340
+ if (!startMatch) return undefined;
341
+
342
+ const startIdx = (startMatch.index ?? 0) + startMatch[0].length;
343
+ const lines = yaml.slice(startIdx).split("\n");
344
+
345
+ const triggers: TriggerConfig[] = [];
346
+ let current: Partial<TriggerConfig> | null = null;
347
+ // Track which section we're in by the last seen section header
348
+ let section: "top" | "when" | "action" = "top";
349
+
350
+ for (const line of lines) {
351
+ // Stop at next top-level key
352
+ if (line.match(/^\S/) && line.trim().length > 0) break;
353
+ if (line.trim().length === 0) continue;
354
+
355
+ const trimmed = line.trim();
356
+
357
+ // New trigger item: "- name: ..."
358
+ const nameMatch = trimmed.match(/^-\s+name:\s*(.+)/);
359
+ if (nameMatch) {
360
+ if (current?.name) {
361
+ if (current.action?.webhook) {
362
+ triggers.push(current as TriggerConfig);
363
+ } else {
364
+ console.warn(`[config] trigger "${current.name}" has no webhook URL — skipping`);
365
+ }
366
+ }
367
+ current = { name: nameMatch[1].trim(), when: {}, action: undefined as unknown as TriggerAction };
368
+ section = "top";
369
+ continue;
370
+ }
371
+
372
+ if (!current) continue;
373
+
374
+ // Section headers — detect by key name regardless of indent
375
+ if (trimmed === "when:") { section = "when"; continue; }
376
+ if (trimmed === "action:") { section = "action"; continue; }
377
+
378
+ // Top-level trigger field
379
+ const eventsMatch = trimmed.match(/^events:\s*\[([^\]]*)\]/);
380
+ if (eventsMatch) {
381
+ current.events = parseYamlList(eventsMatch[1]) as Array<"created" | "updated">;
382
+ continue;
383
+ }
384
+
385
+ // When fields
386
+ if (section === "when") {
387
+ const tagsMatch = trimmed.match(/^tags:\s*\[([^\]]*)\]/);
388
+ if (tagsMatch) { current.when!.tags = parseYamlList(tagsMatch[1]); continue; }
389
+ const hasContentMatch = trimmed.match(/^has_content:\s*(true|false)/);
390
+ if (hasContentMatch) { current.when!.has_content = hasContentMatch[1] === "true"; continue; }
391
+ const missingMetaMatch = trimmed.match(/^missing_metadata:\s*\[([^\]]*)\]/);
392
+ if (missingMetaMatch) { current.when!.missing_metadata = parseYamlList(missingMetaMatch[1]); continue; }
393
+ const hasMetaMatch = trimmed.match(/^has_metadata:\s*\[([^\]]*)\]/);
394
+ if (hasMetaMatch) { current.when!.has_metadata = parseYamlList(hasMetaMatch[1]); continue; }
395
+ }
396
+
397
+ // Action fields
398
+ if (section === "action") {
399
+ const webhookMatch = trimmed.match(/^webhook:\s*(.+)/);
400
+ if (webhookMatch) {
401
+ current.action = { ...(current.action ?? {}), webhook: webhookMatch[1].trim() } as TriggerAction;
402
+ continue;
403
+ }
404
+ const timeoutMatch = trimmed.match(/^timeout:\s*(\d+)/);
405
+ if (timeoutMatch && current.action) {
406
+ current.action.timeout = parseInt(timeoutMatch[1], 10);
407
+ continue;
408
+ }
409
+ const sendMatch = trimmed.match(/^send:\s*(\S+)/);
410
+ if (sendMatch && current.action) {
411
+ current.action.send = sendMatch[1] as TriggerAction["send"];
412
+ continue;
413
+ }
414
+ }
415
+ }
416
+
417
+ // Push the last trigger
418
+ if (current?.name) {
419
+ if (current.action?.webhook) {
420
+ triggers.push(current as TriggerConfig);
421
+ } else {
422
+ console.warn(`[config] trigger "${current.name}" has no webhook URL — skipping`);
423
+ }
424
+ }
425
+
426
+ return triggers.length > 0 ? triggers : undefined;
427
+ }
428
+
429
+ // ---------------------------------------------------------------------------
430
+ // Directory management
431
+ // ---------------------------------------------------------------------------
432
+
433
+ export async function ensureConfigDir(): Promise<void> {
434
+ await mkdir(CONFIG_DIR, { recursive: true });
435
+ await mkdir(VAULTS_DIR, { recursive: true });
436
+ }
437
+
438
+ export function ensureConfigDirSync(): void {
439
+ mkdirSync(CONFIG_DIR, { recursive: true });
440
+ mkdirSync(VAULTS_DIR, { recursive: true });
441
+ }
442
+
443
+ // ---------------------------------------------------------------------------
444
+ // Global config
445
+ // ---------------------------------------------------------------------------
446
+
447
+ export function readGlobalConfig(): GlobalConfig {
448
+ try {
449
+ if (existsSync(GLOBAL_CONFIG_PATH)) {
450
+ const yaml = readFileSync(GLOBAL_CONFIG_PATH, "utf-8");
451
+ const portMatch = yaml.match(/^port:\s*(\d+)/m);
452
+ const defaultVaultMatch = yaml.match(/^default_vault:\s*(\S+)/m);
453
+ const passwordHashMatch = yaml.match(/^owner_password_hash:\s*"([^"]+)"/m);
454
+ const totpSecretMatch = yaml.match(/^totp_secret:\s*"([^"]+)"/m);
455
+ const config: GlobalConfig = {
456
+ port: portMatch ? parseInt(portMatch[1], 10) : DEFAULT_PORT,
457
+ default_vault: defaultVaultMatch?.[1],
458
+ owner_password_hash: passwordHashMatch?.[1],
459
+ totp_secret: totpSecretMatch?.[1],
460
+ };
461
+
462
+ // Parse backup_codes: a YAML list of quoted bcrypt hashes under
463
+ // backup_codes:
464
+ // - "hash1"
465
+ // - "hash2"
466
+ const backupStart = yaml.match(/^backup_codes:\s*$/m);
467
+ if (backupStart) {
468
+ const after = yaml.slice((backupStart.index ?? 0) + backupStart[0].length);
469
+ const lines = after.split("\n");
470
+ const codes: string[] = [];
471
+ for (const line of lines) {
472
+ if (line.match(/^\S/) && line.trim().length > 0) break; // next top-level key
473
+ const m = line.match(/^\s+-\s+"([^"]+)"/);
474
+ if (m) codes.push(m[1]);
475
+ }
476
+ if (codes.length > 0) config.backup_codes = codes;
477
+ }
478
+
479
+ // Parse global api_keys
480
+ const keyBlocks = yaml.split(/\n\s+-\s+id:\s+/).slice(1);
481
+ if (keyBlocks.length > 0) {
482
+ config.api_keys = [];
483
+ for (const block of keyBlocks) {
484
+ const idMatch = block.match(/^(\S+)/);
485
+ const labelMatch = block.match(/label:\s*(.+)/);
486
+ const hashMatch = block.match(/key_hash:\s*(\S+)/);
487
+ const createdAtMatch = block.match(/created_at:\s*"?([^"\n]+)"?/);
488
+ const lastUsedMatch = block.match(/last_used_at:\s*"?([^"\n]+)"?/);
489
+ if (idMatch && hashMatch) {
490
+ config.api_keys.push({
491
+ id: idMatch[1],
492
+ label: (labelMatch?.[1] ?? "default").trim(),
493
+ key_hash: hashMatch[1],
494
+ created_at: createdAtMatch?.[1] ?? new Date().toISOString(),
495
+ last_used_at: lastUsedMatch?.[1],
496
+ });
497
+ }
498
+ }
499
+ }
500
+
501
+ // Parse triggers
502
+ config.triggers = parseTriggers(yaml);
503
+
504
+ return config;
505
+ }
506
+ } catch {}
507
+ return { port: DEFAULT_PORT };
508
+ }
509
+
510
+ export function writeGlobalConfig(config: GlobalConfig): void {
511
+ ensureConfigDirSync();
512
+ const lines = [`port: ${config.port}`];
513
+ if (config.default_vault) lines.push(`default_vault: ${config.default_vault}`);
514
+ if (config.owner_password_hash) {
515
+ lines.push(`owner_password_hash: "${config.owner_password_hash}"`);
516
+ }
517
+ if (config.totp_secret) {
518
+ lines.push(`totp_secret: "${config.totp_secret}"`);
519
+ }
520
+ if (config.backup_codes && config.backup_codes.length > 0) {
521
+ lines.push("backup_codes:");
522
+ for (const hash of config.backup_codes) {
523
+ lines.push(` - "${hash}"`);
524
+ }
525
+ }
526
+
527
+ if (config.api_keys && config.api_keys.length > 0) {
528
+ lines.push("api_keys:");
529
+ for (const key of config.api_keys) {
530
+ lines.push(` - id: ${key.id}`);
531
+ lines.push(` label: ${key.label}`);
532
+ lines.push(` key_hash: ${key.key_hash}`);
533
+ lines.push(` created_at: "${key.created_at}"`);
534
+ if (key.last_used_at) {
535
+ lines.push(` last_used_at: "${key.last_used_at}"`);
536
+ }
537
+ }
538
+ }
539
+
540
+ if (config.triggers && config.triggers.length > 0) {
541
+ lines.push("triggers:");
542
+ for (const trigger of config.triggers) {
543
+ lines.push(` - name: ${trigger.name}`);
544
+ if (trigger.events) {
545
+ lines.push(` events: [${trigger.events.join(", ")}]`);
546
+ }
547
+ lines.push(" when:");
548
+ if (trigger.when.tags?.length) {
549
+ lines.push(` tags: [${trigger.when.tags.join(", ")}]`);
550
+ }
551
+ if (trigger.when.has_content !== undefined) {
552
+ lines.push(` has_content: ${trigger.when.has_content}`);
553
+ }
554
+ if (trigger.when.missing_metadata?.length) {
555
+ lines.push(` missing_metadata: [${trigger.when.missing_metadata.join(", ")}]`);
556
+ }
557
+ if (trigger.when.has_metadata?.length) {
558
+ lines.push(` has_metadata: [${trigger.when.has_metadata.join(", ")}]`);
559
+ }
560
+ lines.push(" action:");
561
+ lines.push(` webhook: ${trigger.action.webhook}`);
562
+ if (trigger.action.send && trigger.action.send !== "json") {
563
+ lines.push(` send: ${trigger.action.send}`);
564
+ }
565
+ if (trigger.action.timeout) {
566
+ lines.push(` timeout: ${trigger.action.timeout}`);
567
+ }
568
+ }
569
+ }
570
+
571
+ // 0600 — owner read/write only. This file may contain the bcrypt password
572
+ // hash and plaintext TOTP secret; it must not be world- or group-readable.
573
+ writeFileSync(GLOBAL_CONFIG_PATH, lines.join("\n") + "\n", { mode: 0o600 });
574
+ // writeFileSync's `mode` only applies on file creation, so chmod an existing
575
+ // file explicitly in case it was written by an older version at 0644.
576
+ try { chmodSync(GLOBAL_CONFIG_PATH, 0o600); } catch {}
577
+ }
578
+
579
+ // ---------------------------------------------------------------------------
580
+ // Vault config
581
+ // ---------------------------------------------------------------------------
582
+
583
+ export function readVaultConfig(name: string): VaultConfig | null {
584
+ const configPath = vaultConfigPath(name);
585
+ try {
586
+ if (existsSync(configPath)) {
587
+ return parseVaultConfig(readFileSync(configPath, "utf-8"), name);
588
+ }
589
+ } catch {}
590
+ return null;
591
+ }
592
+
593
+ export function writeVaultConfig(config: VaultConfig): void {
594
+ const dir = vaultDir(config.name);
595
+ mkdirSync(dir, { recursive: true });
596
+ const configPath = vaultConfigPath(config.name);
597
+ writeFileSync(configPath, serializeVaultConfig(config));
598
+ }
599
+
600
+ // ---------------------------------------------------------------------------
601
+ // Key operations
602
+ // ---------------------------------------------------------------------------
603
+
604
+ export function hashKey(key: string): string {
605
+ return "sha256:" + crypto.createHash("sha256").update(key).digest("hex");
606
+ }
607
+
608
+ export function verifyKey(providedKey: string, storedHash: string): boolean {
609
+ const computed = hashKey(providedKey);
610
+ if (computed.length !== storedHash.length) return false;
611
+ return crypto.timingSafeEqual(Buffer.from(computed), Buffer.from(storedHash));
612
+ }
613
+
614
+ export function generateApiKey(): { fullKey: string; keyId: string } {
615
+ const random = crypto.randomBytes(32).toString("base64url").slice(0, 32);
616
+ return {
617
+ fullKey: `pvk_${random}`,
618
+ keyId: `k_${random.slice(0, 12)}`,
619
+ };
620
+ }
621
+
622
+ // ---------------------------------------------------------------------------
623
+ // Environment file (~/.parachute/.env)
624
+ // ---------------------------------------------------------------------------
625
+
626
+ /**
627
+ * Read the .env file as key-value pairs.
628
+ */
629
+ export function readEnvFile(): Record<string, string> {
630
+ const env: Record<string, string> = {};
631
+ try {
632
+ if (!existsSync(ENV_PATH)) return env;
633
+ const content = readFileSync(ENV_PATH, "utf-8");
634
+ for (const line of content.split("\n")) {
635
+ const trimmed = line.trim();
636
+ if (!trimmed || trimmed.startsWith("#")) continue;
637
+ const eqIdx = trimmed.indexOf("=");
638
+ if (eqIdx === -1) continue;
639
+ const key = trimmed.slice(0, eqIdx).trim();
640
+ let val = trimmed.slice(eqIdx + 1).trim();
641
+ // Strip surrounding quotes
642
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
643
+ val = val.slice(1, -1);
644
+ }
645
+ env[key] = val;
646
+ }
647
+ } catch {}
648
+ return env;
649
+ }
650
+
651
+ /**
652
+ * Write the .env file from key-value pairs.
653
+ */
654
+ export function writeEnvFile(env: Record<string, string>): void {
655
+ ensureConfigDirSync();
656
+ const lines: string[] = [
657
+ "# Parachute Vault configuration",
658
+ "# Managed by: parachute vault config",
659
+ "",
660
+ ];
661
+ for (const [key, val] of Object.entries(env)) {
662
+ if (val.includes(" ") || val.includes('"')) {
663
+ lines.push(`${key}="${val}"`);
664
+ } else {
665
+ lines.push(`${key}=${val}`);
666
+ }
667
+ }
668
+ writeFileSync(ENV_PATH, lines.join("\n") + "\n");
669
+ }
670
+
671
+ /**
672
+ * Set a single env var in the .env file.
673
+ */
674
+ export function setEnvVar(key: string, value: string): void {
675
+ const env = readEnvFile();
676
+ env[key] = value;
677
+ writeEnvFile(env);
678
+ }
679
+
680
+ /**
681
+ * Remove an env var from the .env file.
682
+ */
683
+ export function unsetEnvVar(key: string): void {
684
+ const env = readEnvFile();
685
+ delete env[key];
686
+ writeEnvFile(env);
687
+ }
688
+
689
+ /**
690
+ * Load .env file into process.env (for server startup).
691
+ */
692
+ export function loadEnvFile(): void {
693
+ const env = readEnvFile();
694
+ for (const [key, val] of Object.entries(env)) {
695
+ if (process.env[key] === undefined) {
696
+ process.env[key] = val;
697
+ }
698
+ }
699
+ }
700
+
701
+ // ---------------------------------------------------------------------------
702
+ // Vault listing
703
+ // ---------------------------------------------------------------------------
704
+
705
+ export function listVaults(): string[] {
706
+ try {
707
+ if (!existsSync(VAULTS_DIR)) return [];
708
+ const entries = Bun.spawnSync(["ls", VAULTS_DIR]).stdout.toString().trim();
709
+ if (!entries) return [];
710
+ return entries.split("\n").filter((name) => {
711
+ return existsSync(vaultConfigPath(name));
712
+ });
713
+ } catch {
714
+ return [];
715
+ }
716
+ }