@openparachute/vault 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/CLAUDE.md +2 -2
  3. package/README.md +289 -44
  4. package/core/src/core.test.ts +802 -346
  5. package/core/src/expand.ts +140 -0
  6. package/core/src/hooks.test.ts +27 -27
  7. package/core/src/hooks.ts +1 -1
  8. package/core/src/mcp.ts +102 -39
  9. package/core/src/notes.ts +82 -4
  10. package/core/src/obsidian.test.ts +11 -11
  11. package/core/src/paths.test.ts +46 -46
  12. package/core/src/schema.ts +18 -2
  13. package/core/src/store.ts +51 -51
  14. package/core/src/types.ts +29 -29
  15. package/core/src/wikilinks.test.ts +61 -61
  16. package/docs/HTTP_API.md +4 -2
  17. package/package.json +1 -1
  18. package/src/auth.test.ts +319 -0
  19. package/src/backup-launchd.test.ts +90 -0
  20. package/src/backup-launchd.ts +169 -0
  21. package/src/backup.test.ts +715 -0
  22. package/src/backup.ts +699 -0
  23. package/src/cli.ts +923 -31
  24. package/src/config.test.ts +173 -0
  25. package/src/config.ts +345 -15
  26. package/src/daemon.ts +136 -0
  27. package/src/doctor.test.ts +356 -0
  28. package/src/health.test.ts +201 -0
  29. package/src/health.ts +115 -0
  30. package/src/launchd.test.ts +91 -0
  31. package/src/launchd.ts +37 -40
  32. package/src/mcp-http.ts +1 -1
  33. package/src/mcp-tools.ts +7 -9
  34. package/src/oauth.test.ts +289 -8
  35. package/src/oauth.ts +57 -12
  36. package/src/published.test.ts +21 -21
  37. package/src/routes.ts +152 -70
  38. package/src/routing.test.ts +347 -0
  39. package/src/routing.ts +365 -0
  40. package/src/server.ts +7 -278
  41. package/src/systemd.test.ts +15 -0
  42. package/src/systemd.ts +18 -11
  43. package/src/triggers.test.ts +7 -7
  44. package/src/triggers.ts +6 -6
  45. package/src/vault-store.ts +20 -3
  46. package/src/vault.test.ts +356 -262
  47. package/.claude/settings.local.json +0 -31
  48. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  49. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  50. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  51. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  52. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  53. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  54. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  55. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  56. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  57. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  58. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  59. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  60. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  61. package/religions-abrahamic-filter.png +0 -0
  62. package/religions-buddhism-v2.png +0 -0
  63. package/religions-buddhism.png +0 -0
  64. package/religions-final.png +0 -0
  65. package/religions-v1.png +0 -0
  66. package/religions-v2.png +0 -0
  67. package/religions-zen.png +0 -0
  68. package/web/README.md +0 -73
  69. package/web/bun.lock +0 -827
  70. package/web/eslint.config.js +0 -23
  71. package/web/index.html +0 -15
  72. package/web/package.json +0 -36
  73. package/web/public/favicon.svg +0 -1
  74. package/web/public/icons.svg +0 -24
  75. package/web/src/App.tsx +0 -149
  76. package/web/src/Graph.tsx +0 -200
  77. package/web/src/NoteView.tsx +0 -155
  78. package/web/src/Sidebar.tsx +0 -186
  79. package/web/src/api.ts +0 -21
  80. package/web/src/index.css +0 -50
  81. package/web/src/main.tsx +0 -10
  82. package/web/src/types.ts +0 -37
  83. package/web/src/utils.ts +0 -107
  84. package/web/tsconfig.app.json +0 -25
  85. package/web/tsconfig.json +0 -7
  86. package/web/tsconfig.node.json +0 -24
  87. package/web/vite.config.ts +0 -15
package/src/routing.ts ADDED
@@ -0,0 +1,365 @@
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
+ } from "./oauth.ts";
53
+
54
+ /**
55
+ * Check if a /view request has a valid API key (header or ?key= query param).
56
+ * Returns true if authenticated, false if not. Never rejects — unauthenticated
57
+ * requests still get public notes.
58
+ */
59
+ function isViewAuthenticated(
60
+ req: Request,
61
+ vaultConfig: VaultConfig | null,
62
+ vaultDb?: import("bun:sqlite").Database,
63
+ ): boolean {
64
+ if (!vaultConfig) return false;
65
+ // extractApiKey now checks headers AND ?key= query param
66
+ const key = extractApiKey(req);
67
+ if (!key) return false;
68
+ const auth = authenticateVaultRequest(req, vaultConfig, vaultDb);
69
+ return !("error" in auth);
70
+ }
71
+
72
+ export async function route(
73
+ req: Request,
74
+ path: string,
75
+ clientIp?: string,
76
+ ): Promise<Response> {
77
+ // OAuth discovery endpoints (no auth required)
78
+ if (path === "/.well-known/oauth-protected-resource") {
79
+ return handleProtectedResource(req);
80
+ }
81
+ if (path === "/.well-known/oauth-authorization-server") {
82
+ return handleAuthorizationServer(req);
83
+ }
84
+
85
+ // OAuth flow endpoints (no auth — these ARE the auth)
86
+ if (path === "/oauth/register" || path === "/oauth/authorize" || path === "/oauth/token") {
87
+ const defaultVault = resolveDefaultVault();
88
+ const vaultConfig = defaultVault ? readVaultConfig(defaultVault) : null;
89
+ if (!defaultVault || !vaultConfig) {
90
+ return Response.json(
91
+ { error: "server_error", error_description: "Default vault not configured" },
92
+ { status: 500 },
93
+ );
94
+ }
95
+ const store = getVaultStore(defaultVault);
96
+
97
+ if (path === "/oauth/register") {
98
+ return handleRegister(req, store.db);
99
+ }
100
+ if (path === "/oauth/authorize") {
101
+ const gc = readGlobalConfig();
102
+ const ownerPasswordHash = gc.owner_password_hash ?? null;
103
+ const totpSecret = gc.totp_secret ?? null;
104
+ const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
105
+ if (req.method === "GET") {
106
+ return handleAuthorizeGet(req, store.db, vaultConfig.name, ownerPasswordHash, totpEnrolled);
107
+ }
108
+ if (req.method === "POST") {
109
+ return handleAuthorizePost(req, store.db, {
110
+ vaultName: vaultConfig.name,
111
+ clientIp,
112
+ ownerPasswordHash,
113
+ totpSecret,
114
+ });
115
+ }
116
+ return Response.json({ error: "method_not_allowed" }, { status: 405 });
117
+ }
118
+ if (path === "/oauth/token") {
119
+ // PR #111: handleToken echoes the vault name back to the client so it
120
+ // knows which vault it just connected to.
121
+ return handleToken(req, store.db, defaultVault);
122
+ }
123
+ }
124
+
125
+ // Health check — vault names only for authenticated requests
126
+ if (path === "/health") {
127
+ const auth = authenticateGlobalRequest(req);
128
+ if ("error" in auth) {
129
+ return Response.json({ status: "ok" });
130
+ }
131
+ return Response.json({ status: "ok", vaults: listVaults() });
132
+ }
133
+
134
+ // Unified MCP (all vaults, global auth)
135
+ if (path === "/mcp" || path.startsWith("/mcp/")) {
136
+ const auth = authenticateGlobalRequest(req);
137
+ if ("error" in auth) return auth.error;
138
+ return handleUnifiedMcp(req, auth);
139
+ }
140
+
141
+ // View endpoint — serves notes as HTML (auth-aware, supports ID or path)
142
+ const viewMatch = path.match(/^\/view\/(.+)$/);
143
+ if (viewMatch && req.method === "GET") {
144
+ const defaultVault = resolveDefaultVault();
145
+ const vaultConfig = defaultVault ? readVaultConfig(defaultVault) : null;
146
+ if (!defaultVault || !vaultConfig) {
147
+ return Response.json({ error: "Default vault not found" }, { status: 404 });
148
+ }
149
+ const store = getVaultStore(defaultVault);
150
+ const authenticated = isViewAuthenticated(req, vaultConfig, store.db);
151
+ return handleViewNote(store, decodeURIComponent(viewMatch[1]), {
152
+ authenticated,
153
+ publishedTag: vaultConfig.published_tag,
154
+ });
155
+ }
156
+
157
+ // Backward compat: /public/:noteId → /view/:noteId (preserving query params)
158
+ const publicMatch = path.match(/^\/public\/([^/]+)$/);
159
+ if (publicMatch && req.method === "GET") {
160
+ const dest = new URL(`/view/${publicMatch[1]}`, req.url);
161
+ dest.search = new URL(req.url).search;
162
+ return Response.redirect(dest.toString(), 301);
163
+ }
164
+
165
+ // Public vault names — no auth, no metadata. Lets unauthenticated clients
166
+ // (e.g. the Daily vault-picker dropdown before OAuth) know which vault to
167
+ // target. Only vault names are exposed; descriptions, counts, timestamps,
168
+ // and API keys are never returned from this endpoint.
169
+ //
170
+ // Operators who want to hide vault existence from anonymous callers can set
171
+ // `discovery: disabled` in ~/.parachute/config.yaml — the endpoint then
172
+ // returns 404 as if it didn't exist.
173
+ if (path === "/vaults/list" && req.method === "GET") {
174
+ const globalConfig = readGlobalConfig();
175
+ if (globalConfig.discovery === "disabled") {
176
+ return Response.json({ error: "Not found" }, { status: 404 });
177
+ }
178
+ return Response.json({ vaults: listVaults() });
179
+ }
180
+
181
+ // List vaults — requires auth
182
+ if (path === "/vaults" && req.method === "GET") {
183
+ const auth = authenticateGlobalRequest(req);
184
+ if ("error" in auth) return auth.error;
185
+ const names = listVaults();
186
+ const vaults = names.map((name) => {
187
+ const config = readVaultConfig(name);
188
+ return {
189
+ name,
190
+ description: config?.description,
191
+ created_at: config?.created_at,
192
+ };
193
+ });
194
+ return Response.json({ vaults });
195
+ }
196
+
197
+ // Backward-compatible: /api/* routes to default vault
198
+ if (path.startsWith("/api/")) {
199
+ const defaultVault = resolveDefaultVault();
200
+ const vaultConfig = defaultVault ? readVaultConfig(defaultVault) : null;
201
+ if (!defaultVault || !vaultConfig) {
202
+ return Response.json({ error: "Default vault not found" }, { status: 404 });
203
+ }
204
+ const store = getVaultStore(defaultVault);
205
+ const auth = authenticateVaultRequest(req, vaultConfig, store.db);
206
+ if ("error" in auth) return auth.error;
207
+ if (!isMethodAllowed(req.method, auth.permission)) {
208
+ return Response.json(
209
+ { error: "Forbidden", message: "Insufficient permissions" },
210
+ { status: 403 },
211
+ );
212
+ }
213
+ const apiPath = path.slice(4); // strip "/api"
214
+ if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6));
215
+ if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5));
216
+ if (apiPath === "/find-path") return handleFindPath(req, store);
217
+ if (apiPath === "/vault") {
218
+ return handleVault(req, store, vaultConfig, (desc) => {
219
+ vaultConfig.description = desc;
220
+ writeVaultConfig(vaultConfig);
221
+ });
222
+ }
223
+ if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store);
224
+ if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), defaultVault);
225
+ if (apiPath === "/health") return Response.json({ status: "ok", vault: defaultVault });
226
+ }
227
+
228
+ // Vault-scoped routes: /vaults/{name}/...
229
+ const vaultMatch = path.match(/^\/vaults\/([^/]+)(\/.*)?$/);
230
+ if (!vaultMatch) {
231
+ return Response.json({ error: "Not found" }, { status: 404 });
232
+ }
233
+
234
+ const vaultName = vaultMatch[1];
235
+ const subpath = vaultMatch[2] ?? "";
236
+
237
+ const vaultConfig = readVaultConfig(vaultName);
238
+ if (!vaultConfig) {
239
+ return Response.json({ error: "Vault not found", vault: vaultName }, { status: 404 });
240
+ }
241
+
242
+ // Backward compat: /vaults/{name}/public/:noteId → /view/:noteId
243
+ const vaultPublicMatch = subpath.match(/^\/public\/([^/]+)$/);
244
+ if (vaultPublicMatch && req.method === "GET") {
245
+ const dest = new URL(`/vaults/${vaultName}/view/${vaultPublicMatch[1]}`, req.url);
246
+ dest.search = new URL(req.url).search;
247
+ return Response.redirect(dest.toString(), 301);
248
+ }
249
+
250
+ // View endpoint — serves notes as HTML (auth-aware, vault-scoped, supports ID or path)
251
+ const vaultViewMatch = subpath.match(/^\/view\/(.+)$/);
252
+ if (vaultViewMatch && req.method === "GET") {
253
+ const store = getVaultStore(vaultName);
254
+ const authenticated = isViewAuthenticated(req, vaultConfig, store.db);
255
+ return handleViewNote(store, decodeURIComponent(vaultViewMatch[1]), {
256
+ authenticated,
257
+ publishedTag: vaultConfig.published_tag,
258
+ });
259
+ }
260
+
261
+ // Vault-scoped OAuth endpoints (no auth — these ARE the auth)
262
+ if (subpath === "/oauth/register" || subpath === "/oauth/authorize" || subpath === "/oauth/token") {
263
+ const store = getVaultStore(vaultName);
264
+ if (subpath === "/oauth/register") return handleRegister(req, store.db);
265
+ if (subpath === "/oauth/authorize") {
266
+ const gc = readGlobalConfig();
267
+ const ownerPasswordHash = gc.owner_password_hash ?? null;
268
+ const totpSecret = gc.totp_secret ?? null;
269
+ const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
270
+ if (req.method === "GET") {
271
+ return handleAuthorizeGet(
272
+ req,
273
+ store.db,
274
+ vaultConfig.name,
275
+ ownerPasswordHash,
276
+ totpEnrolled,
277
+ );
278
+ }
279
+ if (req.method === "POST") {
280
+ return handleAuthorizePost(req, store.db, {
281
+ vaultName: vaultConfig.name,
282
+ clientIp,
283
+ ownerPasswordHash,
284
+ totpSecret,
285
+ });
286
+ }
287
+ return Response.json({ error: "method_not_allowed" }, { status: 405 });
288
+ }
289
+ // PR #111: handleToken now requires the vault name so it can (a) pin the
290
+ // OAuth code to the issuing vault (prevents cross-vault code replay) and
291
+ // (b) echo `vault: <name>` back to the client in the token response.
292
+ if (subpath === "/oauth/token") return handleToken(req, store.db, vaultName);
293
+ }
294
+
295
+ // Vault-scoped discovery endpoints. PR #111: the protected-resource
296
+ // advertises a vault-scoped authorization server (`${base}/vaults/${name}`),
297
+ // and the vault-scoped authorization-server metadata returns endpoints
298
+ // scoped to `/vaults/${name}/oauth/*` so tokens mint against this vault's
299
+ // DB. Keeps the RFC 9728 → RFC 8414 chain coherent end-to-end.
300
+ if (subpath === "/.well-known/oauth-protected-resource") {
301
+ return handleProtectedResource(
302
+ req,
303
+ `/vaults/${vaultName}/mcp`,
304
+ `/vaults/${vaultName}`,
305
+ );
306
+ }
307
+ if (subpath === "/.well-known/oauth-authorization-server") {
308
+ return handleAuthorizationServer(req, vaultName);
309
+ }
310
+
311
+ // Auth: per-vault key OR global key
312
+ const store = getVaultStore(vaultName);
313
+ const auth = authenticateVaultRequest(req, vaultConfig, store.db);
314
+ if ("error" in auth) return auth.error;
315
+
316
+ // Per-vault scoped MCP
317
+ if (subpath === "/mcp" || subpath.startsWith("/mcp/")) {
318
+ return handleScopedMcp(req, vaultName, auth);
319
+ }
320
+
321
+ // Bare /vaults/{name} — single-vault root. Returns name, description,
322
+ // createdAt, and stats. One round trip for a viz landing page.
323
+ if (subpath === "" || subpath === "/") {
324
+ if (req.method !== "GET") {
325
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
326
+ }
327
+ const stats = await store.getVaultStats();
328
+ return Response.json({
329
+ name: vaultName,
330
+ description: vaultConfig.description,
331
+ createdAt: vaultConfig.created_at,
332
+ stats,
333
+ });
334
+ }
335
+
336
+ // REST API — enforce permission level
337
+ if (!isMethodAllowed(req.method, auth.permission)) {
338
+ return Response.json(
339
+ { error: "Forbidden", message: "Insufficient permissions" },
340
+ { status: 403 },
341
+ );
342
+ }
343
+
344
+ const apiMatch = subpath.match(/^\/api(\/.*)?$/);
345
+ if (!apiMatch) {
346
+ return Response.json({ error: "Not found" }, { status: 404 });
347
+ }
348
+
349
+ const apiPath = apiMatch[1] ?? "";
350
+
351
+ if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6));
352
+ if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5));
353
+ if (apiPath === "/find-path") return handleFindPath(req, store);
354
+ if (apiPath === "/vault") {
355
+ return handleVault(req, store, vaultConfig, (desc) => {
356
+ vaultConfig.description = desc;
357
+ writeVaultConfig(vaultConfig);
358
+ });
359
+ }
360
+ if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store);
361
+ if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), vaultName);
362
+ if (apiPath === "/health") return Response.json({ status: "ok", vault: vaultName });
363
+
364
+ return Response.json({ error: "Not found" }, { status: 404 });
365
+ }
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
+ });