@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.
- package/core/src/portable-md.test.ts +247 -0
- package/core/src/portable-md.ts +118 -1
- package/package.json +1 -1
- package/src/cli.ts +94 -2
- package/src/config.ts +24 -0
- package/src/export-watch.test.ts +99 -0
- package/src/mirror-config.test.ts +328 -0
- package/src/mirror-config.ts +470 -0
- package/src/mirror-deps.ts +88 -0
- package/src/mirror-manager.test.ts +550 -0
- package/src/mirror-manager.ts +521 -0
- package/src/mirror-registry.ts +26 -0
- package/src/mirror-routes.test.ts +380 -0
- package/src/mirror-routes.ts +152 -0
- package/src/routing.test.ts +76 -0
- package/src/routing.ts +46 -0
- package/src/server.ts +52 -0
|
@@ -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
|
+
}
|
package/src/routing.test.ts
CHANGED
|
@@ -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 });
|