@openparachute/vault 0.3.3 → 0.4.3

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 (80) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +133 -0
  3. package/core/src/core.test.ts +2990 -92
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +413 -68
  6. package/core/src/notes.ts +693 -42
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +331 -0
  11. package/core/src/schema.ts +467 -11
  12. package/core/src/store.ts +262 -8
  13. package/core/src/tag-hierarchy.ts +171 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +96 -7
  16. package/core/src/vault-projection.ts +309 -0
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +360 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +173 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +322 -57
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +307 -0
  30. package/src/hub-jwt.ts +88 -0
  31. package/src/init.test.ts +216 -0
  32. package/src/mcp-http.ts +33 -29
  33. package/src/mcp-install.ts +1 -1
  34. package/src/mcp-tools.ts +318 -19
  35. package/src/module-config.ts +1 -1
  36. package/src/oauth.test.ts +345 -0
  37. package/src/oauth.ts +85 -14
  38. package/src/owner-auth.ts +57 -1
  39. package/src/prompt.ts +6 -5
  40. package/src/routes.ts +796 -61
  41. package/src/routing.test.ts +466 -1
  42. package/src/routing.ts +106 -24
  43. package/src/scopes.test.ts +66 -8
  44. package/src/scopes.ts +163 -37
  45. package/src/server.ts +24 -2
  46. package/src/services-manifest.test.ts +20 -0
  47. package/src/services-manifest.ts +9 -2
  48. package/src/stop-signal.test.ts +85 -0
  49. package/src/storage.test.ts +92 -0
  50. package/src/tag-scope.ts +118 -0
  51. package/src/token-store.test.ts +47 -0
  52. package/src/token-store.ts +128 -13
  53. package/src/tokens-routes.test.ts +727 -0
  54. package/src/tokens-routes.ts +392 -0
  55. package/src/transcription-worker.test.ts +5 -0
  56. package/src/triggers.ts +1 -1
  57. package/src/two-factor.ts +2 -2
  58. package/src/vault-create.test.ts +193 -0
  59. package/src/vault-name.test.ts +123 -0
  60. package/src/vault-name.ts +80 -0
  61. package/src/vault.test.ts +1626 -183
  62. package/tsconfig.json +8 -1
  63. package/.claude/settings.local.json +0 -8
  64. package/.dockerignore +0 -8
  65. package/.env.example +0 -9
  66. package/CHANGELOG.md +0 -175
  67. package/CLAUDE.md +0 -125
  68. package/Caddyfile +0 -3
  69. package/Dockerfile +0 -22
  70. package/bun.lock +0 -219
  71. package/bunfig.toml +0 -2
  72. package/deploy/parachute-vault.service +0 -20
  73. package/docker-compose.yml +0 -50
  74. package/docs/HTTP_API.md +0 -434
  75. package/docs/auth-model.md +0 -340
  76. package/fly.toml +0 -24
  77. package/package/package.json +0 -32
  78. package/railway.json +0 -14
  79. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  80. package/scripts/migrate-audio-to-opus.ts +0 -499
@@ -180,6 +180,50 @@ describe("config", () => {
180
180
  writeGlobalConfig({ port: 1940, discovery: "disabled" });
181
181
  expect(readGlobalConfig().discovery).toBe("disabled");
182
182
  });
183
+
184
+ test("global api_keys round-trip scope: read|write and default missing scope to write", () => {
185
+ // Regression for the silent privilege-escalation bug: the global YAML
186
+ // parser used to drop the `scope` field, leaving `globalKey.scope`
187
+ // undefined. Auth then resolved any non-"read" value to "full", so a
188
+ // user-authored `scope: read` global key would silently get full access.
189
+ writeGlobalConfig({
190
+ port: 1940,
191
+ api_keys: [
192
+ { id: "k_r", label: "reader", scope: "read", key_hash: "sha256:r", created_at: "2026-01-01T00:00:00.000Z" },
193
+ { id: "k_w", label: "writer", scope: "write", key_hash: "sha256:w", created_at: "2026-01-01T00:00:00.000Z" },
194
+ ],
195
+ });
196
+ const loaded = readGlobalConfig();
197
+ expect(loaded.api_keys?.find((k) => k.id === "k_r")?.scope).toBe("read");
198
+ expect(loaded.api_keys?.find((k) => k.id === "k_w")?.scope).toBe("write");
199
+
200
+ // Missing-scope branch: a hand-edited config.yaml entry without a
201
+ // `scope:` line must default to "write" (matches vault-level parser
202
+ // and the historical behavior the writer round-trips).
203
+ const fs = require("fs");
204
+ const path = `${process.env.PARACHUTE_HOME}/vault/config.yaml`;
205
+ fs.writeFileSync(
206
+ path,
207
+ `port: 1940\napi_keys:\n - id: k_legacy\n label: legacy\n key_hash: sha256:legacy\n created_at: "2026-01-01T00:00:00.000Z"\n`,
208
+ );
209
+ const reloaded = readGlobalConfig();
210
+ expect(reloaded.api_keys?.find((k) => k.id === "k_legacy")?.scope).toBe("write");
211
+ });
212
+
213
+ test("round-trips autostart: true|false (#113)", () => {
214
+ // Default: absent means autostart-on (init registers the daemon).
215
+ writeGlobalConfig({ port: 1940 });
216
+ expect(readGlobalConfig().autostart).toBeUndefined();
217
+
218
+ // Explicit true (e.g., user re-enabled after disabling).
219
+ writeGlobalConfig({ port: 1940, autostart: true });
220
+ expect(readGlobalConfig().autostart).toBe(true);
221
+
222
+ // Explicit false — the opt-out signal init writes when --no-autostart is
223
+ // passed. Daemon registration is then skipped on this and future inits.
224
+ writeGlobalConfig({ port: 1940, autostart: false });
225
+ expect(readGlobalConfig().autostart).toBe(false);
226
+ });
183
227
  });
184
228
 
185
229
  // ---------------------------------------------------------------------------
package/src/config.ts CHANGED
@@ -86,6 +86,14 @@ export const ERR_PATH = join(LOGS_DIR, "vault.err");
86
86
  export const DEFAULT_PORT = 1940;
87
87
  export const ASSETS_DIR = join(VAULT_HOME, "assets");
88
88
 
89
+ // Filesystem sentinel for graceful shutdown. `parachute-vault stop` writes
90
+ // this file; the running server polls for it and exits cleanly when it
91
+ // appears. Resolved per-call so PARACHUTE_HOME overrides (tests, Docker)
92
+ // match between writer and reader.
93
+ export function stopSignalPath(): string {
94
+ return join(vaultHomePath(), "stop.signal");
95
+ }
96
+
89
97
  export function vaultDir(name: string): string {
90
98
  return join(dataDirPath(), name);
91
99
  }
@@ -243,6 +251,16 @@ export interface GlobalConfig {
243
251
  * callers.
244
252
  */
245
253
  discovery?: "enabled" | "disabled";
254
+ /**
255
+ * Whether `parachute-vault init` registers the daemon with launchd / systemd
256
+ * (which then auto-starts on boot AND auto-restarts on crash). Defaults to
257
+ * `true` (preserve historical behavior). When `false`, init skips daemon
258
+ * registration AND removes any prior registration — for CI, dev sandboxes,
259
+ * Docker/K8s setups, or environments where another supervisor manages the
260
+ * process. The user is expected to run `parachute-vault serve` manually or
261
+ * point their own supervisor at it.
262
+ */
263
+ autostart?: boolean;
246
264
  /** Backup configuration: schedule, retention, destinations. */
247
265
  backup?: BackupConfig;
248
266
  }
@@ -403,13 +421,13 @@ function parseVaultConfig(yaml: string, name: string): VaultConfig {
403
421
  };
404
422
 
405
423
  const nameMatch = yaml.match(/^name:\s*(.+)/m);
406
- if (nameMatch) config.name = nameMatch[1].trim();
424
+ if (nameMatch) config.name = nameMatch[1]!.trim();
407
425
 
408
426
  const createdMatch = yaml.match(/^created_at:\s*"?([^"\n]+)"?/m);
409
- if (createdMatch) config.created_at = createdMatch[1];
427
+ if (createdMatch) config.created_at = createdMatch[1]!;
410
428
 
411
429
  const pubTagMatch = yaml.match(/^published_tag:\s*(\S+)/m);
412
- if (pubTagMatch) config.published_tag = pubTagMatch[1];
430
+ if (pubTagMatch) config.published_tag = pubTagMatch[1]!;
413
431
 
414
432
  const retentionMatch = yaml.match(/^audio_retention:\s*(\S+)/m);
415
433
  if (retentionMatch) {
@@ -420,14 +438,14 @@ function parseVaultConfig(yaml: string, name: string): VaultConfig {
420
438
  // Parse description (block scalar)
421
439
  const descMatch = yaml.match(/^description:\s*\|\s*\n((?:\s{2}.+\n?)+)/m);
422
440
  if (descMatch) {
423
- config.description = descMatch[1]
441
+ config.description = descMatch[1]!
424
442
  .split("\n")
425
443
  .map((l) => l.replace(/^\s{2}/, ""))
426
444
  .join("\n")
427
445
  .trim();
428
446
  } else {
429
447
  const descSimple = yaml.match(/^description:\s*(.+)/m);
430
- if (descSimple) config.description = descSimple[1].trim().replace(/^"(.*)"$/, "$1");
448
+ if (descSimple) config.description = descSimple[1]!.trim().replace(/^"(.*)"$/, "$1");
431
449
  }
432
450
 
433
451
  // Parse api_keys
@@ -442,10 +460,10 @@ function parseVaultConfig(yaml: string, name: string): VaultConfig {
442
460
 
443
461
  if (idMatch && hashMatch) {
444
462
  config.api_keys.push({
445
- id: idMatch[1],
463
+ id: idMatch[1]!,
446
464
  label: (labelMatch?.[1] ?? "default").trim(),
447
465
  scope: (scopeMatch?.[1] as KeyScope) ?? "write",
448
- key_hash: hashMatch[1],
466
+ key_hash: hashMatch[1]!,
449
467
  created_at: createdAtMatch?.[1] ?? new Date().toISOString(),
450
468
  last_used_at: lastUsedMatch?.[1],
451
469
  });
@@ -499,12 +517,12 @@ function parseTranscriptionContext(yaml: string): TriggerIncludeContext[] | unde
499
517
  if (itemStart) {
500
518
  pushCurrent();
501
519
  current = { tag: "" };
502
- applyContextField(current, itemStart[1], itemStart[2]);
520
+ applyContextField(current, itemStart[1]!, itemStart[2]!);
503
521
  continue;
504
522
  }
505
523
  const fieldMatch = trimmed.match(/^(\w+):\s*(.*)$/);
506
524
  if (fieldMatch && current) {
507
- applyContextField(current, fieldMatch[1], fieldMatch[2]);
525
+ applyContextField(current, fieldMatch[1]!, fieldMatch[2]!);
508
526
  continue;
509
527
  }
510
528
  }
@@ -533,50 +551,52 @@ function parseTagSchemas(yaml: string): Record<string, TagSchema> | undefined {
533
551
  if (line.match(/^\S/) && line.trim().length > 0) break;
534
552
  if (line.trim().length === 0) continue;
535
553
 
536
- const indent = line.match(/^(\s*)/)?.[1].length ?? 0;
554
+ const indent = line.match(/^(\s*)/)?.[1]?.length ?? 0;
537
555
 
538
556
  if (indent === 2) {
539
557
  // Tag name (e.g., " person:")
540
558
  const tagMatch = line.match(/^\s{2}(\S+):\s*$/);
541
559
  if (tagMatch) {
542
- currentTag = tagMatch[1];
560
+ currentTag = tagMatch[1]!;
543
561
  currentField = null;
544
562
  schemas[currentTag] = {};
545
563
  }
546
564
  } else if (indent === 4 && currentTag) {
547
565
  // Tag-level property (description, fields:)
566
+ const schema = schemas[currentTag]!;
548
567
  const descMatch = line.match(/^\s{4}description:\s*"?([^"]*)"?\s*$/);
549
568
  if (descMatch) {
550
- schemas[currentTag].description = descMatch[1];
569
+ schema.description = descMatch[1]!;
551
570
  continue;
552
571
  }
553
572
  const fieldsMatch = line.match(/^\s{4}fields:\s*$/);
554
573
  if (fieldsMatch) {
555
- schemas[currentTag].fields = schemas[currentTag].fields ?? {};
574
+ schema.fields = schema.fields ?? {};
556
575
  currentField = null;
557
576
  }
558
- } else if (indent === 6 && currentTag && schemas[currentTag].fields !== undefined) {
577
+ } else if (indent === 6 && currentTag && schemas[currentTag]?.fields !== undefined) {
559
578
  // Field name (e.g., " first_appeared:")
560
579
  const fieldMatch = line.match(/^\s{6}(\S+):\s*$/);
561
580
  if (fieldMatch) {
562
- currentField = fieldMatch[1];
563
- schemas[currentTag].fields![currentField] = { type: "string" };
581
+ currentField = fieldMatch[1]!;
582
+ schemas[currentTag]!.fields![currentField] = { type: "string" };
564
583
  }
565
- } else if (indent === 8 && currentTag && currentField && schemas[currentTag].fields) {
584
+ } else if (indent === 8 && currentTag && currentField && schemas[currentTag]?.fields) {
566
585
  // Field property (type, description, enum)
586
+ const field = schemas[currentTag]!.fields![currentField]!;
567
587
  const typeMatch = line.match(/^\s{8}type:\s*(\S+)/);
568
588
  if (typeMatch) {
569
- schemas[currentTag].fields![currentField].type = typeMatch[1];
589
+ field.type = typeMatch[1]!;
570
590
  continue;
571
591
  }
572
592
  const fdescMatch = line.match(/^\s{8}description:\s*"?([^"]*)"?\s*$/);
573
593
  if (fdescMatch) {
574
- schemas[currentTag].fields![currentField].description = fdescMatch[1];
594
+ field.description = fdescMatch[1]!;
575
595
  continue;
576
596
  }
577
597
  const enumMatch = line.match(/^\s{8}enum:\s*\[([^\]]*)\]/);
578
598
  if (enumMatch) {
579
- schemas[currentTag].fields![currentField].enum = enumMatch[1]
599
+ field.enum = enumMatch[1]!
580
600
  .split(",")
581
601
  .map((s) => s.trim().replace(/^"(.*)"$/, "$1"));
582
602
  }
@@ -610,7 +630,7 @@ function applyContextField(
610
630
  if (key === "exclude_tag") { item.exclude_tag = value.replace(/^"(.*)"$/, "$1"); return; }
611
631
  if (key === "include_metadata") {
612
632
  const listMatch = value.match(/^\[([^\]]*)\]/);
613
- if (listMatch) item.include_metadata = parseYamlList(listMatch[1]);
633
+ if (listMatch) item.include_metadata = parseYamlList(listMatch[1]!);
614
634
  return;
615
635
  }
616
636
  }
@@ -655,7 +675,7 @@ function parseTriggers(yaml: string): TriggerConfig[] | undefined {
655
675
  console.warn(`[config] trigger "${current.name}" has no webhook URL — skipping`);
656
676
  }
657
677
  }
658
- current = { name: nameMatch[1].trim(), when: {}, action: undefined as unknown as TriggerAction };
678
+ current = { name: nameMatch[1]!.trim(), when: {}, action: undefined as unknown as TriggerAction };
659
679
  section = "top";
660
680
  continue;
661
681
  }
@@ -677,37 +697,37 @@ function parseTriggers(yaml: string): TriggerConfig[] | undefined {
677
697
  // Top-level trigger field
678
698
  const eventsMatch = trimmed.match(/^events:\s*\[([^\]]*)\]/);
679
699
  if (eventsMatch) {
680
- current.events = parseYamlList(eventsMatch[1]) as Array<"created" | "updated">;
700
+ current.events = parseYamlList(eventsMatch[1]!) as Array<"created" | "updated">;
681
701
  continue;
682
702
  }
683
703
 
684
704
  // When fields
685
705
  if (section === "when") {
686
706
  const tagsMatch = trimmed.match(/^tags:\s*\[([^\]]*)\]/);
687
- if (tagsMatch) { current.when!.tags = parseYamlList(tagsMatch[1]); continue; }
707
+ if (tagsMatch) { current.when!.tags = parseYamlList(tagsMatch[1]!); continue; }
688
708
  const hasContentMatch = trimmed.match(/^has_content:\s*(true|false)/);
689
709
  if (hasContentMatch) { current.when!.has_content = hasContentMatch[1] === "true"; continue; }
690
710
  const missingMetaMatch = trimmed.match(/^missing_metadata:\s*\[([^\]]*)\]/);
691
- if (missingMetaMatch) { current.when!.missing_metadata = parseYamlList(missingMetaMatch[1]); continue; }
711
+ if (missingMetaMatch) { current.when!.missing_metadata = parseYamlList(missingMetaMatch[1]!); continue; }
692
712
  const hasMetaMatch = trimmed.match(/^has_metadata:\s*\[([^\]]*)\]/);
693
- if (hasMetaMatch) { current.when!.has_metadata = parseYamlList(hasMetaMatch[1]); continue; }
713
+ if (hasMetaMatch) { current.when!.has_metadata = parseYamlList(hasMetaMatch[1]!); continue; }
694
714
  }
695
715
 
696
716
  // Action fields
697
717
  if (section === "action") {
698
718
  const webhookMatch = trimmed.match(/^webhook:\s*(.+)/);
699
719
  if (webhookMatch) {
700
- current.action = { ...(current.action ?? {}), webhook: webhookMatch[1].trim() } as TriggerAction;
720
+ current.action = { ...(current.action ?? {}), webhook: webhookMatch[1]!.trim() } as TriggerAction;
701
721
  continue;
702
722
  }
703
723
  const timeoutMatch = trimmed.match(/^timeout:\s*(\d+)/);
704
724
  if (timeoutMatch && current.action) {
705
- current.action.timeout = parseInt(timeoutMatch[1], 10);
725
+ current.action.timeout = parseInt(timeoutMatch[1]!, 10);
706
726
  continue;
707
727
  }
708
728
  const sendMatch = trimmed.match(/^send:\s*(\S+)/);
709
729
  if (sendMatch && current.action) {
710
- current.action.send = sendMatch[1] as TriggerAction["send"];
730
+ current.action.send = sendMatch[1]! as TriggerAction["send"];
711
731
  continue;
712
732
  }
713
733
  }
@@ -719,12 +739,12 @@ function parseTriggers(yaml: string): TriggerConfig[] | undefined {
719
739
  if (itemStart) {
720
740
  pushContextItem();
721
741
  currentContext = { tag: "" };
722
- applyContextField(currentContext, itemStart[1], itemStart[2]);
742
+ applyContextField(currentContext, itemStart[1]!, itemStart[2]!);
723
743
  continue;
724
744
  }
725
745
  const fieldMatch = trimmed.match(/^(\w+):\s*(.*)$/);
726
746
  if (fieldMatch && currentContext) {
727
- applyContextField(currentContext, fieldMatch[1], fieldMatch[2]);
747
+ applyContextField(currentContext, fieldMatch[1]!, fieldMatch[2]!);
728
748
  continue;
729
749
  }
730
750
  }
@@ -843,7 +863,7 @@ function parseBackup(yaml: string): BackupConfig | undefined {
843
863
  const tierMatch = trimmed.match(/^(daily|weekly|monthly|yearly):\s*(\S+)/);
844
864
  if (tierMatch) {
845
865
  const tier = tierMatch[1] as keyof RetentionPolicy;
846
- const raw = tierMatch[2].trim();
866
+ const raw = tierMatch[2]!.trim();
847
867
  // "null" / "~" / "unbounded" all mean "keep every year" for the
848
868
  // yearly tier. For the other tiers they'd be meaningless; we
849
869
  // silently treat them as disabled (0) rather than erroring.
@@ -867,13 +887,13 @@ function parseBackup(yaml: string): BackupConfig | undefined {
867
887
  pushDest();
868
888
  hasDest = true;
869
889
  currentDest = {};
870
- (currentDest as Record<string, string>)[itemMatch[1]] = itemMatch[2].trim();
890
+ (currentDest as Record<string, string>)[itemMatch[1]!] = itemMatch[2]!.trim();
871
891
  continue;
872
892
  }
873
893
  // Continuation line inside the current list item.
874
894
  const fieldMatch = trimmed.match(/^(\w+):\s*(.*)$/);
875
895
  if (fieldMatch && hasDest) {
876
- (currentDest as Record<string, string>)[fieldMatch[1]] = fieldMatch[2].trim();
896
+ (currentDest as Record<string, string>)[fieldMatch[1]!] = fieldMatch[2]!.trim();
877
897
  continue;
878
898
  }
879
899
  }
@@ -1106,14 +1126,18 @@ export function readGlobalConfig(): GlobalConfig {
1106
1126
  const passwordHashMatch = yaml.match(/^owner_password_hash:\s*"([^"]+)"/m);
1107
1127
  const totpSecretMatch = yaml.match(/^totp_secret:\s*"([^"]+)"/m);
1108
1128
  const discoveryMatch = yaml.match(/^discovery:\s*(enabled|disabled)/m);
1129
+ const autostartMatch = yaml.match(/^autostart:\s*(true|false)/m);
1109
1130
  const config: GlobalConfig = {
1110
- port: portMatch ? parseInt(portMatch[1], 10) : DEFAULT_PORT,
1131
+ port: portMatch ? parseInt(portMatch[1]!, 10) : DEFAULT_PORT,
1111
1132
  default_vault: defaultVaultMatch?.[1],
1112
1133
  owner_password_hash: passwordHashMatch?.[1],
1113
1134
  totp_secret: totpSecretMatch?.[1],
1114
1135
  };
1115
1136
  if (discoveryMatch) {
1116
- config.discovery = discoveryMatch[1] as "enabled" | "disabled";
1137
+ config.discovery = discoveryMatch[1]! as "enabled" | "disabled";
1138
+ }
1139
+ if (autostartMatch) {
1140
+ config.autostart = autostartMatch[1]! === "true";
1117
1141
  }
1118
1142
 
1119
1143
  // Parse backup_codes: a YAML list of quoted bcrypt hashes under
@@ -1128,7 +1152,7 @@ export function readGlobalConfig(): GlobalConfig {
1128
1152
  for (const line of lines) {
1129
1153
  if (line.match(/^\S/) && line.trim().length > 0) break; // next top-level key
1130
1154
  const m = line.match(/^\s+-\s+"([^"]+)"/);
1131
- if (m) codes.push(m[1]);
1155
+ if (m) codes.push(m[1]!);
1132
1156
  }
1133
1157
  if (codes.length > 0) config.backup_codes = codes;
1134
1158
  }
@@ -1140,14 +1164,16 @@ export function readGlobalConfig(): GlobalConfig {
1140
1164
  for (const block of keyBlocks) {
1141
1165
  const idMatch = block.match(/^(\S+)/);
1142
1166
  const labelMatch = block.match(/label:\s*(.+)/);
1167
+ const scopeMatch = block.match(/scope:\s*(\S+)/);
1143
1168
  const hashMatch = block.match(/key_hash:\s*(\S+)/);
1144
1169
  const createdAtMatch = block.match(/created_at:\s*"?([^"\n]+)"?/);
1145
1170
  const lastUsedMatch = block.match(/last_used_at:\s*"?([^"\n]+)"?/);
1146
1171
  if (idMatch && hashMatch) {
1147
1172
  config.api_keys.push({
1148
- id: idMatch[1],
1173
+ id: idMatch[1]!,
1149
1174
  label: (labelMatch?.[1] ?? "default").trim(),
1150
- key_hash: hashMatch[1],
1175
+ scope: (scopeMatch?.[1] as KeyScope) ?? "write",
1176
+ key_hash: hashMatch[1]!,
1151
1177
  created_at: createdAtMatch?.[1] ?? new Date().toISOString(),
1152
1178
  last_used_at: lastUsedMatch?.[1],
1153
1179
  });
@@ -1172,6 +1198,7 @@ export function writeGlobalConfig(config: GlobalConfig): void {
1172
1198
  const lines = [`port: ${config.port}`];
1173
1199
  if (config.default_vault) lines.push(`default_vault: ${config.default_vault}`);
1174
1200
  if (config.discovery) lines.push(`discovery: ${config.discovery}`);
1201
+ if (config.autostart !== undefined) lines.push(`autostart: ${config.autostart}`);
1175
1202
  if (config.owner_password_hash) {
1176
1203
  lines.push(`owner_password_hash: "${config.owner_password_hash}"`);
1177
1204
  }
@@ -1190,6 +1217,7 @@ export function writeGlobalConfig(config: GlobalConfig): void {
1190
1217
  for (const key of config.api_keys) {
1191
1218
  lines.push(` - id: ${key.id}`);
1192
1219
  lines.push(` label: ${key.label}`);
1220
+ lines.push(` scope: ${key.scope ?? "write"}`);
1193
1221
  lines.push(` key_hash: ${key.key_hash}`);
1194
1222
  lines.push(` created_at: "${key.created_at}"`);
1195
1223
  if (key.last_used_at) {
@@ -1420,7 +1448,7 @@ export function resolveDefaultVault(): string | null {
1420
1448
  return globalConfig.default_vault;
1421
1449
  }
1422
1450
  if (vaults.length === 1) {
1423
- return vaults[0];
1451
+ return vaults[0]!;
1424
1452
  }
1425
1453
  return null;
1426
1454
  }