@openparachute/vault 0.2.4 → 0.3.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/.claude/settings.local.json +2 -25
- package/CHANGELOG.md +64 -0
- package/CLAUDE.md +17 -7
- package/README.md +169 -136
- package/core/src/core.test.ts +591 -19
- package/core/src/hooks.ts +111 -3
- package/core/src/indexed-fields.test.ts +285 -0
- package/core/src/indexed-fields.ts +238 -0
- package/core/src/mcp.ts +127 -6
- package/core/src/notes.ts +153 -11
- package/core/src/query-operators.ts +174 -0
- package/core/src/schema.ts +69 -2
- package/core/src/store.ts +95 -1
- package/core/src/tag-schemas.ts +5 -0
- package/core/src/types.ts +28 -1
- package/docs/HTTP_API.md +105 -1
- package/docs/auth-model.md +340 -0
- package/package/package.json +32 -0
- package/package.json +2 -2
- package/src/auth.test.ts +83 -114
- package/src/auth.ts +68 -6
- package/src/backup-launchd.ts +1 -1
- package/src/backup.test.ts +1 -1
- package/src/backup.ts +18 -17
- package/src/bind.test.ts +28 -0
- package/src/bind.ts +19 -0
- package/src/cli.ts +228 -133
- package/src/config-triggers.test.ts +49 -0
- package/src/config.test.ts +317 -2
- package/src/config.ts +420 -40
- package/src/context.test.ts +136 -0
- package/src/context.ts +115 -0
- package/src/daemon.ts +17 -16
- package/src/doctor.test.ts +9 -7
- package/src/launchd.test.ts +1 -1
- package/src/launchd.ts +6 -6
- package/src/mcp-http.ts +75 -21
- package/src/mcp-install.test.ts +125 -0
- package/src/mcp-install.ts +60 -0
- package/src/mcp-tools.ts +34 -96
- package/src/module-config.ts +109 -0
- package/src/oauth.test.ts +345 -57
- package/src/oauth.ts +155 -35
- package/src/published.test.ts +2 -2
- package/src/routes.ts +209 -33
- package/src/routing.test.ts +817 -300
- package/src/routing.ts +204 -202
- package/src/scopes.test.ts +294 -0
- package/src/scopes.ts +253 -0
- package/src/scribe-env.test.ts +49 -0
- package/src/scribe-env.ts +33 -0
- package/src/server.ts +73 -9
- package/src/services-manifest.test.ts +140 -0
- package/src/services-manifest.ts +99 -0
- package/src/systemd.ts +3 -3
- package/src/token-store.ts +42 -9
- package/src/transcription-worker.test.ts +864 -0
- package/src/transcription-worker.ts +501 -0
- package/src/triggers.test.ts +191 -1
- package/src/triggers.ts +17 -2
- package/src/vault.test.ts +693 -77
- package/src/version.test.ts +1 -1
- 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 -16
package/src/routing.ts
CHANGED
|
@@ -1,38 +1,50 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP request router for the multi-vault server.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* All per-vault resources live under `/vault/<name>/...`. There is no
|
|
5
|
+
* unscoped fallback — a request must name the vault it targets. A fresh
|
|
6
|
+
* install creates a vault named `default`, so `/vault/default/...` is the
|
|
7
|
+
* baseline URL for single-vault deployments.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* -
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* -
|
|
17
|
-
*
|
|
9
|
+
* Dispatch shape:
|
|
10
|
+
*
|
|
11
|
+
* /.well-known/parachute.json — NOT served here (CLI owns it at
|
|
12
|
+
* origin root; vault never handles it)
|
|
13
|
+
* /health — liveness ping, vault names leaked
|
|
14
|
+
* only to authenticated callers
|
|
15
|
+
* /vaults/list — public vault-name discovery (can be
|
|
16
|
+
* disabled globally via config)
|
|
17
|
+
* /vaults — authenticated vault metadata list
|
|
18
|
+
* /vault/<name>/.well-known/* — per-vault OAuth discovery
|
|
19
|
+
* /vault/<name>/oauth/{register,authorize,token}
|
|
20
|
+
* /vault/<name>/mcp[/*] — MCP endpoint (Bearer auth)
|
|
21
|
+
* /vault/<name>/view/<idOrPath> — auth-aware HTML view
|
|
22
|
+
* /vault/<name>/public/<noteId> — legacy alias → /view redirect
|
|
23
|
+
* /vault/<name> — vault metadata + stats (auth)
|
|
24
|
+
* /vault/<name>/api/... — REST surface (auth)
|
|
25
|
+
*
|
|
26
|
+
* There is deliberately no compat for the old `/api/*`, `/mcp`, `/oauth/*`,
|
|
27
|
+
* `/view/*`, or `/vaults/<name>/*` prefixes. Clients must re-authenticate
|
|
28
|
+
* after the upgrade and point at the new URLs.
|
|
18
29
|
*/
|
|
19
30
|
|
|
31
|
+
import pkg from "../package.json" with { type: "json" };
|
|
20
32
|
import type { VaultConfig } from "./config.ts";
|
|
21
33
|
import {
|
|
22
34
|
readVaultConfig,
|
|
23
35
|
readGlobalConfig,
|
|
24
36
|
writeVaultConfig,
|
|
25
37
|
listVaults,
|
|
26
|
-
resolveDefaultVault,
|
|
27
38
|
} from "./config.ts";
|
|
28
39
|
import {
|
|
29
40
|
authenticateVaultRequest,
|
|
30
41
|
authenticateGlobalRequest,
|
|
31
|
-
isMethodAllowed,
|
|
32
42
|
extractApiKey,
|
|
43
|
+
requireScope,
|
|
33
44
|
} from "./auth.ts";
|
|
45
|
+
import { SCOPE_ADMIN, scopeForMethod } from "./scopes.ts";
|
|
34
46
|
import { getVaultStore } from "./vault-store.ts";
|
|
35
|
-
import {
|
|
47
|
+
import { handleScopedMcp } from "./mcp-http.ts";
|
|
36
48
|
import {
|
|
37
49
|
handleNotes,
|
|
38
50
|
handleTags,
|
|
@@ -51,28 +63,19 @@ import {
|
|
|
51
63
|
handleToken,
|
|
52
64
|
getBaseUrl,
|
|
53
65
|
} from "./oauth.ts";
|
|
66
|
+
import { handleConfigSchema, handleConfig } from "./module-config.ts";
|
|
54
67
|
|
|
55
68
|
/**
|
|
56
69
|
* Decorate a 401 response from the MCP endpoint with the RFC 9728 challenge
|
|
57
70
|
* header pointing at the matching protected-resource metadata document.
|
|
58
71
|
*
|
|
59
72
|
* An MCP-capable OAuth client that receives a plain 401 has no structured way
|
|
60
|
-
* to discover which authorization server to use
|
|
61
|
-
*
|
|
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.
|
|
73
|
+
* to discover which authorization server to use; the `WWW-Authenticate`
|
|
74
|
+
* header names the metadata document for the exact endpoint they hit.
|
|
71
75
|
*/
|
|
72
|
-
function mcpWwwAuthenticate(req: Request, vaultName
|
|
76
|
+
function mcpWwwAuthenticate(req: Request, vaultName: string): string {
|
|
73
77
|
const base = getBaseUrl(req);
|
|
74
|
-
|
|
75
|
-
return `Bearer resource_metadata="${base}${prefix}/.well-known/oauth-protected-resource"`;
|
|
78
|
+
return `Bearer resource_metadata="${base}/vault/${vaultName}/.well-known/oauth-protected-resource"`;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
/**
|
|
@@ -83,7 +86,7 @@ function mcpWwwAuthenticate(req: Request, vaultName?: string): string {
|
|
|
83
86
|
async function withMcpChallenge(
|
|
84
87
|
res: Response,
|
|
85
88
|
req: Request,
|
|
86
|
-
vaultName
|
|
89
|
+
vaultName: string,
|
|
87
90
|
): Promise<Response> {
|
|
88
91
|
if (res.status !== 401) return res;
|
|
89
92
|
const body = await res.text();
|
|
@@ -103,46 +106,96 @@ function isViewAuthenticated(
|
|
|
103
106
|
vaultDb?: import("bun:sqlite").Database,
|
|
104
107
|
): boolean {
|
|
105
108
|
if (!vaultConfig) return false;
|
|
106
|
-
// extractApiKey now checks headers AND ?key= query param
|
|
107
109
|
const key = extractApiKey(req);
|
|
108
110
|
if (!key) return false;
|
|
109
111
|
const auth = authenticateVaultRequest(req, vaultConfig, vaultDb);
|
|
110
112
|
return !("error" in auth);
|
|
111
113
|
}
|
|
112
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Public service-info card for the CLI hub page. The CLI fetches this from
|
|
117
|
+
* every service registered in `~/.parachute/well-known/parachute.json` and
|
|
118
|
+
* renders each as a tile — services own their display story, CLI is a dumb
|
|
119
|
+
* aggregator. No auth, no PII, CORS `*` so the hub can call us cross-origin.
|
|
120
|
+
*/
|
|
121
|
+
function handleParachuteInfo(vaultName: string): Response {
|
|
122
|
+
const body = {
|
|
123
|
+
name: "parachute-vault",
|
|
124
|
+
displayName: "Vault",
|
|
125
|
+
tagline: "Agent-native knowledge graph — notes, tags, links, attachments over REST + MCP",
|
|
126
|
+
version: pkg.version,
|
|
127
|
+
iconUrl: `/vault/${vaultName}/.parachute/icon.svg`,
|
|
128
|
+
// Hub renders `kind: "api"` cards as an expandable detail panel (MCP URL,
|
|
129
|
+
// OAuth link, version) rather than navigating to the API's root. Vault
|
|
130
|
+
// has no browser UI, so navigating to it shows raw JSON — not useful.
|
|
131
|
+
kind: "api",
|
|
132
|
+
};
|
|
133
|
+
return Response.json(body, {
|
|
134
|
+
headers: { "Access-Control-Allow-Origin": "*" },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Placeholder monogram icon for the hub tile. Kept inline so the endpoint
|
|
140
|
+
* has zero filesystem dependency — the CLI can render a card even on a
|
|
141
|
+
* pristine install where no assets have been written out yet.
|
|
142
|
+
*/
|
|
143
|
+
const PARACHUTE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><circle cx="32" cy="32" r="30" fill="#879B7E"/><text x="32" y="43" text-anchor="middle" font-family="system-ui,sans-serif" font-size="32" font-weight="600" fill="#FAFAF7">V</text></svg>`;
|
|
144
|
+
|
|
145
|
+
function handleParachuteIcon(): Response {
|
|
146
|
+
return new Response(PARACHUTE_ICON_SVG, {
|
|
147
|
+
headers: {
|
|
148
|
+
"Content-Type": "image/svg+xml",
|
|
149
|
+
// Defense-in-depth: the payload is a static inline SVG with no
|
|
150
|
+
// script/foreignObject, but older Edge/IE have been known to sniff
|
|
151
|
+
// image/svg+xml as HTML under certain conditions. nosniff pins the
|
|
152
|
+
// declared type and takes the sniff path off the table entirely.
|
|
153
|
+
"X-Content-Type-Options": "nosniff",
|
|
154
|
+
"Access-Control-Allow-Origin": "*",
|
|
155
|
+
"Cache-Control": "public, max-age=3600",
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
113
160
|
export async function route(
|
|
114
161
|
req: Request,
|
|
115
162
|
path: string,
|
|
116
163
|
clientIp?: string,
|
|
117
164
|
): Promise<Response> {
|
|
118
|
-
//
|
|
165
|
+
// ---------------------------------------------------------------------
|
|
166
|
+
// OAuth discovery — RFC 8414 §3.1 / RFC 9728 §3 path-insertion form.
|
|
119
167
|
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
168
|
+
// For a resource at `/vault/<name>/mcp`, the spec-mandated metadata URLs
|
|
169
|
+
// are
|
|
122
170
|
//
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
// Path-append (widespread in the wild, shipped in PR #111):
|
|
126
|
-
// <host>/p/.well-known/<metadata-type>
|
|
171
|
+
// /.well-known/oauth-authorization-server/vault/<name>[/mcp]
|
|
172
|
+
// /.well-known/oauth-protected-resource/vault/<name>[/mcp]
|
|
127
173
|
//
|
|
128
|
-
//
|
|
129
|
-
// path-
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
174
|
+
// (path-insertion — `.well-known` goes ABOVE the issuer path), not the
|
|
175
|
+
// path-append shape `/vault/<name>/.well-known/<type>` served further
|
|
176
|
+
// down. Strict clients — Claude Code's MCP SDK, and any RFC 8414-
|
|
177
|
+
// conformant MCP client — only probe the path-insertion form; without
|
|
178
|
+
// these routes they 404 on discovery and can't authenticate.
|
|
179
|
+
//
|
|
180
|
+
// Both shapes return byte-identical JSON via the shared handlers. The
|
|
181
|
+
// path-append branch lives inside the per-vault block alongside the
|
|
182
|
+
// rest of `/vault/<name>/*` routing. PR #124 originally shipped this
|
|
183
|
+
// for the `/vaults/<name>/` URL shape; PR #138 migrated URLs to
|
|
184
|
+
// `/vault/<name>/` and dropped the insertion routes — this restores
|
|
185
|
+
// them for the new URL shape.
|
|
186
|
+
// ---------------------------------------------------------------------
|
|
134
187
|
const protectedResourceInsert = path.match(
|
|
135
|
-
/^\/\.well-known\/oauth-protected-resource\/
|
|
188
|
+
/^\/\.well-known\/oauth-protected-resource\/vault\/([^/]+)(?:\/mcp)?$/,
|
|
136
189
|
);
|
|
137
190
|
if (protectedResourceInsert) {
|
|
138
191
|
const vaultName = protectedResourceInsert[1];
|
|
139
192
|
if (!readVaultConfig(vaultName)) {
|
|
140
193
|
return Response.json({ error: "Vault not found", vault: vaultName }, { status: 404 });
|
|
141
194
|
}
|
|
142
|
-
return handleProtectedResource(req,
|
|
195
|
+
return handleProtectedResource(req, vaultName);
|
|
143
196
|
}
|
|
144
197
|
const authServerInsert = path.match(
|
|
145
|
-
/^\/\.well-known\/oauth-authorization-server\/
|
|
198
|
+
/^\/\.well-known\/oauth-authorization-server\/vault\/([^/]+)(?:\/mcp)?$/,
|
|
146
199
|
);
|
|
147
200
|
if (authServerInsert) {
|
|
148
201
|
const vaultName = authServerInsert[1];
|
|
@@ -152,54 +205,10 @@ export async function route(
|
|
|
152
205
|
return handleAuthorizationServer(req, vaultName);
|
|
153
206
|
}
|
|
154
207
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
if (path === "/.well-known/oauth-authorization-server") {
|
|
159
|
-
return handleAuthorizationServer(req);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// OAuth flow endpoints (no auth — these ARE the auth)
|
|
163
|
-
if (path === "/oauth/register" || path === "/oauth/authorize" || path === "/oauth/token") {
|
|
164
|
-
const defaultVault = resolveDefaultVault();
|
|
165
|
-
const vaultConfig = defaultVault ? readVaultConfig(defaultVault) : null;
|
|
166
|
-
if (!defaultVault || !vaultConfig) {
|
|
167
|
-
return Response.json(
|
|
168
|
-
{ error: "server_error", error_description: "Default vault not configured" },
|
|
169
|
-
{ status: 500 },
|
|
170
|
-
);
|
|
171
|
-
}
|
|
172
|
-
const store = getVaultStore(defaultVault);
|
|
208
|
+
// ---------------------------------------------------------------------
|
|
209
|
+
// Cross-vault / origin-root endpoints
|
|
210
|
+
// ---------------------------------------------------------------------
|
|
173
211
|
|
|
174
|
-
if (path === "/oauth/register") {
|
|
175
|
-
return handleRegister(req, store.db);
|
|
176
|
-
}
|
|
177
|
-
if (path === "/oauth/authorize") {
|
|
178
|
-
const gc = readGlobalConfig();
|
|
179
|
-
const ownerPasswordHash = gc.owner_password_hash ?? null;
|
|
180
|
-
const totpSecret = gc.totp_secret ?? null;
|
|
181
|
-
const totpEnrolled = typeof totpSecret === "string" && totpSecret.length > 0;
|
|
182
|
-
if (req.method === "GET") {
|
|
183
|
-
return handleAuthorizeGet(req, store.db, vaultConfig.name, ownerPasswordHash, totpEnrolled);
|
|
184
|
-
}
|
|
185
|
-
if (req.method === "POST") {
|
|
186
|
-
return handleAuthorizePost(req, store.db, {
|
|
187
|
-
vaultName: vaultConfig.name,
|
|
188
|
-
clientIp,
|
|
189
|
-
ownerPasswordHash,
|
|
190
|
-
totpSecret,
|
|
191
|
-
});
|
|
192
|
-
}
|
|
193
|
-
return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
194
|
-
}
|
|
195
|
-
if (path === "/oauth/token") {
|
|
196
|
-
// PR #111: handleToken echoes the vault name back to the client so it
|
|
197
|
-
// knows which vault it just connected to.
|
|
198
|
-
return handleToken(req, store.db, defaultVault);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Health check — vault names only for authenticated requests
|
|
203
212
|
if (path === "/health") {
|
|
204
213
|
const auth = authenticateGlobalRequest(req);
|
|
205
214
|
if ("error" in auth) {
|
|
@@ -208,45 +217,10 @@ export async function route(
|
|
|
208
217
|
return Response.json({ status: "ok", vaults: listVaults() });
|
|
209
218
|
}
|
|
210
219
|
|
|
211
|
-
//
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
return handleUnifiedMcp(req, auth);
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// View endpoint — serves notes as HTML (auth-aware, supports ID or path)
|
|
219
|
-
const viewMatch = path.match(/^\/view\/(.+)$/);
|
|
220
|
-
if (viewMatch && req.method === "GET") {
|
|
221
|
-
const defaultVault = resolveDefaultVault();
|
|
222
|
-
const vaultConfig = defaultVault ? readVaultConfig(defaultVault) : null;
|
|
223
|
-
if (!defaultVault || !vaultConfig) {
|
|
224
|
-
return Response.json({ error: "Default vault not found" }, { status: 404 });
|
|
225
|
-
}
|
|
226
|
-
const store = getVaultStore(defaultVault);
|
|
227
|
-
const authenticated = isViewAuthenticated(req, vaultConfig, store.db);
|
|
228
|
-
return handleViewNote(store, decodeURIComponent(viewMatch[1]), {
|
|
229
|
-
authenticated,
|
|
230
|
-
publishedTag: vaultConfig.published_tag,
|
|
231
|
-
});
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Backward compat: /public/:noteId → /view/:noteId (preserving query params)
|
|
235
|
-
const publicMatch = path.match(/^\/public\/([^/]+)$/);
|
|
236
|
-
if (publicMatch && req.method === "GET") {
|
|
237
|
-
const dest = new URL(`/view/${publicMatch[1]}`, req.url);
|
|
238
|
-
dest.search = new URL(req.url).search;
|
|
239
|
-
return Response.redirect(dest.toString(), 301);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// Public vault names — no auth, no metadata. Lets unauthenticated clients
|
|
243
|
-
// (e.g. the Daily vault-picker dropdown before OAuth) know which vault to
|
|
244
|
-
// target. Only vault names are exposed; descriptions, counts, timestamps,
|
|
245
|
-
// and API keys are never returned from this endpoint.
|
|
246
|
-
//
|
|
247
|
-
// Operators who want to hide vault existence from anonymous callers can set
|
|
248
|
-
// `discovery: disabled` in ~/.parachute/config.yaml — the endpoint then
|
|
249
|
-
// returns 404 as if it didn't exist.
|
|
220
|
+
// Public vault-name discovery. Lets unauthenticated clients (e.g. the
|
|
221
|
+
// Daily vault-picker dropdown before OAuth) know which vault to target.
|
|
222
|
+
// Operators who want to hide vault existence can set `discovery: disabled`
|
|
223
|
+
// in ~/.parachute/vault/config.yaml — the endpoint then returns 404.
|
|
250
224
|
if (path === "/vaults/list" && req.method === "GET") {
|
|
251
225
|
const globalConfig = readGlobalConfig();
|
|
252
226
|
if (globalConfig.discovery === "disabled") {
|
|
@@ -255,7 +229,7 @@ export async function route(
|
|
|
255
229
|
return Response.json({ vaults: listVaults() });
|
|
256
230
|
}
|
|
257
231
|
|
|
258
|
-
//
|
|
232
|
+
// Authenticated vault metadata list.
|
|
259
233
|
if (path === "/vaults" && req.method === "GET") {
|
|
260
234
|
const auth = authenticateGlobalRequest(req);
|
|
261
235
|
if ("error" in auth) return auth.error;
|
|
@@ -271,39 +245,11 @@ export async function route(
|
|
|
271
245
|
return Response.json({ vaults });
|
|
272
246
|
}
|
|
273
247
|
|
|
274
|
-
//
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const vaultConfig = defaultVault ? readVaultConfig(defaultVault) : null;
|
|
278
|
-
if (!defaultVault || !vaultConfig) {
|
|
279
|
-
return Response.json({ error: "Default vault not found" }, { status: 404 });
|
|
280
|
-
}
|
|
281
|
-
const store = getVaultStore(defaultVault);
|
|
282
|
-
const auth = authenticateVaultRequest(req, vaultConfig, store.db);
|
|
283
|
-
if ("error" in auth) return auth.error;
|
|
284
|
-
if (!isMethodAllowed(req.method, auth.permission)) {
|
|
285
|
-
return Response.json(
|
|
286
|
-
{ error: "Forbidden", message: "Insufficient permissions" },
|
|
287
|
-
{ status: 403 },
|
|
288
|
-
);
|
|
289
|
-
}
|
|
290
|
-
const apiPath = path.slice(4); // strip "/api"
|
|
291
|
-
if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6));
|
|
292
|
-
if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5));
|
|
293
|
-
if (apiPath === "/find-path") return handleFindPath(req, store);
|
|
294
|
-
if (apiPath === "/vault") {
|
|
295
|
-
return handleVault(req, store, vaultConfig, (desc) => {
|
|
296
|
-
vaultConfig.description = desc;
|
|
297
|
-
writeVaultConfig(vaultConfig);
|
|
298
|
-
});
|
|
299
|
-
}
|
|
300
|
-
if (apiPath === "/unresolved-wikilinks") return handleUnresolvedWikilinks(req, store);
|
|
301
|
-
if (apiPath.startsWith("/storage")) return handleStorage(req, apiPath.slice(8), defaultVault);
|
|
302
|
-
if (apiPath === "/health") return Response.json({ status: "ok", vault: defaultVault });
|
|
303
|
-
}
|
|
248
|
+
// ---------------------------------------------------------------------
|
|
249
|
+
// Per-vault routing: /vault/<name>/...
|
|
250
|
+
// ---------------------------------------------------------------------
|
|
304
251
|
|
|
305
|
-
|
|
306
|
-
const vaultMatch = path.match(/^\/vaults\/([^/]+)(\/.*)?$/);
|
|
252
|
+
const vaultMatch = path.match(/^\/vault\/([^/]+)(\/.*)?$/);
|
|
307
253
|
if (!vaultMatch) {
|
|
308
254
|
return Response.json({ error: "Not found" }, { status: 404 });
|
|
309
255
|
}
|
|
@@ -316,15 +262,18 @@ export async function route(
|
|
|
316
262
|
return Response.json({ error: "Vault not found", vault: vaultName }, { status: 404 });
|
|
317
263
|
}
|
|
318
264
|
|
|
319
|
-
//
|
|
265
|
+
// Legacy-style /public/:noteId → /view/:noteId redirect (kept as a
|
|
266
|
+
// convenience for published-note URLs that predate the /view/ path).
|
|
320
267
|
const vaultPublicMatch = subpath.match(/^\/public\/([^/]+)$/);
|
|
321
268
|
if (vaultPublicMatch && req.method === "GET") {
|
|
322
|
-
const dest = new URL(`/
|
|
269
|
+
const dest = new URL(`/vault/${vaultName}/view/${vaultPublicMatch[1]}`, req.url);
|
|
323
270
|
dest.search = new URL(req.url).search;
|
|
324
271
|
return Response.redirect(dest.toString(), 301);
|
|
325
272
|
}
|
|
326
273
|
|
|
327
|
-
// View endpoint —
|
|
274
|
+
// View endpoint — auth-aware HTML renderer. Unauthenticated requests
|
|
275
|
+
// still serve public notes; a valid API key via header or ?key= query
|
|
276
|
+
// parameter unlocks private notes.
|
|
328
277
|
const vaultViewMatch = subpath.match(/^\/view\/(.+)$/);
|
|
329
278
|
if (vaultViewMatch && req.method === "GET") {
|
|
330
279
|
const store = getVaultStore(vaultName);
|
|
@@ -335,7 +284,7 @@ export async function route(
|
|
|
335
284
|
});
|
|
336
285
|
}
|
|
337
286
|
|
|
338
|
-
//
|
|
287
|
+
// OAuth flow endpoints (no auth — these ARE the auth).
|
|
339
288
|
if (subpath === "/oauth/register" || subpath === "/oauth/authorize" || subpath === "/oauth/token") {
|
|
340
289
|
const store = getVaultStore(vaultName);
|
|
341
290
|
if (subpath === "/oauth/register") return handleRegister(req, store.db);
|
|
@@ -363,33 +312,78 @@ export async function route(
|
|
|
363
312
|
}
|
|
364
313
|
return Response.json({ error: "method_not_allowed" }, { status: 405 });
|
|
365
314
|
}
|
|
366
|
-
//
|
|
367
|
-
//
|
|
368
|
-
// (b) echo `vault: <name>` back to the client in the token response.
|
|
315
|
+
// handleToken pins the OAuth code to the issuing vault (prevents
|
|
316
|
+
// cross-vault code replay) and echoes `vault: <name>` in the response.
|
|
369
317
|
if (subpath === "/oauth/token") return handleToken(req, store.db, vaultName);
|
|
370
318
|
}
|
|
371
319
|
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
320
|
+
// Parachute service-info + icon (no auth, CORS *). The CLI hub page at
|
|
321
|
+
// the ecosystem root reads `~/.parachute/well-known/parachute.json`,
|
|
322
|
+
// fans out to each service's `/.parachute/info`, and renders a card per
|
|
323
|
+
// response. Keeping display copy here means the hub never needs a vault
|
|
324
|
+
// release to pick up wording changes.
|
|
325
|
+
if (subpath === "/.parachute/info" || subpath === "/.parachute/icon.svg") {
|
|
326
|
+
if (req.method !== "GET") {
|
|
327
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
328
|
+
}
|
|
329
|
+
return subpath === "/.parachute/info"
|
|
330
|
+
? handleParachuteInfo(vaultName)
|
|
331
|
+
: handleParachuteIcon();
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Module configuration endpoints (Phase 2 of the module architecture).
|
|
335
|
+
// Schema is always public — hub reads it to render the config form, no
|
|
336
|
+
// secrets involved. Current values require `vault:admin` as of Phase 3.
|
|
337
|
+
if (subpath === "/.parachute/config/schema") {
|
|
338
|
+
if (req.method !== "GET") {
|
|
339
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
340
|
+
}
|
|
341
|
+
return handleConfigSchema();
|
|
342
|
+
}
|
|
343
|
+
if (subpath === "/.parachute/config") {
|
|
344
|
+
if (req.method !== "GET") {
|
|
345
|
+
// PUT lands in a future phase — return 405 so clients that already speak
|
|
346
|
+
// the full contract discover the gap rather than silently succeeding.
|
|
347
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
348
|
+
}
|
|
349
|
+
// Admin-gated: the current config includes things that aren't user data
|
|
350
|
+
// (worker intervals, TTLs, retention policy) but are still configuration
|
|
351
|
+
// an attacker could use to map the deployment. `vault:admin` keeps the
|
|
352
|
+
// hub's loopback workflow intact while locking out read-only tokens.
|
|
353
|
+
const configAuth = authenticateVaultRequest(req, vaultConfig, getVaultStore(vaultName).db);
|
|
354
|
+
if ("error" in configAuth) return configAuth.error;
|
|
355
|
+
if (!requireScope(configAuth, SCOPE_ADMIN)) {
|
|
356
|
+
return Response.json(
|
|
357
|
+
{
|
|
358
|
+
error: "Forbidden",
|
|
359
|
+
error_type: "insufficient_scope",
|
|
360
|
+
message: `This endpoint requires the '${SCOPE_ADMIN}' scope.`,
|
|
361
|
+
required_scope: SCOPE_ADMIN,
|
|
362
|
+
granted_scopes: configAuth.scopes,
|
|
363
|
+
},
|
|
364
|
+
{ status: 403 },
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
const globalConfig = readGlobalConfig();
|
|
368
|
+
return handleConfig(vaultConfig, globalConfig);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// OAuth discovery (no auth). The protected-resource metadata advertises
|
|
372
|
+
// this vault's MCP endpoint and names the vault's authorization server;
|
|
373
|
+
// the authorization-server metadata returns endpoints scoped to
|
|
374
|
+
// `/vault/<name>/oauth/*`. Together they keep RFC 9728 → RFC 8414
|
|
375
|
+
// discovery coherent for a single vault.
|
|
377
376
|
if (subpath === "/.well-known/oauth-protected-resource") {
|
|
378
|
-
return handleProtectedResource(
|
|
379
|
-
req,
|
|
380
|
-
`/vaults/${vaultName}/mcp`,
|
|
381
|
-
`/vaults/${vaultName}`,
|
|
382
|
-
);
|
|
377
|
+
return handleProtectedResource(req, vaultName);
|
|
383
378
|
}
|
|
384
379
|
if (subpath === "/.well-known/oauth-authorization-server") {
|
|
385
380
|
return handleAuthorizationServer(req, vaultName);
|
|
386
381
|
}
|
|
387
382
|
|
|
388
|
-
//
|
|
389
|
-
//
|
|
390
|
-
//
|
|
391
|
-
|
|
392
|
-
// the failing request was actually targeting /vaults/{name}/mcp.
|
|
383
|
+
// ---------------------------------------------------------------------
|
|
384
|
+
// Authenticated surface
|
|
385
|
+
// ---------------------------------------------------------------------
|
|
386
|
+
|
|
393
387
|
const store = getVaultStore(vaultName);
|
|
394
388
|
const auth = authenticateVaultRequest(req, vaultConfig, store.db);
|
|
395
389
|
const isScopedMcp = subpath === "/mcp" || subpath.startsWith("/mcp/");
|
|
@@ -397,12 +391,12 @@ export async function route(
|
|
|
397
391
|
return isScopedMcp ? withMcpChallenge(auth.error, req, vaultName) : auth.error;
|
|
398
392
|
}
|
|
399
393
|
|
|
400
|
-
//
|
|
394
|
+
// MCP (per-vault, single-vault session).
|
|
401
395
|
if (isScopedMcp) {
|
|
402
396
|
return handleScopedMcp(req, vaultName, auth);
|
|
403
397
|
}
|
|
404
398
|
|
|
405
|
-
// Bare
|
|
399
|
+
// Bare `/vault/<name>` — single-vault root. Returns name, description,
|
|
406
400
|
// createdAt, and stats. One round trip for a viz landing page.
|
|
407
401
|
if (subpath === "" || subpath === "/") {
|
|
408
402
|
if (req.method !== "GET") {
|
|
@@ -417,27 +411,35 @@ export async function route(
|
|
|
417
411
|
});
|
|
418
412
|
}
|
|
419
413
|
|
|
420
|
-
// REST API — enforce permission level
|
|
421
|
-
if (!isMethodAllowed(req.method, auth.permission)) {
|
|
422
|
-
return Response.json(
|
|
423
|
-
{ error: "Forbidden", message: "Insufficient permissions" },
|
|
424
|
-
{ status: 403 },
|
|
425
|
-
);
|
|
426
|
-
}
|
|
427
|
-
|
|
428
414
|
const apiMatch = subpath.match(/^\/api(\/.*)?$/);
|
|
429
415
|
if (!apiMatch) {
|
|
430
416
|
return Response.json({ error: "Not found" }, { status: 404 });
|
|
431
417
|
}
|
|
432
418
|
|
|
419
|
+
// REST API — scope gate. GET/HEAD/OPTIONS → vault:read,
|
|
420
|
+
// POST/PATCH/PUT/DELETE → vault:write. Inheritance (admin ⊇ write ⊇ read)
|
|
421
|
+
// is handled inside `requireScope`.
|
|
422
|
+
const requiredApiScope = scopeForMethod(req.method);
|
|
423
|
+
if (!requireScope(auth, requiredApiScope)) {
|
|
424
|
+
return Response.json(
|
|
425
|
+
{
|
|
426
|
+
error: "Forbidden",
|
|
427
|
+
error_type: "insufficient_scope",
|
|
428
|
+
message: `This endpoint requires the '${requiredApiScope}' scope.`,
|
|
429
|
+
required_scope: requiredApiScope,
|
|
430
|
+
granted_scopes: auth.scopes,
|
|
431
|
+
},
|
|
432
|
+
{ status: 403 },
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
433
436
|
const apiPath = apiMatch[1] ?? "";
|
|
434
437
|
|
|
435
|
-
if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6));
|
|
438
|
+
if (apiPath.startsWith("/notes")) return handleNotes(req, store, apiPath.slice(6), vaultName);
|
|
436
439
|
if (apiPath.startsWith("/tags")) return handleTags(req, store, apiPath.slice(5));
|
|
437
440
|
if (apiPath === "/find-path") return handleFindPath(req, store);
|
|
438
441
|
if (apiPath === "/vault") {
|
|
439
|
-
return handleVault(req, store, vaultConfig, (
|
|
440
|
-
vaultConfig.description = desc;
|
|
442
|
+
return handleVault(req, store, vaultConfig, () => {
|
|
441
443
|
writeVaultConfig(vaultConfig);
|
|
442
444
|
});
|
|
443
445
|
}
|