@openparachute/vault 0.4.8 → 0.4.9-rc.11

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 (58) hide show
  1. package/core/src/core.test.ts +4 -1
  2. package/core/src/hooks.test.ts +320 -1
  3. package/core/src/hooks.ts +243 -38
  4. package/core/src/indexed-fields.test.ts +151 -0
  5. package/core/src/indexed-fields.ts +98 -0
  6. package/core/src/mcp.ts +99 -41
  7. package/core/src/notes.ts +26 -2
  8. package/core/src/portable-md.test.ts +304 -1
  9. package/core/src/portable-md.ts +418 -2
  10. package/core/src/schema.ts +114 -2
  11. package/core/src/store.ts +185 -2
  12. package/core/src/types.ts +28 -0
  13. package/package.json +2 -2
  14. package/src/auth-hub-jwt.test.ts +147 -0
  15. package/src/auth.ts +121 -1
  16. package/src/auto-transcribe.test.ts +7 -2
  17. package/src/auto-transcribe.ts +6 -2
  18. package/src/cli.ts +131 -36
  19. package/src/config.ts +12 -4
  20. package/src/export-watch.test.ts +74 -0
  21. package/src/export-watch.ts +108 -7
  22. package/src/github-device-flow.test.ts +404 -0
  23. package/src/github-device-flow.ts +415 -0
  24. package/src/hub-jwt.test.ts +27 -2
  25. package/src/hub-jwt.ts +10 -0
  26. package/src/mcp-http.ts +48 -39
  27. package/src/mcp-install-interactive.test.ts +10 -21
  28. package/src/mcp-install-interactive.ts +12 -21
  29. package/src/mcp-install.test.ts +141 -30
  30. package/src/mcp-install.ts +109 -3
  31. package/src/mcp-tools.ts +460 -3
  32. package/src/mirror-config.test.ts +277 -14
  33. package/src/mirror-config.ts +482 -31
  34. package/src/mirror-credentials.test.ts +601 -0
  35. package/src/mirror-credentials.ts +700 -0
  36. package/src/mirror-deps.ts +67 -17
  37. package/src/mirror-import.test.ts +550 -0
  38. package/src/mirror-import.ts +487 -0
  39. package/src/mirror-manager.test.ts +423 -12
  40. package/src/mirror-manager.ts +621 -72
  41. package/src/mirror-per-vault.test.ts +519 -0
  42. package/src/mirror-registry.ts +91 -14
  43. package/src/mirror-routes.test.ts +966 -10
  44. package/src/mirror-routes.ts +1111 -7
  45. package/src/module-config.ts +11 -5
  46. package/src/routes.ts +38 -1
  47. package/src/routing.test.ts +92 -1
  48. package/src/routing.ts +193 -20
  49. package/src/server.ts +116 -35
  50. package/src/storage.test.ts +132 -7
  51. package/src/token-store.ts +300 -5
  52. package/src/transcription-worker.ts +9 -4
  53. package/src/triggers.ts +16 -3
  54. package/src/vault.test.ts +681 -2
  55. package/web/ui/dist/assets/index-Cn-PPMRv.js +60 -0
  56. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  57. package/web/ui/dist/index.html +2 -2
  58. package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
@@ -12,6 +12,10 @@ import os from "node:os";
12
12
  import path from "node:path";
13
13
 
14
14
  import {
15
+ DEFAULT_SAFETY_NET_SECONDS,
16
+ MAX_SAFETY_NET_SECONDS,
17
+ MIN_SAFETY_NET_SECONDS,
18
+ commentOutMirrorBlock,
15
19
  defaultMirrorConfig,
16
20
  parseMirrorConfig,
17
21
  resolveMirrorPath,
@@ -40,11 +44,14 @@ describe("defaultMirrorConfig", () => {
40
44
  expect(d.enabled).toBe(false);
41
45
  expect(d.location).toBe("internal");
42
46
  expect(d.external_path).toBeNull();
43
- expect(d.watch).toBe(false);
47
+ // Post event-driven shift: sync_mode replaces watch. "events" is the
48
+ // new default — when an operator flips enabled on, hooks subscribe
49
+ // automatically.
50
+ expect(d.sync_mode).toBe("events");
44
51
  expect(d.auto_commit).toBe(true);
45
52
  expect(d.auto_push).toBe(false);
46
53
  expect(d.commit_template).toContain("{{date}}");
47
- expect(d.interval_seconds).toBe(5);
54
+ expect(d.safety_net_seconds).toBe(DEFAULT_SAFETY_NET_SECONDS);
48
55
  });
49
56
  });
50
57
 
@@ -58,41 +65,68 @@ describe("parseMirrorConfig", () => {
58
65
  expect(parseMirrorConfig("")).toBeUndefined();
59
66
  });
60
67
 
61
- test("parses a fully-specified mirror block", () => {
68
+ test("parses a fully-specified mirror block (post-event-driven shape)", () => {
62
69
  const yaml = [
63
70
  "port: 1940",
64
71
  "mirror:",
65
72
  " enabled: true",
66
73
  " location: external",
67
74
  " external_path: /home/aaron/mirrors/gitcoin",
68
- " watch: true",
75
+ " sync_mode: events",
69
76
  " auto_commit: true",
70
77
  " auto_push: true",
71
78
  ' commit_template: "vault: {{notes_changed}} note{{plural}}"',
72
- " interval_seconds: 10",
79
+ " safety_net_seconds: 3600",
73
80
  ].join("\n");
74
81
  const m = parseMirrorConfig(yaml);
75
82
  expect(m).toEqual({
76
83
  enabled: true,
77
84
  location: "external",
78
85
  external_path: "/home/aaron/mirrors/gitcoin",
79
- watch: true,
86
+ sync_mode: "events",
80
87
  auto_commit: true,
81
88
  auto_push: true,
82
89
  commit_template: "vault: {{notes_changed}} note{{plural}}",
83
- interval_seconds: 10,
90
+ safety_net_seconds: 3600,
84
91
  });
85
92
  });
86
93
 
87
94
  test("partial mirror block fills missing fields from defaults", () => {
88
- const yaml = "mirror:\n enabled: true\n watch: true\n";
95
+ const yaml = "mirror:\n enabled: true\n sync_mode: manual\n";
89
96
  const m = parseMirrorConfig(yaml)!;
90
97
  expect(m.enabled).toBe(true);
91
- expect(m.watch).toBe(true);
98
+ expect(m.sync_mode).toBe("manual");
92
99
  expect(m.location).toBe("internal");
93
100
  expect(m.auto_commit).toBe(true);
94
101
  });
95
102
 
103
+ test("legacy `watch: true` translates to sync_mode: events", () => {
104
+ const m = parseMirrorConfig("mirror:\n enabled: true\n watch: true\n")!;
105
+ expect(m.sync_mode).toBe("events");
106
+ });
107
+
108
+ test("legacy `watch: false` translates to sync_mode: manual", () => {
109
+ const m = parseMirrorConfig("mirror:\n enabled: true\n watch: false\n")!;
110
+ expect(m.sync_mode).toBe("manual");
111
+ });
112
+
113
+ test("explicit sync_mode wins over legacy watch", () => {
114
+ const yaml = "mirror:\n enabled: true\n watch: true\n sync_mode: manual\n";
115
+ const m = parseMirrorConfig(yaml)!;
116
+ expect(m.sync_mode).toBe("manual");
117
+ });
118
+
119
+ test("legacy `interval_seconds: 5` clamps up to MIN_SAFETY_NET_SECONDS", () => {
120
+ const m = parseMirrorConfig("mirror:\n enabled: true\n interval_seconds: 5\n")!;
121
+ expect(m.safety_net_seconds).toBe(MIN_SAFETY_NET_SECONDS);
122
+ });
123
+
124
+ test("explicit safety_net_seconds wins over legacy interval_seconds", () => {
125
+ const yaml = "mirror:\n enabled: true\n interval_seconds: 5\n safety_net_seconds: 1800\n";
126
+ const m = parseMirrorConfig(yaml)!;
127
+ expect(m.safety_net_seconds).toBe(1800);
128
+ });
129
+
96
130
  test("external_path: null is interpreted as null", () => {
97
131
  const m = parseMirrorConfig(
98
132
  "mirror:\n enabled: true\n external_path: null\n",
@@ -120,11 +154,11 @@ describe("serializeMirrorConfig", () => {
120
154
  enabled: true,
121
155
  location: "external" as const,
122
156
  external_path: "/home/aaron/team-brain",
123
- watch: true,
157
+ sync_mode: "events" as const,
124
158
  auto_commit: true,
125
159
  auto_push: false,
126
160
  commit_template: "export: {{date}} ({{notes_changed}} note{{plural}})",
127
- interval_seconds: 5,
161
+ safety_net_seconds: 3600,
128
162
  };
129
163
  const yaml = serializeMirrorConfig(original).join("\n") + "\n";
130
164
  const parsed = parseMirrorConfig(yaml);
@@ -251,10 +285,147 @@ describe("validateMirrorConfigShape", () => {
251
285
  if (!r.ok) expect(r.field).toBe("enabled");
252
286
  });
253
287
 
254
- test("rejects non-integer interval_seconds", () => {
255
- const r = validateMirrorConfigShape({ interval_seconds: 0.5 });
288
+ test("rejects non-integer safety_net_seconds", () => {
289
+ const r = validateMirrorConfigShape({ safety_net_seconds: 0.5 });
290
+ expect(r.ok).toBe(false);
291
+ if (!r.ok) expect(r.field).toBe("safety_net_seconds");
292
+ });
293
+
294
+ test("rejects safety_net_seconds below MIN", () => {
295
+ const r = validateMirrorConfigShape({ safety_net_seconds: MIN_SAFETY_NET_SECONDS - 1 });
296
+ expect(r.ok).toBe(false);
297
+ if (!r.ok) expect(r.field).toBe("safety_net_seconds");
298
+ });
299
+
300
+ test("rejects safety_net_seconds above MAX", () => {
301
+ const r = validateMirrorConfigShape({ safety_net_seconds: MAX_SAFETY_NET_SECONDS + 1 });
302
+ expect(r.ok).toBe(false);
303
+ if (!r.ok) expect(r.field).toBe("safety_net_seconds");
304
+ });
305
+
306
+ test("legacy interval_seconds field clamps + migrates to safety_net_seconds", () => {
307
+ // Hand-edited config supplies the old field; we still accept it but
308
+ // route it through the safety-net clamp range.
309
+ const r = validateMirrorConfigShape({ interval_seconds: 5 });
310
+ expect(r.ok).toBe(true);
311
+ if (r.ok) expect(r.config.safety_net_seconds).toBe(MIN_SAFETY_NET_SECONDS);
312
+ });
313
+
314
+ test("rejects unknown sync_mode", () => {
315
+ const r = validateMirrorConfigShape({ sync_mode: "interval" });
256
316
  expect(r.ok).toBe(false);
257
- if (!r.ok) expect(r.field).toBe("interval_seconds");
317
+ if (!r.ok) expect(r.field).toBe("sync_mode");
318
+ });
319
+
320
+ test("accepts sync_mode events / manual", () => {
321
+ expect((validateMirrorConfigShape({ sync_mode: "events" }) as { config: { sync_mode: string } }).config.sync_mode).toBe("events");
322
+ expect((validateMirrorConfigShape({ sync_mode: "manual" }) as { config: { sync_mode: string } }).config.sync_mode).toBe("manual");
323
+ });
324
+
325
+ test("legacy watch: true translates to sync_mode: events", () => {
326
+ const r = validateMirrorConfigShape({ watch: true });
327
+ expect(r.ok).toBe(true);
328
+ if (r.ok) expect(r.config.sync_mode).toBe("events");
329
+ });
330
+
331
+ test("legacy watch: false translates to sync_mode: manual", () => {
332
+ const r = validateMirrorConfigShape({ watch: false });
333
+ expect(r.ok).toBe(true);
334
+ if (r.ok) expect(r.config.sync_mode).toBe("manual");
335
+ });
336
+
337
+ test("rejects auto_push + internal location WHEN no credentials are configured", () => {
338
+ // Pre-credentials shape: auto_push + internal was rejected outright
339
+ // (internal mirror = no remote = push would silently fail). Once
340
+ // credentials are wired (PAT or GitHub OAuth), the credential save
341
+ // path sets `origin` on the internal repo too — so push IS
342
+ // meaningful. We keep the rejection only on the no-credentials path,
343
+ // with a clear error pointing the operator at the credential flow.
344
+ const r = validateMirrorConfigShape(
345
+ {
346
+ enabled: true,
347
+ location: "internal",
348
+ auto_push: true,
349
+ },
350
+ { readCredentials: () => null },
351
+ );
352
+ expect(r.ok).toBe(false);
353
+ if (!r.ok) {
354
+ expect(r.field).toBe("auto_push");
355
+ expect(r.error).toContain("credentials");
356
+ }
357
+ });
358
+
359
+ test("auto_push + internal IS accepted when PAT credentials are configured", () => {
360
+ // The three-stacking-gaps bug Aaron hit: History preset (internal
361
+ // location) + PAT saved → expected pushes to fire. validation was
362
+ // the first blocker. Now the combination passes when credentials
363
+ // are present.
364
+ const r = validateMirrorConfigShape(
365
+ {
366
+ enabled: true,
367
+ location: "internal",
368
+ auto_push: true,
369
+ },
370
+ {
371
+ readCredentials: () => ({
372
+ active_method: "pat",
373
+ github_oauth: null,
374
+ pat: {
375
+ token: "ghp_xxxxxxxxxxxxxxxx",
376
+ remote_url: "https://x-access-token:ghp_xxxxxxxxxxxxxxxx@github.com/a/b.git",
377
+ label: "test",
378
+ },
379
+ }),
380
+ },
381
+ );
382
+ expect(r.ok).toBe(true);
383
+ });
384
+
385
+ test("auto_push + internal IS accepted when github_oauth credentials are configured", () => {
386
+ const r = validateMirrorConfigShape(
387
+ {
388
+ enabled: true,
389
+ location: "internal",
390
+ auto_push: true,
391
+ },
392
+ {
393
+ readCredentials: () => ({
394
+ active_method: "github_oauth",
395
+ github_oauth: {
396
+ access_token: "gho_xxxxxxxxxxxx",
397
+ scope: "repo",
398
+ authorized_at: "2026-05-28T03:14:15.000Z",
399
+ user_login: "aaron",
400
+ user_id: 1,
401
+ },
402
+ pat: null,
403
+ }),
404
+ },
405
+ );
406
+ expect(r.ok).toBe(true);
407
+ });
408
+
409
+ test("auto_push + external location is fine", () => {
410
+ const r = validateMirrorConfigShape({
411
+ enabled: true,
412
+ location: "external",
413
+ external_path: "/tmp/foo",
414
+ auto_push: true,
415
+ });
416
+ expect(r.ok).toBe(true);
417
+ });
418
+
419
+ test("auto_push + disabled never errors", () => {
420
+ // Cross-field rule gates on `enabled`. A disabled config with stale
421
+ // auto_push: true + internal is the upgrade-path shape; operators
422
+ // shouldn't have to clear the field to disable.
423
+ const r = validateMirrorConfigShape({
424
+ enabled: false,
425
+ location: "internal",
426
+ auto_push: true,
427
+ });
428
+ expect(r.ok).toBe(true);
258
429
  });
259
430
 
260
431
  test("rejects empty commit_template", () => {
@@ -326,3 +497,95 @@ describe("validateExternalPath", () => {
326
497
  if (r.ok) expect(r.resolved_path).toBe(dir);
327
498
  });
328
499
  });
500
+
501
+ // ---------------------------------------------------------------------------
502
+ // commentOutMirrorBlock — vault#400 migration YAML rewrite (extracted from
503
+ // server.ts per vault#408 review N3). Runs against the operator's real
504
+ // config.yaml, so it gets direct coverage here.
505
+ // ---------------------------------------------------------------------------
506
+
507
+ describe("commentOutMirrorBlock", () => {
508
+ test("comments out a real serializer-shaped mirror block; leaves other keys intact", () => {
509
+ // Build the block exactly as serializeMirrorConfig emits it — pins the
510
+ // real production shape rather than a hand-written approximation.
511
+ const block = serializeMirrorConfig({
512
+ ...defaultMirrorConfig(),
513
+ enabled: true,
514
+ location: "external",
515
+ external_path: "/home/aaron/mirrors/brain",
516
+ auto_push: true,
517
+ }).join("\n");
518
+ const yaml = `port: 1940
519
+ default_vault: brain
520
+ ${block}
521
+ auto_transcribe:
522
+ enabled: true
523
+ `;
524
+ const out = commentOutMirrorBlock(yaml);
525
+
526
+ // No LIVE mirror block survives (the parser anchor won't match).
527
+ expect(parseMirrorConfig(out)).toBeUndefined();
528
+ // Every mirror line is commented.
529
+ expect(out).toContain("# mirror:");
530
+ expect(out).toContain("# enabled: true");
531
+ expect(out).toContain("# external_path: /home/aaron/mirrors/brain");
532
+ expect(out).toContain("# auto_push: true");
533
+ // Provenance marker added.
534
+ expect(out).toContain("# [vault#400] migrated to per-vault");
535
+ // Non-mirror top-level keys untouched (byte-for-byte).
536
+ expect(out).toContain("port: 1940");
537
+ expect(out).toContain("default_vault: brain");
538
+ expect(out).toContain("auto_transcribe:");
539
+ expect(out).toContain(" enabled: true");
540
+ // The mirror block must NOT have swallowed the auto_transcribe block —
541
+ // its child line stays a live (uncommented) 2-space-indent field.
542
+ expect(out).not.toContain("# enabled: true\n# auto_transcribe");
543
+ const at = out.indexOf("auto_transcribe:");
544
+ expect(out.slice(at)).toContain("\n enabled: true");
545
+ });
546
+
547
+ test("idempotent — running on already-commented output is a no-op", () => {
548
+ const block = serializeMirrorConfig({
549
+ ...defaultMirrorConfig(),
550
+ enabled: true,
551
+ }).join("\n");
552
+ const yaml = `port: 1940\n${block}\ndiscovery: enabled\n`;
553
+ const once = commentOutMirrorBlock(yaml);
554
+ const twice = commentOutMirrorBlock(once);
555
+ expect(twice).toBe(once); // second pass changes nothing
556
+ });
557
+
558
+ test("no mirror block → returns input unchanged", () => {
559
+ const yaml = `port: 1940
560
+ default_vault: brain
561
+ discovery: enabled
562
+ `;
563
+ expect(commentOutMirrorBlock(yaml)).toBe(yaml);
564
+ });
565
+
566
+ test("mirror block at EOF (no trailing key) is fully commented", () => {
567
+ const block = serializeMirrorConfig({
568
+ ...defaultMirrorConfig(),
569
+ enabled: true,
570
+ auto_commit: false,
571
+ }).join("\n");
572
+ const yaml = `port: 1940\n${block}\n`;
573
+ const out = commentOutMirrorBlock(yaml);
574
+ expect(parseMirrorConfig(out)).toBeUndefined();
575
+ expect(out).toContain("# auto_commit: false");
576
+ expect(out).toContain("port: 1940"); // live, untouched
577
+ });
578
+
579
+ test("preserves a blank line between the mirror block and the next key", () => {
580
+ const block = serializeMirrorConfig({
581
+ ...defaultMirrorConfig(),
582
+ enabled: true,
583
+ }).join("\n");
584
+ // Blank line separates the block from `discovery:` — must stay blank
585
+ // (not commented) and `discovery:` must stay live.
586
+ const yaml = `port: 1940\n${block}\n\ndiscovery: enabled\n`;
587
+ const out = commentOutMirrorBlock(yaml);
588
+ expect(out).toContain("\n\ndiscovery: enabled"); // blank line preserved, key live
589
+ expect(parseMirrorConfig(out)).toBeUndefined();
590
+ });
591
+ });