@openparachute/vault 0.6.3 → 0.6.4-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 (51) hide show
  1. package/README.md +46 -11
  2. package/core/src/attribution.test.ts +273 -0
  3. package/core/src/core.test.ts +126 -0
  4. package/core/src/cursor.ts +8 -0
  5. package/core/src/enforced-writes.test.ts +533 -0
  6. package/core/src/mcp.ts +235 -9
  7. package/core/src/migrate-tag-field.test.ts +471 -0
  8. package/core/src/migrate-tag-field.ts +638 -0
  9. package/core/src/notes.ts +280 -7
  10. package/core/src/query-operators.ts +117 -0
  11. package/core/src/schema-defaults.ts +162 -9
  12. package/core/src/schema.ts +61 -2
  13. package/core/src/store.ts +51 -19
  14. package/core/src/tag-schemas.ts +12 -0
  15. package/core/src/triggers-store.ts +6 -0
  16. package/core/src/types.ts +35 -14
  17. package/core/src/vault-projection.ts +19 -0
  18. package/package.json +1 -1
  19. package/src/admin-spa.test.ts +18 -5
  20. package/src/admin-spa.ts +24 -3
  21. package/src/attribution-threading.test.ts +350 -0
  22. package/src/auth.ts +82 -4
  23. package/src/cli.ts +345 -9
  24. package/src/config.ts +11 -0
  25. package/src/first-boot-create.test.ts +155 -0
  26. package/src/import-daemon-busy.test.ts +8 -17
  27. package/src/mcp-http.ts +27 -0
  28. package/src/mcp-tools.ts +31 -4
  29. package/src/mirror-credentials.test.ts +47 -0
  30. package/src/mirror-credentials.ts +33 -0
  31. package/src/mirror-history.test.ts +426 -0
  32. package/src/mirror-manager.ts +202 -22
  33. package/src/mirror-routes.test.ts +78 -0
  34. package/src/mirror-routes.ts +195 -1
  35. package/src/module-config.ts +46 -80
  36. package/src/routes.ts +209 -41
  37. package/src/routing.test.ts +115 -25
  38. package/src/routing.ts +64 -2
  39. package/src/scale.bench.test.ts +82 -0
  40. package/src/scopes.test.ts +24 -0
  41. package/src/scopes.ts +63 -0
  42. package/src/self-register.test.ts +5 -5
  43. package/src/self-register.ts +8 -3
  44. package/src/server.ts +58 -38
  45. package/src/subscribe.test.ts +23 -2
  46. package/src/test-support/spawn.ts +85 -0
  47. package/src/triggers-api.ts +24 -0
  48. package/src/triggers.test.ts +33 -0
  49. package/src/triggers.ts +17 -0
  50. package/src/vault-remove.test.ts +4 -5
  51. package/src/vault.test.ts +188 -17
package/src/mcp-tools.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { generateMcpTools } from "../core/src/mcp.ts";
9
- import type { McpToolDef } from "../core/src/mcp.ts";
9
+ import type { McpToolDef, GenerateMcpToolsOpts } from "../core/src/mcp.ts";
10
10
  import { getNoteTags } from "../core/src/notes.ts";
11
11
  import type { Note } from "../core/src/types.ts";
12
12
  import {
@@ -16,7 +16,7 @@ import {
16
16
  } from "../core/src/vault-projection.ts";
17
17
  import { readVaultConfig, writeVaultConfig } from "./config.ts";
18
18
  import { getVaultStore } from "./vault-store.ts";
19
- import { hasScopeForVault, parseScopes, validateMintedScopes } from "./scopes.ts";
19
+ import { hasScopeForVault, hasMigrateScopeForVault, parseScopes, validateMintedScopes, logStrictBypass } from "./scopes.ts";
20
20
  import type { AuthResult } from "./auth.ts";
21
21
  import {
22
22
  expandTokenTagScope,
@@ -166,10 +166,37 @@ export function generateScopedMcpTools(
166
166
  )
167
167
  : undefined;
168
168
 
169
+ // Write-attribution (vault#298). Every write through an MCP session arrives
170
+ // on the `mcp` channel — so we REFINE the auth's base `via` (the generic
171
+ // credential class) to `mcp` here, where the path/channel is known. The
172
+ // operator bearer keeps `operator` (its credential class IS its channel and
173
+ // is more informative than `mcp` for cross-container hub→vault writes); any
174
+ // other credential's via becomes `mcp`. `actor` (the principal) passes
175
+ // through unchanged.
176
+ const writeContext = auth
177
+ ? { actor: auth.actor, via: auth.via === "operator" ? "operator" : "mcp" }
178
+ : undefined;
179
+
180
+ // Migration-bypass (vault#299): a `vault:migrate`-scoped MCP session skips
181
+ // strict-schema enforcement and logs every bypassed write. Orthogonal to
182
+ // read/write/admin — an admin token does NOT bypass unless it also holds
183
+ // `migrate`. `onStrictBypass` writes the same structured log line the REST
184
+ // path uses (the audit-log table, #300, is deferred).
185
+ const strictBypass = auth ? hasMigrateScopeForVault(auth.scopes, vaultName) : false;
186
+ const onStrictBypass: GenerateMcpToolsOpts["onStrictBypass"] = strictBypass
187
+ ? (info) => logStrictBypass(info)
188
+ : undefined;
189
+
169
190
  const tools = generateMcpTools(
170
191
  store,
171
- expandVisibility || nearTraversable
172
- ? { ...(expandVisibility ? { expandVisibility } : {}), ...(nearTraversable ? { nearTraversable } : {}) }
192
+ expandVisibility || nearTraversable || writeContext || strictBypass
193
+ ? {
194
+ ...(expandVisibility ? { expandVisibility } : {}),
195
+ ...(nearTraversable ? { nearTraversable } : {}),
196
+ ...(writeContext ? { writeContext } : {}),
197
+ ...(strictBypass ? { strictBypass } : {}),
198
+ ...(onStrictBypass ? { onStrictBypass } : {}),
199
+ }
173
200
  : undefined,
174
201
  );
175
202
 
@@ -104,6 +104,53 @@ describe("serialize + parse round-trip", () => {
104
104
  expect(out).toEqual(creds);
105
105
  });
106
106
 
107
+ test("github_oauth with persisted owner/name round-trips through the bytes (vault#401)", () => {
108
+ // owner/name are persisted at repo-select time so the selection survives a
109
+ // cleared git origin + restart. They must serialize and parse back
110
+ // byte-faithfully.
111
+ const creds: MirrorCredentials = {
112
+ active_method: "github_oauth",
113
+ github_oauth: {
114
+ access_token: "gho_abc123def456ghi789",
115
+ scope: "repo",
116
+ authorized_at: "2026-05-28T03:14:15.000Z",
117
+ user_login: "aaron",
118
+ user_id: 12345,
119
+ owner: "aaron",
120
+ name: "backup-repo",
121
+ },
122
+ pat: null,
123
+ };
124
+ const out = parseCredentials(serializeCredentials(creds));
125
+ expect(out).toEqual(creds);
126
+ expect(out.github_oauth?.owner).toBe("aaron");
127
+ expect(out.github_oauth?.name).toBe("backup-repo");
128
+ });
129
+
130
+ test("github_oauth WITHOUT owner/name stays byte-stable (back-compat)", () => {
131
+ // Credentials written before a repo is picked (or before vault#401) carry
132
+ // no owner/name. Serialize must NOT emit empty owner/name lines, and the
133
+ // round-trip must leave the fields absent (not "").
134
+ const creds: MirrorCredentials = {
135
+ active_method: "github_oauth",
136
+ github_oauth: {
137
+ access_token: "gho_abc123def456ghi789",
138
+ scope: "repo",
139
+ authorized_at: "2026-05-28T03:14:15.000Z",
140
+ user_login: "aaron",
141
+ user_id: 12345,
142
+ },
143
+ pat: null,
144
+ };
145
+ const serialized = serializeCredentials(creds);
146
+ expect(serialized).not.toContain("owner:");
147
+ expect(serialized).not.toContain("name:");
148
+ const out = parseCredentials(serialized);
149
+ expect(out).toEqual(creds);
150
+ expect(out.github_oauth?.owner).toBeUndefined();
151
+ expect(out.github_oauth?.name).toBeUndefined();
152
+ });
153
+
107
154
  test("github_oauth with empty scope round-trips (GitHub App tokens)", () => {
108
155
  // Tokens from the shared Parachute GitHub App carry scope: "" (GitHub
109
156
  // Apps use fine-grained permissions, not scopes). The empty string must
@@ -86,6 +86,18 @@ export interface GitHubOAuthCredential {
86
86
  user_login: string;
87
87
  /** GitHub numeric user id — stable across login renames. */
88
88
  user_id: number;
89
+ /**
90
+ * The selected repo's owner — persisted at repo-select time (vault#401).
91
+ * Optional: absent before the operator picks a repo, and absent in
92
+ * credentials files written before this field existed (back-compat). When
93
+ * present, `applyCredentialsToRemote` reconstructs the authed remote URL
94
+ * from `owner`/`name` directly rather than regex-parsing the current git
95
+ * origin — so the selection survives the origin being cleared (e.g.
96
+ * `DELETE /auth` then re-OAuth) and a server restart.
97
+ */
98
+ owner?: string;
99
+ /** The selected repo's name — persisted alongside `owner` (vault#401). */
100
+ name?: string;
89
101
  }
90
102
 
91
103
  /**
@@ -138,6 +150,10 @@ export interface MirrorCredentialsPublic {
138
150
  scope: string;
139
151
  authorized_at: string;
140
152
  token_preview: string;
153
+ /** Selected repo owner, when a repo has been picked (vault#401). */
154
+ owner?: string;
155
+ /** Selected repo name, when a repo has been picked (vault#401). */
156
+ name?: string;
141
157
  } | null;
142
158
  pat: {
143
159
  label: string;
@@ -233,6 +249,15 @@ export function serializeCredentials(creds: MirrorCredentials): string {
233
249
  lines.push(` authorized_at: ${quoteIfNeeded(creds.github_oauth.authorized_at)}`);
234
250
  lines.push(` user_login: ${quoteIfNeeded(creds.github_oauth.user_login)}`);
235
251
  lines.push(` user_id: ${creds.github_oauth.user_id}`);
252
+ // owner/name persisted at repo-select time (vault#401). Emitted only when
253
+ // present so credentials written before a repo is picked (or before this
254
+ // field existed) stay byte-stable.
255
+ if (creds.github_oauth.owner !== undefined) {
256
+ lines.push(` owner: ${quoteIfNeeded(creds.github_oauth.owner)}`);
257
+ }
258
+ if (creds.github_oauth.name !== undefined) {
259
+ lines.push(` name: ${quoteIfNeeded(creds.github_oauth.name)}`);
260
+ }
236
261
  } else {
237
262
  lines.push("github_oauth: null");
238
263
  }
@@ -356,6 +381,8 @@ export function parseCredentials(yaml: string): MirrorCredentials {
356
381
  else if (key === "scope") oauth.scope = parseScalar(rawVal!);
357
382
  else if (key === "authorized_at") oauth.authorized_at = parseScalar(rawVal!);
358
383
  else if (key === "user_login") oauth.user_login = parseScalar(rawVal!);
384
+ else if (key === "owner") oauth.owner = parseScalar(rawVal!);
385
+ else if (key === "name") oauth.name = parseScalar(rawVal!);
359
386
  else if (key === "user_id") {
360
387
  const n = Number(parseScalar(rawVal!));
361
388
  if (Number.isFinite(n)) oauth.user_id = n;
@@ -571,6 +598,12 @@ export function sanitizeCredentials(
571
598
  scope: creds.github_oauth.scope,
572
599
  authorized_at: creds.github_oauth.authorized_at,
573
600
  token_preview: previewToken(creds.github_oauth.access_token),
601
+ ...(creds.github_oauth.owner !== undefined
602
+ ? { owner: creds.github_oauth.owner }
603
+ : {}),
604
+ ...(creds.github_oauth.name !== undefined
605
+ ? { name: creds.github_oauth.name }
606
+ : {}),
574
607
  }
575
608
  : null,
576
609
  pat: creds.pat
@@ -0,0 +1,426 @@
1
+ /**
2
+ * Tests for the git-history read surface (vault#300).
3
+ *
4
+ * The vault is already git-backed via the mirror — one file per note — so
5
+ * `git log` IS the write history. These tests exercise:
6
+ * - the `readMirrorHistory` / `showMirrorRevision` / `noteHistoryPathspec`
7
+ * helpers (mirror-manager.ts) against a real seeded git repo in a tmpdir
8
+ * - the `handleMirrorHistory` / `handleMirrorHistoryShow` REST handlers
9
+ * (mirror-routes.ts), including the not-initialized + path-scoped cases
10
+ *
11
+ * Routing-level admin-gate enforcement lives in routing.test.ts (alongside
12
+ * the sibling mirror routes); these are the after-auth handler + helper
13
+ * tests, matching the mirror-manager / mirror-routes test split.
14
+ *
15
+ * Like the other mirror tests: real tempdirs + real `git` so we exercise
16
+ * the actual log/show behavior, not a mock.
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
+ HISTORY_MAX_LIMIT,
26
+ noteHistoryPathspec,
27
+ readMirrorHistory,
28
+ showMirrorRevision,
29
+ MirrorManager,
30
+ type MirrorDeps,
31
+ } from "./mirror-manager.ts";
32
+ import {
33
+ handleMirrorHistory,
34
+ handleMirrorHistoryShow,
35
+ } from "./mirror-routes.ts";
36
+ import { defaultMirrorConfig, type MirrorConfig } from "./mirror-config.ts";
37
+
38
+ // Keep HOME / PARACHUTE_HOME from leaking between test files — same pattern
39
+ // as mirror-manager.test.ts.
40
+ const ORIG_HOME = process.env.HOME;
41
+ const ORIG_PARACHUTE_HOME = process.env.PARACHUTE_HOME;
42
+ afterEach(() => {
43
+ if (ORIG_HOME === undefined) delete process.env.HOME;
44
+ else process.env.HOME = ORIG_HOME;
45
+ if (ORIG_PARACHUTE_HOME === undefined) delete process.env.PARACHUTE_HOME;
46
+ else process.env.PARACHUTE_HOME = ORIG_PARACHUTE_HOME;
47
+ });
48
+ afterAll(() => {
49
+ if (ORIG_HOME === undefined) delete process.env.HOME;
50
+ else process.env.HOME = ORIG_HOME;
51
+ });
52
+
53
+ function tmp(prefix: string): string {
54
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
55
+ }
56
+
57
+ function initRepo(dir: string): void {
58
+ Bun.spawnSync(["git", "init", "-q", "-b", "main"], { cwd: dir });
59
+ Bun.spawnSync(["git", "config", "user.email", "t@p.computer"], { cwd: dir });
60
+ Bun.spawnSync(["git", "config", "user.name", "T P"], { cwd: dir });
61
+ Bun.spawnSync(["git", "config", "commit.gpgsign", "false"], { cwd: dir });
62
+ }
63
+
64
+ /** Write a file (creating parent dirs), `git add -A`, commit with `message`. */
65
+ function commitFile(dir: string, relPath: string, content: string, message: string): void {
66
+ const full = path.join(dir, relPath);
67
+ fs.mkdirSync(path.dirname(full), { recursive: true });
68
+ fs.writeFileSync(full, content);
69
+ Bun.spawnSync(["git", "add", "-A"], { cwd: dir });
70
+ Bun.spawnSync(["git", "commit", "-q", "-m", message], { cwd: dir });
71
+ }
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // noteHistoryPathspec — normalize + traversal guard
75
+ // ---------------------------------------------------------------------------
76
+
77
+ describe("noteHistoryPathspec", () => {
78
+ test("appends .md to a bare note path", () => {
79
+ expect(noteHistoryPathspec("Inbox/today")).toBe("Inbox/today.md");
80
+ });
81
+
82
+ test("is idempotent on an already-suffixed path", () => {
83
+ expect(noteHistoryPathspec("Inbox/today.md")).toBe("Inbox/today.md");
84
+ });
85
+
86
+ test("rejects traversal", () => {
87
+ expect(noteHistoryPathspec("../etc/passwd")).toBeNull();
88
+ expect(noteHistoryPathspec("Inbox/../../secret")).toBeNull();
89
+ });
90
+
91
+ test("rejects absolute paths", () => {
92
+ expect(noteHistoryPathspec("/etc/passwd")).toBeNull();
93
+ });
94
+
95
+ test("rejects empty / whitespace", () => {
96
+ expect(noteHistoryPathspec("")).toBeNull();
97
+ expect(noteHistoryPathspec(" ")).toBeNull();
98
+ });
99
+ });
100
+
101
+ // ---------------------------------------------------------------------------
102
+ // readMirrorHistory — the core git-log read
103
+ // ---------------------------------------------------------------------------
104
+
105
+ describe("readMirrorHistory", () => {
106
+ test("returns commits newest-first with sha/date/message", async () => {
107
+ const dir = tmp("vault-history-");
108
+ initRepo(dir);
109
+ commitFile(dir, "Inbox/one.md", "one", "export: first (1 note)");
110
+ commitFile(dir, "Inbox/two.md", "two", "export: second (1 note)");
111
+
112
+ const history = await readMirrorHistory(dir);
113
+ expect(history.length).toBe(2);
114
+ // Newest first.
115
+ expect(history[0]!.message).toBe("export: second (1 note)");
116
+ expect(history[1]!.message).toBe("export: first (1 note)");
117
+ // Each entry has a full sha + an ISO date.
118
+ for (const entry of history) {
119
+ expect(entry.sha).toMatch(/^[0-9a-f]{40}$/);
120
+ expect(Number.isNaN(Date.parse(entry.date))).toBe(false);
121
+ }
122
+ });
123
+
124
+ test("?path filters to a single note's commits, --follow across renames", async () => {
125
+ const dir = tmp("vault-history-path-");
126
+ initRepo(dir);
127
+ commitFile(dir, "Notes/alpha.md", "a1", "export: alpha created");
128
+ commitFile(dir, "Notes/beta.md", "b1", "export: beta created");
129
+ commitFile(dir, "Notes/alpha.md", "a2", "export: alpha edited");
130
+
131
+ const alpha = await readMirrorHistory(dir, { notePath: "Notes/alpha" });
132
+ // Two commits touched alpha; beta's commit is excluded.
133
+ expect(alpha.length).toBe(2);
134
+ expect(alpha.map((e) => e.message)).toEqual([
135
+ "export: alpha edited",
136
+ "export: alpha created",
137
+ ]);
138
+
139
+ const beta = await readMirrorHistory(dir, { notePath: "Notes/beta" });
140
+ expect(beta.length).toBe(1);
141
+ expect(beta[0]!.message).toBe("export: beta created");
142
+ });
143
+
144
+ test("limit caps the number of commits returned", async () => {
145
+ const dir = tmp("vault-history-limit-");
146
+ initRepo(dir);
147
+ for (let i = 0; i < 5; i++) {
148
+ commitFile(dir, `Notes/n${i}.md`, `v${i}`, `export: commit ${i}`);
149
+ }
150
+ const limited = await readMirrorHistory(dir, { limit: 2 });
151
+ expect(limited.length).toBe(2);
152
+ // Newest two.
153
+ expect(limited[0]!.message).toBe("export: commit 4");
154
+ expect(limited[1]!.message).toBe("export: commit 3");
155
+ });
156
+
157
+ test("an out-of-range limit is clamped to HISTORY_MAX_LIMIT (never exceeds the ceiling)", async () => {
158
+ const dir = tmp("vault-history-ceiling-");
159
+ initRepo(dir);
160
+ // Seed just a few commits — the assertion is on the ARG cap, not commit
161
+ // count: an absurd limit must never spawn `git log` with --max-count
162
+ // above the hard ceiling. With <ceiling commits the result is bounded by
163
+ // commit count, so we assert it's never MORE than the ceiling.
164
+ commitFile(dir, "Notes/a.md", "a", "export: a");
165
+ commitFile(dir, "Notes/b.md", "b", "export: b");
166
+ const history = await readMirrorHistory(dir, { limit: 99_999_999 });
167
+ expect(history.length).toBeLessThanOrEqual(HISTORY_MAX_LIMIT);
168
+ // Sanity: a huge limit still returns the (few) real commits.
169
+ expect(history.length).toBe(2);
170
+ });
171
+
172
+ test("no commits yet → empty list, not an error", async () => {
173
+ const dir = tmp("vault-history-empty-");
174
+ initRepo(dir); // repo but no commits
175
+ const history = await readMirrorHistory(dir);
176
+ expect(history).toEqual([]);
177
+ });
178
+
179
+ test("not a git repo → empty list, not an error", async () => {
180
+ const dir = tmp("vault-history-nonrepo-");
181
+ // No `git init`.
182
+ const history = await readMirrorHistory(dir);
183
+ expect(history).toEqual([]);
184
+ });
185
+
186
+ test("a path with no history → empty list", async () => {
187
+ const dir = tmp("vault-history-nopath-");
188
+ initRepo(dir);
189
+ commitFile(dir, "Notes/exists.md", "x", "export: exists");
190
+ const history = await readMirrorHistory(dir, { notePath: "Notes/ghost" });
191
+ expect(history).toEqual([]);
192
+ });
193
+
194
+ test("an unsafe path → empty list (never an unscoped log)", async () => {
195
+ const dir = tmp("vault-history-unsafe-");
196
+ initRepo(dir);
197
+ commitFile(dir, "Notes/a.md", "a", "export: a");
198
+ commitFile(dir, "Notes/b.md", "b", "export: b");
199
+ // A traversal path must NOT fall back to the full (unscoped) log.
200
+ const history = await readMirrorHistory(dir, { notePath: "../../../etc/passwd" });
201
+ expect(history).toEqual([]);
202
+ });
203
+
204
+ test("redacts tokens that appear in a commit subject", async () => {
205
+ const dir = tmp("vault-history-redact-");
206
+ initRepo(dir);
207
+ commitFile(dir, "Notes/a.md", "a", "synced from https://x-access-token:ghp_supersecrettoken123@github.com/a/b");
208
+ const history = await readMirrorHistory(dir);
209
+ expect(history.length).toBe(1);
210
+ expect(history[0]!.message).not.toContain("ghp_supersecrettoken123");
211
+ expect(history[0]!.message).toContain("***");
212
+ });
213
+ });
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // showMirrorRevision — read a past revision of a note
217
+ // ---------------------------------------------------------------------------
218
+
219
+ describe("showMirrorRevision", () => {
220
+ test("returns the file content at a past revision", async () => {
221
+ const dir = tmp("vault-show-");
222
+ initRepo(dir);
223
+ commitFile(dir, "Notes/a.md", "version one", "export: a v1");
224
+ // Capture the sha at v1.
225
+ const v1 = (await readMirrorHistory(dir))[0]!.sha;
226
+ commitFile(dir, "Notes/a.md", "version two", "export: a v2");
227
+
228
+ const past = await showMirrorRevision(dir, v1, "Notes/a");
229
+ expect(past).toBe("version one");
230
+
231
+ const current = await showMirrorRevision(dir, (await readMirrorHistory(dir))[0]!.sha, "Notes/a");
232
+ expect(current).toBe("version two");
233
+ });
234
+
235
+ test("unknown sha → null", async () => {
236
+ const dir = tmp("vault-show-badsha-");
237
+ initRepo(dir);
238
+ commitFile(dir, "Notes/a.md", "x", "export: a");
239
+ const result = await showMirrorRevision(dir, "deadbeef", "Notes/a");
240
+ expect(result).toBeNull();
241
+ });
242
+
243
+ test("non-hex / option-looking sha → null (no smuggled ref)", async () => {
244
+ const dir = tmp("vault-show-refsmuggle-");
245
+ initRepo(dir);
246
+ commitFile(dir, "Notes/a.md", "x", "export: a");
247
+ expect(await showMirrorRevision(dir, "HEAD", "Notes/a")).toBeNull();
248
+ expect(await showMirrorRevision(dir, "--output=/tmp/x", "Notes/a")).toBeNull();
249
+ });
250
+
251
+ test("unsafe path → null", async () => {
252
+ const dir = tmp("vault-show-unsafe-");
253
+ initRepo(dir);
254
+ commitFile(dir, "Notes/a.md", "x", "export: a");
255
+ const sha = (await readMirrorHistory(dir))[0]!.sha;
256
+ expect(await showMirrorRevision(dir, sha, "../../../etc/passwd")).toBeNull();
257
+ });
258
+ });
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // Route handlers — handleMirrorHistory / handleMirrorHistoryShow
262
+ // ---------------------------------------------------------------------------
263
+
264
+ /**
265
+ * Build a MirrorManager whose status reports `mirror_path = mirrorPath`
266
+ * without standing up the full lifecycle — the history handlers only read
267
+ * `getStatus().mirror_path`, so we start a minimal enabled internal mirror
268
+ * pointed at a tmp repo we control.
269
+ *
270
+ * Simpler: construct the manager with fake deps and force the status by
271
+ * starting it against an internal mirror dir we then seed. But the history
272
+ * read is path-only, so we instead use a tiny manager whose deps resolve the
273
+ * internal mirror under a tmp PARACHUTE_HOME and start it (which bootstraps a
274
+ * real git repo), then commit into that repo.
275
+ */
276
+ function makeStartedManager(home: string): { manager: MirrorManager; deps: MirrorDeps } {
277
+ process.env.PARACHUTE_HOME = home;
278
+ process.env.HOME = home;
279
+ fs.mkdirSync(path.join(home, "vault", "data", "default"), { recursive: true });
280
+ let stored: MirrorConfig | undefined = { ...defaultMirrorConfig(), enabled: true };
281
+ const deps: MirrorDeps = {
282
+ vaultName: "default",
283
+ runExport: async () => ({ notes: 0 }),
284
+ firstChangedNoteTitle: async () => "",
285
+ readMirrorConfig: () => stored,
286
+ writeMirrorConfig: (c) => {
287
+ stored = c;
288
+ },
289
+ };
290
+ return { manager: new MirrorManager(deps), deps };
291
+ }
292
+
293
+ describe("handleMirrorHistory route", () => {
294
+ test("not-initialized mirror → 200 empty history + note (not 500)", async () => {
295
+ const home = tmp("vault-route-noinit-");
296
+ const { manager } = makeStartedManager(home);
297
+ // Never started → no mirror_path resolved.
298
+ const res = await handleMirrorHistory(
299
+ new Request("http://localhost/vault/default/.parachute/mirror/history"),
300
+ manager,
301
+ );
302
+ expect(res.status).toBe(200);
303
+ const body = (await res.json()) as { history: unknown[]; mirror_path: null; note?: string };
304
+ expect(body.history).toEqual([]);
305
+ expect(body.mirror_path).toBeNull();
306
+ expect(body.note).toBeDefined();
307
+ });
308
+
309
+ test("started mirror with commits → 200 history list", async () => {
310
+ const home = tmp("vault-route-hist-");
311
+ const { manager } = makeStartedManager(home);
312
+ await manager.start();
313
+ const mirrorPath = manager.getStatus().mirror_path!;
314
+ expect(mirrorPath).toBeTruthy();
315
+ commitFile(mirrorPath, "Notes/a.md", "a", "export: a created");
316
+ commitFile(mirrorPath, "Notes/b.md", "b", "export: b created");
317
+
318
+ const res = await handleMirrorHistory(
319
+ new Request("http://localhost/vault/default/.parachute/mirror/history"),
320
+ manager,
321
+ );
322
+ expect(res.status).toBe(200);
323
+ const body = (await res.json()) as {
324
+ history: Array<{ sha: string; date: string; message: string }>;
325
+ mirror_path: string;
326
+ };
327
+ // Includes the bootstrap commit + our two; newest first.
328
+ expect(body.history.length).toBeGreaterThanOrEqual(3);
329
+ expect(body.history[0]!.message).toBe("export: b created");
330
+ expect(body.mirror_path).toBe(mirrorPath);
331
+ });
332
+
333
+ test("?path scopes to one note", async () => {
334
+ const home = tmp("vault-route-histpath-");
335
+ const { manager } = makeStartedManager(home);
336
+ await manager.start();
337
+ const mirrorPath = manager.getStatus().mirror_path!;
338
+ commitFile(mirrorPath, "Notes/alpha.md", "a1", "export: alpha");
339
+ commitFile(mirrorPath, "Notes/beta.md", "b1", "export: beta");
340
+
341
+ const res = await handleMirrorHistory(
342
+ new Request("http://localhost/vault/default/.parachute/mirror/history?path=Notes/alpha"),
343
+ manager,
344
+ );
345
+ const body = (await res.json()) as {
346
+ history: Array<{ message: string }>;
347
+ path?: string;
348
+ };
349
+ expect(body.path).toBe("Notes/alpha");
350
+ expect(body.history.length).toBe(1);
351
+ expect(body.history[0]!.message).toBe("export: alpha");
352
+ });
353
+
354
+ test("?limit caps the count", async () => {
355
+ const home = tmp("vault-route-histlimit-");
356
+ const { manager } = makeStartedManager(home);
357
+ await manager.start();
358
+ const mirrorPath = manager.getStatus().mirror_path!;
359
+ for (let i = 0; i < 4; i++) commitFile(mirrorPath, `Notes/n${i}.md`, `v${i}`, `export: c${i}`);
360
+
361
+ const res = await handleMirrorHistory(
362
+ new Request("http://localhost/vault/default/.parachute/mirror/history?limit=2"),
363
+ manager,
364
+ );
365
+ const body = (await res.json()) as { history: unknown[] };
366
+ expect(body.history.length).toBe(2);
367
+ });
368
+
369
+ test("invalid limit → 400", async () => {
370
+ const home = tmp("vault-route-histbadlimit-");
371
+ const { manager } = makeStartedManager(home);
372
+ await manager.start();
373
+ const res = await handleMirrorHistory(
374
+ new Request("http://localhost/vault/default/.parachute/mirror/history?limit=-3"),
375
+ manager,
376
+ );
377
+ expect(res.status).toBe(400);
378
+ });
379
+ });
380
+
381
+ describe("handleMirrorHistoryShow route", () => {
382
+ test("returns a past revision's content", async () => {
383
+ const home = tmp("vault-route-show-");
384
+ const { manager } = makeStartedManager(home);
385
+ await manager.start();
386
+ const mirrorPath = manager.getStatus().mirror_path!;
387
+ commitFile(mirrorPath, "Notes/a.md", "v1 content", "export: a v1");
388
+ const sha = (await readMirrorHistory(mirrorPath, { notePath: "Notes/a" }))[0]!.sha;
389
+ commitFile(mirrorPath, "Notes/a.md", "v2 content", "export: a v2");
390
+
391
+ const res = await handleMirrorHistoryShow(
392
+ new Request(
393
+ `http://localhost/vault/default/.parachute/mirror/history/show?sha=${sha}&path=Notes/a`,
394
+ ),
395
+ manager,
396
+ );
397
+ expect(res.status).toBe(200);
398
+ const body = (await res.json()) as { content: string; sha: string; path: string };
399
+ expect(body.content).toBe("v1 content");
400
+ expect(body.path).toBe("Notes/a");
401
+ });
402
+
403
+ test("missing sha/path → 400", async () => {
404
+ const home = tmp("vault-route-show-missing-");
405
+ const { manager } = makeStartedManager(home);
406
+ await manager.start();
407
+ const res = await handleMirrorHistoryShow(
408
+ new Request("http://localhost/vault/default/.parachute/mirror/history/show?sha=abc123"),
409
+ manager,
410
+ );
411
+ expect(res.status).toBe(400);
412
+ });
413
+
414
+ test("unknown sha/path → 404", async () => {
415
+ const home = tmp("vault-route-show-404-");
416
+ const { manager } = makeStartedManager(home);
417
+ await manager.start();
418
+ const res = await handleMirrorHistoryShow(
419
+ new Request(
420
+ "http://localhost/vault/default/.parachute/mirror/history/show?sha=deadbeef&path=Notes/ghost",
421
+ ),
422
+ manager,
423
+ );
424
+ expect(res.status).toBe(404);
425
+ });
426
+ });