@openparachute/vault 0.1.0 → 0.2.1

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 +87 -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 +66 -13
  36. package/src/published.test.ts +21 -21
  37. package/src/routes.ts +152 -70
  38. package/src/routing.test.ts +478 -0
  39. package/src/routing.ts +413 -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
package/src/server.ts CHANGED
@@ -6,21 +6,19 @@
6
6
  * GET /health — health check
7
7
  * * /mcp — unified MCP (all vaults, vault param)
8
8
  * * /vaults/{name}/mcp — scoped MCP (single vault, no vault param)
9
- * GET /vaults — list vaults
9
+ * GET /vaults — list vaults with metadata (authenticated)
10
+ * GET /vaults/list — list vault names (public; disable via config.discovery)
10
11
  * * /vaults/{name}/api/... — per-vault REST API
12
+ *
13
+ * The request pipeline lives in ./routing.ts (exported for unit testing).
11
14
  */
12
15
 
13
16
  import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig, listVaults, DEFAULT_PORT, ensureConfigDirSync, loadEnvFile, generateApiKey, hashKey } from "./config.ts";
14
- import { authenticateVaultRequest, authenticateGlobalRequest, isMethodAllowed, extractApiKey } from "./auth.ts";
15
- import type { AuthResult } from "./auth.ts";
16
- import type { VaultConfig } from "./config.ts";
17
17
  import { migrateVaultKeys } from "./token-store.ts";
18
18
  import { getVaultStore } from "./vault-store.ts";
19
- import { handleUnifiedMcp, handleScopedMcp } from "./mcp-http.ts";
20
- import { handleNotes, handleTags, handleFindPath, handleVault, handleUnresolvedWikilinks, handleStorage, handleViewNote } from "./routes.ts";
21
19
  import { defaultHookRegistry } from "../core/src/hooks.ts";
22
20
  import { registerTriggers } from "./triggers.ts";
23
- import { handleProtectedResource, handleAuthorizationServer, handleRegister, handleAuthorizeGet, handleAuthorizePost, handleToken } from "./oauth.ts";
21
+ import { route } from "./routing.ts";
24
22
 
25
23
  // Register webhook triggers from global config. Replaces the old hardcoded
26
24
  // tts-hook and transcription-hook with config-driven webhooks.
@@ -76,11 +74,11 @@ for (const vaultName of listVaults()) {
76
74
  const vaultConfig = readVaultConfig(vaultName);
77
75
  if (vaultConfig?.tag_schemas && Object.keys(vaultConfig.tag_schemas).length > 0) {
78
76
  const store = getVaultStore(vaultName);
79
- const existingTags = new Set(store.listTagSchemas().map((s) => s.tag));
77
+ const existingTags = new Set((await store.listTagSchemas()).map((s) => s.tag));
80
78
  let migrated = 0;
81
79
  for (const [tag, schema] of Object.entries(vaultConfig.tag_schemas)) {
82
80
  if (!existingTags.has(tag)) {
83
- store.upsertTagSchema(tag, schema);
81
+ await store.upsertTagSchema(tag, schema);
84
82
  migrated++;
85
83
  }
86
84
  }
@@ -179,272 +177,3 @@ async function shutdown(signal: string): Promise<void> {
179
177
  process.on("SIGINT", () => void shutdown("SIGINT"));
180
178
  process.on("SIGTERM", () => void shutdown("SIGTERM"));
181
179
 
182
- /**
183
- * Check if a /view request has a valid API key (header or ?key= query param).
184
- * Returns true if authenticated, false if not. Never rejects — unauthenticated
185
- * requests still get public notes.
186
- */
187
- function isViewAuthenticated(req: Request, vaultConfig: VaultConfig | null, vaultDb?: import("bun:sqlite").Database): boolean {
188
- if (!vaultConfig) return false;
189
- // extractApiKey now checks headers AND ?key= query param
190
- const key = extractApiKey(req);
191
- if (!key) return false;
192
- const auth = authenticateVaultRequest(req, vaultConfig, vaultDb);
193
- return !("error" in auth);
194
- }
195
-
196
- async function route(req: Request, path: string, clientIp?: string): Promise<Response> {
197
- // OAuth discovery endpoints (no auth required)
198
- if (path === "/.well-known/oauth-protected-resource") {
199
- return handleProtectedResource(req);
200
- }
201
- if (path === "/.well-known/oauth-authorization-server") {
202
- return handleAuthorizationServer(req);
203
- }
204
-
205
- // OAuth flow endpoints (no auth — these ARE the auth)
206
- if (path === "/oauth/register" || path === "/oauth/authorize" || path === "/oauth/token") {
207
- const defaultVault = readGlobalConfig().default_vault ?? "default";
208
- const vaultConfig = readVaultConfig(defaultVault);
209
- if (!vaultConfig) {
210
- return Response.json({ error: "server_error", error_description: "Default vault not configured" }, { status: 500 });
211
- }
212
- const store = getVaultStore(defaultVault);
213
-
214
- if (path === "/oauth/register") {
215
- return handleRegister(req, store.db);
216
- }
217
- if (path === "/oauth/authorize") {
218
- const gc = readGlobalConfig();
219
- const ownerPasswordHash = gc.owner_password_hash ?? null;
220
- const totpSecret = gc.totp_secret ?? null;
221
- const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
222
- if (req.method === "GET") {
223
- return handleAuthorizeGet(req, store.db, vaultConfig.name, ownerPasswordHash, totpEnrolled);
224
- }
225
- if (req.method === "POST") {
226
- return handleAuthorizePost(req, store.db, {
227
- vaultName: vaultConfig.name,
228
- clientIp,
229
- ownerPasswordHash,
230
- totpSecret,
231
- });
232
- }
233
- return Response.json({ error: "method_not_allowed" }, { status: 405 });
234
- }
235
- if (path === "/oauth/token") {
236
- return handleToken(req, store.db);
237
- }
238
- }
239
-
240
- // Health check — vault names only for authenticated requests
241
- if (path === "/health") {
242
- const auth = authenticateGlobalRequest(req);
243
- if ("error" in auth) {
244
- return Response.json({ status: "ok" });
245
- }
246
- return Response.json({ status: "ok", vaults: listVaults() });
247
- }
248
-
249
- // Unified MCP (all vaults, global auth)
250
- if (path === "/mcp" || path.startsWith("/mcp/")) {
251
- const auth = authenticateGlobalRequest(req);
252
- if ("error" in auth) return auth.error;
253
- return handleUnifiedMcp(req, auth);
254
- }
255
-
256
- // View endpoint — serves notes as HTML (auth-aware, supports ID or path)
257
- const viewMatch = path.match(/^\/view\/(.+)$/);
258
- if (viewMatch && req.method === "GET") {
259
- const defaultVault = readGlobalConfig().default_vault ?? "default";
260
- const vaultConfig = readVaultConfig(defaultVault);
261
- if (!vaultConfig) {
262
- return Response.json({ error: "Default vault not found" }, { status: 404 });
263
- }
264
- const store = getVaultStore(defaultVault);
265
- const authenticated = isViewAuthenticated(req, vaultConfig, store.db);
266
- return handleViewNote(store, decodeURIComponent(viewMatch[1]), {
267
- authenticated,
268
- publishedTag: vaultConfig.published_tag,
269
- });
270
- }
271
-
272
- // Backward compat: /public/:noteId → /view/:noteId (preserving query params)
273
- const publicMatch = path.match(/^\/public\/([^/]+)$/);
274
- if (publicMatch && req.method === "GET") {
275
- const dest = new URL(`/view/${publicMatch[1]}`, req.url);
276
- dest.search = new URL(req.url).search;
277
- return Response.redirect(dest.toString(), 301);
278
- }
279
-
280
-
281
- // List vaults — requires auth
282
- if (path === "/vaults" && req.method === "GET") {
283
- const auth = authenticateGlobalRequest(req);
284
- if ("error" in auth) return auth.error;
285
- const names = listVaults();
286
- const vaults = names.map((name) => {
287
- const config = readVaultConfig(name);
288
- return {
289
- name,
290
- description: config?.description,
291
- created_at: config?.created_at,
292
- };
293
- });
294
- return Response.json({ vaults });
295
- }
296
-
297
- // Backward-compatible: /api/* routes to default vault
298
- if (path.startsWith("/api/")) {
299
- const defaultVault = readGlobalConfig().default_vault ?? "default";
300
- const vaultConfig = readVaultConfig(defaultVault);
301
- if (!vaultConfig) {
302
- return Response.json({ error: "Default vault not found" }, { status: 404 });
303
- }
304
- const store = getVaultStore(defaultVault);
305
- const auth = authenticateVaultRequest(req, vaultConfig, store.db);
306
- if ("error" in auth) return auth.error;
307
- if (!isMethodAllowed(req.method, auth.permission)) {
308
- return Response.json({ error: "Forbidden", message: "Insufficient permissions" }, { status: 403 });
309
- }
310
- const apiPath = path.slice(4); // strip "/api"
311
- if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6));
312
- if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5));
313
- if (apiPath === "/find-path") return handleFindPath(req, store);
314
- if (apiPath === "/vault") return handleVault(req, store, vaultConfig, (desc) => {
315
- vaultConfig.description = desc;
316
- writeVaultConfig(vaultConfig);
317
- });
318
- if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store);
319
- if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), defaultVault);
320
- if (apiPath === "/health") return Response.json({ status: "ok", vault: defaultVault });
321
- }
322
-
323
- // Vault-scoped routes: /vaults/{name}/...
324
- const vaultMatch = path.match(/^\/vaults\/([^/]+)(\/.*)?$/);
325
- if (!vaultMatch) {
326
- return Response.json({ error: "Not found" }, { status: 404 });
327
- }
328
-
329
- const vaultName = vaultMatch[1];
330
- const subpath = vaultMatch[2] ?? "";
331
-
332
- const vaultConfig = readVaultConfig(vaultName);
333
- if (!vaultConfig) {
334
- return Response.json(
335
- { error: "Vault not found", vault: vaultName },
336
- { status: 404 },
337
- );
338
- }
339
-
340
- // Backward compat: /vaults/{name}/public/:noteId → /view/:noteId
341
- const vaultPublicMatch = subpath.match(/^\/public\/([^/]+)$/);
342
- if (vaultPublicMatch && req.method === "GET") {
343
- const dest = new URL(`/vaults/${vaultName}/view/${vaultPublicMatch[1]}`, req.url);
344
- dest.search = new URL(req.url).search;
345
- return Response.redirect(dest.toString(), 301);
346
- }
347
-
348
- // View endpoint — serves notes as HTML (auth-aware, vault-scoped, supports ID or path)
349
- const vaultViewMatch = subpath.match(/^\/view\/(.+)$/);
350
- if (vaultViewMatch && req.method === "GET") {
351
- const store = getVaultStore(vaultName);
352
- const authenticated = isViewAuthenticated(req, vaultConfig, store.db);
353
- return handleViewNote(store, decodeURIComponent(vaultViewMatch[1]), {
354
- authenticated,
355
- publishedTag: vaultConfig.published_tag,
356
- });
357
- }
358
-
359
- // Vault-scoped OAuth endpoints (no auth — these ARE the auth)
360
- if (subpath === "/oauth/register" || subpath === "/oauth/authorize" || subpath === "/oauth/token") {
361
- const store = getVaultStore(vaultName);
362
- if (subpath === "/oauth/register") return handleRegister(req, store.db);
363
- if (subpath === "/oauth/authorize") {
364
- const gc = readGlobalConfig();
365
- const ownerPasswordHash = gc.owner_password_hash ?? null;
366
- const totpSecret = gc.totp_secret ?? null;
367
- const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
368
- if (req.method === "GET") return handleAuthorizeGet(req, store.db, vaultConfig.name, ownerPasswordHash, totpEnrolled);
369
- if (req.method === "POST") return handleAuthorizePost(req, store.db, {
370
- vaultName: vaultConfig.name,
371
- clientIp,
372
- ownerPasswordHash,
373
- totpSecret,
374
- });
375
- return Response.json({ error: "method_not_allowed" }, { status: 405 });
376
- }
377
- if (subpath === "/oauth/token") return handleToken(req, store.db);
378
- }
379
-
380
- // Vault-scoped discovery endpoints
381
- if (subpath === "/.well-known/oauth-protected-resource") return handleProtectedResource(req, `/vaults/${vaultName}/mcp`);
382
- if (subpath === "/.well-known/oauth-authorization-server") return handleAuthorizationServer(req);
383
-
384
- // Auth: per-vault key OR global key
385
- const store = getVaultStore(vaultName);
386
- const auth = authenticateVaultRequest(req, vaultConfig, store.db);
387
- if ("error" in auth) return auth.error;
388
-
389
- // Per-vault scoped MCP
390
- if (subpath === "/mcp" || subpath.startsWith("/mcp/")) {
391
- return handleScopedMcp(req, vaultName, auth);
392
- }
393
-
394
- // Bare /vaults/{name} — single-vault root. Returns name, description,
395
- // createdAt, and stats. One round trip for a viz landing page.
396
- if (subpath === "" || subpath === "/") {
397
- if (req.method !== "GET") {
398
- return Response.json({ error: "Method not allowed" }, { status: 405 });
399
- }
400
- const stats = store.getVaultStats();
401
- return Response.json({
402
- name: vaultName,
403
- description: vaultConfig.description,
404
- createdAt: vaultConfig.created_at,
405
- stats,
406
- });
407
- }
408
-
409
- // REST API — enforce permission level
410
- if (!isMethodAllowed(req.method, auth.permission)) {
411
- return Response.json(
412
- { error: "Forbidden", message: "Insufficient permissions" },
413
- { status: 403 },
414
- );
415
- }
416
-
417
- const apiMatch = subpath.match(/^\/api(\/.*)?$/);
418
- if (!apiMatch) {
419
- return Response.json({ error: "Not found" }, { status: 404 });
420
- }
421
-
422
- const apiPath = apiMatch[1] ?? "";
423
-
424
- if (apiPath.startsWith("/notes")) {
425
- return handleNotes(req, store, apiPath.slice(6));
426
- }
427
- if (apiPath.startsWith("/tags")) {
428
- return handleTags(req, store, apiPath.slice(5));
429
- }
430
- if (apiPath === "/find-path") {
431
- return handleFindPath(req, store);
432
- }
433
- if (apiPath === "/vault") {
434
- return handleVault(req, store, vaultConfig, (desc) => {
435
- vaultConfig.description = desc;
436
- writeVaultConfig(vaultConfig);
437
- });
438
- }
439
- if (apiPath === "/unresolved-wikilinks") {
440
- return handleUnresolvedWikilinks(req, store);
441
- }
442
- if (apiPath.startsWith("/storage")) {
443
- return handleStorage(req, apiPath.slice(8), vaultName);
444
- }
445
- if (apiPath === "/health") {
446
- return Response.json({ status: "ok", vault: vaultName });
447
- }
448
-
449
- return Response.json({ error: "Not found" }, { status: 404 });
450
- }
@@ -0,0 +1,15 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { generateUnit } from "./systemd.ts";
3
+ import { WRAPPER_PATH } from "./daemon.ts";
4
+
5
+ describe("generateUnit", () => {
6
+ test("invokes the shared wrapper rather than hardcoding server.ts", () => {
7
+ const unit = generateUnit();
8
+ // Same incident on Linux: the old unit hardcoded the absolute path to
9
+ // server.ts inside ExecStart. Now ExecStart points at the wrapper, and
10
+ // the wrapper resolves the path from a pointer file at boot.
11
+ expect(unit).toContain(`ExecStart=/bin/bash ${WRAPPER_PATH}`);
12
+ expect(unit).not.toMatch(/server\.ts/);
13
+ expect(unit).toContain("Restart=on-failure");
14
+ });
15
+ });
package/src/systemd.ts CHANGED
@@ -6,28 +6,33 @@
6
6
  */
7
7
 
8
8
  import { homedir } from "os";
9
- import { join, resolve } from "path";
9
+ import { join } from "path";
10
10
  import { writeFile, mkdir, unlink } from "fs/promises";
11
11
  import { existsSync } from "fs";
12
12
  import { $ } from "bun";
13
- import { CONFIG_DIR, ENV_PATH, LOG_PATH, ERR_PATH } from "./config.ts";
13
+ import { CONFIG_DIR, LOG_PATH, ERR_PATH } from "./config.ts";
14
+ import { WRAPPER_PATH, writeDaemonWrapper } from "./daemon.ts";
14
15
 
15
16
  const SERVICE_NAME = "parachute-vault";
16
17
  const SERVICE_DIR = join(homedir(), ".config", "systemd", "user");
17
18
  const SERVICE_PATH = join(SERVICE_DIR, `${SERVICE_NAME}.service`);
18
19
 
19
- function generateUnit(serverPath: string, bunPath: string): string {
20
+ /**
21
+ * systemd unit invokes the shared start.sh wrapper. Env + server path
22
+ * resolution lives in the wrapper (see daemon.ts) — keeping systemd and
23
+ * launchd aligned on a single source of truth.
24
+ */
25
+ export function generateUnit(): string {
20
26
  return `[Unit]
21
27
  Description=Parachute Vault
22
28
  After=network.target
23
29
 
24
30
  [Service]
25
31
  Type=simple
26
- WorkingDirectory=${resolve(serverPath, "..")}
27
- ExecStart=${bunPath} ${serverPath}
32
+ WorkingDirectory=${CONFIG_DIR}
33
+ ExecStart=/bin/bash ${WRAPPER_PATH}
28
34
  Restart=on-failure
29
35
  RestartSec=5
30
- EnvironmentFile=${ENV_PATH}
31
36
  StandardOutput=append:${LOG_PATH}
32
37
  StandardError=append:${ERR_PATH}
33
38
 
@@ -36,12 +41,11 @@ WantedBy=default.target
36
41
  `;
37
42
  }
38
43
 
39
- export async function installSystemdService(): Promise<void> {
40
- const serverPath = resolve(import.meta.dir, "server.ts");
41
- const bunPath = (await $`which bun`.text()).trim();
44
+ export async function installSystemdService(): Promise<{ serverPath: string }> {
45
+ const { serverPath } = await writeDaemonWrapper();
42
46
 
43
47
  await mkdir(SERVICE_DIR, { recursive: true });
44
- await writeFile(SERVICE_PATH, generateUnit(serverPath, bunPath));
48
+ await writeFile(SERVICE_PATH, generateUnit());
45
49
 
46
50
  // Enable lingering so user services run without login session
47
51
  try {
@@ -52,7 +56,10 @@ export async function installSystemdService(): Promise<void> {
52
56
 
53
57
  await $`systemctl --user daemon-reload`.quiet();
54
58
  await $`systemctl --user enable ${SERVICE_NAME}`.quiet();
55
- await $`systemctl --user start ${SERVICE_NAME}`.quiet();
59
+ // Idempotent: `restart` works whether or not the service was running.
60
+ await $`systemctl --user restart ${SERVICE_NAME}`.quiet();
61
+
62
+ return { serverPath };
56
63
  }
57
64
 
58
65
  export async function uninstallSystemdService(): Promise<void> {
@@ -97,13 +97,13 @@ describe("buildPredicate", () => {
97
97
  });
98
98
  });
99
99
 
100
- describe("registerTriggers — dispatch modes", () => {
100
+ describe("registerTriggers — dispatch modes", async () => {
101
101
  let webhookServer: ReturnType<typeof Bun.serve>;
102
102
  let webhookPort: number;
103
103
  let lastRequest: { method: string; url: string; headers: Headers; body: unknown; formData?: FormData } | null = null;
104
104
  let webhookHandler: (req: Request) => Response | Promise<Response>;
105
105
 
106
- beforeAll(() => {
106
+ beforeAll(async () => {
107
107
  webhookHandler = () => Response.json({});
108
108
  webhookServer = Bun.serve({
109
109
  hostname: "127.0.0.1",
@@ -224,7 +224,7 @@ describe("registerTriggers — dispatch modes", () => {
224
224
  expect((file as File).name).toBe("recording.wav");
225
225
 
226
226
  // Verify note content was updated
227
- const updated = store.getNote("n2");
227
+ const updated = await store.getNote("n2");
228
228
  expect(updated?.content).toBe("transcribed content");
229
229
 
230
230
  // Cleanup
@@ -272,12 +272,12 @@ describe("registerTriggers — dispatch modes", () => {
272
272
  expect(body.input).toBe("Hello world");
273
273
 
274
274
  // Verify attachment was created
275
- const attachments = store.getAttachments("n3");
275
+ const attachments = await store.getAttachments("n3");
276
276
  expect(attachments.length).toBe(1);
277
277
  expect(attachments[0].mimeType).toBe("audio/ogg");
278
278
 
279
279
  // Verify metadata includes provider info
280
- const updated = store.getNote("n3");
280
+ const updated = await store.getNote("n3");
281
281
  const meta = updated?.metadata as Record<string, unknown>;
282
282
  expect(meta.tts_provider).toBe("kokoro");
283
283
  expect(meta.tts_voice).toBe("af_heart");
@@ -307,7 +307,7 @@ describe("registerTriggers — dispatch modes", () => {
307
307
  await hooks.dispatch("created", note, store);
308
308
  await new Promise(r => setTimeout(r, 50));
309
309
 
310
- const updated = store.getNote("n4");
310
+ const updated = await store.getNote("n4");
311
311
  const meta = updated?.metadata as Record<string, unknown>;
312
312
  expect(meta.skip_test_skipped_reason).toBe("no audio attachment found");
313
313
  });
@@ -330,7 +330,7 @@ describe("registerTriggers — dispatch modes", () => {
330
330
  await hooks.dispatch("created", note, store);
331
331
  await new Promise(r => setTimeout(r, 50));
332
332
 
333
- const updated = store.getNote("n5");
333
+ const updated = await store.getNote("n5");
334
334
  const meta = updated?.metadata as Record<string, unknown>;
335
335
  expect(meta.empty_test_skipped_reason).toBe("note has no content to synthesize");
336
336
  });
package/src/triggers.ts CHANGED
@@ -313,7 +313,7 @@ export function registerTriggers(
313
313
 
314
314
  // Phase 1: claim
315
315
  try {
316
- store.updateNote(note.id, {
316
+ await store.updateNote(note.id, {
317
317
  metadata: { ...existingMeta, [pendingKey]: pendingAt },
318
318
  skipUpdatedAt: true,
319
319
  });
@@ -324,7 +324,7 @@ export function registerTriggers(
324
324
 
325
325
  // Fire the webhook using the configured send mode
326
326
  let webhookResult: WebhookResponse;
327
- const attachments = store.getAttachments(note.id);
327
+ const attachments = await store.getAttachments(note.id);
328
328
  const controller = new AbortController();
329
329
  const timer = setTimeout(() => controller.abort(), timeout);
330
330
  try {
@@ -358,7 +358,7 @@ export function registerTriggers(
358
358
  // trigger an infinite webhook loop on every update.
359
359
  if (webhookResult.skipped_reason) {
360
360
  try {
361
- store.updateNote(note.id, {
361
+ await store.updateNote(note.id, {
362
362
  metadata: {
363
363
  ...existingMeta,
364
364
  [pendingKey]: undefined,
@@ -378,16 +378,16 @@ export function registerTriggers(
378
378
  // Add attachments first
379
379
  if (webhookResult.attachments?.length) {
380
380
  for (const att of webhookResult.attachments) {
381
- store.addAttachment(note.id, att.path, att.mimeType, att.meta);
381
+ await store.addAttachment(note.id, att.path, att.mimeType, att.meta);
382
382
  }
383
383
  }
384
384
 
385
385
  // Read fresh metadata to avoid clobbering concurrent edits
386
- const fresh = store.getNote(note.id);
386
+ const fresh = await store.getNote(note.id);
387
387
  const freshMeta = (fresh?.metadata as Record<string, unknown> | undefined) ?? existingMeta;
388
388
  const { [pendingKey]: _drop, ...restMeta } = freshMeta;
389
389
 
390
- store.updateNote(note.id, {
390
+ await store.updateNote(note.id, {
391
391
  ...(webhookResult.content !== undefined ? { content: webhookResult.content } : {}),
392
392
  metadata: {
393
393
  ...restMeta,
@@ -38,10 +38,27 @@ export function getVaultNameForStore(store: SqliteStore): string | undefined {
38
38
  return storeToVault.get(store);
39
39
  }
40
40
 
41
- /** Close all open stores. */
42
- export function closeAllStores(): void {
41
+ /**
42
+ * Close all open stores. When `silent` is true, swallow errors from
43
+ * `db.close()` — used by test cleanup where the underlying DB files may
44
+ * already be gone.
45
+ */
46
+ export function closeAllStores(silent = false): void {
43
47
  for (const [, store] of stores) {
44
- store.db.close();
48
+ if (silent) {
49
+ try { store.db.close(); } catch {}
50
+ } else {
51
+ store.db.close();
52
+ }
45
53
  }
46
54
  stores.clear();
47
55
  }
56
+
57
+ /**
58
+ * Test-only: close and clear all cached stores. Used between tests that
59
+ * swap `PARACHUTE_HOME` out from under the cache, which would otherwise
60
+ * hold handles to DBs whose files no longer exist.
61
+ */
62
+ export function clearVaultStoreCache(): void {
63
+ closeAllStores(true);
64
+ }