@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
@@ -0,0 +1,519 @@
1
+ /**
2
+ * vault#400 — per-vault mirror config + manager + routes.
3
+ *
4
+ * The bug this file pins: before vault#400, the vault admin SPA showed the
5
+ * SAME git remote (the default vault's) on EVERY vault's mirror page, because
6
+ * the server held a SINGLE MirrorManager bound to the default/first vault and
7
+ * every mirror route resolved to it — ignoring the URL's vault name. Config
8
+ * was a single server-wide `mirror:` block, so configuring vault B clobbered
9
+ * vault A. vault#399 fixed credential STORAGE per-vault; this completes the
10
+ * job with per-vault CONFIG + per-vault MANAGER + routes that resolve by URL
11
+ * vault name.
12
+ *
13
+ * These tests exercise the REAL production seams end-to-end — real per-vault
14
+ * config files, the real registry, the real route handlers, real per-vault
15
+ * SQLite stores via `buildMirrorDeps` — against a fresh PARACHUTE_HOME. Two
16
+ * vaults (A, B) with DIFFERENT configs prove no bleed + no clobber.
17
+ */
18
+
19
+ import { describe, test, expect, afterEach, afterAll } from "bun:test";
20
+ import fs from "node:fs";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+
24
+ import {
25
+ defaultMirrorConfig,
26
+ mirrorConfigPath,
27
+ readMirrorConfigForVault,
28
+ writeMirrorConfigForVault,
29
+ migrateLegacyServerWideConfig,
30
+ type MirrorConfig,
31
+ } from "./mirror-config.ts";
32
+ import {
33
+ clearMirrorManagers,
34
+ getMirrorManager,
35
+ setMirrorManager,
36
+ setMirrorManagerFactory,
37
+ } from "./mirror-registry.ts";
38
+ import { MirrorManager } from "./mirror-manager.ts";
39
+ import { buildMirrorDeps } from "./mirror-deps.ts";
40
+ import {
41
+ handleMirrorGet,
42
+ handleMirrorPut,
43
+ handleAuthGet,
44
+ } from "./mirror-routes.ts";
45
+ import {
46
+ writeCredentials,
47
+ emptyCredentials,
48
+ mirrorCredentialsPath,
49
+ type MirrorCredentials,
50
+ } from "./mirror-credentials.ts";
51
+ import { writeVaultConfig } from "./config.ts";
52
+ import { clearVaultStoreCache } from "./vault-store.ts";
53
+
54
+ const ORIG_HOME = process.env.HOME;
55
+ const ORIG_PARACHUTE_HOME = process.env.PARACHUTE_HOME;
56
+
57
+ afterEach(() => {
58
+ clearMirrorManagers();
59
+ // `getVaultStore` caches SqliteStore handles in a process-wide Map keyed by
60
+ // vault name. Tests reuse vault names ("alpha"/"beta") across cases in fresh
61
+ // tempdirs, so without clearing the cache a later case picks up a handle
62
+ // pointing at a DELETED tempdir → SQLite "disk I/O error" on export. Clear
63
+ // it every test (matches auth.test.ts / routing.test.ts / export-watch.test.ts).
64
+ clearVaultStoreCache();
65
+ if (ORIG_HOME === undefined) delete process.env.HOME;
66
+ else process.env.HOME = ORIG_HOME;
67
+ if (ORIG_PARACHUTE_HOME === undefined) delete process.env.PARACHUTE_HOME;
68
+ else process.env.PARACHUTE_HOME = ORIG_PARACHUTE_HOME;
69
+ });
70
+ afterAll(() => {
71
+ if (ORIG_HOME === undefined) delete process.env.HOME;
72
+ else process.env.HOME = ORIG_HOME;
73
+ });
74
+
75
+ function tmpHome(prefix: string): string {
76
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
77
+ process.env.PARACHUTE_HOME = home;
78
+ process.env.HOME = home;
79
+ return home;
80
+ }
81
+
82
+ /** Create a real vault on disk (data dir + vault.yaml + SQLite store). */
83
+ function makeVault(name: string): void {
84
+ fs.mkdirSync(path.join(process.env.PARACHUTE_HOME!, "vault", "data", name), {
85
+ recursive: true,
86
+ });
87
+ writeVaultConfig({
88
+ name,
89
+ api_keys: [],
90
+ created_at: new Date().toISOString(),
91
+ });
92
+ }
93
+
94
+ /** A real, registered, started manager for `name` via production deps. */
95
+ async function standUpVault(name: string): Promise<MirrorManager> {
96
+ const mgr = new MirrorManager(buildMirrorDeps(name));
97
+ setMirrorManager(name, mgr);
98
+ await mgr.start();
99
+ return mgr;
100
+ }
101
+
102
+ function patCredentials(remoteUrl: string): MirrorCredentials {
103
+ return {
104
+ ...emptyCredentials(),
105
+ active_method: "pat",
106
+ pat: { token: "ghp_faketoken1234567890", remote_url: remoteUrl, label: "test" },
107
+ };
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Per-vault config storage (vault#400)
112
+ // ---------------------------------------------------------------------------
113
+
114
+ describe("per-vault config storage", () => {
115
+ let home: string;
116
+ afterEach(() => {
117
+ if (home) fs.rmSync(home, { recursive: true, force: true });
118
+ });
119
+
120
+ test("writes to data/<vault>/mirror-config.yaml; reads back per-vault", () => {
121
+ home = tmpHome("pv-config-");
122
+ makeVault("alpha");
123
+ makeVault("beta");
124
+
125
+ const cfgA: MirrorConfig = {
126
+ ...defaultMirrorConfig(),
127
+ enabled: true,
128
+ location: "external",
129
+ external_path: "/tmp/alpha-mirror",
130
+ auto_commit: false,
131
+ };
132
+ writeMirrorConfigForVault("alpha", cfgA);
133
+
134
+ // File landed in alpha's data dir, not beta's, not server-wide.
135
+ expect(fs.existsSync(mirrorConfigPath("alpha"))).toBe(true);
136
+ expect(fs.existsSync(mirrorConfigPath("beta"))).toBe(false);
137
+ expect(mirrorConfigPath("alpha")).toContain(path.join("data", "alpha"));
138
+
139
+ // Read back is alpha's; beta is undefined (never configured).
140
+ const back = readMirrorConfigForVault("alpha");
141
+ expect(back?.enabled).toBe(true);
142
+ expect(back?.external_path).toBe("/tmp/alpha-mirror");
143
+ expect(back?.auto_commit).toBe(false);
144
+ expect(readMirrorConfigForVault("beta")).toBeUndefined();
145
+ });
146
+
147
+ test("writing beta does NOT mutate alpha's config file (no clobber)", () => {
148
+ home = tmpHome("pv-noclobber-");
149
+ makeVault("alpha");
150
+ makeVault("beta");
151
+
152
+ writeMirrorConfigForVault("alpha", {
153
+ ...defaultMirrorConfig(),
154
+ enabled: true,
155
+ external_path: "/tmp/alpha",
156
+ });
157
+ const alphaBefore = fs.readFileSync(mirrorConfigPath("alpha"), "utf8");
158
+
159
+ writeMirrorConfigForVault("beta", {
160
+ ...defaultMirrorConfig(),
161
+ enabled: true,
162
+ location: "external",
163
+ external_path: "/tmp/beta",
164
+ });
165
+
166
+ const alphaAfter = fs.readFileSync(mirrorConfigPath("alpha"), "utf8");
167
+ expect(alphaAfter).toBe(alphaBefore); // byte-identical — untouched
168
+ expect(readMirrorConfigForVault("beta")?.external_path).toBe("/tmp/beta");
169
+ });
170
+ });
171
+
172
+ // ---------------------------------------------------------------------------
173
+ // Per-vault manager registry (vault#400)
174
+ // ---------------------------------------------------------------------------
175
+
176
+ describe("per-vault manager registry", () => {
177
+ let home: string;
178
+ afterEach(() => {
179
+ if (home) fs.rmSync(home, { recursive: true, force: true });
180
+ });
181
+
182
+ test("get/set keyed by vault; distinct instances", () => {
183
+ home = tmpHome("pv-registry-");
184
+ makeVault("alpha");
185
+ makeVault("beta");
186
+ const a = new MirrorManager(buildMirrorDeps("alpha"));
187
+ const b = new MirrorManager(buildMirrorDeps("beta"));
188
+ setMirrorManager("alpha", a);
189
+ setMirrorManager("beta", b);
190
+ expect(getMirrorManager("alpha")).toBe(a);
191
+ expect(getMirrorManager("beta")).toBe(b);
192
+ expect(getMirrorManager("alpha")).not.toBe(getMirrorManager("beta"));
193
+ expect(getMirrorManager("alpha")!.getVaultName()).toBe("alpha");
194
+ expect(getMirrorManager("beta")!.getVaultName()).toBe("beta");
195
+ });
196
+
197
+ test("no factory + no entry → null; factory builds on demand + binds vault", () => {
198
+ home = tmpHome("pv-lazy-");
199
+ makeVault("gamma");
200
+ expect(getMirrorManager("gamma")).toBeNull(); // no factory installed yet
201
+
202
+ setMirrorManagerFactory((name) => new MirrorManager(buildMirrorDeps(name)));
203
+ const built = getMirrorManager("gamma");
204
+ expect(built).not.toBeNull();
205
+ expect(built!.getVaultName()).toBe("gamma");
206
+ // Cached — second get returns the same lazily-built instance.
207
+ expect(getMirrorManager("gamma")).toBe(built);
208
+ });
209
+
210
+ test("lazily-built manager reports THIS vault's persisted config (getEffectiveConfig)", () => {
211
+ home = tmpHome("pv-effective-");
212
+ makeVault("delta");
213
+ // delta has enabled config on disk but no live (started) manager.
214
+ writeMirrorConfigForVault("delta", {
215
+ ...defaultMirrorConfig(),
216
+ enabled: true,
217
+ location: "external",
218
+ external_path: "/tmp/delta",
219
+ });
220
+ setMirrorManagerFactory((name) => new MirrorManager(buildMirrorDeps(name)));
221
+ const mgr = getMirrorManager("delta")!;
222
+ // Never started, so live getConfig() is still the default (disabled) —
223
+ // but getEffectiveConfig() reads the persisted truth.
224
+ expect(mgr.getConfig().enabled).toBe(false);
225
+ expect(mgr.getEffectiveConfig().enabled).toBe(true);
226
+ expect(mgr.getEffectiveConfig().external_path).toBe("/tmp/delta");
227
+ });
228
+ });
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // THE BUG: routes resolve per-vault — GET A returns A's, GET B returns B's
232
+ // ---------------------------------------------------------------------------
233
+
234
+ describe("mirror routes resolve per-vault (the 'same remote on every page' bug)", () => {
235
+ let home: string;
236
+ afterEach(() => {
237
+ if (home) fs.rmSync(home, { recursive: true, force: true });
238
+ });
239
+
240
+ test("GET A returns A's config; GET B returns B's — never A's on B's page", async () => {
241
+ home = tmpHome("pv-get-distinct-");
242
+ makeVault("alpha");
243
+ makeVault("beta");
244
+
245
+ // Two DIFFERENT configs persisted per-vault.
246
+ writeMirrorConfigForVault("alpha", {
247
+ ...defaultMirrorConfig(),
248
+ enabled: true,
249
+ location: "external",
250
+ external_path: "/tmp/alpha-mirror",
251
+ auto_push: false,
252
+ });
253
+ writeMirrorConfigForVault("beta", {
254
+ ...defaultMirrorConfig(),
255
+ enabled: true,
256
+ location: "external",
257
+ external_path: "/tmp/beta-mirror",
258
+ auto_push: false,
259
+ });
260
+
261
+ setMirrorManagerFactory((name) => new MirrorManager(buildMirrorDeps(name)));
262
+
263
+ // Resolve EACH vault's manager via the registry exactly as routing.ts does.
264
+ const resA = handleMirrorGet(getMirrorManager("alpha")!);
265
+ const resB = handleMirrorGet(getMirrorManager("beta")!);
266
+ const bodyA = (await resA.json()) as { config: MirrorConfig };
267
+ const bodyB = (await resB.json()) as { config: MirrorConfig };
268
+
269
+ expect(bodyA.config.external_path).toBe("/tmp/alpha-mirror");
270
+ expect(bodyB.config.external_path).toBe("/tmp/beta-mirror");
271
+ // The exact symptom: B's page must NOT show A's remote.
272
+ expect(bodyB.config.external_path).not.toBe(bodyA.config.external_path);
273
+ });
274
+
275
+ test("GET on an unconfigured vault returns 'not configured' (disabled), not the default vault's", async () => {
276
+ home = tmpHome("pv-get-unconfigured-");
277
+ makeVault("alpha"); // default/first — configured + enabled
278
+ makeVault("beta"); // never configured
279
+
280
+ writeMirrorConfigForVault("alpha", {
281
+ ...defaultMirrorConfig(),
282
+ enabled: true,
283
+ location: "external",
284
+ external_path: "/tmp/alpha-mirror",
285
+ });
286
+ setMirrorManagerFactory((name) => new MirrorManager(buildMirrorDeps(name)));
287
+
288
+ const resB = handleMirrorGet(getMirrorManager("beta")!);
289
+ const bodyB = (await resB.json()) as { config: MirrorConfig; status: { enabled: boolean } };
290
+ // beta is disabled + has no external_path — NOT alpha's "/tmp/alpha-mirror".
291
+ expect(bodyB.config.enabled).toBe(false);
292
+ expect(bodyB.config.external_path).toBeNull();
293
+ expect(bodyB.status.enabled).toBe(false);
294
+ });
295
+
296
+ test("PUT B does not mutate A's persisted config (no clobber via route)", async () => {
297
+ home = tmpHome("pv-put-noclobber-");
298
+ makeVault("alpha");
299
+ makeVault("beta");
300
+
301
+ // A configured + persisted first.
302
+ writeMirrorConfigForVault("alpha", {
303
+ ...defaultMirrorConfig(),
304
+ enabled: true,
305
+ location: "internal",
306
+ auto_commit: false,
307
+ sync_mode: "manual",
308
+ });
309
+ const alphaBefore = readMirrorConfigForVault("alpha")!;
310
+
311
+ setMirrorManagerFactory((name) => new MirrorManager(buildMirrorDeps(name)));
312
+
313
+ // PUT B's config via the real handler (resolved per-vault).
314
+ const req = new Request("http://x/vault/beta/.parachute/mirror", {
315
+ method: "PUT",
316
+ body: JSON.stringify({
317
+ enabled: true,
318
+ location: "internal",
319
+ sync_mode: "manual",
320
+ auto_commit: true, // DIFFERENT from A
321
+ }),
322
+ });
323
+ const res = await handleMirrorPut(req, getMirrorManager("beta")!);
324
+ expect(res.status).toBe(200);
325
+
326
+ // A's persisted config is unchanged.
327
+ const alphaAfter = readMirrorConfigForVault("alpha")!;
328
+ expect(alphaAfter).toEqual(alphaBefore);
329
+ expect(alphaAfter.auto_commit).toBe(false); // A still false
330
+ // B got its own config.
331
+ expect(readMirrorConfigForVault("beta")!.auto_commit).toBe(true);
332
+
333
+ await getMirrorManager("beta")!.stop();
334
+ await getMirrorManager("alpha")!.stop();
335
+ });
336
+ });
337
+
338
+ // ---------------------------------------------------------------------------
339
+ // Per-vault credentials surface (vault#399 storage, vault#400 routing)
340
+ // ---------------------------------------------------------------------------
341
+
342
+ describe("getMirrorAuth resolves per-vault (never bleeds A's creds onto B)", () => {
343
+ let home: string;
344
+ afterEach(() => {
345
+ if (home) fs.rmSync(home, { recursive: true, force: true });
346
+ });
347
+
348
+ test("auth GET returns B's remote, or none — never A's", async () => {
349
+ home = tmpHome("pv-auth-");
350
+ makeVault("alpha");
351
+ makeVault("beta");
352
+
353
+ // A has a PAT pointing at A's repo. B has its OWN PAT at B's repo.
354
+ writeCredentials("alpha", patCredentials("https://x-access-token:tok@github.com/me/alpha.git"));
355
+ writeCredentials("beta", patCredentials("https://x-access-token:tok@github.com/me/beta.git"));
356
+
357
+ // Files are per-vault.
358
+ expect(mirrorCredentialsPath("alpha")).toContain(path.join("data", "alpha"));
359
+ expect(mirrorCredentialsPath("beta")).toContain(path.join("data", "beta"));
360
+
361
+ const a = new MirrorManager(buildMirrorDeps("alpha"));
362
+ const b = new MirrorManager(buildMirrorDeps("beta"));
363
+ setMirrorManager("alpha", a);
364
+ setMirrorManager("beta", b);
365
+
366
+ const bodyA = (await handleAuthGet(a).json()) as { pat: { remote_url: string } | null };
367
+ const bodyB = (await handleAuthGet(b).json()) as { pat: { remote_url: string } | null };
368
+ // Redacted, but host/path survive sanitization — assert the repo differs.
369
+ expect(bodyA.pat?.remote_url).toContain("alpha.git");
370
+ expect(bodyB.pat?.remote_url).toContain("beta.git");
371
+ expect(bodyB.pat?.remote_url).not.toContain("alpha.git");
372
+ });
373
+
374
+ test("B with no credentials returns none even when A has some", async () => {
375
+ home = tmpHome("pv-auth-none-");
376
+ makeVault("alpha");
377
+ makeVault("beta");
378
+ writeCredentials("alpha", patCredentials("https://x-access-token:tok@github.com/me/alpha.git"));
379
+ // beta: no credentials file written.
380
+
381
+ const b = new MirrorManager(buildMirrorDeps("beta"));
382
+ const bodyB = (await handleAuthGet(b).json()) as { active_method: string | null; pat: unknown };
383
+ expect(bodyB.active_method).toBeNull();
384
+ expect(bodyB.pat).toBeNull();
385
+ });
386
+ });
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // Both vaults enabled simultaneously — boot stands up both managers
390
+ // ---------------------------------------------------------------------------
391
+
392
+ describe("both vaults enabled simultaneously", () => {
393
+ let home: string;
394
+ afterEach(() => {
395
+ if (home) fs.rmSync(home, { recursive: true, force: true });
396
+ });
397
+
398
+ test("two internal mirrors both bootstrap + run with independent paths", async () => {
399
+ home = tmpHome("pv-both-enabled-");
400
+ makeVault("alpha");
401
+ makeVault("beta");
402
+
403
+ writeMirrorConfigForVault("alpha", {
404
+ ...defaultMirrorConfig(),
405
+ enabled: true,
406
+ location: "internal",
407
+ sync_mode: "manual",
408
+ auto_commit: false,
409
+ });
410
+ writeMirrorConfigForVault("beta", {
411
+ ...defaultMirrorConfig(),
412
+ enabled: true,
413
+ location: "internal",
414
+ sync_mode: "manual",
415
+ auto_commit: false,
416
+ });
417
+
418
+ const a = await standUpVault("alpha");
419
+ const b = await standUpVault("beta");
420
+
421
+ const statusA = a.getStatus();
422
+ const statusB = b.getStatus();
423
+ expect(statusA.enabled).toBe(true);
424
+ expect(statusB.enabled).toBe(true);
425
+ // Independent on-disk mirror paths under each vault's own data dir.
426
+ expect(statusA.mirror_path).toContain(path.join("data", "alpha", "mirror"));
427
+ expect(statusB.mirror_path).toContain(path.join("data", "beta", "mirror"));
428
+ expect(statusA.mirror_path).not.toBe(statusB.mirror_path);
429
+ // Both are real git repos.
430
+ expect(fs.existsSync(path.join(statusA.mirror_path!, ".git"))).toBe(true);
431
+ expect(fs.existsSync(path.join(statusB.mirror_path!, ".git"))).toBe(true);
432
+
433
+ await a.stop();
434
+ await b.stop();
435
+ });
436
+ });
437
+
438
+ // ---------------------------------------------------------------------------
439
+ // Config migration — legacy server-wide `mirror:` block → owning vault
440
+ // ---------------------------------------------------------------------------
441
+
442
+ describe("legacy server-wide config migration (vault#400)", () => {
443
+ let home: string;
444
+ afterEach(() => {
445
+ if (home) fs.rmSync(home, { recursive: true, force: true });
446
+ });
447
+
448
+ test("migrates the legacy block to the owning vault; others unaffected", () => {
449
+ home = tmpHome("pv-migrate-");
450
+ makeVault("first"); // owning vault (default/first)
451
+ makeVault("second");
452
+
453
+ const legacy: MirrorConfig = {
454
+ ...defaultMirrorConfig(),
455
+ enabled: true,
456
+ location: "external",
457
+ external_path: "/tmp/legacy-mirror",
458
+ auto_push: true,
459
+ };
460
+ let commented = false;
461
+ const result = migrateLegacyServerWideConfig(legacy, "first", () => {
462
+ commented = true;
463
+ });
464
+
465
+ expect(result.migrated).toBe(true);
466
+ if (result.migrated) expect(result.targetVaultName).toBe("first");
467
+ expect(commented).toBe(true);
468
+
469
+ // first got the legacy config; second got nothing.
470
+ const firstCfg = readMirrorConfigForVault("first");
471
+ expect(firstCfg?.enabled).toBe(true);
472
+ expect(firstCfg?.external_path).toBe("/tmp/legacy-mirror");
473
+ expect(firstCfg?.auto_push).toBe(true);
474
+ expect(readMirrorConfigForVault("second")).toBeUndefined();
475
+ });
476
+
477
+ test("idempotent — second run is a no-op when target already configured", () => {
478
+ home = tmpHome("pv-migrate-idem-");
479
+ makeVault("first");
480
+
481
+ const legacy: MirrorConfig = {
482
+ ...defaultMirrorConfig(),
483
+ enabled: true,
484
+ external_path: "/tmp/legacy",
485
+ location: "external",
486
+ };
487
+ const first = migrateLegacyServerWideConfig(legacy, "first", () => {});
488
+ expect(first.migrated).toBe(true);
489
+ const afterFirst = readMirrorConfigForVault("first");
490
+
491
+ // Operator then edits first's config; a second migration must NOT clobber.
492
+ writeMirrorConfigForVault("first", {
493
+ ...afterFirst!,
494
+ external_path: "/tmp/operator-edited",
495
+ });
496
+ const second = migrateLegacyServerWideConfig(legacy, "first", () => {});
497
+ expect(second.migrated).toBe(false);
498
+ if (!second.migrated) expect(second.reason).toBe("target_already_configured");
499
+ // Operator's edit survives.
500
+ expect(readMirrorConfigForVault("first")?.external_path).toBe("/tmp/operator-edited");
501
+ });
502
+
503
+ test("no legacy block → no-op", () => {
504
+ home = tmpHome("pv-migrate-noblock-");
505
+ makeVault("first");
506
+ const result = migrateLegacyServerWideConfig(undefined, "first", () => {});
507
+ expect(result.migrated).toBe(false);
508
+ if (!result.migrated) expect(result.reason).toBe("no_legacy_block");
509
+ expect(readMirrorConfigForVault("first")).toBeUndefined();
510
+ });
511
+
512
+ test("no target vault → no-op (leaves block for a future boot)", () => {
513
+ home = tmpHome("pv-migrate-notarget-");
514
+ const legacy: MirrorConfig = { ...defaultMirrorConfig(), enabled: true };
515
+ const result = migrateLegacyServerWideConfig(legacy, null, () => {});
516
+ expect(result.migrated).toBe(false);
517
+ if (!result.migrated) expect(result.reason).toBe("no_target_vault");
518
+ });
519
+ });
@@ -1,26 +1,103 @@
1
1
  /**
2
- * Process-singleton holder for the active MirrorManager.
2
+ * Per-vault holder for MirrorManager instances.
3
3
  *
4
4
  * Why a registry module: `server.ts` owns the lifecycle (constructs + starts
5
- * on boot, drains on shutdown), but `routing.ts` needs to hand the same
6
- * instance to the `/admin/mirror` HTTP handlers. A shared module with a
7
- * setter + getter is the lightest seam no top-level circular import,
8
- * no globals on `process`, no DI framework.
5
+ * each enabled vault's manager on boot, drains on shutdown), but `routing.ts`
6
+ * needs to hand the SAME instance to the `/.parachute/mirror[/*]` HTTP
7
+ * handlers for the vault named in the URL. A shared module with per-vault
8
+ * get/set is the lightest seam — no top-level circular import, no globals on
9
+ * `process`, no DI framework.
9
10
  *
10
- * Tests instantiate their own manager + call `setMirrorManager` to inject
11
- * it before exercising the route handlers.
11
+ * ## Why per-vault (vault#400)
12
+ *
13
+ * Before vault#400 this module held a SINGLE `activeManager` bound to the
14
+ * mirror-owning vault (`resolveMirrorVaultName` = default/first). Every
15
+ * mirror route — for EVERY vault in the URL — resolved to that one manager,
16
+ * so `GET /vault/gitcoin/.parachute/mirror` returned the DEFAULT vault's
17
+ * config + status + git remote. The admin SPA showed the same remote on
18
+ * every vault's mirror page. vault#399 fixed credential STORAGE to be
19
+ * per-vault; vault#400 completes the job: per-vault config + per-vault
20
+ * manager + routes that resolve by the URL vault name.
21
+ *
22
+ * Now the registry is a `Map<vaultName, MirrorManager>`. A vault that has
23
+ * config but no manager yet (runtime-enabled without a restart) is built
24
+ * lazily on first `getMirrorManager(vaultName)` via the installed factory.
25
+ *
26
+ * Tests instantiate their own manager + call `setMirrorManager(name, mgr)`
27
+ * to inject it before exercising the route handlers, OR install a fake
28
+ * factory via `setMirrorManagerFactory`. `clearMirrorManagers()` resets
29
+ * the map between tests.
12
30
  */
13
31
 
14
32
  import type { MirrorManager } from "./mirror-manager.ts";
15
33
 
16
- let activeManager: MirrorManager | null = null;
34
+ /**
35
+ * Factory that builds a fresh MirrorManager for a vault. Installed by
36
+ * production wiring (server.ts) so the registry can lazily stand up a
37
+ * manager for a vault that has config but no live instance yet — without
38
+ * the registry importing the heavy production deps (mirror-deps → config,
39
+ * vault-store, routes) at module load. Tests install a fake factory or
40
+ * skip lazy-build entirely by pre-seeding the map.
41
+ */
42
+ export type MirrorManagerFactory = (vaultName: string) => MirrorManager;
43
+
44
+ const managers = new Map<string, MirrorManager>();
45
+ let factory: MirrorManagerFactory | null = null;
46
+
47
+ /**
48
+ * Install (or replace) the lazy-build factory. server.ts wires this once at
49
+ * boot to `(name) => new MirrorManager(buildMirrorDeps(name))`. Until it's
50
+ * set, `getMirrorManager` won't lazily build — it returns whatever's already
51
+ * in the map (or null).
52
+ */
53
+ export function setMirrorManagerFactory(f: MirrorManagerFactory | null): void {
54
+ factory = f;
55
+ }
56
+
57
+ /** Install (or replace) the manager for a vault. Pass null to evict. */
58
+ export function setMirrorManager(
59
+ vaultName: string,
60
+ manager: MirrorManager | null,
61
+ ): void {
62
+ if (manager === null) {
63
+ managers.delete(vaultName);
64
+ } else {
65
+ managers.set(vaultName, manager);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Retrieve the manager for `vaultName`. If none is registered and a factory
71
+ * is installed, build one lazily, register it, and return it — so a vault
72
+ * whose mirror config was flipped on at runtime works without a restart.
73
+ *
74
+ * The lazily-built manager is NOT auto-started here (start() is async + does
75
+ * filesystem work); callers that need it running call `manager.start()`.
76
+ * Route handlers read `getConfig()`/`getStatus()` which reflect "disabled /
77
+ * not configured" until a start happens — exactly the right answer for a
78
+ * vault the operator hasn't enabled. The PUT handler's `reload()` is what
79
+ * stands it up when the operator enables it.
80
+ *
81
+ * Returns null only when no manager exists AND no factory is installed
82
+ * (e.g. tests that pre-seed the map deliberately, or a boot that hasn't
83
+ * wired the factory yet).
84
+ */
85
+ export function getMirrorManager(vaultName: string): MirrorManager | null {
86
+ const existing = managers.get(vaultName);
87
+ if (existing) return existing;
88
+ if (!factory) return null;
89
+ const built = factory(vaultName);
90
+ managers.set(vaultName, built);
91
+ return built;
92
+ }
17
93
 
18
- /** Install (or replace) the process-wide mirror manager. */
19
- export function setMirrorManager(manager: MirrorManager | null): void {
20
- activeManager = manager;
94
+ /** Snapshot of vault names with a live manager. Used by shutdown drain. */
95
+ export function listMirrorManagers(): MirrorManager[] {
96
+ return Array.from(managers.values());
21
97
  }
22
98
 
23
- /** Retrieve the current mirror manager. Null when boot hasn't wired one yet. */
24
- export function getMirrorManager(): MirrorManager | null {
25
- return activeManager;
99
+ /** Reset the registry (drops all managers + the factory). Test seam. */
100
+ export function clearMirrorManagers(): void {
101
+ managers.clear();
102
+ factory = null;
26
103
  }