@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.
- package/CHANGELOG.md +87 -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 +66 -13
- package/src/published.test.ts +21 -21
- package/src/routes.ts +152 -70
- package/src/routing.test.ts +478 -0
- package/src/routing.ts +413 -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,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
|
+
}
|