@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/routing.ts ADDED
@@ -0,0 +1,413 @@
1
+ /**
2
+ * HTTP request router for the multi-vault server.
3
+ *
4
+ * Extracted from server.ts so routes are unit-testable without spinning up
5
+ * Bun.serve(). server.ts imports this and wires it into the listener.
6
+ *
7
+ * Path dispatch order (skim):
8
+ * - /.well-known/oauth-* — public OAuth discovery
9
+ * - /oauth/* — unscoped OAuth (targets default vault)
10
+ * - /health — lightweight ping
11
+ * - /mcp, /mcp/* — unified MCP (global auth)
12
+ * - /view/:id — default-vault HTML view
13
+ * - /public/:id — backward-compat redirect → /view
14
+ * - /vaults/list — PUBLIC vault names (no auth, no metadata)
15
+ * - /vaults — authenticated vault metadata listing
16
+ * - /api/* — default-vault REST API
17
+ * - /vaults/:name/* — vault-scoped: view, oauth, mcp, api
18
+ */
19
+
20
+ import type { VaultConfig } from "./config.ts";
21
+ import {
22
+ readVaultConfig,
23
+ readGlobalConfig,
24
+ writeVaultConfig,
25
+ listVaults,
26
+ resolveDefaultVault,
27
+ } from "./config.ts";
28
+ import {
29
+ authenticateVaultRequest,
30
+ authenticateGlobalRequest,
31
+ isMethodAllowed,
32
+ extractApiKey,
33
+ } from "./auth.ts";
34
+ import { getVaultStore } from "./vault-store.ts";
35
+ import { handleUnifiedMcp, handleScopedMcp } from "./mcp-http.ts";
36
+ import {
37
+ handleNotes,
38
+ handleTags,
39
+ handleFindPath,
40
+ handleVault,
41
+ handleUnresolvedWikilinks,
42
+ handleStorage,
43
+ handleViewNote,
44
+ } from "./routes.ts";
45
+ import {
46
+ handleProtectedResource,
47
+ handleAuthorizationServer,
48
+ handleRegister,
49
+ handleAuthorizeGet,
50
+ handleAuthorizePost,
51
+ handleToken,
52
+ getBaseUrl,
53
+ } from "./oauth.ts";
54
+
55
+ /**
56
+ * Decorate a 401 response from the MCP endpoint with the RFC 9728 challenge
57
+ * header pointing at the matching protected-resource metadata document.
58
+ *
59
+ * An MCP-capable OAuth client that receives a plain 401 has no structured way
60
+ * to discover which authorization server to use, and SDKs that follow RFC 9728
61
+ * (including Claude Code's) default to probing the *root* `/.well-known/oauth-
62
+ * protected-resource`. That document advertises `resource: <base>/mcp` — which
63
+ * then fails the SDK's strict resource-URL match when the client is actually
64
+ * connecting to `/vaults/{name}/mcp`. The `WWW-Authenticate` header tells the
65
+ * client exactly which metadata document applies to the endpoint it just hit,
66
+ * closing the mismatch.
67
+ *
68
+ * Scoped calls pass `vaultName`; unscoped omits it. Other 401-emitting
69
+ * endpoints (`/api/*`, `/vaults`, `/health` when authenticated) are not MCP
70
+ * resources and intentionally do not carry this header.
71
+ */
72
+ function mcpWwwAuthenticate(req: Request, vaultName?: string): string {
73
+ const base = getBaseUrl(req);
74
+ const prefix = vaultName ? `/vaults/${vaultName}` : "";
75
+ return `Bearer resource_metadata="${base}${prefix}/.well-known/oauth-protected-resource"`;
76
+ }
77
+
78
+ /**
79
+ * Clone a 401 Response and attach the `WWW-Authenticate` challenge header.
80
+ * The auth module returns a fully-baked `Response`, and headers on a consumed
81
+ * `Response` can't be mutated in place; cloning is the cheap path.
82
+ */
83
+ async function withMcpChallenge(
84
+ res: Response,
85
+ req: Request,
86
+ vaultName?: string,
87
+ ): Promise<Response> {
88
+ if (res.status !== 401) return res;
89
+ const body = await res.text();
90
+ const headers = new Headers(res.headers);
91
+ headers.set("WWW-Authenticate", mcpWwwAuthenticate(req, vaultName));
92
+ return new Response(body, { status: 401, headers });
93
+ }
94
+
95
+ /**
96
+ * Check if a /view request has a valid API key (header or ?key= query param).
97
+ * Returns true if authenticated, false if not. Never rejects — unauthenticated
98
+ * requests still get public notes.
99
+ */
100
+ function isViewAuthenticated(
101
+ req: Request,
102
+ vaultConfig: VaultConfig | null,
103
+ vaultDb?: import("bun:sqlite").Database,
104
+ ): boolean {
105
+ if (!vaultConfig) return false;
106
+ // extractApiKey now checks headers AND ?key= query param
107
+ const key = extractApiKey(req);
108
+ if (!key) return false;
109
+ const auth = authenticateVaultRequest(req, vaultConfig, vaultDb);
110
+ return !("error" in auth);
111
+ }
112
+
113
+ export async function route(
114
+ req: Request,
115
+ path: string,
116
+ clientIp?: string,
117
+ ): Promise<Response> {
118
+ // OAuth discovery endpoints (no auth required)
119
+ if (path === "/.well-known/oauth-protected-resource") {
120
+ return handleProtectedResource(req);
121
+ }
122
+ if (path === "/.well-known/oauth-authorization-server") {
123
+ return handleAuthorizationServer(req);
124
+ }
125
+
126
+ // OAuth flow endpoints (no auth — these ARE the auth)
127
+ if (path === "/oauth/register" || path === "/oauth/authorize" || path === "/oauth/token") {
128
+ const defaultVault = resolveDefaultVault();
129
+ const vaultConfig = defaultVault ? readVaultConfig(defaultVault) : null;
130
+ if (!defaultVault || !vaultConfig) {
131
+ return Response.json(
132
+ { error: "server_error", error_description: "Default vault not configured" },
133
+ { status: 500 },
134
+ );
135
+ }
136
+ const store = getVaultStore(defaultVault);
137
+
138
+ if (path === "/oauth/register") {
139
+ return handleRegister(req, store.db);
140
+ }
141
+ if (path === "/oauth/authorize") {
142
+ const gc = readGlobalConfig();
143
+ const ownerPasswordHash = gc.owner_password_hash ?? null;
144
+ const totpSecret = gc.totp_secret ?? null;
145
+ const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
146
+ if (req.method === "GET") {
147
+ return handleAuthorizeGet(req, store.db, vaultConfig.name, ownerPasswordHash, totpEnrolled);
148
+ }
149
+ if (req.method === "POST") {
150
+ return handleAuthorizePost(req, store.db, {
151
+ vaultName: vaultConfig.name,
152
+ clientIp,
153
+ ownerPasswordHash,
154
+ totpSecret,
155
+ });
156
+ }
157
+ return Response.json({ error: "method_not_allowed" }, { status: 405 });
158
+ }
159
+ if (path === "/oauth/token") {
160
+ // PR #111: handleToken echoes the vault name back to the client so it
161
+ // knows which vault it just connected to.
162
+ return handleToken(req, store.db, defaultVault);
163
+ }
164
+ }
165
+
166
+ // Health check — vault names only for authenticated requests
167
+ if (path === "/health") {
168
+ const auth = authenticateGlobalRequest(req);
169
+ if ("error" in auth) {
170
+ return Response.json({ status: "ok" });
171
+ }
172
+ return Response.json({ status: "ok", vaults: listVaults() });
173
+ }
174
+
175
+ // Unified MCP (all vaults, global auth)
176
+ if (path === "/mcp" || path.startsWith("/mcp/")) {
177
+ const auth = authenticateGlobalRequest(req);
178
+ if ("error" in auth) return withMcpChallenge(auth.error, req);
179
+ return handleUnifiedMcp(req, auth);
180
+ }
181
+
182
+ // View endpoint — serves notes as HTML (auth-aware, supports ID or path)
183
+ const viewMatch = path.match(/^\/view\/(.+)$/);
184
+ if (viewMatch && req.method === "GET") {
185
+ const defaultVault = resolveDefaultVault();
186
+ const vaultConfig = defaultVault ? readVaultConfig(defaultVault) : null;
187
+ if (!defaultVault || !vaultConfig) {
188
+ return Response.json({ error: "Default vault not found" }, { status: 404 });
189
+ }
190
+ const store = getVaultStore(defaultVault);
191
+ const authenticated = isViewAuthenticated(req, vaultConfig, store.db);
192
+ return handleViewNote(store, decodeURIComponent(viewMatch[1]), {
193
+ authenticated,
194
+ publishedTag: vaultConfig.published_tag,
195
+ });
196
+ }
197
+
198
+ // Backward compat: /public/:noteId → /view/:noteId (preserving query params)
199
+ const publicMatch = path.match(/^\/public\/([^/]+)$/);
200
+ if (publicMatch && req.method === "GET") {
201
+ const dest = new URL(`/view/${publicMatch[1]}`, req.url);
202
+ dest.search = new URL(req.url).search;
203
+ return Response.redirect(dest.toString(), 301);
204
+ }
205
+
206
+ // Public vault names — no auth, no metadata. Lets unauthenticated clients
207
+ // (e.g. the Daily vault-picker dropdown before OAuth) know which vault to
208
+ // target. Only vault names are exposed; descriptions, counts, timestamps,
209
+ // and API keys are never returned from this endpoint.
210
+ //
211
+ // Operators who want to hide vault existence from anonymous callers can set
212
+ // `discovery: disabled` in ~/.parachute/config.yaml — the endpoint then
213
+ // returns 404 as if it didn't exist.
214
+ if (path === "/vaults/list" && req.method === "GET") {
215
+ const globalConfig = readGlobalConfig();
216
+ if (globalConfig.discovery === "disabled") {
217
+ return Response.json({ error: "Not found" }, { status: 404 });
218
+ }
219
+ return Response.json({ vaults: listVaults() });
220
+ }
221
+
222
+ // List vaults — requires auth
223
+ if (path === "/vaults" && req.method === "GET") {
224
+ const auth = authenticateGlobalRequest(req);
225
+ if ("error" in auth) return auth.error;
226
+ const names = listVaults();
227
+ const vaults = names.map((name) => {
228
+ const config = readVaultConfig(name);
229
+ return {
230
+ name,
231
+ description: config?.description,
232
+ created_at: config?.created_at,
233
+ };
234
+ });
235
+ return Response.json({ vaults });
236
+ }
237
+
238
+ // Backward-compatible: /api/* routes to default vault
239
+ if (path.startsWith("/api/")) {
240
+ const defaultVault = resolveDefaultVault();
241
+ const vaultConfig = defaultVault ? readVaultConfig(defaultVault) : null;
242
+ if (!defaultVault || !vaultConfig) {
243
+ return Response.json({ error: "Default vault not found" }, { status: 404 });
244
+ }
245
+ const store = getVaultStore(defaultVault);
246
+ const auth = authenticateVaultRequest(req, vaultConfig, store.db);
247
+ if ("error" in auth) return auth.error;
248
+ if (!isMethodAllowed(req.method, auth.permission)) {
249
+ return Response.json(
250
+ { error: "Forbidden", message: "Insufficient permissions" },
251
+ { status: 403 },
252
+ );
253
+ }
254
+ const apiPath = path.slice(4); // strip "/api"
255
+ if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6));
256
+ if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5));
257
+ if (apiPath === "/find-path") return handleFindPath(req, store);
258
+ if (apiPath === "/vault") {
259
+ return handleVault(req, store, vaultConfig, (desc) => {
260
+ vaultConfig.description = desc;
261
+ writeVaultConfig(vaultConfig);
262
+ });
263
+ }
264
+ if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store);
265
+ if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), defaultVault);
266
+ if (apiPath === "/health") return Response.json({ status: "ok", vault: defaultVault });
267
+ }
268
+
269
+ // Vault-scoped routes: /vaults/{name}/...
270
+ const vaultMatch = path.match(/^\/vaults\/([^/]+)(\/.*)?$/);
271
+ if (!vaultMatch) {
272
+ return Response.json({ error: "Not found" }, { status: 404 });
273
+ }
274
+
275
+ const vaultName = vaultMatch[1];
276
+ const subpath = vaultMatch[2] ?? "";
277
+
278
+ const vaultConfig = readVaultConfig(vaultName);
279
+ if (!vaultConfig) {
280
+ return Response.json({ error: "Vault not found", vault: vaultName }, { status: 404 });
281
+ }
282
+
283
+ // Backward compat: /vaults/{name}/public/:noteId → /view/:noteId
284
+ const vaultPublicMatch = subpath.match(/^\/public\/([^/]+)$/);
285
+ if (vaultPublicMatch && req.method === "GET") {
286
+ const dest = new URL(`/vaults/${vaultName}/view/${vaultPublicMatch[1]}`, req.url);
287
+ dest.search = new URL(req.url).search;
288
+ return Response.redirect(dest.toString(), 301);
289
+ }
290
+
291
+ // View endpoint — serves notes as HTML (auth-aware, vault-scoped, supports ID or path)
292
+ const vaultViewMatch = subpath.match(/^\/view\/(.+)$/);
293
+ if (vaultViewMatch && req.method === "GET") {
294
+ const store = getVaultStore(vaultName);
295
+ const authenticated = isViewAuthenticated(req, vaultConfig, store.db);
296
+ return handleViewNote(store, decodeURIComponent(vaultViewMatch[1]), {
297
+ authenticated,
298
+ publishedTag: vaultConfig.published_tag,
299
+ });
300
+ }
301
+
302
+ // Vault-scoped OAuth endpoints (no auth — these ARE the auth)
303
+ if (subpath === "/oauth/register" || subpath === "/oauth/authorize" || subpath === "/oauth/token") {
304
+ const store = getVaultStore(vaultName);
305
+ if (subpath === "/oauth/register") return handleRegister(req, store.db);
306
+ if (subpath === "/oauth/authorize") {
307
+ const gc = readGlobalConfig();
308
+ const ownerPasswordHash = gc.owner_password_hash ?? null;
309
+ const totpSecret = gc.totp_secret ?? null;
310
+ const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
311
+ if (req.method === "GET") {
312
+ return handleAuthorizeGet(
313
+ req,
314
+ store.db,
315
+ vaultConfig.name,
316
+ ownerPasswordHash,
317
+ totpEnrolled,
318
+ );
319
+ }
320
+ if (req.method === "POST") {
321
+ return handleAuthorizePost(req, store.db, {
322
+ vaultName: vaultConfig.name,
323
+ clientIp,
324
+ ownerPasswordHash,
325
+ totpSecret,
326
+ });
327
+ }
328
+ return Response.json({ error: "method_not_allowed" }, { status: 405 });
329
+ }
330
+ // PR #111: handleToken now requires the vault name so it can (a) pin the
331
+ // OAuth code to the issuing vault (prevents cross-vault code replay) and
332
+ // (b) echo `vault: <name>` back to the client in the token response.
333
+ if (subpath === "/oauth/token") return handleToken(req, store.db, vaultName);
334
+ }
335
+
336
+ // Vault-scoped discovery endpoints. PR #111: the protected-resource
337
+ // advertises a vault-scoped authorization server (`${base}/vaults/${name}`),
338
+ // and the vault-scoped authorization-server metadata returns endpoints
339
+ // scoped to `/vaults/${name}/oauth/*` so tokens mint against this vault's
340
+ // DB. Keeps the RFC 9728 → RFC 8414 chain coherent end-to-end.
341
+ if (subpath === "/.well-known/oauth-protected-resource") {
342
+ return handleProtectedResource(
343
+ req,
344
+ `/vaults/${vaultName}/mcp`,
345
+ `/vaults/${vaultName}`,
346
+ );
347
+ }
348
+ if (subpath === "/.well-known/oauth-authorization-server") {
349
+ return handleAuthorizationServer(req, vaultName);
350
+ }
351
+
352
+ // Auth: per-vault key OR global key.
353
+ // The auth check is shared between the scoped MCP branch and the scoped
354
+ // /api/* branches, so we can't unconditionally attach the MCP-only
355
+ // WWW-Authenticate challenge here — we pass the challenge back only when
356
+ // the failing request was actually targeting /vaults/{name}/mcp.
357
+ const store = getVaultStore(vaultName);
358
+ const auth = authenticateVaultRequest(req, vaultConfig, store.db);
359
+ const isScopedMcp = subpath === "/mcp" || subpath.startsWith("/mcp/");
360
+ if ("error" in auth) {
361
+ return isScopedMcp ? withMcpChallenge(auth.error, req, vaultName) : auth.error;
362
+ }
363
+
364
+ // Per-vault scoped MCP
365
+ if (isScopedMcp) {
366
+ return handleScopedMcp(req, vaultName, auth);
367
+ }
368
+
369
+ // Bare /vaults/{name} — single-vault root. Returns name, description,
370
+ // createdAt, and stats. One round trip for a viz landing page.
371
+ if (subpath === "" || subpath === "/") {
372
+ if (req.method !== "GET") {
373
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
374
+ }
375
+ const stats = await store.getVaultStats();
376
+ return Response.json({
377
+ name: vaultName,
378
+ description: vaultConfig.description,
379
+ createdAt: vaultConfig.created_at,
380
+ stats,
381
+ });
382
+ }
383
+
384
+ // REST API — enforce permission level
385
+ if (!isMethodAllowed(req.method, auth.permission)) {
386
+ return Response.json(
387
+ { error: "Forbidden", message: "Insufficient permissions" },
388
+ { status: 403 },
389
+ );
390
+ }
391
+
392
+ const apiMatch = subpath.match(/^\/api(\/.*)?$/);
393
+ if (!apiMatch) {
394
+ return Response.json({ error: "Not found" }, { status: 404 });
395
+ }
396
+
397
+ const apiPath = apiMatch[1] ?? "";
398
+
399
+ if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6));
400
+ if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5));
401
+ if (apiPath === "/find-path") return handleFindPath(req, store);
402
+ if (apiPath === "/vault") {
403
+ return handleVault(req, store, vaultConfig, (desc) => {
404
+ vaultConfig.description = desc;
405
+ writeVaultConfig(vaultConfig);
406
+ });
407
+ }
408
+ if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store);
409
+ if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), vaultName);
410
+ if (apiPath === "/health") return Response.json({ status: "ok", vault: vaultName });
411
+
412
+ return Response.json({ error: "Not found" }, { status: 404 });
413
+ }