@openparachute/vault 0.1.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 (103) hide show
  1. package/.claude/settings.local.json +31 -0
  2. package/.dockerignore +8 -0
  3. package/.env.example +9 -0
  4. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +2 -0
  5. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +1 -0
  6. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +2 -0
  7. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +2 -0
  8. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +1 -0
  9. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +1 -0
  10. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +211 -0
  11. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +59 -0
  12. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +232 -0
  13. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +182 -0
  14. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +91 -0
  15. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +70 -0
  16. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +59 -0
  17. package/CLAUDE.md +115 -0
  18. package/Caddyfile +3 -0
  19. package/Dockerfile +22 -0
  20. package/LICENSE +661 -0
  21. package/README.md +356 -0
  22. package/bun.lock +219 -0
  23. package/bunfig.toml +2 -0
  24. package/core/package.json +7 -0
  25. package/core/src/core.test.ts +940 -0
  26. package/core/src/hooks.test.ts +361 -0
  27. package/core/src/hooks.ts +234 -0
  28. package/core/src/links.ts +352 -0
  29. package/core/src/mcp.ts +672 -0
  30. package/core/src/notes.ts +520 -0
  31. package/core/src/obsidian.test.ts +380 -0
  32. package/core/src/obsidian.ts +322 -0
  33. package/core/src/paths.test.ts +197 -0
  34. package/core/src/paths.ts +53 -0
  35. package/core/src/schema.ts +331 -0
  36. package/core/src/store.ts +303 -0
  37. package/core/src/tag-schemas.ts +104 -0
  38. package/core/src/test-preload.ts +8 -0
  39. package/core/src/types.ts +140 -0
  40. package/core/src/wikilinks.test.ts +277 -0
  41. package/core/src/wikilinks.ts +402 -0
  42. package/deploy/parachute-vault.service +20 -0
  43. package/docker-compose.yml +50 -0
  44. package/docs/HTTP_API.md +328 -0
  45. package/fly.toml +24 -0
  46. package/package.json +32 -0
  47. package/railway.json +14 -0
  48. package/religions-abrahamic-filter.png +0 -0
  49. package/religions-buddhism-v2.png +0 -0
  50. package/religions-buddhism.png +0 -0
  51. package/religions-final.png +0 -0
  52. package/religions-v1.png +0 -0
  53. package/religions-v2.png +0 -0
  54. package/religions-zen.png +0 -0
  55. package/scripts/migrate-audio-to-opus.test.ts +237 -0
  56. package/scripts/migrate-audio-to-opus.ts +499 -0
  57. package/src/auth.ts +170 -0
  58. package/src/cli.ts +1131 -0
  59. package/src/config-triggers.test.ts +83 -0
  60. package/src/config.test.ts +125 -0
  61. package/src/config.ts +716 -0
  62. package/src/db.ts +14 -0
  63. package/src/launchd.ts +109 -0
  64. package/src/mcp-http.ts +113 -0
  65. package/src/mcp-tools.ts +155 -0
  66. package/src/oauth.test.ts +1242 -0
  67. package/src/oauth.ts +729 -0
  68. package/src/owner-auth.ts +159 -0
  69. package/src/prompt.ts +141 -0
  70. package/src/published.test.ts +214 -0
  71. package/src/qrcode-terminal.d.ts +9 -0
  72. package/src/routes.ts +822 -0
  73. package/src/server.ts +450 -0
  74. package/src/systemd.ts +84 -0
  75. package/src/token-store.test.ts +174 -0
  76. package/src/token-store.ts +241 -0
  77. package/src/triggers.test.ts +397 -0
  78. package/src/triggers.ts +412 -0
  79. package/src/two-factor.test.ts +246 -0
  80. package/src/two-factor.ts +222 -0
  81. package/src/vault-store.ts +47 -0
  82. package/src/vault.test.ts +1309 -0
  83. package/tsconfig.json +29 -0
  84. package/web/README.md +73 -0
  85. package/web/bun.lock +827 -0
  86. package/web/eslint.config.js +23 -0
  87. package/web/index.html +15 -0
  88. package/web/package.json +36 -0
  89. package/web/public/favicon.svg +1 -0
  90. package/web/public/icons.svg +24 -0
  91. package/web/src/App.tsx +149 -0
  92. package/web/src/Graph.tsx +200 -0
  93. package/web/src/NoteView.tsx +155 -0
  94. package/web/src/Sidebar.tsx +186 -0
  95. package/web/src/api.ts +21 -0
  96. package/web/src/index.css +50 -0
  97. package/web/src/main.tsx +10 -0
  98. package/web/src/types.ts +37 -0
  99. package/web/src/utils.ts +107 -0
  100. package/web/tsconfig.app.json +25 -0
  101. package/web/tsconfig.json +7 -0
  102. package/web/tsconfig.node.json +24 -0
  103. package/web/vite.config.ts +15 -0
@@ -0,0 +1,1309 @@
1
+ /**
2
+ * Tests for the multi-vault system using bun:sqlite.
3
+ */
4
+
5
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
6
+ import { Database } from "bun:sqlite";
7
+ import { mkdirSync, rmSync, existsSync } from "fs";
8
+ import { join } from "path";
9
+ import { tmpdir } from "os";
10
+ import { BunStore } from "./vault-store.ts";
11
+ import { generateMcpTools } from "../core/src/mcp.ts";
12
+ import { getLinksHydrated } from "../core/src/links.ts";
13
+ import { handleNotes, handleTags, handleFindPath, handleVault } from "./routes.ts";
14
+ import { extractApiKey } from "./auth.ts";
15
+
16
+ let db: Database;
17
+ let store: BunStore;
18
+ let tmpDir: string;
19
+
20
+ beforeEach(() => {
21
+ tmpDir = join(tmpdir(), `vault-test-${Date.now()}`);
22
+ mkdirSync(tmpDir, { recursive: true });
23
+ db = new Database(join(tmpDir, "test.db"));
24
+ store = new BunStore(db);
25
+ });
26
+
27
+ afterEach(() => {
28
+ db.close();
29
+ rmSync(tmpDir, { recursive: true, force: true });
30
+ });
31
+
32
+ describe("BunStore", () => {
33
+ test("creates and retrieves a note", () => {
34
+ const note = store.createNote("Hello world");
35
+ expect(note.id).toBeTruthy();
36
+ expect(note.content).toBe("Hello world");
37
+
38
+ const fetched = store.getNote(note.id);
39
+ expect(fetched).not.toBeNull();
40
+ expect(fetched!.content).toBe("Hello world");
41
+ });
42
+
43
+ test("creates note with tags", () => {
44
+ const note = store.createNote("Tagged note", { tags: ["daily", "pinned"] });
45
+ expect(note.tags).toContain("daily");
46
+ expect(note.tags).toContain("pinned");
47
+ });
48
+
49
+ test("creates note with path", () => {
50
+ const note = store.createNote("Doc note", { path: "blog/first-post" });
51
+ expect(note.path).toBe("blog/first-post");
52
+ });
53
+
54
+ test("updates a note", () => {
55
+ const note = store.createNote("Original");
56
+ const updated = store.updateNote(note.id, { content: "Updated" });
57
+ expect(updated.content).toBe("Updated");
58
+ expect(updated.updatedAt).toBeTruthy();
59
+ });
60
+
61
+ test("user updates bump updatedAt", () => {
62
+ const note = store.createNote("Original");
63
+ expect(note.updatedAt).toBeUndefined();
64
+ const updated = store.updateNote(note.id, { content: "Edited by user" });
65
+ expect(updated.updatedAt).toBeTruthy();
66
+ });
67
+
68
+ test("skipUpdatedAt preserves updatedAt (hook-style writes)", async () => {
69
+ // Hook writes (e.g., the reader-audio hook's metadata markers) must not
70
+ // count as user activity. See issue #44 — hook writes were bumping
71
+ // updatedAt and wrecking Daily's reader sort.
72
+ const note = store.createNote("Content");
73
+ expect(note.updatedAt).toBeUndefined();
74
+
75
+ // Fresh note: a machine write must not set updatedAt.
76
+ store.updateNote(note.id, {
77
+ metadata: { audio_pending_at: "2026-04-09T10:00:00.000Z" },
78
+ skipUpdatedAt: true,
79
+ });
80
+ let fetched = store.getNote(note.id)!;
81
+ expect(fetched.updatedAt).toBeUndefined();
82
+ expect((fetched.metadata as { audio_pending_at?: string } | undefined)?.audio_pending_at).toBe(
83
+ "2026-04-09T10:00:00.000Z",
84
+ );
85
+
86
+ // Now a real user edit bumps it.
87
+ await new Promise((r) => setTimeout(r, 5));
88
+ store.updateNote(note.id, { content: "User edit" });
89
+ fetched = store.getNote(note.id)!;
90
+ const userTs = fetched.updatedAt;
91
+ expect(userTs).toBeTruthy();
92
+
93
+ // A subsequent machine write must not overwrite the user's timestamp.
94
+ await new Promise((r) => setTimeout(r, 5));
95
+ store.updateNote(note.id, {
96
+ metadata: {
97
+ ...(fetched.metadata as Record<string, unknown>),
98
+ audio_rendered_at: "2026-04-09T10:05:00.000Z",
99
+ },
100
+ skipUpdatedAt: true,
101
+ });
102
+ fetched = store.getNote(note.id)!;
103
+ expect(fetched.updatedAt).toBe(userTs!);
104
+ expect((fetched.metadata as { audio_rendered_at?: string } | undefined)?.audio_rendered_at).toBe(
105
+ "2026-04-09T10:05:00.000Z",
106
+ );
107
+ });
108
+
109
+ test("deletes a note", () => {
110
+ const note = store.createNote("To delete");
111
+ store.deleteNote(note.id);
112
+ expect(store.getNote(note.id)).toBeNull();
113
+ });
114
+
115
+ test("queries notes by tag", () => {
116
+ store.createNote("A", { tags: ["daily"] });
117
+ store.createNote("B", { tags: ["doc"] });
118
+ store.createNote("C", { tags: ["daily", "pinned"] });
119
+
120
+ const daily = store.queryNotes({ tags: ["daily"] });
121
+ expect(daily.length).toBe(2);
122
+ });
123
+
124
+ test("queries with exclude tags", () => {
125
+ store.createNote("A", { tags: ["daily"] });
126
+ store.createNote("B", { tags: ["daily", "archived"] });
127
+
128
+ const active = store.queryNotes({ tags: ["daily"], excludeTags: ["archived"] });
129
+ expect(active.length).toBe(1);
130
+ expect(active[0].content).toBe("A");
131
+ });
132
+
133
+ test("full-text search", () => {
134
+ store.createNote("The quick brown fox");
135
+ store.createNote("A lazy dog");
136
+
137
+ const results = store.searchNotes("fox");
138
+ expect(results.length).toBe(1);
139
+ expect(results[0].content).toContain("fox");
140
+ });
141
+
142
+ test("tags and untags notes", () => {
143
+ const note = store.createNote("Taggable");
144
+ store.tagNote(note.id, ["important"]);
145
+ let fetched = store.getNote(note.id)!;
146
+ expect(fetched.tags).toContain("important");
147
+
148
+ store.untagNote(note.id, ["important"]);
149
+ fetched = store.getNote(note.id)!;
150
+ expect(fetched.tags).not.toContain("important");
151
+ });
152
+
153
+ test("lists tags with counts", () => {
154
+ store.createNote("A", { tags: ["daily"] });
155
+ store.createNote("B", { tags: ["daily"] });
156
+ store.createNote("C", { tags: ["doc"] });
157
+
158
+ const tags = store.listTags();
159
+ const daily = tags.find((t) => t.name === "daily");
160
+ expect(daily?.count).toBe(2);
161
+ const doc = tags.find((t) => t.name === "doc");
162
+ expect(doc?.count).toBe(1);
163
+ });
164
+
165
+ test("creates and queries links", () => {
166
+ const a = store.createNote("Note A");
167
+ const b = store.createNote("Note B");
168
+
169
+ const link = store.createLink(a.id, b.id, "related-to");
170
+ expect(link.sourceId).toBe(a.id);
171
+ expect(link.targetId).toBe(b.id);
172
+ expect(link.relationship).toBe("related-to");
173
+
174
+ const outbound = store.getLinks(a.id, { direction: "outbound" });
175
+ expect(outbound.length).toBe(1);
176
+
177
+ const inbound = store.getLinks(b.id, { direction: "inbound" });
178
+ expect(inbound.length).toBe(1);
179
+
180
+ store.deleteLink(a.id, b.id, "related-to");
181
+ expect(store.getLinks(a.id).length).toBe(0);
182
+ });
183
+
184
+ test("attachments", () => {
185
+ const note = store.createNote("With attachment");
186
+ const att = store.addAttachment(note.id, "/path/to/file.png", "image/png");
187
+ expect(att.noteId).toBe(note.id);
188
+
189
+ const atts = store.getAttachments(note.id);
190
+ expect(atts.length).toBe(1);
191
+ expect(atts[0].mimeType).toBe("image/png");
192
+ });
193
+
194
+ test("starts with no tags", () => {
195
+ const tags = store.listTags();
196
+ expect(tags.length).toBe(0);
197
+ });
198
+
199
+ test("gets note by path", () => {
200
+ store.createNote("README content", { path: "Projects/Parachute/README" });
201
+ const note = store.getNoteByPath("Projects/Parachute/README");
202
+ expect(note).not.toBeNull();
203
+ expect(note!.content).toBe("README content");
204
+ expect(note!.path).toBe("Projects/Parachute/README");
205
+ });
206
+
207
+ test("gets multiple notes by IDs", () => {
208
+ const a = store.createNote("A");
209
+ const b = store.createNote("B");
210
+ const c = store.createNote("C");
211
+
212
+ const fetched = store.getNotes([a.id, c.id]);
213
+ expect(fetched.length).toBe(2);
214
+ expect(fetched.map((n) => n.content)).toContain("A");
215
+ expect(fetched.map((n) => n.content)).toContain("C");
216
+ });
217
+
218
+ test("queries by path prefix", () => {
219
+ store.createNote("Root README", { path: "README" });
220
+ store.createNote("Project README", { path: "Projects/Parachute/README" });
221
+ store.createNote("Project Notes", { path: "Projects/Parachute/Notes" });
222
+ store.createNote("Other", { path: "Other/Stuff" });
223
+
224
+ const results = store.queryNotes({ pathPrefix: "Projects/Parachute" });
225
+ expect(results.length).toBe(2);
226
+ expect(results.map((n) => n.path)).toContain("Projects/Parachute/README");
227
+ expect(results.map((n) => n.path)).toContain("Projects/Parachute/Notes");
228
+ });
229
+ });
230
+
231
+ describe("metadata", () => {
232
+ test("creates note with metadata", () => {
233
+ const note = store.createNote("Meeting notes", {
234
+ path: "Meetings/standup",
235
+ metadata: { status: "draft", priority: "high", attendees: ["alice", "bob"] },
236
+ });
237
+ expect(note.metadata).toBeDefined();
238
+ expect(note.metadata!.status).toBe("draft");
239
+ expect(note.metadata!.priority).toBe("high");
240
+ expect(note.metadata!.attendees).toEqual(["alice", "bob"]);
241
+ });
242
+
243
+ test("updates note metadata", () => {
244
+ const note = store.createNote("Doc", { metadata: { status: "draft" } });
245
+ const updated = store.updateNote(note.id, { metadata: { status: "published", version: 2 } });
246
+ expect(updated.metadata!.status).toBe("published");
247
+ expect(updated.metadata!.version).toBe(2);
248
+ });
249
+
250
+ test("queries notes by metadata", () => {
251
+ store.createNote("Draft 1", { metadata: { status: "draft" } });
252
+ store.createNote("Draft 2", { metadata: { status: "draft" } });
253
+ store.createNote("Published", { metadata: { status: "published" } });
254
+
255
+ const drafts = store.queryNotes({ metadata: { status: "draft" } });
256
+ expect(drafts.length).toBe(2);
257
+
258
+ const published = store.queryNotes({ metadata: { status: "published" } });
259
+ expect(published.length).toBe(1);
260
+ expect(published[0].content).toBe("Published");
261
+ });
262
+
263
+ test("notes without metadata return undefined metadata", () => {
264
+ const note = store.createNote("Plain note");
265
+ expect(note.metadata).toBeUndefined();
266
+ });
267
+
268
+ test("creates link with metadata", () => {
269
+ const a = store.createNote("A");
270
+ const b = store.createNote("B");
271
+ const link = store.createLink(a.id, b.id, "related-to", {
272
+ confidence: 0.9,
273
+ context: "mentioned in meeting",
274
+ });
275
+ expect(link.metadata).toBeDefined();
276
+ expect(link.metadata!.confidence).toBe(0.9);
277
+ expect(link.metadata!.context).toBe("mentioned in meeting");
278
+ });
279
+
280
+ test("hydrated links include note metadata", () => {
281
+ const a = store.createNote("A", { metadata: { type: "project" } });
282
+ const b = store.createNote("B", { metadata: { type: "task" } });
283
+ store.createLink(a.id, b.id, "contains");
284
+
285
+ const links = getLinksHydrated(db, a.id);
286
+ expect(links[0].sourceNote?.metadata?.type).toBe("project");
287
+ expect(links[0].targetNote?.metadata?.type).toBe("task");
288
+ });
289
+ });
290
+
291
+ describe("bulk operations", () => {
292
+ test("creates multiple notes at once", () => {
293
+ const notes = store.createNotes([
294
+ { content: "Note 1", tags: ["daily"] },
295
+ { content: "Note 2", tags: ["doc"] },
296
+ { content: "Note 3" },
297
+ ]);
298
+ expect(notes.length).toBe(3);
299
+ expect(notes[0].tags).toContain("daily");
300
+ expect(notes[1].tags).toContain("doc");
301
+ });
302
+
303
+ test("createNotes accepts per-note metadata and created_at (mixed batch)", () => {
304
+ const notes = store.createNotes([
305
+ { content: "Plain", tags: ["daily"] },
306
+ {
307
+ content: "With metadata",
308
+ path: "Imports/with-meta",
309
+ metadata: { source: "tana-import", tana_type: "flow" },
310
+ },
311
+ {
312
+ content: "With backdated created_at",
313
+ path: "Imports/backdated",
314
+ metadata: { source: "tana-import" },
315
+ created_at: "2020-01-15T12:00:00.000Z",
316
+ },
317
+ ]);
318
+ expect(notes.length).toBe(3);
319
+
320
+ // Plain note: no source metadata, recent createdAt
321
+ expect(notes[0].metadata?.source).toBeUndefined();
322
+ expect(notes[0].tags).toContain("daily");
323
+
324
+ // Metadata-only note: metadata flows through, createdAt is recent
325
+ expect(notes[1].metadata?.source).toBe("tana-import");
326
+ expect(notes[1].metadata?.tana_type).toBe("flow");
327
+ expect(notes[1].path).toBe("Imports/with-meta");
328
+
329
+ // Backdated note: createdAt honored exactly
330
+ expect(notes[2].createdAt).toBe("2020-01-15T12:00:00.000Z");
331
+ expect(notes[2].metadata?.source).toBe("tana-import");
332
+ });
333
+
334
+ test("createNotes preserves per-note metadata isolation across many notes", () => {
335
+ const inputs = Array.from({ length: 5 }, (_, i) => ({
336
+ content: `Day ${i}`,
337
+ path: `Journal/2024-06-${String(i + 1).padStart(2, "0")}`,
338
+ tags: ["captured"],
339
+ metadata: {
340
+ source: "tana-import",
341
+ tana_path: `daily/2024-06-${i + 1}.md`,
342
+ index: i,
343
+ },
344
+ created_at: `2024-06-${String(i + 1).padStart(2, "0")}T12:00:00.000Z`,
345
+ }));
346
+ const notes = store.createNotes(inputs);
347
+ expect(notes.length).toBe(5);
348
+ for (let i = 0; i < 5; i++) {
349
+ expect(notes[i].path).toBe(`Journal/2024-06-${String(i + 1).padStart(2, "0")}`);
350
+ expect(notes[i].metadata?.index).toBe(i);
351
+ expect(notes[i].metadata?.tana_path).toBe(`daily/2024-06-${i + 1}.md`);
352
+ expect(notes[i].createdAt).toBe(`2024-06-${String(i + 1).padStart(2, "0")}T12:00:00.000Z`);
353
+ expect(notes[i].tags).toContain("captured");
354
+ }
355
+ });
356
+
357
+ test("createNotes is backwards compatible — omitted metadata/created_at use defaults", () => {
358
+ const before = new Date().toISOString();
359
+ const notes = store.createNotes([
360
+ { content: "Just content" },
361
+ { content: "Content + tags", tags: ["x"] },
362
+ ]);
363
+ const after = new Date().toISOString();
364
+ expect(notes[0].metadata?.source).toBeUndefined();
365
+ expect(notes[1].metadata?.source).toBeUndefined();
366
+ // createdAt defaults to "now" — should fall in [before, after]
367
+ expect(notes[0].createdAt >= before).toBe(true);
368
+ expect(notes[0].createdAt <= after).toBe(true);
369
+ });
370
+
371
+ test("batch tags multiple notes", () => {
372
+ const a = store.createNote("A");
373
+ const b = store.createNote("B");
374
+ const c = store.createNote("C");
375
+
376
+ store.batchTag([a.id, b.id, c.id], ["important", "review"]);
377
+
378
+ expect(store.getNote(a.id)!.tags).toContain("important");
379
+ expect(store.getNote(b.id)!.tags).toContain("review");
380
+ expect(store.getNote(c.id)!.tags).toContain("important");
381
+ });
382
+
383
+ test("batch untags multiple notes", () => {
384
+ const a = store.createNote("A", { tags: ["daily", "pinned"] });
385
+ const b = store.createNote("B", { tags: ["daily", "pinned"] });
386
+
387
+ store.batchUntag([a.id, b.id], ["pinned"]);
388
+
389
+ expect(store.getNote(a.id)!.tags).toContain("daily");
390
+ expect(store.getNote(a.id)!.tags).not.toContain("pinned");
391
+ expect(store.getNote(b.id)!.tags).not.toContain("pinned");
392
+ });
393
+ });
394
+
395
+ describe("deeper link queries", () => {
396
+ test("traverses links multi-hop", () => {
397
+ const a = store.createNote("A");
398
+ const b = store.createNote("B");
399
+ const c = store.createNote("C");
400
+ const d = store.createNote("D");
401
+
402
+ store.createLink(a.id, b.id, "related-to");
403
+ store.createLink(b.id, c.id, "related-to");
404
+ store.createLink(c.id, d.id, "related-to");
405
+
406
+ // 1 hop from A: should find B
407
+ const hop1 = store.traverseLinks(a.id, { max_depth: 1 });
408
+ expect(hop1.length).toBe(1);
409
+ expect(hop1[0].noteId).toBe(b.id);
410
+
411
+ // 2 hops from A: should find B and C
412
+ const hop2 = store.traverseLinks(a.id, { max_depth: 2 });
413
+ expect(hop2.length).toBe(2);
414
+ const ids2 = hop2.map((n) => n.noteId);
415
+ expect(ids2).toContain(b.id);
416
+ expect(ids2).toContain(c.id);
417
+
418
+ // 3 hops from A: should find B, C, and D
419
+ const hop3 = store.traverseLinks(a.id, { max_depth: 3 });
420
+ expect(hop3.length).toBe(3);
421
+ });
422
+
423
+ test("traverses with relationship filter", () => {
424
+ const a = store.createNote("A");
425
+ const b = store.createNote("B");
426
+ const c = store.createNote("C");
427
+
428
+ store.createLink(a.id, b.id, "mentions");
429
+ store.createLink(a.id, c.id, "related-to");
430
+
431
+ const mentions = store.traverseLinks(a.id, { max_depth: 1, relationship: "mentions" });
432
+ expect(mentions.length).toBe(1);
433
+ expect(mentions[0].noteId).toBe(b.id);
434
+ });
435
+
436
+ test("finds path between notes", () => {
437
+ const a = store.createNote("A");
438
+ const b = store.createNote("B");
439
+ const c = store.createNote("C");
440
+
441
+ store.createLink(a.id, b.id, "related-to");
442
+ store.createLink(b.id, c.id, "mentions");
443
+
444
+ const result = store.findPath(a.id, c.id);
445
+ expect(result).not.toBeNull();
446
+ expect(result!.path).toEqual([a.id, b.id, c.id]);
447
+ expect(result!.relationships).toEqual(["related-to", "mentions"]);
448
+ });
449
+
450
+ test("get-links returns hydrated note summaries", () => {
451
+ const a = store.createNote("Note A", { path: "a", tags: ["important"] });
452
+ const b = store.createNote("Note B", { path: "b" });
453
+ store.createLink(a.id, b.id, "related-to");
454
+
455
+ const result = getLinksHydrated(db, a.id);
456
+ expect(result.length).toBe(1);
457
+ expect(result[0].targetNote?.path).toBe("b");
458
+ expect(result[0].sourceNote?.path).toBe("a");
459
+ expect(result[0].sourceNote?.tags).toContain("important");
460
+ });
461
+
462
+ test("returns null when no path exists", () => {
463
+ const a = store.createNote("A");
464
+ const b = store.createNote("B");
465
+ // No link between them
466
+
467
+ const result = store.findPath(a.id, b.id);
468
+ expect(result).toBeNull();
469
+ });
470
+ });
471
+
472
+ describe("MCP tools", () => {
473
+ test("generates all 9 core tools", () => {
474
+ const tools = generateMcpTools(store);
475
+ expect(tools.length).toBe(9);
476
+
477
+ const names = tools.map((t) => t.name);
478
+ expect(names).toContain("query-notes");
479
+ expect(names).toContain("create-note");
480
+ expect(names).toContain("update-note");
481
+ expect(names).toContain("delete-note");
482
+ expect(names).toContain("list-tags");
483
+ expect(names).toContain("update-tag");
484
+ expect(names).toContain("delete-tag");
485
+ expect(names).toContain("find-path");
486
+ expect(names).toContain("vault-info");
487
+ });
488
+
489
+ test("query-notes by id works", () => {
490
+ const tools = generateMcpTools(store);
491
+ const note = store.createNote("By ID", { path: "test/note" });
492
+
493
+ const query = tools.find((t) => t.name === "query-notes")!;
494
+ const result = query.execute({ id: note.id }) as any;
495
+ expect(result.content).toBe("By ID");
496
+ expect(result.path).toBe("test/note");
497
+ });
498
+
499
+ test("query-notes by path works", () => {
500
+ const tools = generateMcpTools(store);
501
+ store.createNote("By Path", { path: "Projects/README" });
502
+
503
+ const query = tools.find((t) => t.name === "query-notes")!;
504
+ const result = query.execute({ id: "Projects/README" }) as any;
505
+ expect(result.content).toBe("By Path");
506
+ });
507
+
508
+ test("create-note tool works via execute", () => {
509
+ const tools = generateMcpTools(store);
510
+ const createNote = tools.find((t) => t.name === "create-note")!;
511
+ const result = createNote.execute({ content: "MCP note", tags: ["daily"] }) as any;
512
+ expect(result.content).toBe("MCP note");
513
+ expect(result.tags).toContain("daily");
514
+ });
515
+
516
+ test("every tool has inputSchema and execute", () => {
517
+ const tools = generateMcpTools(store);
518
+ for (const tool of tools) {
519
+ expect(tool.inputSchema).toBeDefined();
520
+ expect(tool.execute).toBeFunction();
521
+ }
522
+ });
523
+ });
524
+
525
+ describe("unified MCP wrapper", () => {
526
+ test("vault-info routes through vault param", async () => {
527
+ const { generateUnifiedMcpTools } = await import("./mcp-tools.ts");
528
+ const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
529
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
530
+
531
+ const vaultName = `unified-stats-${Date.now()}`;
532
+ writeVaultConfig({
533
+ name: vaultName,
534
+ api_keys: [],
535
+ created_at: new Date().toISOString(),
536
+ description: "Test vault",
537
+ });
538
+ writeGlobalConfig({ port: 1940, default_vault: vaultName });
539
+
540
+ const vaultStore = getVaultStore(vaultName);
541
+ vaultStore.createNote("alpha", { tags: ["x", "y"] });
542
+ vaultStore.createNote("beta", { tags: ["x"] });
543
+
544
+ const tools = generateUnifiedMcpTools();
545
+ const vaultInfo = tools.find((t) => t.name === "vault-info");
546
+ expect(vaultInfo).toBeTruthy();
547
+
548
+ const result = vaultInfo!.execute({ vault: vaultName, include_stats: true }) as any;
549
+ expect(result.name).toBe(vaultName);
550
+ expect(result.description).toBe("Test vault");
551
+ expect(result.stats.totalNotes).toBe(2);
552
+ expect(result.stats.tagCount).toBe(2);
553
+
554
+ closeAllStores();
555
+ });
556
+
557
+ test("list-tags with schema works through unified wrapper", async () => {
558
+ const { generateUnifiedMcpTools } = await import("./mcp-tools.ts");
559
+ const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
560
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
561
+
562
+ const vaultName = `tag-schema-${Date.now()}`;
563
+ writeVaultConfig({
564
+ name: vaultName,
565
+ api_keys: [],
566
+ created_at: new Date().toISOString(),
567
+ });
568
+ writeGlobalConfig({ port: 1940, default_vault: vaultName });
569
+
570
+ const vaultStore = getVaultStore(vaultName);
571
+ vaultStore.createNote("A", { tags: ["person"] });
572
+ vaultStore.upsertTagSchema("person", {
573
+ description: "A person",
574
+ fields: { name: { type: "string", description: "Full name" } },
575
+ });
576
+
577
+ const tools = generateUnifiedMcpTools();
578
+
579
+ // list-tags with tag param for single tag detail
580
+ const listTags = tools.find((t) => t.name === "list-tags")!;
581
+ const detail = listTags.execute({ vault: vaultName, tag: "person" }) as any;
582
+ expect(detail.name).toBe("person");
583
+ expect(detail.count).toBe(1);
584
+ expect(detail.description).toBe("A person");
585
+ expect(detail.fields.name.type).toBe("string");
586
+
587
+ closeAllStores();
588
+ });
589
+
590
+ test("create-note with schema tag auto-populates defaults", async () => {
591
+ const { generateUnifiedMcpTools } = await import("./mcp-tools.ts");
592
+ const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
593
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
594
+
595
+ const vaultName = `schema-create-${Date.now()}`;
596
+ writeVaultConfig({
597
+ name: vaultName,
598
+ api_keys: [],
599
+ created_at: new Date().toISOString(),
600
+ });
601
+ writeGlobalConfig({ port: 1940, default_vault: vaultName });
602
+
603
+ const vaultStore = getVaultStore(vaultName);
604
+ vaultStore.upsertTagSchema("person", {
605
+ description: "A person",
606
+ fields: {
607
+ first_appeared: { type: "string", description: "When" },
608
+ relationship: { type: "string", description: "How" },
609
+ },
610
+ });
611
+
612
+ const tools = generateUnifiedMcpTools();
613
+ const createNote = tools.find((t) => t.name === "create-note")!;
614
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
615
+
616
+ // Create a note tagged person with no metadata — defaults auto-populated
617
+ const result = createNote.execute({
618
+ vault: vaultName,
619
+ content: "Alice",
620
+ tags: ["person"],
621
+ }) as any;
622
+ expect(result.content).toBe("Alice");
623
+
624
+ // Verify defaults were written
625
+ const fresh = queryNotes.execute({ vault: vaultName, id: result.id }) as any;
626
+ expect(fresh.metadata.first_appeared).toBe("");
627
+ expect(fresh.metadata.relationship).toBe("");
628
+
629
+ // Create with explicit metadata — preserved
630
+ const result2 = createNote.execute({
631
+ vault: vaultName,
632
+ content: "Bob",
633
+ tags: ["person"],
634
+ metadata: { first_appeared: "2024-01", relationship: "friend" },
635
+ }) as any;
636
+ const fresh2 = queryNotes.execute({ vault: vaultName, id: result2.id }) as any;
637
+ expect(fresh2.metadata.first_appeared).toBe("2024-01");
638
+ expect(fresh2.metadata.relationship).toBe("friend");
639
+
640
+ closeAllStores();
641
+ });
642
+
643
+ test("update-note tags.add with schema auto-populates defaults", async () => {
644
+ const { generateUnifiedMcpTools } = await import("./mcp-tools.ts");
645
+ const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
646
+ const { getVaultStore, closeAllStores: close } = await import("./vault-store.ts");
647
+
648
+ const vaultName = `schema-defaults-${Date.now()}`;
649
+ writeVaultConfig({
650
+ name: vaultName,
651
+ api_keys: [],
652
+ created_at: new Date().toISOString(),
653
+ });
654
+ writeGlobalConfig({ port: 1940, default_vault: vaultName });
655
+
656
+ const vaultStore = getVaultStore(vaultName);
657
+ vaultStore.upsertTagSchema("person", {
658
+ description: "A person",
659
+ fields: {
660
+ first_appeared: { type: "string", description: "When" },
661
+ relationship: { type: "string", description: "How" },
662
+ },
663
+ });
664
+ vaultStore.upsertTagSchema("project", {
665
+ description: "A project",
666
+ fields: {
667
+ status: { type: "string", enum: ["active", "completed", "abandoned"], description: "Status" },
668
+ active: { type: "boolean", description: "Is active" },
669
+ priority: { type: "integer", description: "Priority level" },
670
+ },
671
+ });
672
+ const tools = generateUnifiedMcpTools();
673
+ const createNote = tools.find((t) => t.name === "create-note")!;
674
+ const updateNote = tools.find((t) => t.name === "update-note")!;
675
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
676
+
677
+ // Create a note, then add #person tag via update-note
678
+ const note = createNote.execute({ vault: vaultName, content: "Alice" }) as any;
679
+ updateNote.execute({ vault: vaultName, id: note.id, tags: { add: ["person"] } });
680
+ const after = queryNotes.execute({ vault: vaultName, id: note.id }) as any;
681
+ expect(after.metadata.first_appeared).toBe("");
682
+ expect(after.metadata.relationship).toBe("");
683
+
684
+ // Tag note that already has partial metadata — only missing fields populated
685
+ const note2 = createNote.execute({
686
+ vault: vaultName,
687
+ content: "Bob",
688
+ metadata: { first_appeared: "2023-11" },
689
+ }) as any;
690
+ updateNote.execute({ vault: vaultName, id: note2.id, tags: { add: ["person"] } });
691
+ const after2 = queryNotes.execute({ vault: vaultName, id: note2.id }) as any;
692
+ expect(after2.metadata.first_appeared).toBe("2023-11"); // preserved
693
+ expect(after2.metadata.relationship).toBe(""); // added
694
+
695
+ // Tag with #project — enum defaults to first value, boolean to false, integer to 0
696
+ const note4 = createNote.execute({ vault: vaultName, content: "My Project" }) as any;
697
+ updateNote.execute({ vault: vaultName, id: note4.id, tags: { add: ["project"] } });
698
+ const after4 = queryNotes.execute({ vault: vaultName, id: note4.id }) as any;
699
+ expect(after4.metadata.status).toBe("active");
700
+ expect(after4.metadata.active).toBe(false);
701
+ expect(after4.metadata.priority).toBe(0);
702
+
703
+ // Multiple schema tags at once — all defaults merged
704
+ const note5 = createNote.execute({ vault: vaultName, content: "Multi" }) as any;
705
+ updateNote.execute({ vault: vaultName, id: note5.id, tags: { add: ["person", "project"] } });
706
+ const after5 = queryNotes.execute({ vault: vaultName, id: note5.id }) as any;
707
+ expect(after5.metadata.first_appeared).toBe("");
708
+ expect(after5.metadata.relationship).toBe("");
709
+ expect(after5.metadata.status).toBe("active");
710
+ expect(after5.metadata.active).toBe(false);
711
+
712
+ close();
713
+ });
714
+
715
+ test("update-note tags.add auto-populate does not bump updatedAt", async () => {
716
+ const { generateUnifiedMcpTools } = await import("./mcp-tools.ts");
717
+ const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
718
+ const { getVaultStore, closeAllStores: close } = await import("./vault-store.ts");
719
+
720
+ const vaultName = `schema-noupdate-${Date.now()}`;
721
+ writeVaultConfig({
722
+ name: vaultName,
723
+ api_keys: [],
724
+ created_at: new Date().toISOString(),
725
+ });
726
+ writeGlobalConfig({ port: 1940, default_vault: vaultName });
727
+
728
+ const vaultStore = getVaultStore(vaultName);
729
+ vaultStore.upsertTagSchema("person", {
730
+ description: "A person",
731
+ fields: { name: { type: "string" } },
732
+ });
733
+
734
+ const tools = generateUnifiedMcpTools();
735
+ const createNote = tools.find((t) => t.name === "create-note")!;
736
+ const updateNote = tools.find((t) => t.name === "update-note")!;
737
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
738
+
739
+ const note = createNote.execute({ vault: vaultName, content: "Test" }) as any;
740
+ const originalUpdatedAt = note.updatedAt;
741
+ updateNote.execute({ vault: vaultName, id: note.id, tags: { add: ["person"] } });
742
+ const after = queryNotes.execute({ vault: vaultName, id: note.id }) as any;
743
+ expect(after.updatedAt).toBe(originalUpdatedAt);
744
+ expect(after.metadata.name).toBe("");
745
+
746
+ close();
747
+ });
748
+ });
749
+
750
+ describe("auth permissions", () => {
751
+ test("read permission allows read-only tools", () => {
752
+ const { isToolAllowed } = require("./auth.ts");
753
+ expect(isToolAllowed("query-notes", "read")).toBe(true);
754
+ expect(isToolAllowed("list-tags", "read")).toBe(true);
755
+ expect(isToolAllowed("find-path", "read")).toBe(true);
756
+ expect(isToolAllowed("vault-info", "read")).toBe(true);
757
+ expect(isToolAllowed("list-vaults", "read")).toBe(true);
758
+ });
759
+
760
+ test("read permission blocks mutation tools", () => {
761
+ const { isToolAllowed } = require("./auth.ts");
762
+ expect(isToolAllowed("create-note", "read")).toBe(false);
763
+ expect(isToolAllowed("update-note", "read")).toBe(false);
764
+ expect(isToolAllowed("delete-note", "read")).toBe(false);
765
+ expect(isToolAllowed("update-tag", "read")).toBe(false);
766
+ expect(isToolAllowed("delete-tag", "read")).toBe(false);
767
+ });
768
+
769
+ test("full permission allows all tools", () => {
770
+ const { isToolAllowed } = require("./auth.ts");
771
+ expect(isToolAllowed("create-note", "full")).toBe(true);
772
+ expect(isToolAllowed("update-note", "full")).toBe(true);
773
+ expect(isToolAllowed("delete-note", "full")).toBe(true);
774
+ expect(isToolAllowed("update-tag", "full")).toBe(true);
775
+ expect(isToolAllowed("delete-tag", "full")).toBe(true);
776
+ expect(isToolAllowed("query-notes", "full")).toBe(true);
777
+ });
778
+
779
+ test("read permission allows GET but not POST/PATCH/DELETE", () => {
780
+ const { isMethodAllowed } = require("./auth.ts");
781
+ expect(isMethodAllowed("GET", "read")).toBe(true);
782
+ expect(isMethodAllowed("HEAD", "read")).toBe(true);
783
+ expect(isMethodAllowed("POST", "read")).toBe(false);
784
+ expect(isMethodAllowed("PATCH", "read")).toBe(false);
785
+ expect(isMethodAllowed("DELETE", "read")).toBe(false);
786
+ });
787
+
788
+ test("full permission allows all methods", () => {
789
+ const { isMethodAllowed } = require("./auth.ts");
790
+ expect(isMethodAllowed("GET", "full")).toBe(true);
791
+ expect(isMethodAllowed("POST", "full")).toBe(true);
792
+ expect(isMethodAllowed("PATCH", "full")).toBe(true);
793
+ expect(isMethodAllowed("DELETE", "full")).toBe(true);
794
+ });
795
+ });
796
+
797
+ // ---- HTTP route handlers ----
798
+
799
+ const BASE = "http://localhost/api";
800
+
801
+ function mkReq(method: string, path: string, body?: unknown): Request {
802
+ const init: RequestInit = { method };
803
+ if (body !== undefined) {
804
+ init.body = JSON.stringify(body);
805
+ init.headers = { "Content-Type": "application/json" };
806
+ }
807
+ return new Request(`${BASE}${path}`, init);
808
+ }
809
+
810
+ describe("HTTP /notes", () => {
811
+ test("GET /notes defaults to lean index (no content field)", async () => {
812
+ store.createNote("one content", { path: "a", tags: ["t"] });
813
+ store.createNote("two content", { path: "b", tags: ["t"] });
814
+ const res = await handleNotes(mkReq("GET", "/notes"), store, "");
815
+ const body = await res.json() as any[];
816
+ expect(body).toHaveLength(2);
817
+ expect(body[0]).not.toHaveProperty("content");
818
+ expect(body[0]).toHaveProperty("byteSize");
819
+ expect(body[0]).toHaveProperty("preview");
820
+ });
821
+
822
+ test("GET /notes?include_content=true returns full notes", async () => {
823
+ store.createNote("full body", { path: "a" });
824
+ const res = await handleNotes(mkReq("GET", "/notes?include_content=true"), store, "");
825
+ const body = await res.json() as any[];
826
+ expect(body[0].content).toBe("full body");
827
+ });
828
+
829
+ test("GET /notes?search=fox full-text search", async () => {
830
+ store.createNote("The quick brown fox");
831
+ store.createNote("A lazy dog");
832
+ const res = await handleNotes(mkReq("GET", "/notes?search=fox"), store, "");
833
+ const body = await res.json() as any[];
834
+ expect(body).toHaveLength(1);
835
+ });
836
+
837
+ test("GET /notes?search=fox&include_metadata=false strips metadata from search results", async () => {
838
+ store.createNote("The quick brown fox", { metadata: { summary: "animal" } });
839
+ const res = await handleNotes(mkReq("GET", "/notes?search=fox&include_metadata=false"), store, "");
840
+ const body = await res.json() as any[];
841
+ expect(body).toHaveLength(1);
842
+ expect(body[0].metadata).toBeUndefined();
843
+ });
844
+
845
+ test("GET /notes/:id defaults to full content", async () => {
846
+ const n = store.createNote("hello", { id: "x" });
847
+ const res = await handleNotes(mkReq("GET", "/notes/x"), store, "/x");
848
+ const body = await res.json() as any;
849
+ expect(body.content).toBe("hello");
850
+ });
851
+
852
+ test("GET /notes/:id?include_content=false returns lean shape", async () => {
853
+ store.createNote("hello", { id: "x" });
854
+ const res = await handleNotes(mkReq("GET", "/notes/x?include_content=false"), store, "/x");
855
+ const body = await res.json() as any;
856
+ expect(body).not.toHaveProperty("content");
857
+ expect(body.byteSize).toBe(5);
858
+ expect(body.preview).toBe("hello");
859
+ });
860
+
861
+ test("GET /notes?include_metadata=false strips metadata from list", async () => {
862
+ store.createNote("a", { tags: ["m"], metadata: { summary: "hello", status: "ok" } });
863
+ store.createNote("b", { tags: ["m"], metadata: { summary: "world" } });
864
+ const res = await handleNotes(mkReq("GET", "/notes?tag=m&include_metadata=false"), store, "");
865
+ const body = await res.json() as any[];
866
+ expect(body).toHaveLength(2);
867
+ for (const n of body) {
868
+ expect(n.metadata).toBeUndefined();
869
+ }
870
+ });
871
+
872
+ test("GET /notes?include_metadata=summary,status returns only those fields", async () => {
873
+ store.createNote("a", { tags: ["mf"], metadata: { summary: "hello", status: "ok", extra: true } });
874
+ const res = await handleNotes(mkReq("GET", "/notes?tag=mf&include_metadata=summary,status"), store, "");
875
+ const body = await res.json() as any[];
876
+ expect(body).toHaveLength(1);
877
+ expect(body[0].metadata).toEqual({ summary: "hello", status: "ok" });
878
+ });
879
+
880
+ test("GET /notes/:id?include_metadata=false strips metadata from single note", async () => {
881
+ store.createNote("hello", { id: "xm", metadata: { summary: "s" } });
882
+ const res = await handleNotes(mkReq("GET", "/notes/xm?include_metadata=false"), store, "/xm");
883
+ const body = await res.json() as any;
884
+ expect(body.metadata).toBeUndefined();
885
+ expect(body.content).toBe("hello");
886
+ });
887
+
888
+ test("GET /notes/:id?include_metadata=summary returns only specified fields", async () => {
889
+ store.createNote("hello", { id: "xm2", metadata: { summary: "s", status: "draft" } });
890
+ const res = await handleNotes(mkReq("GET", "/notes/xm2?include_metadata=summary"), store, "/xm2");
891
+ const body = await res.json() as any;
892
+ expect(body.metadata).toEqual({ summary: "s" });
893
+ });
894
+
895
+ test("POST /notes accepts createdAt (camelCase) in body", async () => {
896
+ const res = await handleNotes(
897
+ mkReq("POST", "/notes", { content: "hi", createdAt: "2025-01-01T00:00:00.000Z" }),
898
+ store,
899
+ "",
900
+ );
901
+ const body = await res.json() as any;
902
+ expect(body.createdAt).toBe("2025-01-01T00:00:00.000Z");
903
+ });
904
+
905
+ test("POST /notes/:id/attachments accepts mimeType (camelCase) in body", async () => {
906
+ const n = store.createNote("x", { id: "x" });
907
+ const res = await handleNotes(
908
+ mkReq("POST", "/notes/x/attachments", { path: "files/a.png", mimeType: "image/png" }),
909
+ store,
910
+ "/x/attachments",
911
+ );
912
+ expect(res.status).toBe(201);
913
+ const body = await res.json() as any;
914
+ expect(body.mimeType).toBe("image/png");
915
+ });
916
+ });
917
+
918
+ describe("HTTP GET /notes?format=graph", () => {
919
+ test("returns nodes and edges for linked notes", async () => {
920
+ const a = store.createNote("A", { id: "a", path: "People/Alice", tags: ["person"] });
921
+ const b = store.createNote("B", { id: "b", path: "People/Bob", tags: ["person"] });
922
+ const c = store.createNote("C", { id: "c", path: "Projects/X" });
923
+ store.createLink("a", "b", "knows");
924
+ store.createLink("a", "c", "works-on");
925
+
926
+ const res = await handleNotes(
927
+ mkReq("GET", "/notes?format=graph&include_links=true"),
928
+ store,
929
+ "",
930
+ );
931
+ const body = await res.json() as any;
932
+ expect(body.nodes).toHaveLength(3);
933
+ expect(body.edges).toHaveLength(2);
934
+ // Nodes have id, path, tags
935
+ const alice = body.nodes.find((n: any) => n.id === "a");
936
+ expect(alice.path).toBe("People/Alice");
937
+ expect(alice.tags).toEqual(["person"]);
938
+ // Edges have source, target, relationship
939
+ expect(body.edges).toContainEqual({ source: "a", target: "b", relationship: "knows" });
940
+ expect(body.edges).toContainEqual({ source: "a", target: "c", relationship: "works-on" });
941
+ });
942
+
943
+ test("returns empty edges when include_links is not set", async () => {
944
+ store.createNote("A", { id: "a" });
945
+ store.createNote("B", { id: "b" });
946
+ store.createLink("a", "b", "ref");
947
+
948
+ const res = await handleNotes(
949
+ mkReq("GET", "/notes?format=graph"),
950
+ store,
951
+ "",
952
+ );
953
+ const body = await res.json() as any;
954
+ expect(body.nodes).toHaveLength(2);
955
+ expect(body.edges).toHaveLength(0);
956
+ });
957
+
958
+ test("composes with near param for subgraph", async () => {
959
+ const a = store.createNote("A", { id: "a", path: "People/Mickey" });
960
+ const b = store.createNote("B", { id: "b" });
961
+ const c = store.createNote("C", { id: "c" });
962
+ const d = store.createNote("D", { id: "d" }); // not connected
963
+ store.createLink("a", "b", "knows");
964
+ store.createLink("b", "c", "knows");
965
+
966
+ const res = await handleNotes(
967
+ mkReq("GET", "/notes?format=graph&include_links=true&near[note_id]=People/Mickey&near[depth]=2"),
968
+ store,
969
+ "",
970
+ );
971
+ const body = await res.json() as any;
972
+ // a, b, c are within 2 hops; d is not
973
+ expect(body.nodes).toHaveLength(3);
974
+ expect(body.nodes.map((n: any) => n.id).sort()).toEqual(["a", "b", "c"]);
975
+ expect(body.edges).toHaveLength(2);
976
+ });
977
+
978
+ test("near with depth=1 limits subgraph", async () => {
979
+ const a = store.createNote("A", { id: "a" });
980
+ const b = store.createNote("B", { id: "b" });
981
+ const c = store.createNote("C", { id: "c" });
982
+ store.createLink("a", "b", "ref");
983
+ store.createLink("b", "c", "ref");
984
+
985
+ const res = await handleNotes(
986
+ mkReq("GET", "/notes?format=graph&include_links=true&near[note_id]=a&near[depth]=1"),
987
+ store,
988
+ "",
989
+ );
990
+ const body = await res.json() as any;
991
+ // Only a and b within 1 hop
992
+ expect(body.nodes).toHaveLength(2);
993
+ expect(body.edges).toHaveLength(1);
994
+ expect(body.edges[0]).toEqual({ source: "a", target: "b", relationship: "ref" });
995
+ });
996
+ });
997
+
998
+ describe("HTTP PATCH /notes/:idOrPath (update)", () => {
999
+ test("PATCH updates content and merges metadata", async () => {
1000
+ const note = store.createNote("original", { id: "x", metadata: { a: 1 } });
1001
+ const res = await handleNotes(
1002
+ mkReq("PATCH", "/notes/x", { content: "updated", metadata: { b: 2 } }),
1003
+ store,
1004
+ "/x",
1005
+ );
1006
+ const body = await res.json() as any;
1007
+ expect(body.content).toBe("updated");
1008
+ expect(body.metadata).toEqual({ a: 1, b: 2 });
1009
+ });
1010
+
1011
+ test("PATCH adds/removes tags", async () => {
1012
+ store.createNote("x", { id: "x", tags: ["old"] });
1013
+ const res = await handleNotes(
1014
+ mkReq("PATCH", "/notes/x", { tags: { add: ["new"], remove: ["old"] } }),
1015
+ store,
1016
+ "/x",
1017
+ );
1018
+ const body = await res.json() as any;
1019
+ expect(body.tags).toContain("new");
1020
+ expect(body.tags).not.toContain("old");
1021
+ });
1022
+
1023
+ test("PATCH adds/removes links", async () => {
1024
+ store.createNote("a", { id: "a" });
1025
+ store.createNote("b", { id: "b" });
1026
+ const res = await handleNotes(
1027
+ mkReq("PATCH", "/notes/a", { links: { add: [{ target: "b", relationship: "mentions" }] } }),
1028
+ store,
1029
+ "/a",
1030
+ );
1031
+ expect(res.status).toBe(200);
1032
+ const links = store.getLinks("a", { direction: "outbound" });
1033
+ expect(links).toHaveLength(1);
1034
+
1035
+ // Remove
1036
+ await handleNotes(
1037
+ mkReq("PATCH", "/notes/a", { links: { remove: [{ target: "b", relationship: "mentions" }] } }),
1038
+ store,
1039
+ "/a",
1040
+ );
1041
+ expect(store.getLinks("a", { direction: "outbound" })).toHaveLength(0);
1042
+ });
1043
+
1044
+ test("PATCH resolves note by path", async () => {
1045
+ store.createNote("x", { path: "Projects/README" });
1046
+ const res = await handleNotes(
1047
+ mkReq("PATCH", `/notes/${encodeURIComponent("Projects/README")}`, { content: "updated" }),
1048
+ store,
1049
+ `/${encodeURIComponent("Projects/README")}`,
1050
+ );
1051
+ const body = await res.json() as any;
1052
+ expect(body.content).toBe("updated");
1053
+ });
1054
+
1055
+ test("DELETE resolves note by path", async () => {
1056
+ store.createNote("x", { path: "Temp/note" });
1057
+ const res = await handleNotes(
1058
+ mkReq("DELETE", `/notes/${encodeURIComponent("Temp/note")}`),
1059
+ store,
1060
+ `/${encodeURIComponent("Temp/note")}`,
1061
+ );
1062
+ const body = await res.json() as any;
1063
+ expect(body.deleted).toBe(true);
1064
+ expect(store.getNoteByPath("Temp/note")).toBeNull();
1065
+ });
1066
+ });
1067
+
1068
+ describe("HTTP /tags", () => {
1069
+ test("GET /tags lists all tags", async () => {
1070
+ store.createNote("A", { tags: ["daily"] });
1071
+ store.createNote("B", { tags: ["daily", "pinned"] });
1072
+ const res = await handleTags(mkReq("GET", "/tags"), store);
1073
+ const body = await res.json() as any[];
1074
+ const daily = body.find((t: any) => t.name === "daily");
1075
+ expect(daily.count).toBe(2);
1076
+ });
1077
+
1078
+ test("GET /tags?tag=name returns single tag detail with schema", async () => {
1079
+ store.createNote("A", { tags: ["person"] });
1080
+ store.upsertTagSchema("person", { description: "A person", fields: { name: { type: "string" } } });
1081
+ const res = await handleTags(mkReq("GET", "/tags?tag=person"), store);
1082
+ const body = await res.json() as any;
1083
+ expect(body.name).toBe("person");
1084
+ expect(body.count).toBe(1);
1085
+ expect(body.description).toBe("A person");
1086
+ expect(body.fields.name.type).toBe("string");
1087
+ });
1088
+
1089
+ test("PUT /tags/:name upserts schema", async () => {
1090
+ const res = await handleTags(
1091
+ mkReq("PUT", "/tags/person", { description: "A person", fields: { name: { type: "string" } } }),
1092
+ store,
1093
+ "/person",
1094
+ );
1095
+ const body = await res.json() as any;
1096
+ expect(body.tag).toBe("person");
1097
+ expect(body.description).toBe("A person");
1098
+ });
1099
+
1100
+ test("DELETE /tags/:name removes tag and schema", async () => {
1101
+ store.createNote("A", { tags: ["doomed"] });
1102
+ store.upsertTagSchema("doomed", { description: "will be deleted" });
1103
+ const res = await handleTags(mkReq("DELETE", "/tags/doomed"), store, "/doomed");
1104
+ const body = await res.json() as any;
1105
+ expect(body.deleted).toBe(true);
1106
+ expect(store.listTags().some((t) => t.name === "doomed")).toBe(false);
1107
+ });
1108
+ });
1109
+
1110
+ describe("HTTP /find-path", () => {
1111
+ test("finds path between two notes", async () => {
1112
+ store.createNote("a", { id: "a" });
1113
+ store.createNote("b", { id: "b" });
1114
+ store.createNote("c", { id: "c" });
1115
+ store.createLink("a", "b", "mentions");
1116
+ store.createLink("b", "c", "related-to");
1117
+ const res = handleFindPath(mkReq("GET", "/find-path?source=a&target=c"), store);
1118
+ const body = await res.json() as any;
1119
+ expect(body.path).toEqual(["a", "b", "c"]);
1120
+ expect(body.relationships).toEqual(["mentions", "related-to"]);
1121
+ });
1122
+
1123
+ test("returns null when no path exists", async () => {
1124
+ store.createNote("a", { id: "a" });
1125
+ store.createNote("b", { id: "b" });
1126
+ const res = handleFindPath(mkReq("GET", "/find-path?source=a&target=b"), store);
1127
+ const body = await res.json() as any;
1128
+ expect(body).toBeNull();
1129
+ });
1130
+
1131
+ test("requires source and target params", async () => {
1132
+ const res = handleFindPath(mkReq("GET", "/find-path?source=a"), store);
1133
+ expect(res.status).toBe(400);
1134
+ });
1135
+ });
1136
+
1137
+ describe("stateless MCP transport", () => {
1138
+ test("tools/call works without prior initialize handshake", async () => {
1139
+ const { handleUnifiedMcp } = await import("./mcp-http.ts");
1140
+ const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
1141
+ const { getVaultStore, closeAllStores } = await import("./vault-store.ts");
1142
+
1143
+ const vaultName = `stateless-mcp-${Date.now()}`;
1144
+ writeVaultConfig({
1145
+ name: vaultName,
1146
+ api_keys: [],
1147
+ created_at: new Date().toISOString(),
1148
+ });
1149
+ writeGlobalConfig({ port: 1940, default_vault: vaultName });
1150
+
1151
+ const vaultStore = getVaultStore(vaultName);
1152
+ vaultStore.createNote("test note", { tags: ["daily"] });
1153
+
1154
+ // Direct tools/call — no initialize, no session header
1155
+ const req = new Request("http://localhost:1940/mcp", {
1156
+ method: "POST",
1157
+ headers: {
1158
+ "content-type": "application/json",
1159
+ "accept": "application/json, text/event-stream",
1160
+ },
1161
+ body: JSON.stringify({
1162
+ jsonrpc: "2.0",
1163
+ id: 1,
1164
+ method: "tools/call",
1165
+ params: { name: "vault-info", arguments: { vault: vaultName, include_stats: true } },
1166
+ }),
1167
+ });
1168
+
1169
+ const res = await handleUnifiedMcp(req, "write");
1170
+ expect(res.status).toBe(200);
1171
+
1172
+ const body = await res.json() as any;
1173
+ expect(body.result).toBeDefined();
1174
+ const content = JSON.parse(body.result.content[0].text);
1175
+ expect(content.stats.totalNotes).toBe(1);
1176
+
1177
+ closeAllStores();
1178
+ });
1179
+
1180
+ test("tools/list works without prior initialize handshake", async () => {
1181
+ const { handleUnifiedMcp } = await import("./mcp-http.ts");
1182
+ const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
1183
+ const { closeAllStores } = await import("./vault-store.ts");
1184
+
1185
+ const vaultName = `stateless-list-${Date.now()}`;
1186
+ writeVaultConfig({
1187
+ name: vaultName,
1188
+ api_keys: [],
1189
+ created_at: new Date().toISOString(),
1190
+ });
1191
+ writeGlobalConfig({ port: 1940, default_vault: vaultName });
1192
+
1193
+ const req = new Request("http://localhost:1940/mcp", {
1194
+ method: "POST",
1195
+ headers: {
1196
+ "content-type": "application/json",
1197
+ "accept": "application/json, text/event-stream",
1198
+ },
1199
+ body: JSON.stringify({
1200
+ jsonrpc: "2.0",
1201
+ id: 1,
1202
+ method: "tools/list",
1203
+ params: {},
1204
+ }),
1205
+ });
1206
+
1207
+ const res = await handleUnifiedMcp(req, "write");
1208
+ expect(res.status).toBe(200);
1209
+
1210
+ const body = await res.json() as any;
1211
+ expect(body.result.tools).toBeDefined();
1212
+ expect(body.result.tools.length).toBeGreaterThan(0);
1213
+ const toolNames = body.result.tools.map((t: any) => t.name);
1214
+ expect(toolNames).toContain("create-note");
1215
+ expect(toolNames).toContain("vault-info");
1216
+
1217
+ closeAllStores();
1218
+ });
1219
+
1220
+ test("initialize still works for clients that send it", async () => {
1221
+ const { handleUnifiedMcp } = await import("./mcp-http.ts");
1222
+ const { writeVaultConfig, writeGlobalConfig } = await import("./config.ts");
1223
+ const { closeAllStores } = await import("./vault-store.ts");
1224
+
1225
+ const vaultName = `stateless-init-${Date.now()}`;
1226
+ writeVaultConfig({
1227
+ name: vaultName,
1228
+ api_keys: [],
1229
+ created_at: new Date().toISOString(),
1230
+ });
1231
+ writeGlobalConfig({ port: 1940, default_vault: vaultName });
1232
+
1233
+ const req = new Request("http://localhost:1940/mcp", {
1234
+ method: "POST",
1235
+ headers: {
1236
+ "content-type": "application/json",
1237
+ "accept": "application/json, text/event-stream",
1238
+ },
1239
+ body: JSON.stringify({
1240
+ jsonrpc: "2.0",
1241
+ id: 1,
1242
+ method: "initialize",
1243
+ params: {
1244
+ protocolVersion: "2024-11-05",
1245
+ capabilities: {},
1246
+ clientInfo: { name: "test", version: "1.0" },
1247
+ },
1248
+ }),
1249
+ });
1250
+
1251
+ const res = await handleUnifiedMcp(req, "write");
1252
+ expect(res.status).toBe(200);
1253
+
1254
+ const body = await res.json() as any;
1255
+ expect(body.result.protocolVersion).toBe("2024-11-05");
1256
+ expect(body.result.serverInfo.name).toBe("parachute-vault");
1257
+ expect(body.result.capabilities.tools).toBeDefined();
1258
+
1259
+ closeAllStores();
1260
+ });
1261
+ });
1262
+
1263
+ describe("extractApiKey", () => {
1264
+ test("extracts from Authorization: Bearer header", () => {
1265
+ const req = new Request("http://localhost/api/notes", {
1266
+ headers: { Authorization: "Bearer pvt_abc123" },
1267
+ });
1268
+ expect(extractApiKey(req)).toBe("pvt_abc123");
1269
+ });
1270
+
1271
+ test("extracts from X-API-Key header", () => {
1272
+ const req = new Request("http://localhost/api/notes", {
1273
+ headers: { "X-API-Key": "pvk_xyz789" },
1274
+ });
1275
+ expect(extractApiKey(req)).toBe("pvk_xyz789");
1276
+ });
1277
+
1278
+ test("extracts from ?key= query parameter", () => {
1279
+ const req = new Request("http://localhost/mcp?key=pvt_querykey");
1280
+ expect(extractApiKey(req)).toBe("pvt_querykey");
1281
+ });
1282
+
1283
+ test("prefers Authorization header over query param", () => {
1284
+ const req = new Request("http://localhost/mcp?key=pvt_query", {
1285
+ headers: { Authorization: "Bearer pvt_header" },
1286
+ });
1287
+ expect(extractApiKey(req)).toBe("pvt_header");
1288
+ });
1289
+
1290
+ test("prefers X-API-Key header over query param", () => {
1291
+ const req = new Request("http://localhost/mcp?key=pvt_query", {
1292
+ headers: { "X-API-Key": "pvk_header" },
1293
+ });
1294
+ expect(extractApiKey(req)).toBe("pvk_header");
1295
+ });
1296
+
1297
+ test("prefers Authorization header over X-API-Key header", () => {
1298
+ const req = new Request("http://localhost/api/notes", {
1299
+ headers: { Authorization: "Bearer pvt_bearer", "X-API-Key": "pvk_xapi" },
1300
+ });
1301
+ expect(extractApiKey(req)).toBe("pvt_bearer");
1302
+ });
1303
+
1304
+ test("returns null when no key provided", () => {
1305
+ const req = new Request("http://localhost/api/notes");
1306
+ expect(extractApiKey(req)).toBeNull();
1307
+ });
1308
+ });
1309
+