@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.
- package/CHANGELOG.md +80 -0
- package/CLAUDE.md +2 -2
- package/README.md +289 -44
- package/core/src/core.test.ts +802 -346
- package/core/src/expand.ts +140 -0
- package/core/src/hooks.test.ts +27 -27
- package/core/src/hooks.ts +1 -1
- package/core/src/mcp.ts +102 -39
- package/core/src/notes.ts +82 -4
- package/core/src/obsidian.test.ts +11 -11
- package/core/src/paths.test.ts +46 -46
- package/core/src/schema.ts +18 -2
- package/core/src/store.ts +51 -51
- package/core/src/types.ts +29 -29
- package/core/src/wikilinks.test.ts +61 -61
- package/docs/HTTP_API.md +4 -2
- package/package.json +1 -1
- package/src/auth.test.ts +319 -0
- package/src/backup-launchd.test.ts +90 -0
- package/src/backup-launchd.ts +169 -0
- package/src/backup.test.ts +715 -0
- package/src/backup.ts +699 -0
- package/src/cli.ts +923 -31
- package/src/config.test.ts +173 -0
- package/src/config.ts +345 -15
- package/src/daemon.ts +136 -0
- package/src/doctor.test.ts +356 -0
- package/src/health.test.ts +201 -0
- package/src/health.ts +115 -0
- package/src/launchd.test.ts +91 -0
- package/src/launchd.ts +37 -40
- package/src/mcp-http.ts +1 -1
- package/src/mcp-tools.ts +7 -9
- package/src/oauth.test.ts +289 -8
- package/src/oauth.ts +57 -12
- package/src/published.test.ts +21 -21
- package/src/routes.ts +152 -70
- package/src/routing.test.ts +347 -0
- package/src/routing.ts +365 -0
- package/src/server.ts +7 -278
- package/src/systemd.test.ts +15 -0
- package/src/systemd.ts +18 -11
- package/src/triggers.test.ts +7 -7
- package/src/triggers.ts +6 -6
- package/src/vault-store.ts +20 -3
- package/src/vault.test.ts +356 -262
- package/.claude/settings.local.json +0 -31
- package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
- package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
- package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
- package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
- package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
- package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
- package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
- package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
- package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
- package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
- package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
- package/religions-abrahamic-filter.png +0 -0
- package/religions-buddhism-v2.png +0 -0
- package/religions-buddhism.png +0 -0
- package/religions-final.png +0 -0
- package/religions-v1.png +0 -0
- package/religions-v2.png +0 -0
- package/religions-zen.png +0 -0
- package/web/README.md +0 -73
- package/web/bun.lock +0 -827
- package/web/eslint.config.js +0 -23
- package/web/index.html +0 -15
- package/web/package.json +0 -36
- package/web/public/favicon.svg +0 -1
- package/web/public/icons.svg +0 -24
- package/web/src/App.tsx +0 -149
- package/web/src/Graph.tsx +0 -200
- package/web/src/NoteView.tsx +0 -155
- package/web/src/Sidebar.tsx +0 -186
- package/web/src/api.ts +0 -21
- package/web/src/index.css +0 -50
- package/web/src/main.tsx +0 -10
- package/web/src/types.ts +0 -37
- package/web/src/utils.ts +0 -107
- package/web/tsconfig.app.json +0 -25
- package/web/tsconfig.json +0 -7
- package/web/tsconfig.node.json +0 -24
- 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 {
|
|
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
|
+
});
|