@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,397 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from "bun:test";
2
+ import { buildPredicate, registerTriggers } from "./triggers.ts";
3
+ import { HookRegistry } from "../core/src/hooks.ts";
4
+ import type { Note, Store, Attachment } from "../core/src/types.ts";
5
+ import type { TriggerConfig } from "./config.ts";
6
+
7
+ function makeNote(overrides: Partial<Note> = {}): Note {
8
+ return {
9
+ id: "test-1",
10
+ content: "hello world",
11
+ tags: [],
12
+ metadata: {},
13
+ createdAt: "2025-01-01T00:00:00Z",
14
+ updatedAt: "2025-01-01T00:00:00Z",
15
+ ...overrides,
16
+ };
17
+ }
18
+
19
+ describe("buildPredicate", () => {
20
+ it("matches when all conditions are met", () => {
21
+ const pred = buildPredicate(
22
+ { tags: ["reader"], has_content: true, missing_metadata: ["audio_rendered_at"] },
23
+ "tts_reader",
24
+ );
25
+ const note = makeNote({ tags: ["reader"], content: "some text" });
26
+ expect(pred(note)).toBe(true);
27
+ });
28
+
29
+ it("rejects when pending marker is set", () => {
30
+ const pred = buildPredicate({ tags: ["reader"] }, "tts_reader");
31
+ const note = makeNote({
32
+ tags: ["reader"],
33
+ metadata: { tts_reader_pending_at: "2025-01-01" },
34
+ });
35
+ expect(pred(note)).toBe(false);
36
+ });
37
+
38
+ it("rejects when rendered marker is set", () => {
39
+ const pred = buildPredicate({ tags: ["reader"] }, "tts_reader");
40
+ const note = makeNote({
41
+ tags: ["reader"],
42
+ metadata: { tts_reader_rendered_at: "2025-01-01" },
43
+ });
44
+ expect(pred(note)).toBe(false);
45
+ });
46
+
47
+ it("rejects when required tag is missing", () => {
48
+ const pred = buildPredicate({ tags: ["reader"] }, "tts_reader");
49
+ const note = makeNote({ tags: ["other"] });
50
+ expect(pred(note)).toBe(false);
51
+ });
52
+
53
+ it("rejects when has_content=true and content is empty", () => {
54
+ const pred = buildPredicate({ has_content: true }, "test");
55
+ expect(pred(makeNote({ content: "" }))).toBe(false);
56
+ expect(pred(makeNote({ content: " " }))).toBe(false);
57
+ });
58
+
59
+ it("rejects when has_content=false and content is present", () => {
60
+ const pred = buildPredicate({ has_content: false }, "test");
61
+ expect(pred(makeNote({ content: "hello" }))).toBe(false);
62
+ });
63
+
64
+ it("matches has_content=false when content is empty", () => {
65
+ const pred = buildPredicate({ has_content: false }, "test");
66
+ expect(pred(makeNote({ content: "" }))).toBe(true);
67
+ });
68
+
69
+ it("rejects when missing_metadata key is present", () => {
70
+ const pred = buildPredicate({ missing_metadata: ["done"] }, "test");
71
+ const note = makeNote({ metadata: { done: true } });
72
+ expect(pred(note)).toBe(false);
73
+ });
74
+
75
+ it("matches when missing_metadata key is absent", () => {
76
+ const pred = buildPredicate({ missing_metadata: ["done"] }, "test");
77
+ const note = makeNote({ metadata: {} });
78
+ expect(pred(note)).toBe(true);
79
+ });
80
+
81
+ it("rejects when has_metadata key is absent", () => {
82
+ const pred = buildPredicate({ has_metadata: ["source"] }, "test");
83
+ const note = makeNote({ metadata: {} });
84
+ expect(pred(note)).toBe(false);
85
+ });
86
+
87
+ it("matches when has_metadata key is present", () => {
88
+ const pred = buildPredicate({ has_metadata: ["source"] }, "test");
89
+ const note = makeNote({ metadata: { source: "voice" } });
90
+ expect(pred(note)).toBe(true);
91
+ });
92
+
93
+ it("requires all tags to match", () => {
94
+ const pred = buildPredicate({ tags: ["reader", "important"] }, "test");
95
+ expect(pred(makeNote({ tags: ["reader"] }))).toBe(false);
96
+ expect(pred(makeNote({ tags: ["reader", "important"] }))).toBe(true);
97
+ });
98
+ });
99
+
100
+ describe("registerTriggers — dispatch modes", () => {
101
+ let webhookServer: ReturnType<typeof Bun.serve>;
102
+ let webhookPort: number;
103
+ let lastRequest: { method: string; url: string; headers: Headers; body: unknown; formData?: FormData } | null = null;
104
+ let webhookHandler: (req: Request) => Response | Promise<Response>;
105
+
106
+ beforeAll(() => {
107
+ webhookHandler = () => Response.json({});
108
+ webhookServer = Bun.serve({
109
+ hostname: "127.0.0.1",
110
+ port: 0,
111
+ async fetch(req) {
112
+ const url = new URL(req.url);
113
+ const contentType = req.headers.get("Content-Type") ?? "";
114
+ if (contentType.includes("json")) {
115
+ lastRequest = { method: req.method, url: url.pathname, headers: req.headers, body: await req.json() };
116
+ } else if (contentType.includes("multipart")) {
117
+ const formData = await req.formData();
118
+ lastRequest = { method: req.method, url: url.pathname, headers: req.headers, body: null, formData };
119
+ } else {
120
+ lastRequest = { method: req.method, url: url.pathname, headers: req.headers, body: await req.text() };
121
+ }
122
+ return webhookHandler(req);
123
+ },
124
+ });
125
+ webhookPort = webhookServer.port;
126
+ });
127
+
128
+ afterAll(() => {
129
+ webhookServer?.stop(true);
130
+ });
131
+
132
+ function makeMockStore(note: Note, attachments: Attachment[] = []): Store {
133
+ const notes = new Map<string, Note>();
134
+ notes.set(note.id, { ...note });
135
+ const attachmentStore = new Map<string, Attachment[]>();
136
+ attachmentStore.set(note.id, [...attachments]);
137
+
138
+ return {
139
+ getNote: (id: string) => notes.get(id) ?? null,
140
+ updateNote: (id: string, updates: Record<string, unknown>) => {
141
+ const n = notes.get(id);
142
+ if (!n) throw new Error(`note ${id} not found`);
143
+ if (updates.content !== undefined) n.content = updates.content as string;
144
+ if (updates.metadata !== undefined) n.metadata = updates.metadata as Record<string, unknown>;
145
+ notes.set(id, n);
146
+ return n;
147
+ },
148
+ getAttachments: (id: string) => attachmentStore.get(id) ?? [],
149
+ addAttachment: (noteId: string, path: string, mimeType: string, meta?: Record<string, unknown>) => {
150
+ const att: Attachment = { id: crypto.randomUUID(), noteId, path, mimeType, metadata: meta, createdAt: new Date().toISOString() };
151
+ const existing = attachmentStore.get(noteId) ?? [];
152
+ existing.push(att);
153
+ attachmentStore.set(noteId, existing);
154
+ return att;
155
+ },
156
+ } as unknown as Store;
157
+ }
158
+
159
+ it("send=json dispatches full note payload (default behavior)", async () => {
160
+ const hooks = new HookRegistry();
161
+ const note = makeNote({ id: "n1", content: "hello", tags: ["test"] });
162
+ const store = makeMockStore(note);
163
+
164
+ webhookHandler = () => Response.json({ metadata: { processed: true } });
165
+
166
+ registerTriggers(hooks, [{
167
+ name: "json_test",
168
+ when: { tags: ["test"] },
169
+ action: { webhook: `http://127.0.0.1:${webhookPort}/hook` },
170
+ }], { error: () => {}, info: () => {} });
171
+
172
+ await hooks.dispatch("created", note, store);
173
+ // Give async handler time to complete
174
+ await new Promise(r => setTimeout(r, 50));
175
+
176
+ expect(lastRequest).not.toBeNull();
177
+ expect(lastRequest!.method).toBe("POST");
178
+ const body = lastRequest!.body as Record<string, unknown>;
179
+ expect(body.trigger).toBe("json_test");
180
+ expect((body.note as Record<string, unknown>).content).toBe("hello");
181
+ });
182
+
183
+ it("send=attachment sends multipart form-data with audio file", async () => {
184
+ const hooks = new HookRegistry();
185
+ const note = makeNote({ id: "n2", content: "", tags: ["capture"] });
186
+
187
+ // Create a temp audio file
188
+ const tmpDir = `/tmp/trigger-test-${Date.now()}`;
189
+ const { mkdirSync, writeFileSync } = await import("fs");
190
+ mkdirSync(`${tmpDir}/2026-04-11`, { recursive: true });
191
+ writeFileSync(`${tmpDir}/2026-04-11/recording.wav`, Buffer.from("fake-wav-bytes"));
192
+
193
+ const attachment: Attachment = {
194
+ id: "att-1",
195
+ noteId: "n2",
196
+ path: "2026-04-11/recording.wav",
197
+ mimeType: "audio/wav",
198
+ createdAt: "2025-01-01T00:00:00Z",
199
+ };
200
+ const store = makeMockStore(note, [attachment]);
201
+
202
+ // Mock getVaultNameForStore and assetsDir to use our tmp dir
203
+ const originalAssetsDir = process.env.ASSETS_DIR;
204
+ process.env.ASSETS_DIR = tmpDir;
205
+
206
+ webhookHandler = () => Response.json({ text: "transcribed content" });
207
+
208
+ registerTriggers(hooks, [{
209
+ name: "attachment_test",
210
+ when: { tags: ["capture"], has_content: false },
211
+ action: {
212
+ webhook: `http://127.0.0.1:${webhookPort}/transcribe`,
213
+ send: "attachment",
214
+ },
215
+ }], { error: () => {}, info: () => {} });
216
+
217
+ await hooks.dispatch("created", note, store);
218
+ await new Promise(r => setTimeout(r, 50));
219
+
220
+ expect(lastRequest).not.toBeNull();
221
+ expect(lastRequest!.formData).toBeDefined();
222
+ const file = lastRequest!.formData!.get("file");
223
+ expect(file).toBeInstanceOf(File);
224
+ expect((file as File).name).toBe("recording.wav");
225
+
226
+ // Verify note content was updated
227
+ const updated = store.getNote("n2");
228
+ expect(updated?.content).toBe("transcribed content");
229
+
230
+ // Cleanup
231
+ if (originalAssetsDir) process.env.ASSETS_DIR = originalAssetsDir;
232
+ else delete process.env.ASSETS_DIR;
233
+ const { rmSync } = await import("fs");
234
+ rmSync(tmpDir, { recursive: true, force: true });
235
+ });
236
+
237
+ it("send=content sends TTS input and saves audio response as attachment", async () => {
238
+ const hooks = new HookRegistry();
239
+ const note = makeNote({ id: "n3", content: "Hello world", tags: ["reader"] });
240
+ const store = makeMockStore(note);
241
+
242
+ const tmpDir = `/tmp/trigger-test-tts-${Date.now()}`;
243
+ const { mkdirSync } = await import("fs");
244
+ mkdirSync(tmpDir, { recursive: true });
245
+
246
+ const originalAssetsDir = process.env.ASSETS_DIR;
247
+ process.env.ASSETS_DIR = tmpDir;
248
+
249
+ const fakeAudio = Buffer.from("fake-ogg-opus-audio");
250
+ webhookHandler = () => new Response(fakeAudio, {
251
+ headers: {
252
+ "Content-Type": "audio/ogg",
253
+ "X-TTS-Provider": "kokoro",
254
+ "X-TTS-Voice": "af_heart",
255
+ },
256
+ });
257
+
258
+ registerTriggers(hooks, [{
259
+ name: "content_test",
260
+ when: { tags: ["reader"], has_content: true },
261
+ action: {
262
+ webhook: `http://127.0.0.1:${webhookPort}/speech`,
263
+ send: "content",
264
+ },
265
+ }], { error: () => {}, info: () => {} });
266
+
267
+ await hooks.dispatch("created", note, store);
268
+ await new Promise(r => setTimeout(r, 50));
269
+
270
+ expect(lastRequest).not.toBeNull();
271
+ const body = lastRequest!.body as Record<string, unknown>;
272
+ expect(body.input).toBe("Hello world");
273
+
274
+ // Verify attachment was created
275
+ const attachments = store.getAttachments("n3");
276
+ expect(attachments.length).toBe(1);
277
+ expect(attachments[0].mimeType).toBe("audio/ogg");
278
+
279
+ // Verify metadata includes provider info
280
+ const updated = store.getNote("n3");
281
+ const meta = updated?.metadata as Record<string, unknown>;
282
+ expect(meta.tts_provider).toBe("kokoro");
283
+ expect(meta.tts_voice).toBe("af_heart");
284
+ expect(meta.content_test_rendered_at).toBeDefined();
285
+
286
+ // Cleanup
287
+ if (originalAssetsDir) process.env.ASSETS_DIR = originalAssetsDir;
288
+ else delete process.env.ASSETS_DIR;
289
+ const { rmSync } = await import("fs");
290
+ rmSync(tmpDir, { recursive: true, force: true });
291
+ });
292
+
293
+ it("send=attachment skips when no audio attachment exists", async () => {
294
+ const hooks = new HookRegistry();
295
+ const note = makeNote({ id: "n4", content: "", tags: ["capture"] });
296
+ const store = makeMockStore(note);
297
+
298
+ registerTriggers(hooks, [{
299
+ name: "skip_test",
300
+ when: { tags: ["capture"], has_content: false },
301
+ action: {
302
+ webhook: `http://127.0.0.1:${webhookPort}/transcribe`,
303
+ send: "attachment",
304
+ },
305
+ }], { error: () => {}, info: () => {} });
306
+
307
+ await hooks.dispatch("created", note, store);
308
+ await new Promise(r => setTimeout(r, 50));
309
+
310
+ const updated = store.getNote("n4");
311
+ const meta = updated?.metadata as Record<string, unknown>;
312
+ expect(meta.skip_test_skipped_reason).toBe("no audio attachment found");
313
+ });
314
+
315
+ it("send=content skips when note content is empty", async () => {
316
+ const hooks = new HookRegistry();
317
+ // Note: predicate has_content would normally filter this, but test the dispatch guard
318
+ const note = makeNote({ id: "n5", content: "", tags: ["reader"] });
319
+ const store = makeMockStore(note);
320
+
321
+ registerTriggers(hooks, [{
322
+ name: "empty_test",
323
+ when: { tags: ["reader"] }, // no has_content filter — tests the dispatch guard
324
+ action: {
325
+ webhook: `http://127.0.0.1:${webhookPort}/speech`,
326
+ send: "content",
327
+ },
328
+ }], { error: () => {}, info: () => {} });
329
+
330
+ await hooks.dispatch("created", note, store);
331
+ await new Promise(r => setTimeout(r, 50));
332
+
333
+ const updated = store.getNote("n5");
334
+ const meta = updated?.metadata as Record<string, unknown>;
335
+ expect(meta.empty_test_skipped_reason).toBe("note has no content to synthesize");
336
+ });
337
+ });
338
+
339
+ describe("registerTriggers — validation", () => {
340
+ it("skips triggers with invalid webhook URLs", () => {
341
+ const hooks = new HookRegistry();
342
+ const errors: string[] = [];
343
+ const logger = {
344
+ error: (...args: unknown[]) => errors.push(args.map(String).join(" ")),
345
+ info: () => {},
346
+ };
347
+
348
+ registerTriggers(hooks, [
349
+ {
350
+ name: "bad-url",
351
+ when: { tags: ["test"] },
352
+ action: { webhook: "not-a-url" },
353
+ },
354
+ {
355
+ name: "bad-scheme",
356
+ when: { tags: ["test"] },
357
+ action: { webhook: "ftp://example.com/hook" },
358
+ },
359
+ {
360
+ name: "good",
361
+ when: { tags: ["test"] },
362
+ action: { webhook: "http://localhost:8080/hook" },
363
+ },
364
+ ], logger);
365
+
366
+ expect(hooks.size).toBe(1); // only "good" registered
367
+ expect(errors.length).toBe(2);
368
+ expect(errors[0]).toContain("bad-url");
369
+ expect(errors[1]).toContain("bad-scheme");
370
+ });
371
+
372
+ it("registers triggers with send/response modes", () => {
373
+ const hooks = new HookRegistry();
374
+ const infos: string[] = [];
375
+ const logger = {
376
+ error: () => {},
377
+ info: (...args: unknown[]) => infos.push(args.map(String).join(" ")),
378
+ };
379
+
380
+ registerTriggers(hooks, [
381
+ {
382
+ name: "tts",
383
+ when: { tags: ["reader"] },
384
+ action: { webhook: "http://localhost:3100/v1/audio/speech", send: "content" },
385
+ },
386
+ {
387
+ name: "transcribe",
388
+ when: { tags: ["capture"] },
389
+ action: { webhook: "http://localhost:3200/v1/audio/transcriptions", send: "attachment" },
390
+ },
391
+ ], logger);
392
+
393
+ expect(hooks.size).toBe(2);
394
+ expect(infos.some(s => s.includes("send=content"))).toBe(true);
395
+ expect(infos.some(s => s.includes("send=attachment"))).toBe(true);
396
+ });
397
+ });