@openparachute/vault 0.3.1 → 0.4.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 (82) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +9 -5
  3. package/core/src/core.test.ts +2252 -7
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +801 -67
  6. package/core/src/note-schemas.ts +232 -0
  7. package/core/src/notes.ts +313 -35
  8. package/core/src/obsidian.ts +3 -3
  9. package/core/src/paths.ts +1 -1
  10. package/core/src/query-operators.ts +23 -7
  11. package/core/src/schema-defaults.ts +287 -0
  12. package/core/src/schema.ts +393 -9
  13. package/core/src/store.ts +248 -6
  14. package/core/src/tag-hierarchy.ts +137 -0
  15. package/core/src/tag-schemas.ts +242 -42
  16. package/core/src/types.ts +100 -6
  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 +231 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +144 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +384 -78
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +296 -0
  30. package/src/hub-jwt.ts +79 -0
  31. package/src/init-summary.test.ts +133 -0
  32. package/src/init-summary.ts +90 -0
  33. package/src/init.test.ts +216 -0
  34. package/src/mcp-http.ts +30 -28
  35. package/src/mcp-install.ts +1 -1
  36. package/src/mcp-tools.ts +294 -6
  37. package/src/module-config.ts +1 -1
  38. package/src/oauth.test.ts +345 -0
  39. package/src/oauth.ts +85 -14
  40. package/src/owner-auth.ts +57 -1
  41. package/src/prompt.ts +31 -14
  42. package/src/routes.ts +686 -58
  43. package/src/routing.test.ts +466 -1
  44. package/src/routing.ts +108 -24
  45. package/src/scopes.test.ts +66 -8
  46. package/src/scopes.ts +163 -37
  47. package/src/server.ts +24 -2
  48. package/src/services-manifest.test.ts +20 -0
  49. package/src/services-manifest.ts +9 -2
  50. package/src/stop-signal.test.ts +85 -0
  51. package/src/storage.test.ts +92 -0
  52. package/src/tag-scope.ts +118 -0
  53. package/src/token-store.test.ts +47 -0
  54. package/src/token-store.ts +128 -13
  55. package/src/tokens-routes.test.ts +720 -0
  56. package/src/tokens-routes.ts +392 -0
  57. package/src/transcription-worker.test.ts +5 -0
  58. package/src/triggers.ts +1 -1
  59. package/src/two-factor.ts +2 -2
  60. package/src/vault-create.test.ts +193 -0
  61. package/src/vault-name.test.ts +123 -0
  62. package/src/vault-name.ts +80 -0
  63. package/src/vault.test.ts +868 -3
  64. package/tsconfig.json +8 -1
  65. package/.claude/settings.local.json +0 -8
  66. package/.dockerignore +0 -8
  67. package/.env.example +0 -9
  68. package/CHANGELOG.md +0 -175
  69. package/CLAUDE.md +0 -125
  70. package/Caddyfile +0 -3
  71. package/Dockerfile +0 -22
  72. package/bun.lock +0 -219
  73. package/bunfig.toml +0 -2
  74. package/deploy/parachute-vault.service +0 -20
  75. package/docker-compose.yml +0 -50
  76. package/docs/HTTP_API.md +0 -434
  77. package/docs/auth-model.md +0 -340
  78. package/fly.toml +0 -24
  79. package/package/package.json +0 -32
  80. package/railway.json +0 -14
  81. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  82. 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
  }
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Hub-issued JWT validation — vault as resource server.
3
+ *
4
+ * Spins up a fake JWKS endpoint (Bun.serve) with a known RSA keypair, signs
5
+ * JWTs locally with `jose.SignJWT`, and asserts `validateHubJwt` accepts the
6
+ * good ones and rejects every failure mode the spec cares about: bad
7
+ * signature, wrong issuer, expired, missing kid, unknown kid, JWKS
8
+ * unreachable. Audience permissiveness is exercised — both `aud="operator"`
9
+ * and `aud="<client_id>"` shapes pass.
10
+ *
11
+ * Each test resets the JWKS cache so the origin/keys can change between
12
+ * cases. The cache is module-scoped; without `resetJwksCache()` we'd reuse
13
+ * the previous origin's getter and miss test rotations.
14
+ */
15
+ import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test";
16
+ import { generateKeyPair, exportJWK, SignJWT } from "jose";
17
+ import { resetJwksCache, validateHubJwt, looksLikeJwt } from "./hub-jwt.ts";
18
+
19
+ interface Keypair {
20
+ privateKey: CryptoKey;
21
+ publicJwk: { kty: string; n: string; e: string; kid: string; alg: string; use: string };
22
+ kid: string;
23
+ }
24
+
25
+ async function makeKeypair(kid: string): Promise<Keypair> {
26
+ const { privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true });
27
+ const jwk = await exportJWK(publicKey);
28
+ return {
29
+ privateKey,
30
+ publicJwk: {
31
+ kty: "RSA",
32
+ n: jwk.n!,
33
+ e: jwk.e!,
34
+ kid,
35
+ alg: "RS256",
36
+ use: "sig",
37
+ },
38
+ kid,
39
+ };
40
+ }
41
+
42
+ interface JwksFixture {
43
+ origin: string;
44
+ stop: () => void;
45
+ setKeys: (keys: Keypair[]) => void;
46
+ setUnreachable: (down: boolean) => void;
47
+ }
48
+
49
+ function startJwksFixture(): JwksFixture {
50
+ let keys: Keypair[] = [];
51
+ let down = false;
52
+ const server = Bun.serve({
53
+ port: 0,
54
+ fetch(req) {
55
+ const url = new URL(req.url);
56
+ if (url.pathname !== "/.well-known/jwks.json") {
57
+ return new Response("not found", { status: 404 });
58
+ }
59
+ if (down) return new Response("upstream down", { status: 503 });
60
+ return Response.json({ keys: keys.map((k) => k.publicJwk) });
61
+ },
62
+ });
63
+ return {
64
+ origin: `http://127.0.0.1:${server.port}`,
65
+ stop: () => server.stop(true),
66
+ setKeys: (next) => { keys = next; },
67
+ setUnreachable: (v) => { down = v; },
68
+ };
69
+ }
70
+
71
+ interface SignOpts {
72
+ iss?: string;
73
+ aud?: string | string[];
74
+ sub?: string;
75
+ scope?: string;
76
+ jti?: string;
77
+ clientId?: string;
78
+ ttlSeconds?: number;
79
+ expiresAtSeconds?: number;
80
+ omitKid?: boolean;
81
+ kid?: string;
82
+ }
83
+
84
+ async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
85
+ const iat = Math.floor(Date.now() / 1000);
86
+ const exp = opts.expiresAtSeconds ?? iat + (opts.ttlSeconds ?? 60);
87
+ const builder = new SignJWT({
88
+ scope: opts.scope ?? "vault:read vault:write",
89
+ client_id: opts.clientId ?? "test-client",
90
+ })
91
+ .setProtectedHeader(opts.omitKid ? { alg: "RS256" } : { alg: "RS256", kid: opts.kid ?? kp.kid })
92
+ .setIssuer(opts.iss ?? "http://issuer.invalid")
93
+ .setSubject(opts.sub ?? "user-1")
94
+ .setAudience(opts.aud ?? "operator")
95
+ .setIssuedAt(iat)
96
+ .setExpirationTime(exp)
97
+ .setJti(opts.jti ?? "jti-1");
98
+ return await builder.sign(kp.privateKey);
99
+ }
100
+
101
+ let fixture: JwksFixture;
102
+ let kp: Keypair;
103
+ let prevHubOrigin: string | undefined;
104
+
105
+ beforeAll(async () => {
106
+ fixture = startJwksFixture();
107
+ kp = await makeKeypair("k1");
108
+ fixture.setKeys([kp]);
109
+ });
110
+
111
+ afterAll(() => {
112
+ fixture.stop();
113
+ if (prevHubOrigin === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
114
+ else process.env.PARACHUTE_HUB_ORIGIN = prevHubOrigin;
115
+ });
116
+
117
+ beforeEach(() => {
118
+ // Each test sets its own origin for clarity.
119
+ prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
120
+ process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
121
+ fixture.setUnreachable(false);
122
+ fixture.setKeys([kp]);
123
+ resetJwksCache();
124
+ });
125
+
126
+ describe("looksLikeJwt", () => {
127
+ test("`eyJ` prefix → true", () => {
128
+ expect(looksLikeJwt("eyJhbGciOiJSUzI1NiJ9.x.y")).toBe(true);
129
+ });
130
+
131
+ test("pvt_ token → false", () => {
132
+ expect(looksLikeJwt("pvt_abcdef0123456789")).toBe(false);
133
+ });
134
+
135
+ test("empty / random → false", () => {
136
+ expect(looksLikeJwt("")).toBe(false);
137
+ expect(looksLikeJwt("hello-world")).toBe(false);
138
+ });
139
+ });
140
+
141
+ describe("validateHubJwt — happy path", () => {
142
+ test("valid JWT with correct iss → claims surface", async () => {
143
+ const token = await signJwt(kp, { iss: fixture.origin, scope: "vault:work:read vault:work:write" });
144
+ const claims = await validateHubJwt(token);
145
+ expect(claims.sub).toBe("user-1");
146
+ expect(claims.scopes).toEqual(["vault:work:read", "vault:work:write"]);
147
+ expect(claims.aud).toBe("operator");
148
+ expect(claims.jti).toBe("jti-1");
149
+ expect(claims.clientId).toBe("test-client");
150
+ });
151
+
152
+ test("aud=operator accepted when expectedAudience not set", async () => {
153
+ const token = await signJwt(kp, { iss: fixture.origin, aud: "operator" });
154
+ const claims = await validateHubJwt(token);
155
+ expect(claims.aud).toBe("operator");
156
+ });
157
+
158
+ test("aud=<client_id> accepted when expectedAudience not set", async () => {
159
+ const token = await signJwt(kp, {
160
+ iss: fixture.origin,
161
+ aud: "did:plc:randomclientid",
162
+ clientId: "did:plc:randomclientid",
163
+ });
164
+ const claims = await validateHubJwt(token);
165
+ expect(claims.aud).toBe("did:plc:randomclientid");
166
+ });
167
+
168
+ test("empty scope claim → empty scopes array", async () => {
169
+ const token = await signJwt(kp, { iss: fixture.origin, scope: "" });
170
+ const claims = await validateHubJwt(token);
171
+ expect(claims.scopes).toEqual([]);
172
+ });
173
+
174
+ test("audience strict-check passes when expected matches", async () => {
175
+ const token = await signJwt(kp, { iss: fixture.origin, aud: "vault.work" });
176
+ const claims = await validateHubJwt(token, { expectedAudience: "vault.work" });
177
+ expect(claims.aud).toBe("vault.work");
178
+ });
179
+ });
180
+
181
+ describe("validateHubJwt — audience strict-check", () => {
182
+ test("mismatched audience throws with the expected vs got values", async () => {
183
+ const token = await signJwt(kp, { iss: fixture.origin, aud: "vault.personal" });
184
+ await expect(
185
+ validateHubJwt(token, { expectedAudience: "vault.work" }),
186
+ ).rejects.toThrow(/audience mismatch.*vault\.work.*vault\.personal/);
187
+ });
188
+
189
+ test("missing audience claim throws when expected is set", async () => {
190
+ // jose's SignJWT requires .setAudience() — provide an unrelated value to
191
+ // exercise "not the expected one" rather than a literal missing claim.
192
+ const token = await signJwt(kp, { iss: fixture.origin, aud: "operator" });
193
+ await expect(
194
+ validateHubJwt(token, { expectedAudience: "vault.work" }),
195
+ ).rejects.toThrow(/audience mismatch/);
196
+ });
197
+
198
+ test("expectedAudience: null skips the check (cross-vault path)", async () => {
199
+ const token = await signJwt(kp, { iss: fixture.origin, aud: "vault.anything" });
200
+ const claims = await validateHubJwt(token, { expectedAudience: null });
201
+ expect(claims.aud).toBe("vault.anything");
202
+ });
203
+
204
+ test("array aud: passes when expected is one of the entries", async () => {
205
+ const token = await signJwt(kp, {
206
+ iss: fixture.origin,
207
+ aud: ["vault.work", "vault.personal", "operator"],
208
+ });
209
+ const claims = await validateHubJwt(token, { expectedAudience: "vault.personal" });
210
+ expect(claims.aud).toBe("vault.personal");
211
+ });
212
+
213
+ test("array aud: rejects when expected is not in the entries", async () => {
214
+ const token = await signJwt(kp, {
215
+ iss: fixture.origin,
216
+ aud: ["vault.work", "operator"],
217
+ });
218
+ await expect(
219
+ validateHubJwt(token, { expectedAudience: "vault.personal" }),
220
+ ).rejects.toThrow(/audience mismatch.*vault\.personal.*vault\.work.*operator/);
221
+ });
222
+
223
+ test("array aud: surfaces the first entry when no expectation is set", async () => {
224
+ const token = await signJwt(kp, {
225
+ iss: fixture.origin,
226
+ aud: ["vault.first", "vault.second"],
227
+ });
228
+ const claims = await validateHubJwt(token, { expectedAudience: null });
229
+ expect(claims.aud).toBe("vault.first");
230
+ });
231
+ });
232
+
233
+ describe("validateHubJwt — failure modes", () => {
234
+ test("wrong issuer → throws", async () => {
235
+ const token = await signJwt(kp, { iss: "http://attacker.example" });
236
+ await expect(validateHubJwt(token)).rejects.toThrow(/verification failed/);
237
+ });
238
+
239
+ test("expired token → throws", async () => {
240
+ const past = Math.floor(Date.now() / 1000) - 10;
241
+ const token = await signJwt(kp, { iss: fixture.origin, expiresAtSeconds: past });
242
+ await expect(validateHubJwt(token)).rejects.toThrow(/verification failed/);
243
+ });
244
+
245
+ test("bad signature (token signed by an unpublished key) → throws", async () => {
246
+ const otherKp = await makeKeypair("k1"); // same kid, different key
247
+ const token = await signJwt(otherKp, { iss: fixture.origin });
248
+ await expect(validateHubJwt(token)).rejects.toThrow(/verification failed/);
249
+ });
250
+
251
+ test("unknown kid → throws", async () => {
252
+ const token = await signJwt(kp, { iss: fixture.origin, kid: "does-not-exist" });
253
+ await expect(validateHubJwt(token)).rejects.toThrow(/verification failed/);
254
+ });
255
+
256
+ test("missing kid header → throws when JWKS has multiple keys", async () => {
257
+ // jose's createRemoteJWKSet falls back to the only key when JWKS has just
258
+ // one — so to exercise the "no kid" failure path we need ≥2 keys.
259
+ const kp2 = await makeKeypair("k2");
260
+ fixture.setKeys([kp, kp2]);
261
+ resetJwksCache();
262
+ const token = await signJwt(kp, { iss: fixture.origin, omitKid: true });
263
+ await expect(validateHubJwt(token)).rejects.toThrow(/verification failed/);
264
+ });
265
+
266
+ test("JWKS endpoint unreachable → throws (fail closed)", async () => {
267
+ fixture.setUnreachable(true);
268
+ const token = await signJwt(kp, { iss: fixture.origin });
269
+ await expect(validateHubJwt(token)).rejects.toThrow(/verification failed/);
270
+ });
271
+
272
+ test("missing `sub` claim → throws", async () => {
273
+ // SignJWT requires .setSubject; build the token manually-ish: pass empty sub.
274
+ const iat = Math.floor(Date.now() / 1000);
275
+ const token = await new SignJWT({ scope: "vault:read" })
276
+ .setProtectedHeader({ alg: "RS256", kid: kp.kid })
277
+ .setIssuer(fixture.origin)
278
+ .setAudience("operator")
279
+ .setIssuedAt(iat)
280
+ .setExpirationTime(iat + 60)
281
+ .setJti("jti-no-sub")
282
+ .sign(kp.privateKey);
283
+ await expect(validateHubJwt(token)).rejects.toThrow(/missing required `sub`/);
284
+ });
285
+ });
286
+
287
+ describe("validateHubJwt — JWKS rotation", () => {
288
+ test("rotated key (new kid published) verifies after cache reset", async () => {
289
+ const kp2 = await makeKeypair("k2");
290
+ fixture.setKeys([kp, kp2]);
291
+ resetJwksCache();
292
+ const token = await signJwt(kp2, { iss: fixture.origin });
293
+ const claims = await validateHubJwt(token);
294
+ expect(claims.sub).toBe("user-1");
295
+ });
296
+ });