@openparachute/vault 0.4.8 → 0.4.9-rc.10

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 (40) hide show
  1. package/core/src/hooks.test.ts +320 -1
  2. package/core/src/hooks.ts +243 -38
  3. package/core/src/mcp.ts +35 -0
  4. package/core/src/portable-md.test.ts +252 -1
  5. package/core/src/portable-md.ts +370 -2
  6. package/core/src/schema.ts +51 -2
  7. package/core/src/store.ts +68 -2
  8. package/package.json +1 -1
  9. package/src/auth.ts +29 -1
  10. package/src/auto-transcribe.test.ts +7 -2
  11. package/src/auto-transcribe.ts +6 -2
  12. package/src/export-watch.test.ts +74 -0
  13. package/src/export-watch.ts +108 -7
  14. package/src/github-device-flow.test.ts +404 -0
  15. package/src/github-device-flow.ts +415 -0
  16. package/src/mcp-http.ts +24 -36
  17. package/src/mcp-tools.ts +286 -2
  18. package/src/mirror-config.test.ts +184 -14
  19. package/src/mirror-config.ts +220 -24
  20. package/src/mirror-credentials.test.ts +450 -0
  21. package/src/mirror-credentials.ts +577 -0
  22. package/src/mirror-deps.ts +42 -1
  23. package/src/mirror-import.test.ts +550 -0
  24. package/src/mirror-import.ts +484 -0
  25. package/src/mirror-manager.test.ts +423 -12
  26. package/src/mirror-manager.ts +579 -62
  27. package/src/mirror-routes.test.ts +966 -10
  28. package/src/mirror-routes.ts +1096 -5
  29. package/src/module-config.ts +11 -5
  30. package/src/routing.test.ts +92 -1
  31. package/src/routing.ts +165 -1
  32. package/src/server.ts +21 -8
  33. package/src/token-store.ts +158 -5
  34. package/src/transcription-worker.ts +9 -4
  35. package/src/triggers.ts +16 -3
  36. package/src/vault.test.ts +380 -1
  37. package/web/ui/dist/assets/{index-BOa-JJtV.css → index-DBe8Xiah.css} +1 -1
  38. package/web/ui/dist/assets/index-DE18QJMx.js +60 -0
  39. package/web/ui/dist/index.html +2 -2
  40. package/web/ui/dist/assets/index-BzA5LgE3.js +0 -60
@@ -23,6 +23,7 @@ import { existsSync, statSync } from "fs";
23
23
  import { join } from "path";
24
24
 
25
25
  import { DEFAULT_COMMIT_TEMPLATE, isGitRepo } from "./export-watch.ts";
26
+ import { readCredentials, type MirrorCredentials } from "./mirror-credentials.ts";
26
27
 
27
28
  // ---------------------------------------------------------------------------
28
29
  // Types
@@ -37,6 +38,32 @@ import { DEFAULT_COMMIT_TEMPLATE, isGitRepo } from "./export-watch.ts";
37
38
  */
38
39
  export type MirrorLocation = "internal" | "external";
39
40
 
41
+ /**
42
+ * How the mirror stays current with vault state.
43
+ *
44
+ * - `events` → in-process hooks fire on every note/tag/attachment
45
+ * mutation; the manager debounces them (~500ms) into a single export
46
+ * pass. A background safety-net poll (default 1h) catches anything
47
+ * missed (direct SQL writes, dropped hook dispatches, restart gaps).
48
+ * This is the default for new configs and the post-vault#382 shape.
49
+ * - `manual` → no event subscriptions, no polling. The operator runs
50
+ * "Run export now" (admin SPA button or POST `/.parachute/mirror/run-now`)
51
+ * or `parachute-vault export` from the CLI on their own cadence.
52
+ *
53
+ * The pre-vault#382 `interval_seconds`-driven watch loop has retired —
54
+ * existing configs migrate to `events` (when `watch: true`) or `manual`
55
+ * (when `watch: false`). The legacy field still parses, repurposed as
56
+ * `safety_net_seconds` when explicit.
57
+ */
58
+ export type MirrorSyncMode = "events" | "manual";
59
+
60
+ /** Default safety-net poll interval in seconds (1 hour). */
61
+ export const DEFAULT_SAFETY_NET_SECONDS = 3600;
62
+ /** Minimum safety-net poll interval. Anything tighter would defeat the point of "safety net" — that's what the event path is for. */
63
+ export const MIN_SAFETY_NET_SECONDS = 60;
64
+ /** Maximum safety-net poll interval (24 hours). */
65
+ export const MAX_SAFETY_NET_SECONDS = 86400;
66
+
40
67
  /**
41
68
  * The persistent mirror configuration block. Lives under the `mirror:` key
42
69
  * in the global config.yaml (one mirror per vault server today — multi-vault
@@ -46,32 +73,44 @@ export type MirrorLocation = "internal" | "external";
46
73
  * - `enabled` — master switch. When false (the default for upgrading
47
74
  * vaults), no mirror behavior runs at all. The other fields are
48
75
  * preserved so the operator can flip enabled back on without losing
49
- * their location/path/watch settings.
76
+ * their location/path/sync_mode settings.
50
77
  * - `location` — "internal" or "external". Drives `resolveMirrorPath`.
51
78
  * - `external_path` — required when location=external. Operator-picked
52
79
  * absolute path. Must exist + be a git repo when first validated.
53
- * - `watch` — when true, the manager runs the export-watch loop in the
54
- * vault server process. When false, the mirror gets a one-shot export
55
- * on boot/config-change only; subsequent updates need an explicit
56
- * manual export.
80
+ * - `sync_mode` — "events" (subscribe to in-process hooks; debounce ~500ms;
81
+ * safety-net poll on top) or "manual" (no auto-fire; operator triggers
82
+ * manually). Default "events".
57
83
  * - `auto_commit` — after each export pass, `git add -A && git commit`.
58
84
  * Reuses the existing `runGitCommitCycle` from vault#346.
59
85
  * - `auto_push` — after commit, `git push`. Failures non-fatal.
60
86
  * - `commit_template` — passed verbatim to `renderCommitMessage`. Same
61
87
  * variable set as the CLI: `{{date}}`, `{{notes_changed}}`,
62
88
  * `{{plural}}`, `{{first_note_title}}`, `{{vault_name}}`.
63
- * - `interval_seconds` — watch-loop poll interval. Default 5, matching
64
- * the CLI flag's default.
89
+ * - `safety_net_seconds` — `sync_mode: events` runs an hourly background
90
+ * sweep on top of the event-driven path. This field controls the
91
+ * interval (default 3600). Clamped to `[MIN_SAFETY_NET_SECONDS,
92
+ * MAX_SAFETY_NET_SECONDS]` at validation time.
93
+ *
94
+ * Legacy / migration:
95
+ * - `watch` (boolean) — pre-vault#382 field. `true` → `sync_mode: events`,
96
+ * `false` → `sync_mode: manual`. Parsed for back-compat; the canonical
97
+ * form on the wire is `sync_mode`.
98
+ * - `interval_seconds` (positive integer) — pre-vault#382 watch-loop
99
+ * poll interval. Reinterpreted as `safety_net_seconds` when no
100
+ * explicit `safety_net_seconds` is present AND the value lies in the
101
+ * valid range. Out-of-range / missing → default 3600. The field is
102
+ * no longer surfaced in the UI; the SPA stops emitting it but the
103
+ * parser keeps accepting it for hand-edited configs.
65
104
  */
66
105
  export interface MirrorConfig {
67
106
  enabled: boolean;
68
107
  location: MirrorLocation;
69
108
  external_path: string | null;
70
- watch: boolean;
109
+ sync_mode: MirrorSyncMode;
71
110
  auto_commit: boolean;
72
111
  auto_push: boolean;
73
112
  commit_template: string;
74
- interval_seconds: number;
113
+ safety_net_seconds: number;
75
114
  }
76
115
 
77
116
  /**
@@ -85,11 +124,11 @@ export function defaultMirrorConfig(): MirrorConfig {
85
124
  enabled: false,
86
125
  location: "internal",
87
126
  external_path: null,
88
- watch: false,
127
+ sync_mode: "events",
89
128
  auto_commit: true,
90
129
  auto_push: false,
91
130
  commit_template: DEFAULT_COMMIT_TEMPLATE,
92
- interval_seconds: 5,
131
+ safety_net_seconds: DEFAULT_SAFETY_NET_SECONDS,
93
132
  };
94
133
  }
95
134
 
@@ -129,6 +168,13 @@ export function parseMirrorConfig(yaml: string): MirrorConfig | undefined {
129
168
  const lines = yaml.slice(startIdx).split("\n");
130
169
 
131
170
  const config = defaultMirrorConfig();
171
+ // Track legacy field state for migration: if `sync_mode` is absent and
172
+ // `watch` is set, derive sync_mode from watch. If `safety_net_seconds`
173
+ // is absent and `interval_seconds` is set (legacy field), repurpose it.
174
+ let sawSyncMode = false;
175
+ let sawSafetyNet = false;
176
+ let legacyWatch: boolean | null = null;
177
+ let legacyInterval: number | null = null;
132
178
 
133
179
  for (const line of lines) {
134
180
  // Stop at the next top-level key.
@@ -140,7 +186,7 @@ export function parseMirrorConfig(yaml: string): MirrorConfig | undefined {
140
186
  const boolField = (
141
187
  name: keyof Pick<
142
188
  MirrorConfig,
143
- "enabled" | "watch" | "auto_commit" | "auto_push"
189
+ "enabled" | "auto_commit" | "auto_push"
144
190
  >,
145
191
  ): boolean => {
146
192
  const m = trimmed.match(new RegExp(`^${name}:\\s*(true|false)\\s*$`));
@@ -151,10 +197,22 @@ export function parseMirrorConfig(yaml: string): MirrorConfig | undefined {
151
197
  return false;
152
198
  };
153
199
  if (boolField("enabled")) continue;
154
- if (boolField("watch")) continue;
155
200
  if (boolField("auto_commit")) continue;
156
201
  if (boolField("auto_push")) continue;
157
202
 
203
+ const watchMatch = trimmed.match(/^watch:\s*(true|false)\s*$/);
204
+ if (watchMatch) {
205
+ legacyWatch = watchMatch[1] === "true";
206
+ continue;
207
+ }
208
+
209
+ const syncModeMatch = trimmed.match(/^sync_mode:\s*(events|manual)\s*$/);
210
+ if (syncModeMatch) {
211
+ config.sync_mode = syncModeMatch[1] as MirrorSyncMode;
212
+ sawSyncMode = true;
213
+ continue;
214
+ }
215
+
158
216
  const locationMatch = trimmed.match(/^location:\s*(internal|external)\s*$/);
159
217
  if (locationMatch) {
160
218
  config.location = locationMatch[1] as MirrorLocation;
@@ -181,17 +239,47 @@ export function parseMirrorConfig(yaml: string): MirrorConfig | undefined {
181
239
  continue;
182
240
  }
183
241
 
242
+ const safetyNetMatch = trimmed.match(/^safety_net_seconds:\s*(\d+)\s*$/);
243
+ if (safetyNetMatch) {
244
+ const n = parseInt(safetyNetMatch[1]!, 10);
245
+ if (Number.isFinite(n) && n > 0) {
246
+ config.safety_net_seconds = clampSafetyNet(n);
247
+ sawSafetyNet = true;
248
+ }
249
+ continue;
250
+ }
251
+
184
252
  const intervalMatch = trimmed.match(/^interval_seconds:\s*(\d+)\s*$/);
185
253
  if (intervalMatch) {
186
254
  const n = parseInt(intervalMatch[1]!, 10);
187
- if (Number.isFinite(n) && n > 0) config.interval_seconds = n;
255
+ if (Number.isFinite(n) && n > 0) legacyInterval = n;
188
256
  continue;
189
257
  }
190
258
  }
191
259
 
260
+ // Migration: explicit `sync_mode` wins; otherwise derive from `watch`.
261
+ // A config that carries neither falls through to defaults (events).
262
+ if (!sawSyncMode && legacyWatch !== null) {
263
+ config.sync_mode = legacyWatch ? "events" : "manual";
264
+ }
265
+ // Migration: explicit `safety_net_seconds` wins; otherwise upgrade
266
+ // `interval_seconds` (legacy short-cadence values like 5 get clamped
267
+ // up to MIN_SAFETY_NET_SECONDS — the safety-net path doesn't want
268
+ // 5-second polls). Out-of-range values fall through to the default.
269
+ if (!sawSafetyNet && legacyInterval !== null) {
270
+ config.safety_net_seconds = clampSafetyNet(legacyInterval);
271
+ }
272
+
192
273
  return config;
193
274
  }
194
275
 
276
+ /** Clamp safety_net_seconds to the valid range. */
277
+ function clampSafetyNet(n: number): number {
278
+ if (n < MIN_SAFETY_NET_SECONDS) return MIN_SAFETY_NET_SECONDS;
279
+ if (n > MAX_SAFETY_NET_SECONDS) return MAX_SAFETY_NET_SECONDS;
280
+ return n;
281
+ }
282
+
195
283
  /**
196
284
  * Serialize a MirrorConfig as YAML lines suitable for appending under the
197
285
  * top-level keys of `config.yaml`. Returns the lines without a trailing
@@ -215,12 +303,12 @@ export function serializeMirrorConfig(config: MirrorConfig): string[] {
215
303
  ` external_path: ${needsQuote ? `"${config.external_path}"` : config.external_path}`,
216
304
  );
217
305
  }
218
- lines.push(` watch: ${config.watch}`);
306
+ lines.push(` sync_mode: ${config.sync_mode}`);
219
307
  lines.push(` auto_commit: ${config.auto_commit}`);
220
308
  lines.push(` auto_push: ${config.auto_push}`);
221
309
  // Templates contain `{{ }}` and frequently `:` — always quote.
222
310
  lines.push(` commit_template: "${config.commit_template.replace(/"/g, '\\"')}"`);
223
- lines.push(` interval_seconds: ${config.interval_seconds}`);
311
+ lines.push(` safety_net_seconds: ${config.safety_net_seconds}`);
224
312
  return lines;
225
313
  }
226
314
 
@@ -285,12 +373,21 @@ export type ShapeValidation = ShapeValidationOk | ShapeValidationError;
285
373
  * `defaultMirrorConfig()`; rejects values that don't conform to the
286
374
  * declared types.
287
375
  *
288
- * Does NOT touch the filesystem operators get a fast 400 on shape
289
- * errors before vault attempts any filesystem work. Filesystem-level
290
- * validation (path exists, is a git repo) lives in `validateExternalPath`.
376
+ * Mostly pure: the one filesystem touch is reading
377
+ * `.mirror-credentials.yaml` to decide whether `auto_push: true +
378
+ * location: internal` is acceptable (credentials carry a remote URL, so
379
+ * "internal mirrors have no remote" no longer holds). The credentials
380
+ * read is injectable via `opts.readCredentials` so tests stay hermetic;
381
+ * production callers omit it and pick up the canonical `readCredentials`
382
+ * from `./mirror-credentials.ts`.
383
+ *
384
+ * Filesystem-level validation of `external_path` (exists, is a git repo)
385
+ * still lives in `validateExternalPath` — that's I/O and stays out of
386
+ * this function.
291
387
  */
292
388
  export function validateMirrorConfigShape(
293
389
  input: unknown,
390
+ opts: { readCredentials?: () => MirrorCredentials | null } = {},
294
391
  ): ShapeValidation {
295
392
  if (input === null || typeof input !== "object") {
296
393
  return {
@@ -334,11 +431,24 @@ export function validateMirrorConfigShape(
334
431
  }
335
432
  }
336
433
 
434
+ // Legacy back-compat: `watch: boolean` translates to sync_mode.
435
+ // `sync_mode` takes priority if both are present.
337
436
  if ("watch" in blob) {
338
437
  if (typeof blob.watch !== "boolean") {
339
- return { ok: false, field: "watch", error: "`watch` must be boolean." };
438
+ return { ok: false, error: "`watch` must be boolean." };
439
+ }
440
+ out.sync_mode = blob.watch ? "events" : "manual";
441
+ }
442
+
443
+ if ("sync_mode" in blob) {
444
+ if (blob.sync_mode !== "events" && blob.sync_mode !== "manual") {
445
+ return {
446
+ ok: false,
447
+ field: "sync_mode",
448
+ error: '`sync_mode` must be "events" or "manual".',
449
+ };
340
450
  }
341
- out.watch = blob.watch;
451
+ out.sync_mode = blob.sync_mode;
342
452
  }
343
453
 
344
454
  if ("auto_commit" in blob) {
@@ -382,7 +492,33 @@ export function validateMirrorConfigShape(
382
492
  out.commit_template = blob.commit_template;
383
493
  }
384
494
 
385
- if ("interval_seconds" in blob) {
495
+ if ("safety_net_seconds" in blob) {
496
+ if (
497
+ typeof blob.safety_net_seconds !== "number" ||
498
+ !Number.isFinite(blob.safety_net_seconds) ||
499
+ blob.safety_net_seconds <= 0 ||
500
+ !Number.isInteger(blob.safety_net_seconds)
501
+ ) {
502
+ return {
503
+ ok: false,
504
+ field: "safety_net_seconds",
505
+ error: "`safety_net_seconds` must be a positive integer.",
506
+ };
507
+ }
508
+ if (
509
+ blob.safety_net_seconds < MIN_SAFETY_NET_SECONDS ||
510
+ blob.safety_net_seconds > MAX_SAFETY_NET_SECONDS
511
+ ) {
512
+ return {
513
+ ok: false,
514
+ field: "safety_net_seconds",
515
+ error: `\`safety_net_seconds\` must be between ${MIN_SAFETY_NET_SECONDS} and ${MAX_SAFETY_NET_SECONDS}.`,
516
+ };
517
+ }
518
+ out.safety_net_seconds = blob.safety_net_seconds;
519
+ } else if ("interval_seconds" in blob) {
520
+ // Legacy field still accepted for hand-edited configs. Migrate by
521
+ // clamping into the safety-net range.
386
522
  if (
387
523
  typeof blob.interval_seconds !== "number" ||
388
524
  !Number.isFinite(blob.interval_seconds) ||
@@ -391,11 +527,15 @@ export function validateMirrorConfigShape(
391
527
  ) {
392
528
  return {
393
529
  ok: false,
394
- field: "interval_seconds",
395
530
  error: "`interval_seconds` must be a positive integer.",
396
531
  };
397
532
  }
398
- out.interval_seconds = blob.interval_seconds;
533
+ const clamped = blob.interval_seconds < MIN_SAFETY_NET_SECONDS
534
+ ? MIN_SAFETY_NET_SECONDS
535
+ : blob.interval_seconds > MAX_SAFETY_NET_SECONDS
536
+ ? MAX_SAFETY_NET_SECONDS
537
+ : blob.interval_seconds;
538
+ out.safety_net_seconds = clamped;
399
539
  }
400
540
 
401
541
  // Cross-field rule: external requires external_path — but ONLY when
@@ -414,6 +554,62 @@ export function validateMirrorConfigShape(
414
554
  };
415
555
  }
416
556
 
557
+ // Cross-field rule: auto_push + internal location was historically
558
+ // rejected outright — the assumption being "internal mirrors live under
559
+ // vault's data dir with no configured remote, so push would always
560
+ // fail." That assumption no longer holds once credentials are wired:
561
+ // the credential save path (handleAuthPat / handleAuthGithubSelectRepo)
562
+ // sets `origin` on the internal repo to the embedded-credential URL,
563
+ // so `git push` to GitHub/GitLab/etc. is meaningful even when the
564
+ // working tree lives under `~/.parachute/vault/data/<name>/mirror/`.
565
+ //
566
+ // New rule: auto_push + internal is rejected ONLY when no credentials
567
+ // are configured. If credentials ARE wired (PAT or GitHub OAuth),
568
+ // accept the combination — vault has a remote to push to. Operators
569
+ // hitting Aaron's three-stacking-gaps bug (History preset + PAT saved,
570
+ // pushes never fire) get unblocked.
571
+ //
572
+ // Asymmetry note (reviewer-flagged on vault#392): external + auto_push +
573
+ // no vault-stored credentials is INTENTIONALLY not rejected here.
574
+ // External mirrors are operator-managed paths — operators may have
575
+ // configured push credentials via system-level git config (SSH agent,
576
+ // ~/.git-credentials, GH_TOKEN env, `gh auth login`), none of which
577
+ // vault can detect by reading `.mirror-credentials.yaml`. Rejecting on
578
+ // "vault doesn't see credentials" would refuse legitimate
579
+ // operator-managed setups. Push failures on missing credentials surface
580
+ // via the non-fatal warning path in gitPush — operators see them in
581
+ // `last_push_error` rather than being blocked at save time. Internal
582
+ // location is different: internal mirrors live under vault's data
583
+ // dir, so vault IS the only thing that can wire a remote, which is
584
+ // why the gate below requires vault-stored credentials specifically.
585
+ if (out.enabled && out.auto_push && out.location === "internal") {
586
+ const readCreds = opts.readCredentials ?? readCredentials;
587
+ let creds: MirrorCredentials | null = null;
588
+ try {
589
+ creds = readCreds();
590
+ } catch {
591
+ // If the credentials file is unreadable we conservatively fail
592
+ // closed — same outcome as "no credentials." Better to surface the
593
+ // actionable "configure credentials first" error than to accept a
594
+ // config that won't actually push.
595
+ creds = null;
596
+ }
597
+ const hasCredentials = !!(
598
+ creds &&
599
+ creds.active_method &&
600
+ ((creds.active_method === "pat" && creds.pat) ||
601
+ (creds.active_method === "github_oauth" && creds.github_oauth))
602
+ );
603
+ if (!hasCredentials) {
604
+ return {
605
+ ok: false,
606
+ field: "auto_push",
607
+ error:
608
+ "Configure git credentials before enabling auto-push on an internal mirror — vault has no remote to push to without them. Connect GitHub or paste a Personal Access Token in the Git remote section, or set auto_push to false.",
609
+ };
610
+ }
611
+ }
612
+
417
613
  return { ok: true, config: out };
418
614
  }
419
615