@openparachute/vault 0.4.6 → 0.4.7-rc.2

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.
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Tests for `/admin/mirror` route handlers — GET/PUT shapes, validation
3
+ * gates, atomic persist + restart (vault-sync Phase A1).
4
+ *
5
+ * Auth gating happens upstream in `routing.ts`; these tests cover the
6
+ * after-auth handler logic.
7
+ */
8
+
9
+ import { describe, test, expect, beforeEach, afterEach, afterAll } from "bun:test";
10
+ import fs from "node:fs";
11
+ import os from "node:os";
12
+ import path from "node:path";
13
+
14
+ import { defaultMirrorConfig, type MirrorConfig } from "./mirror-config.ts";
15
+ import {
16
+ MirrorManager,
17
+ type MirrorDeps,
18
+ } from "./mirror-manager.ts";
19
+ import { handleMirrorGet, handleMirrorPut } from "./mirror-routes.ts";
20
+
21
+ // Same env-restore pattern as mirror-manager.test.ts — keeps HOME +
22
+ // PARACHUTE_HOME from leaking between test files.
23
+ const ORIG_HOME = process.env.HOME;
24
+ const ORIG_PARACHUTE_HOME = process.env.PARACHUTE_HOME;
25
+ afterEach(() => {
26
+ if (ORIG_HOME === undefined) delete process.env.HOME;
27
+ else process.env.HOME = ORIG_HOME;
28
+ if (ORIG_PARACHUTE_HOME === undefined) delete process.env.PARACHUTE_HOME;
29
+ else process.env.PARACHUTE_HOME = ORIG_PARACHUTE_HOME;
30
+ });
31
+ afterAll(() => {
32
+ if (ORIG_HOME === undefined) delete process.env.HOME;
33
+ else process.env.HOME = ORIG_HOME;
34
+ });
35
+
36
+ function tmp(prefix: string): string {
37
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
38
+ }
39
+
40
+ function initRepo(dir: string): void {
41
+ Bun.spawnSync(["git", "init", "-q", "-b", "main"], { cwd: dir });
42
+ Bun.spawnSync(["git", "config", "user.email", "t@p.computer"], { cwd: dir });
43
+ Bun.spawnSync(["git", "config", "user.name", "T P"], { cwd: dir });
44
+ Bun.spawnSync(["git", "config", "commit.gpgsign", "false"], { cwd: dir });
45
+ }
46
+
47
+ function makeManager(home: string): {
48
+ manager: MirrorManager;
49
+ deps: MirrorDeps & { storedConfig: MirrorConfig | undefined };
50
+ exportCalls: () => Array<{ outDir: string }>;
51
+ } {
52
+ process.env.PARACHUTE_HOME = home;
53
+ process.env.HOME = home;
54
+ fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
55
+ const state: {
56
+ config: MirrorConfig | undefined;
57
+ calls: Array<{ outDir: string }>;
58
+ } = { config: undefined, calls: [] };
59
+ const deps: MirrorDeps = {
60
+ vaultName: "default",
61
+ runExport: async ({ outDir }) => {
62
+ state.calls.push({ outDir });
63
+ return { notes: 0 };
64
+ },
65
+ firstChangedNoteTitle: async () => "",
66
+ readMirrorConfig: () => state.config,
67
+ writeMirrorConfig: (c) => {
68
+ state.config = c;
69
+ },
70
+ };
71
+ Object.defineProperty(deps, "storedConfig", {
72
+ get: () => state.config,
73
+ enumerable: true,
74
+ });
75
+ const manager = new MirrorManager(deps);
76
+ return {
77
+ manager,
78
+ deps: deps as MirrorDeps & { storedConfig: MirrorConfig | undefined },
79
+ exportCalls: () => state.calls,
80
+ };
81
+ }
82
+
83
+ // ---------------------------------------------------------------------------
84
+ // GET /admin/mirror
85
+ // ---------------------------------------------------------------------------
86
+
87
+ describe("handleMirrorGet", () => {
88
+ let home: string;
89
+ afterEach(() => {
90
+ if (home) fs.rmSync(home, { recursive: true, force: true });
91
+ });
92
+
93
+ test("returns defaults + status when no config has been written", async () => {
94
+ home = tmp("mirror-get-defaults-");
95
+ const { manager } = makeManager(home);
96
+ const res = handleMirrorGet(manager);
97
+ expect(res.status).toBe(200);
98
+ const body = (await res.json()) as { config: MirrorConfig; status: { enabled: boolean } };
99
+ expect(body.config).toEqual(defaultMirrorConfig());
100
+ expect(body.status.enabled).toBe(false);
101
+ });
102
+
103
+ test("after a successful start with enabled config, reports running status", async () => {
104
+ home = tmp("mirror-get-enabled-");
105
+ const { manager, deps } = makeManager(home);
106
+ deps.writeMirrorConfig({
107
+ ...defaultMirrorConfig(),
108
+ enabled: true,
109
+ location: "internal",
110
+ watch: false,
111
+ auto_commit: false,
112
+ });
113
+ await manager.start();
114
+ const res = handleMirrorGet(manager);
115
+ const body = (await res.json()) as {
116
+ config: MirrorConfig;
117
+ status: { enabled: boolean; mirror_path: string | null };
118
+ };
119
+ expect(body.config.enabled).toBe(true);
120
+ expect(body.status.enabled).toBe(true);
121
+ expect(body.status.mirror_path).toContain("mirror");
122
+ await manager.stop();
123
+ });
124
+ });
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // PUT /admin/mirror
128
+ // ---------------------------------------------------------------------------
129
+
130
+ describe("handleMirrorPut", () => {
131
+ let home: string;
132
+ afterEach(() => {
133
+ if (home) fs.rmSync(home, { recursive: true, force: true });
134
+ });
135
+
136
+ test("rejects invalid JSON body with 400", async () => {
137
+ home = tmp("mirror-put-badjson-");
138
+ const { manager } = makeManager(home);
139
+ const req = new Request("http://x/admin/mirror", {
140
+ method: "PUT",
141
+ body: "{not-json",
142
+ headers: { "Content-Type": "application/json" },
143
+ });
144
+ const res = await handleMirrorPut(req, manager);
145
+ expect(res.status).toBe(400);
146
+ const body = (await res.json()) as { error: string };
147
+ expect(body.error).toContain("Invalid JSON");
148
+ });
149
+
150
+ test("rejects shape errors with 400 + field localization", async () => {
151
+ home = tmp("mirror-put-shape-");
152
+ const { manager } = makeManager(home);
153
+ const req = new Request("http://x/admin/mirror", {
154
+ method: "PUT",
155
+ body: JSON.stringify({ location: "moon" }),
156
+ });
157
+ const res = await handleMirrorPut(req, manager);
158
+ expect(res.status).toBe(400);
159
+ const body = (await res.json()) as { field: string; message: string };
160
+ expect(body.field).toBe("location");
161
+ expect(body.message).toContain("location");
162
+ });
163
+
164
+ test("rejects external + missing external_path with 400", async () => {
165
+ home = tmp("mirror-put-noext-");
166
+ const { manager } = makeManager(home);
167
+ const req = new Request("http://x/admin/mirror", {
168
+ method: "PUT",
169
+ body: JSON.stringify({ enabled: true, location: "external" }),
170
+ });
171
+ const res = await handleMirrorPut(req, manager);
172
+ expect(res.status).toBe(400);
173
+ const body = (await res.json()) as { field: string };
174
+ expect(body.field).toBe("external_path");
175
+ });
176
+
177
+ test("rejects external + non-existent path with 400 + actionable error", async () => {
178
+ home = tmp("mirror-put-missing-");
179
+ const { manager } = makeManager(home);
180
+ const req = new Request("http://x/admin/mirror", {
181
+ method: "PUT",
182
+ body: JSON.stringify({
183
+ enabled: true,
184
+ location: "external",
185
+ external_path: "/definitely/not/here",
186
+ }),
187
+ });
188
+ const res = await handleMirrorPut(req, manager);
189
+ expect(res.status).toBe(400);
190
+ const body = (await res.json()) as { message: string };
191
+ expect(body.message).toContain("doesn't exist");
192
+ });
193
+
194
+ test("rejects external + non-git directory with 400", async () => {
195
+ home = tmp("mirror-put-nogit-");
196
+ const { manager } = makeManager(home);
197
+ const external = tmp("mirror-put-plain-");
198
+ try {
199
+ const req = new Request("http://x/admin/mirror", {
200
+ method: "PUT",
201
+ body: JSON.stringify({
202
+ enabled: true,
203
+ location: "external",
204
+ external_path: external,
205
+ }),
206
+ });
207
+ const res = await handleMirrorPut(req, manager);
208
+ expect(res.status).toBe(400);
209
+ const body = (await res.json()) as { message: string };
210
+ expect(body.message).toContain("isn't a git repository");
211
+ } finally {
212
+ fs.rmSync(external, { recursive: true, force: true });
213
+ }
214
+ });
215
+
216
+ test("accepts a valid external config, persists, restarts watch", async () => {
217
+ home = tmp("mirror-put-happy-");
218
+ const external = tmp("mirror-put-ext-");
219
+ initRepo(external);
220
+ fs.writeFileSync(path.join(external, ".gitkeep"), "");
221
+ Bun.spawnSync(["git", "add", "-A"], { cwd: external });
222
+ Bun.spawnSync(["git", "commit", "-q", "-m", "i"], { cwd: external });
223
+ try {
224
+ const { manager, deps, exportCalls } = makeManager(home);
225
+ const req = new Request("http://x/admin/mirror", {
226
+ method: "PUT",
227
+ body: JSON.stringify({
228
+ enabled: true,
229
+ location: "external",
230
+ external_path: external,
231
+ watch: false,
232
+ auto_commit: false,
233
+ }),
234
+ });
235
+ const res = await handleMirrorPut(req, manager);
236
+ expect(res.status).toBe(200);
237
+ const body = (await res.json()) as { config: MirrorConfig; status: { enabled: boolean; mirror_path: string } };
238
+ expect(body.config.enabled).toBe(true);
239
+ expect(body.status.enabled).toBe(true);
240
+ expect(body.status.mirror_path).toBe(external);
241
+ // Persisted via writeMirrorConfig.
242
+ expect(deps.storedConfig?.external_path).toBe(external);
243
+ // Initial export ran into the new path.
244
+ expect(exportCalls()).toHaveLength(1);
245
+ expect(exportCalls()[0]!.outDir).toBe(external);
246
+ await manager.stop();
247
+ } finally {
248
+ fs.rmSync(external, { recursive: true, force: true });
249
+ }
250
+ });
251
+
252
+ test("accepts disable-only PUT even when external_path no longer valid", async () => {
253
+ home = tmp("mirror-put-disable-");
254
+ const { manager } = makeManager(home);
255
+ const req = new Request("http://x/admin/mirror", {
256
+ method: "PUT",
257
+ body: JSON.stringify({
258
+ enabled: false,
259
+ location: "external",
260
+ external_path: "/this/path/is/gone",
261
+ }),
262
+ });
263
+ const res = await handleMirrorPut(req, manager);
264
+ // enabled:false skips the filesystem check.
265
+ expect(res.status).toBe(200);
266
+ const body = (await res.json()) as { config: MirrorConfig; status: { enabled: boolean } };
267
+ expect(body.status.enabled).toBe(false);
268
+ expect(body.config.external_path).toBe("/this/path/is/gone");
269
+ await manager.stop();
270
+ });
271
+
272
+ test("accepts disable-only PUT with external location and no external_path at all", async () => {
273
+ // Reviewer-flagged regression: previously `validateMirrorConfigShape`
274
+ // ran the cross-field "external requires external_path" rule
275
+ // unconditionally, so `{enabled: false, location: "external"}` (no
276
+ // path) returned 400. The rule now gates on `enabled`, matching
277
+ // the disable-should-never-fail-on-path-issues intent of the
278
+ // route-layer filesystem check skip.
279
+ home = tmp("mirror-put-disable-nopath-");
280
+ const { manager } = makeManager(home);
281
+ const req = new Request("http://x/admin/mirror", {
282
+ method: "PUT",
283
+ body: JSON.stringify({
284
+ enabled: false,
285
+ location: "external",
286
+ // no external_path
287
+ }),
288
+ });
289
+ const res = await handleMirrorPut(req, manager);
290
+ expect(res.status).toBe(200);
291
+ const body = (await res.json()) as { config: MirrorConfig; status: { enabled: boolean } };
292
+ expect(body.status.enabled).toBe(false);
293
+ expect(body.config.external_path).toBeNull();
294
+ await manager.stop();
295
+ });
296
+
297
+ test("PUT restarts watch loop with new interval", async () => {
298
+ home = tmp("mirror-put-restart-");
299
+ const { manager } = makeManager(home);
300
+ // Enable with watch.
301
+ const req1 = new Request("http://x/admin/mirror", {
302
+ method: "PUT",
303
+ body: JSON.stringify({
304
+ enabled: true,
305
+ location: "internal",
306
+ watch: true,
307
+ auto_commit: false,
308
+ interval_seconds: 1,
309
+ }),
310
+ });
311
+ const res1 = await handleMirrorPut(req1, manager);
312
+ expect(res1.status).toBe(200);
313
+ expect(manager.getStatus().watch_running).toBe(true);
314
+
315
+ // Disable.
316
+ const req2 = new Request("http://x/admin/mirror", {
317
+ method: "PUT",
318
+ body: JSON.stringify({ enabled: false }),
319
+ });
320
+ const res2 = await handleMirrorPut(req2, manager);
321
+ expect(res2.status).toBe(200);
322
+ expect(manager.getStatus().watch_running).toBe(false);
323
+ await manager.stop();
324
+ });
325
+
326
+ test("two PUTs fired in quick succession both apply; manager ends in the second config's state", async () => {
327
+ // Reviewer concern: a second PUT entering `reload()` while the
328
+ // first PUT's `stop()` is still inside its 250ms in-flight settle
329
+ // window could theoretically race the `stopping` flag. JS's
330
+ // microtask-serialized awaits make this safe in practice — each
331
+ // PUT's reload→start chain runs to completion on its own tick
332
+ // before the next runs — but pinning the expected outcome with a
333
+ // test documents the behavior + catches a regression if the
334
+ // serialization ever relaxes.
335
+ //
336
+ // What we assert:
337
+ // - Both PUTs return 200 (no crash, no stuck-in-flight).
338
+ // - After both resolve, the manager is in the SECOND config's
339
+ // shape (last-writer-wins; not a stale first-config state
340
+ // leaking through).
341
+ home = tmp("mirror-put-concurrent-");
342
+ const { manager } = makeManager(home);
343
+ const put = (body: Record<string, unknown>) =>
344
+ handleMirrorPut(
345
+ new Request("http://x/admin/mirror", {
346
+ method: "PUT",
347
+ body: JSON.stringify(body),
348
+ }),
349
+ manager,
350
+ );
351
+ const [res1, res2] = await Promise.all([
352
+ put({
353
+ enabled: true,
354
+ location: "internal",
355
+ watch: true,
356
+ auto_commit: false,
357
+ interval_seconds: 1,
358
+ }),
359
+ put({
360
+ enabled: true,
361
+ location: "internal",
362
+ watch: true,
363
+ auto_commit: false,
364
+ interval_seconds: 2,
365
+ }),
366
+ ]);
367
+ expect(res1.status).toBe(200);
368
+ expect(res2.status).toBe(200);
369
+ // Both PUTs read the same config-storage seam (deps.writeMirrorConfig)
370
+ // and serialize through the manager's async start() under the
371
+ // microtask queue. Final config reflects whichever PUT entered
372
+ // `reload()` last — practically the second one — but the salient
373
+ // assertion is "the manager isn't stuck": enabled + watch_running.
374
+ const status = manager.getStatus();
375
+ expect(status.enabled).toBe(true);
376
+ expect(status.watch_running).toBe(true);
377
+ expect(manager.getConfig().interval_seconds).toBe(2);
378
+ await manager.stop();
379
+ });
380
+ });
@@ -0,0 +1,152 @@
1
+ /**
2
+ * HTTP surface for the mirror lifecycle.
3
+ *
4
+ * GET /vault/<name>/.parachute/mirror — read current config + runtime status
5
+ * PUT /vault/<name>/.parachute/mirror — update config + reload watch loop
6
+ *
7
+ * URL note: the design doc names this `/admin/mirror`, but vault's
8
+ * existing routing already mounts the admin SPA's static-file bundle at
9
+ * `/vault/<name>/admin/*` (vault#252). Putting the API endpoint there
10
+ * would collide with the SPA mount. We use the existing `.parachute/`
11
+ * namespace instead — sibling to `.parachute/config`, `.parachute/info`,
12
+ * `.parachute/icon.svg` — which matches the module-protocol convention
13
+ * for per-module API surfaces. The hub admin SPA (Phase A2) will call
14
+ * this URL; operators issuing `curl` calls use it directly.
15
+ *
16
+ * Both endpoints gate on `vault:admin` — see `routing.ts` for the
17
+ * upstream auth wiring. This module is the after-auth handler; the
18
+ * caller has already verified the scope.
19
+ *
20
+ * These two endpoints unblock the Phase A2 hub admin SPA from configuring
21
+ * vault-side mirrors. For Phase A1 the only consumers are direct API
22
+ * callers (curl, the future SPA) and operators editing config.yaml by
23
+ * hand + restarting the vault.
24
+ */
25
+
26
+ import {
27
+ defaultMirrorConfig,
28
+ validateExternalPath,
29
+ validateMirrorConfigShape,
30
+ type MirrorConfig,
31
+ } from "./mirror-config.ts";
32
+ import type { MirrorManager } from "./mirror-manager.ts";
33
+
34
+ /**
35
+ * `GET /vault/<name>/.parachute/mirror` — return the persisted config +
36
+ * the runtime status the manager is currently tracking.
37
+ *
38
+ * Always returns 200 (auth was already enforced upstream). When no
39
+ * mirror config has ever been written, returns the defaults — the
40
+ * operator + the hub SPA see a consistent shape regardless of whether
41
+ * any persistence has happened yet.
42
+ */
43
+ export function handleMirrorGet(manager: MirrorManager): Response {
44
+ const config = manager.getConfig();
45
+ const status = manager.getStatus();
46
+ return Response.json(
47
+ {
48
+ config,
49
+ status,
50
+ },
51
+ { headers: { "Access-Control-Allow-Origin": "*" } },
52
+ );
53
+ }
54
+
55
+ /**
56
+ * `PUT /vault/<name>/.parachute/mirror` — accept a JSON body with the
57
+ * mirror config block, validate, persist, restart the in-process
58
+ * lifecycle.
59
+ *
60
+ * Request shape: same JSON as the MirrorConfig type — { enabled,
61
+ * location, external_path, watch, auto_commit, auto_push,
62
+ * commit_template, interval_seconds }. All fields optional; missing
63
+ * fields fall back to defaults.
64
+ *
65
+ * Validation surface:
66
+ * - JSON shape: location ∈ {internal, external}, types match, etc.
67
+ * Returns 400 with `field`-localized error on failure.
68
+ * - For enabled=true + location=external: the supplied external_path
69
+ * must exist on the filesystem AND be a git repo. Returns 400
70
+ * with an actionable error message on failure.
71
+ * - For enabled=false (any location): skip BOTH the cross-field
72
+ * "external requires external_path" check AND the filesystem
73
+ * check. Disable should never fail validation on path-related
74
+ * issues — the operator's just trying to turn off a mirror whose
75
+ * path may have gone away.
76
+ *
77
+ * Response: 200 with the new config + status snapshot.
78
+ */
79
+ export async function handleMirrorPut(
80
+ req: Request,
81
+ manager: MirrorManager,
82
+ ): Promise<Response> {
83
+ let body: unknown;
84
+ try {
85
+ body = await req.json();
86
+ } catch (err) {
87
+ return Response.json(
88
+ {
89
+ error: "Invalid JSON body",
90
+ message: (err as Error).message ?? String(err),
91
+ },
92
+ { status: 400 },
93
+ );
94
+ }
95
+
96
+ const shape = validateMirrorConfigShape(body);
97
+ if (!shape.ok) {
98
+ return Response.json(
99
+ {
100
+ error: "Invalid mirror config",
101
+ field: shape.field,
102
+ message: shape.error,
103
+ },
104
+ { status: 400 },
105
+ );
106
+ }
107
+
108
+ const config: MirrorConfig = shape.config;
109
+
110
+ // Filesystem-level validation runs only when the operator is asking us
111
+ // to *do* something with an external path. Disabling the mirror by-
112
+ // flipping enabled to false shouldn't fail because the path went away.
113
+ if (config.enabled && config.location === "external" && config.external_path) {
114
+ const pathCheck = await validateExternalPath(config.external_path);
115
+ if (!pathCheck.ok) {
116
+ return Response.json(
117
+ {
118
+ error: "Invalid external_path",
119
+ field: "external_path",
120
+ message: pathCheck.error,
121
+ },
122
+ { status: 400 },
123
+ );
124
+ }
125
+ }
126
+
127
+ // Persist + restart lifecycle. `reload` writes the config first and
128
+ // then calls `start()`, so a crash between the two leaves the operator-
129
+ // intended state on disk (next boot applies it).
130
+ const status = await manager.reload(config);
131
+ return Response.json(
132
+ {
133
+ config: manager.getConfig(),
134
+ status,
135
+ },
136
+ { headers: { "Access-Control-Allow-Origin": "*" } },
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Convenience for tests + future callers: build the GET response from a
142
+ * known-good config without needing a real MirrorManager.
143
+ */
144
+ export function buildMirrorGetResponse(
145
+ config: MirrorConfig | undefined,
146
+ status: ReturnType<MirrorManager["getStatus"]>,
147
+ ): { config: MirrorConfig; status: ReturnType<MirrorManager["getStatus"]> } {
148
+ return {
149
+ config: config ?? defaultMirrorConfig(),
150
+ status,
151
+ };
152
+ }
@@ -1725,3 +1725,79 @@ describe("/health — always 200 (Render/Docker healthcheck contract)", () => {
1725
1725
  expect(body.vaults).toBeUndefined();
1726
1726
  });
1727
1727
  });
1728
+
1729
+ // ---------------------------------------------------------------------------
1730
+ // /vault/<name>/admin/mirror — vault-sync Phase A1 admin surface.
1731
+ //
1732
+ // These tests pin the auth gate + the no-manager-bootstrapped fallback;
1733
+ // shape + lifecycle behaviour is covered in mirror-routes.test.ts /
1734
+ // mirror-manager.test.ts. We exercise the route here to make sure the
1735
+ // `vault:admin` enforcement matches the rest of the admin surface.
1736
+ // ---------------------------------------------------------------------------
1737
+
1738
+ describe("/vault/<name>/.parachute/mirror — auth + dispatch", () => {
1739
+ test("unauthenticated → 401", async () => {
1740
+ createVault("journal");
1741
+ const p = "/vault/journal/.parachute/mirror";
1742
+ const res = await route(new Request(`http://localhost:1940${p}`), p);
1743
+ expect(res.status).toBe(401);
1744
+ });
1745
+
1746
+ test("vault:read token → 403 insufficient_scope", async () => {
1747
+ createVault("journal");
1748
+ const store = getVaultStore("journal");
1749
+ const { fullToken } = generateToken();
1750
+ createToken(store.db, fullToken, {
1751
+ label: "reader",
1752
+ permission: "read",
1753
+ scopes: ["vault:read"],
1754
+ });
1755
+ const p = "/vault/journal/.parachute/mirror";
1756
+ const res = await route(
1757
+ new Request(`http://localhost:1940${p}`, {
1758
+ headers: { authorization: `Bearer ${fullToken}` },
1759
+ }),
1760
+ p,
1761
+ );
1762
+ expect(res.status).toBe(403);
1763
+ const body = (await res.json()) as { error_type?: string; required_scope?: string };
1764
+ expect(body.error_type).toBe("insufficient_scope");
1765
+ expect(body.required_scope).toBe("vault:admin");
1766
+ });
1767
+
1768
+ test("admin token reaches the handler — returns 503 when manager hasn't been wired (no boot)", async () => {
1769
+ // The routing-test harness boots `route()` without starting the
1770
+ // server.ts wiring that constructs a MirrorManager. We expect the
1771
+ // handler to surface 503 — distinct from an auth or shape error —
1772
+ // so operators can tell "manager not initialized" apart from
1773
+ // "you used the wrong creds".
1774
+ createVault("journal");
1775
+ const token = createAdminToken("journal");
1776
+ const p = "/vault/journal/.parachute/mirror";
1777
+ const res = await route(
1778
+ new Request(`http://localhost:1940${p}`, {
1779
+ headers: { authorization: `Bearer ${token}` },
1780
+ }),
1781
+ p,
1782
+ );
1783
+ // 200 if a previous test left a manager wired (test ordering varies),
1784
+ // 503 otherwise. Both prove the auth gate passed.
1785
+ expect([200, 503]).toContain(res.status);
1786
+ });
1787
+
1788
+ test("non-GET/PUT methods return 405 when manager isn't wired", async () => {
1789
+ createVault("journal");
1790
+ const token = createAdminToken("journal");
1791
+ const p = "/vault/journal/.parachute/mirror";
1792
+ // 503 short-circuits the method check today — that's fine; what we
1793
+ // want to assert is that we don't crash + the status is non-2xx.
1794
+ const res = await route(
1795
+ new Request(`http://localhost:1940${p}`, {
1796
+ method: "DELETE",
1797
+ headers: { authorization: `Bearer ${token}` },
1798
+ }),
1799
+ p,
1800
+ );
1801
+ expect([405, 503]).toContain(res.status);
1802
+ });
1803
+ });
package/src/routing.ts CHANGED
@@ -69,6 +69,8 @@ import {
69
69
  import { handleConfigSchema, handleConfig } from "./module-config.ts";
70
70
  import { buildAuthStatus } from "./auth-status.ts";
71
71
  import { getAuthorizeRateLimiter } from "./owner-auth.ts";
72
+ import { handleMirrorGet, handleMirrorPut } from "./mirror-routes.ts";
73
+ import { getMirrorManager } from "./mirror-registry.ts";
72
74
 
73
75
  /**
74
76
  * Decorate a 401 response from the MCP endpoint with the RFC 9728 challenge
@@ -479,6 +481,50 @@ export async function route(
479
481
  return handleTokens(req, store, vaultName, auth.scopes, auth.scoped_tags, tokensMatch[1] ?? "");
480
482
  }
481
483
 
484
+ // /.parachute/mirror — vault-sync Phase A1. Admin-gated read+write of
485
+ // the persistent mirror config + runtime status. Lives under
486
+ // `.parachute/` (alongside info/icon/config) rather than `admin/`
487
+ // because `/vault/<name>/admin/*` is reserved for the admin SPA's
488
+ // static-file mount; the API surface goes under `.parachute/` by the
489
+ // module-protocol convention. Per the design doc, the hub admin SPA
490
+ // (Phase A2 — future PR) is the eventual primary consumer; for Phase
491
+ // A1 these endpoints unblock direct API callers and the by-hand
492
+ // config workflow.
493
+ if (subpath === "/.parachute/mirror") {
494
+ if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
495
+ return Response.json(
496
+ {
497
+ error: "Forbidden",
498
+ error_type: "insufficient_scope",
499
+ message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
500
+ required_scope: SCOPE_ADMIN,
501
+ granted_scopes: auth.scopes,
502
+ },
503
+ { status: 403 },
504
+ );
505
+ }
506
+ const manager = getMirrorManager();
507
+ if (!manager) {
508
+ // The boot path constructs a manager when at least one vault
509
+ // exists; a missing manager here means either a startup error or
510
+ // a brand-new deploy that hasn't finished first-boot. Surface a
511
+ // clear 503 rather than a JSON null so the operator + the hub
512
+ // SPA know it's a service-state issue, not a misconfig on their
513
+ // end.
514
+ return Response.json(
515
+ {
516
+ error: "Mirror manager not initialized",
517
+ message:
518
+ "The vault server hasn't wired a mirror manager yet (no vaults exist, or boot failed). Check logs for [mirror] entries.",
519
+ },
520
+ { status: 503 },
521
+ );
522
+ }
523
+ if (req.method === "GET") return handleMirrorGet(manager);
524
+ if (req.method === "PUT") return handleMirrorPut(req, manager);
525
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
526
+ }
527
+
482
528
  const apiMatch = subpath.match(/^\/api(\/.*)?$/);
483
529
  if (!apiMatch) {
484
530
  return Response.json({ error: "Not found" }, { status: 404 });