@openparachute/vault 0.1.0 → 0.2.0

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 (87) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/CLAUDE.md +2 -2
  3. package/README.md +289 -44
  4. package/core/src/core.test.ts +802 -346
  5. package/core/src/expand.ts +140 -0
  6. package/core/src/hooks.test.ts +27 -27
  7. package/core/src/hooks.ts +1 -1
  8. package/core/src/mcp.ts +102 -39
  9. package/core/src/notes.ts +82 -4
  10. package/core/src/obsidian.test.ts +11 -11
  11. package/core/src/paths.test.ts +46 -46
  12. package/core/src/schema.ts +18 -2
  13. package/core/src/store.ts +51 -51
  14. package/core/src/types.ts +29 -29
  15. package/core/src/wikilinks.test.ts +61 -61
  16. package/docs/HTTP_API.md +4 -2
  17. package/package.json +1 -1
  18. package/src/auth.test.ts +319 -0
  19. package/src/backup-launchd.test.ts +90 -0
  20. package/src/backup-launchd.ts +169 -0
  21. package/src/backup.test.ts +715 -0
  22. package/src/backup.ts +699 -0
  23. package/src/cli.ts +923 -31
  24. package/src/config.test.ts +173 -0
  25. package/src/config.ts +345 -15
  26. package/src/daemon.ts +136 -0
  27. package/src/doctor.test.ts +356 -0
  28. package/src/health.test.ts +201 -0
  29. package/src/health.ts +115 -0
  30. package/src/launchd.test.ts +91 -0
  31. package/src/launchd.ts +37 -40
  32. package/src/mcp-http.ts +1 -1
  33. package/src/mcp-tools.ts +7 -9
  34. package/src/oauth.test.ts +289 -8
  35. package/src/oauth.ts +57 -12
  36. package/src/published.test.ts +21 -21
  37. package/src/routes.ts +152 -70
  38. package/src/routing.test.ts +347 -0
  39. package/src/routing.ts +365 -0
  40. package/src/server.ts +7 -278
  41. package/src/systemd.test.ts +15 -0
  42. package/src/systemd.ts +18 -11
  43. package/src/triggers.test.ts +7 -7
  44. package/src/triggers.ts +6 -6
  45. package/src/vault-store.ts +20 -3
  46. package/src/vault.test.ts +356 -262
  47. package/.claude/settings.local.json +0 -31
  48. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  49. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  50. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  51. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  52. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  53. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  54. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  55. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  56. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  57. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  58. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  59. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  60. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  61. package/religions-abrahamic-filter.png +0 -0
  62. package/religions-buddhism-v2.png +0 -0
  63. package/religions-buddhism.png +0 -0
  64. package/religions-final.png +0 -0
  65. package/religions-v1.png +0 -0
  66. package/religions-v2.png +0 -0
  67. package/religions-zen.png +0 -0
  68. package/web/README.md +0 -73
  69. package/web/bun.lock +0 -827
  70. package/web/eslint.config.js +0 -23
  71. package/web/index.html +0 -15
  72. package/web/package.json +0 -36
  73. package/web/public/favicon.svg +0 -1
  74. package/web/public/icons.svg +0 -24
  75. package/web/src/App.tsx +0 -149
  76. package/web/src/Graph.tsx +0 -200
  77. package/web/src/NoteView.tsx +0 -155
  78. package/web/src/Sidebar.tsx +0 -186
  79. package/web/src/api.ts +0 -21
  80. package/web/src/index.css +0 -50
  81. package/web/src/main.tsx +0 -10
  82. package/web/src/types.ts +0 -37
  83. package/web/src/utils.ts +0 -107
  84. package/web/tsconfig.app.json +0 -25
  85. package/web/tsconfig.json +0 -7
  86. package/web/tsconfig.node.json +0 -24
  87. package/web/vite.config.ts +0 -15
@@ -0,0 +1,347 @@
1
+ /**
2
+ * Tests for the HTTP routing layer (src/routing.ts).
3
+ *
4
+ * Two surfaces under test here:
5
+ *
6
+ * 1. `/vaults/list` — public, unauthenticated discovery endpoint.
7
+ * Intended for the Daily vault-picker dropdown pre-OAuth.
8
+ * Must never leak anything beyond vault names; must return 404 when
9
+ * the operator disables discovery in config.yaml.
10
+ *
11
+ * 2. Single-vault auto-default. A user with exactly one vault (named
12
+ * anything — not necessarily "default") should be able to hit unscoped
13
+ * routes (/mcp, /api/*, /oauth/*) and have them transparently target
14
+ * their sole vault. Previously `/mcp` tried to look up a vault literally
15
+ * named "default" and failed. The coherence invariant from PR #111 says
16
+ * `/mcp` and `/vaults/:name/mcp` must behave identically for that vault.
17
+ *
18
+ * Uses PARACHUTE_HOME override so each test's vaults live in a tmp dir and
19
+ * never touch ~/.parachute.
20
+ */
21
+
22
+ import { describe, test, expect, beforeEach, afterAll } from "bun:test";
23
+ import { rmSync, existsSync, mkdirSync } from "fs";
24
+ import { join } from "path";
25
+ import { tmpdir } from "os";
26
+
27
+ const testDir = join(
28
+ tmpdir(),
29
+ `vault-routing-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
30
+ );
31
+ process.env.PARACHUTE_HOME = testDir;
32
+
33
+ // Dynamic import after env override so modules pick up the tmp dir.
34
+ const { route } = await import("./routing.ts");
35
+ const {
36
+ readGlobalConfig,
37
+ writeGlobalConfig,
38
+ writeVaultConfig,
39
+ resolveDefaultVault,
40
+ listVaults,
41
+ } = await import("./config.ts");
42
+ // clearVaultStoreCache was added in #111 for exactly this kind of test
43
+ // that wipes its PARACHUTE_HOME between runs — it closes stores silently
44
+ // even when the DB files are already gone.
45
+ const { clearVaultStoreCache } = await import("./vault-store.ts");
46
+
47
+ function createVault(name: string, description?: string): void {
48
+ writeVaultConfig({
49
+ name,
50
+ api_keys: [],
51
+ created_at: new Date().toISOString(),
52
+ description,
53
+ });
54
+ }
55
+
56
+ function reset(): void {
57
+ clearVaultStoreCache();
58
+ if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
59
+ mkdirSync(testDir, { recursive: true });
60
+ mkdirSync(join(testDir, "vaults"), { recursive: true });
61
+ writeGlobalConfig({ port: 1940 });
62
+ }
63
+
64
+ beforeEach(() => {
65
+ reset();
66
+ });
67
+
68
+ afterAll(() => {
69
+ clearVaultStoreCache();
70
+ if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
71
+ });
72
+
73
+ // ---------------------------------------------------------------------------
74
+ // resolveDefaultVault — the function that powers every "unscoped" route.
75
+ // ---------------------------------------------------------------------------
76
+
77
+ describe("resolveDefaultVault", () => {
78
+ test("returns the configured default_vault when it exists", () => {
79
+ createVault("journal");
80
+ createVault("work");
81
+ writeGlobalConfig({ port: 1940, default_vault: "journal" });
82
+ expect(resolveDefaultVault()).toBe("journal");
83
+ });
84
+
85
+ test("returns the sole vault when default_vault is unset", () => {
86
+ createVault("journal");
87
+ // No default_vault in global config.
88
+ expect(resolveDefaultVault()).toBe("journal");
89
+ });
90
+
91
+ test("returns the sole vault even if default_vault points to a deleted one", () => {
92
+ // Simulates: user had two vaults, removed the one that was the default,
93
+ // but config.yaml still references the old name.
94
+ createVault("journal");
95
+ writeGlobalConfig({ port: 1940, default_vault: "deleted-vault" });
96
+ expect(resolveDefaultVault()).toBe("journal");
97
+ });
98
+
99
+ test("returns null when multiple vaults exist and no valid default", () => {
100
+ createVault("journal");
101
+ createVault("work");
102
+ writeGlobalConfig({ port: 1940, default_vault: "missing" });
103
+ expect(resolveDefaultVault()).toBeNull();
104
+ });
105
+
106
+ test("returns null when no vaults exist", () => {
107
+ expect(resolveDefaultVault()).toBeNull();
108
+ });
109
+
110
+ test("does not special-case the name 'default' — a vault named 'journal' alone is the default", () => {
111
+ // This is the Aaron-acked bug: having to go to /vaults/journal/mcp
112
+ // when it's the only vault was confusing. Now the name doesn't matter.
113
+ createVault("journal");
114
+ expect(resolveDefaultVault()).toBe("journal");
115
+ expect(listVaults()).toEqual(["journal"]);
116
+ // And explicitly: "default" is NOT synthesized when it doesn't exist.
117
+ expect(resolveDefaultVault()).not.toBe("default");
118
+ });
119
+ });
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // /vaults/list — Task 1: public discovery endpoint for the Daily picker.
123
+ // ---------------------------------------------------------------------------
124
+
125
+ describe("GET /vaults/list (public discovery)", () => {
126
+ test("unauthenticated request returns 200 with the list of names", async () => {
127
+ createVault("journal");
128
+ createVault("work");
129
+ const req = new Request("http://localhost:1940/vaults/list");
130
+ const res = await route(req, "/vaults/list");
131
+ expect(res.status).toBe(200);
132
+ const body = (await res.json()) as { vaults: string[] };
133
+ // Order mirrors listVaults() (alphabetical from `ls`). Assert as set
134
+ // to avoid coupling to the shell order on exotic filesystems.
135
+ expect(new Set(body.vaults)).toEqual(new Set(["journal", "work"]));
136
+ });
137
+
138
+ test("response contains only names — no descriptions, timestamps, counts, or keys", async () => {
139
+ createVault("journal", "Private journal — do not expose this description");
140
+ const req = new Request("http://localhost:1940/vaults/list");
141
+ const res = await route(req, "/vaults/list");
142
+ const body = await res.json();
143
+
144
+ // Must be exactly { vaults: [string, ...] }. Anything else is a leak.
145
+ expect(Object.keys(body as object).sort()).toEqual(["vaults"]);
146
+ expect((body as { vaults: unknown[] }).vaults).toEqual(["journal"]);
147
+
148
+ // Defense in depth: stringify and grep for anything sensitive.
149
+ const dump = JSON.stringify(body);
150
+ expect(dump).not.toContain("Private journal");
151
+ expect(dump).not.toContain("description");
152
+ expect(dump).not.toContain("created_at");
153
+ expect(dump).not.toContain("api_keys");
154
+ expect(dump).not.toContain("key_hash");
155
+ });
156
+
157
+ test("returns an empty list (still 200) when no vaults exist", async () => {
158
+ const req = new Request("http://localhost:1940/vaults/list");
159
+ const res = await route(req, "/vaults/list");
160
+ expect(res.status).toBe(200);
161
+ const body = (await res.json()) as { vaults: string[] };
162
+ expect(body.vaults).toEqual([]);
163
+ });
164
+
165
+ test("returns 404 when discovery is disabled in config", async () => {
166
+ createVault("journal");
167
+ writeGlobalConfig({ port: 1940, discovery: "disabled" });
168
+ const req = new Request("http://localhost:1940/vaults/list");
169
+ const res = await route(req, "/vaults/list");
170
+ expect(res.status).toBe(404);
171
+ const body = await res.json();
172
+ // Must not leak that any vaults exist.
173
+ const dump = JSON.stringify(body);
174
+ expect(dump).not.toContain("journal");
175
+ });
176
+
177
+ test("returns 200 when discovery is explicitly enabled", async () => {
178
+ createVault("journal");
179
+ writeGlobalConfig({ port: 1940, discovery: "enabled" });
180
+ const req = new Request("http://localhost:1940/vaults/list");
181
+ const res = await route(req, "/vaults/list");
182
+ expect(res.status).toBe(200);
183
+ });
184
+
185
+ test("ignores Authorization header (endpoint is public)", async () => {
186
+ // Even with an obviously-wrong token, the endpoint must still succeed.
187
+ // This catches a regression where we'd accidentally gate it behind auth.
188
+ createVault("journal");
189
+ const req = new Request("http://localhost:1940/vaults/list", {
190
+ headers: { Authorization: "Bearer not-a-real-token" },
191
+ });
192
+ const res = await route(req, "/vaults/list");
193
+ expect(res.status).toBe(200);
194
+ });
195
+
196
+ test("rejects non-GET methods (falls through to vault-scoped 404 path)", async () => {
197
+ // Non-GET methods on /vaults/list fall through to the /vaults/:name
198
+ // matcher with name="list". Since no vault named "list" can exist
199
+ // (reserved name), we get a 404. This is the expected behavior —
200
+ // the endpoint is GET-only.
201
+ createVault("journal");
202
+ const req = new Request("http://localhost:1940/vaults/list", { method: "POST" });
203
+ const res = await route(req, "/vaults/list");
204
+ expect(res.status).toBe(404);
205
+ });
206
+
207
+ test("discovery disabled still allows authenticated /vaults listing (they are separate concerns)", async () => {
208
+ // /vaults (authenticated, with metadata) is orthogonal to /vaults/list
209
+ // (public, names only). Disabling discovery hides the public endpoint
210
+ // but not the authenticated one.
211
+ createVault("journal");
212
+ writeGlobalConfig({ port: 1940, discovery: "disabled" });
213
+ // We can at least assert that /vaults still returns 401 (auth required)
214
+ // rather than 404 (disabled). Past that, auth is covered elsewhere.
215
+ const req = new Request("http://localhost:1940/vaults");
216
+ const res = await route(req, "/vaults");
217
+ expect(res.status).toBe(401);
218
+ });
219
+ });
220
+
221
+ // ---------------------------------------------------------------------------
222
+ // Single-vault auto-default — Task 2: /mcp, /api/*, /oauth/* target the
223
+ // only vault regardless of its name.
224
+ // ---------------------------------------------------------------------------
225
+
226
+ describe("single-vault auto-default", () => {
227
+ test("one vault named 'journal' → /mcp requires auth (would error if vault not resolvable)", async () => {
228
+ // With one vault named "journal" and no default_vault set, /mcp must
229
+ // still hit the MCP handler. The handler itself requires a valid
230
+ // Bearer token — we assert 401 (auth failure), proving the request
231
+ // reached the MCP layer and did NOT bail out with "Default vault not
232
+ // found". Before this fix, /mcp would error because the code
233
+ // hardcoded the fallback name "default".
234
+ createVault("journal");
235
+ // Deliberately no default_vault — single-vault fallback should kick in.
236
+ const req = new Request("http://localhost:1940/mcp");
237
+ const res = await route(req, "/mcp");
238
+ expect(res.status).toBe(401);
239
+ });
240
+
241
+ test("one vault named 'journal' → /api/notes reaches auth (not 404'd)", async () => {
242
+ createVault("journal");
243
+ const req = new Request("http://localhost:1940/api/notes");
244
+ const res = await route(req, "/api/notes");
245
+ // Unauthenticated: 401 from per-vault auth. Before the fix, it would
246
+ // have 404'd with "Default vault not found".
247
+ expect(res.status).toBe(401);
248
+ });
249
+
250
+ test("one vault named 'journal' → /oauth/register reaches the OAuth handler (not 500'd)", async () => {
251
+ createVault("journal");
252
+ const req = new Request("http://localhost:1940/oauth/register", {
253
+ method: "POST",
254
+ headers: { "Content-Type": "application/json" },
255
+ body: JSON.stringify({ client_name: "test", redirect_uris: ["https://x.example/cb"] }),
256
+ });
257
+ const res = await route(req, "/oauth/register");
258
+ // Successful registration (201) or 400 from a validation issue —
259
+ // either way, we're PAST the "Default vault not configured" path.
260
+ expect(res.status).not.toBe(500);
261
+ expect([201, 400]).toContain(res.status);
262
+ });
263
+
264
+ test("one vault named 'journal' → /vaults/journal/mcp still works identically (coherence invariant)", async () => {
265
+ // This is the invariant from PR #111: the scoped and unscoped MCP paths
266
+ // must behave identically for a single-vault deployment. Both should
267
+ // land on the MCP auth gate and return 401 unauthenticated.
268
+ createVault("journal");
269
+ const unscopedReq = new Request("http://localhost:1940/mcp");
270
+ const unscopedRes = await route(unscopedReq, "/mcp");
271
+ const scopedReq = new Request("http://localhost:1940/vaults/journal/mcp");
272
+ const scopedRes = await route(scopedReq, "/vaults/journal/mcp");
273
+ expect(unscopedRes.status).toBe(scopedRes.status);
274
+ expect(unscopedRes.status).toBe(401);
275
+ });
276
+
277
+ test("multiple vaults, no default_vault → /mcp returns a clear error, not a silent guess", async () => {
278
+ createVault("journal");
279
+ createVault("work");
280
+ // No default_vault set.
281
+ const req = new Request("http://localhost:1940/mcp");
282
+ const res = await route(req, "/mcp");
283
+ // We refuse to guess when multiple vaults exist. Hitting /mcp here
284
+ // must NOT silently target one of them. We expect a non-200 status
285
+ // that is not the auth gate (since resolveDefaultVault returns null,
286
+ // we return 401 because auth runs first, but the underlying MCP
287
+ // handler never runs — the previous test guarantees that). For the
288
+ // /api/ path, which checks the vault before auth, we get 404.
289
+ const apiReq = new Request("http://localhost:1940/api/notes");
290
+ const apiRes = await route(apiReq, "/api/notes");
291
+ expect(apiRes.status).toBe(404);
292
+ });
293
+
294
+ test("multiple vaults, default_vault='journal' → /api/notes targets journal", async () => {
295
+ createVault("journal");
296
+ createVault("work");
297
+ writeGlobalConfig({ port: 1940, default_vault: "journal" });
298
+ const req = new Request("http://localhost:1940/api/notes");
299
+ const res = await route(req, "/api/notes");
300
+ // Reaches auth (401) rather than "Default vault not found" (404).
301
+ expect(res.status).toBe(401);
302
+ });
303
+
304
+ test("default_vault points to a deleted vault but one other exists → fall back to the survivor", async () => {
305
+ createVault("journal");
306
+ writeGlobalConfig({ port: 1940, default_vault: "deleted-vault" });
307
+ // resolveDefaultVault ignores the stale pointer and returns journal.
308
+ expect(resolveDefaultVault()).toBe("journal");
309
+ // And /api/notes now routes to it — 401 from per-vault auth, not 404.
310
+ const req = new Request("http://localhost:1940/api/notes");
311
+ const res = await route(req, "/api/notes");
312
+ expect(res.status).toBe(401);
313
+ });
314
+
315
+ test("default_vault points to a deleted vault and multiple others exist → error (no guessing)", async () => {
316
+ createVault("journal");
317
+ createVault("work");
318
+ writeGlobalConfig({ port: 1940, default_vault: "deleted-vault" });
319
+ expect(resolveDefaultVault()).toBeNull();
320
+ const req = new Request("http://localhost:1940/api/notes");
321
+ const res = await route(req, "/api/notes");
322
+ expect(res.status).toBe(404);
323
+ });
324
+
325
+ test("no vaults exist → /mcp returns auth error (MCP handler short-circuits on empty global config)", async () => {
326
+ // Edge case: /mcp runs global auth first. With no vaults and no keys,
327
+ // auth fails → 401. This is fine — the handler never sees the empty
328
+ // state. We assert we never return 500.
329
+ const req = new Request("http://localhost:1940/mcp");
330
+ const res = await route(req, "/mcp");
331
+ expect(res.status).not.toBe(500);
332
+ });
333
+
334
+ test("empty config file (no default_vault) with single vault — existing deployments keep working", async () => {
335
+ // Migration concern: users with a config.yaml that never had
336
+ // default_vault set (pre-#111 deployments) should not break.
337
+ // Drop a minimal config.yaml that only specifies port.
338
+ writeGlobalConfig({ port: 1940 });
339
+ createVault("my-only-vault");
340
+ const cfg = readGlobalConfig();
341
+ expect(cfg.default_vault).toBeUndefined();
342
+ // /api/notes still routes to my-only-vault via single-vault fallback.
343
+ const req = new Request("http://localhost:1940/api/notes");
344
+ const res = await route(req, "/api/notes");
345
+ expect(res.status).toBe(401); // reached per-vault auth
346
+ });
347
+ });