@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.
Files changed (102) hide show
  1. package/.claude/settings.local.json +2 -25
  2. package/CHANGELOG.md +64 -0
  3. package/CLAUDE.md +17 -7
  4. package/README.md +169 -136
  5. package/core/src/core.test.ts +591 -19
  6. package/core/src/hooks.ts +111 -3
  7. package/core/src/indexed-fields.test.ts +285 -0
  8. package/core/src/indexed-fields.ts +238 -0
  9. package/core/src/mcp.ts +127 -6
  10. package/core/src/notes.ts +153 -11
  11. package/core/src/query-operators.ts +174 -0
  12. package/core/src/schema.ts +69 -2
  13. package/core/src/store.ts +95 -1
  14. package/core/src/tag-schemas.ts +5 -0
  15. package/core/src/types.ts +28 -1
  16. package/docs/HTTP_API.md +105 -1
  17. package/docs/auth-model.md +340 -0
  18. package/package/package.json +32 -0
  19. package/package.json +2 -2
  20. package/src/auth.test.ts +83 -114
  21. package/src/auth.ts +68 -6
  22. package/src/backup-launchd.ts +1 -1
  23. package/src/backup.test.ts +1 -1
  24. package/src/backup.ts +18 -17
  25. package/src/bind.test.ts +28 -0
  26. package/src/bind.ts +19 -0
  27. package/src/cli.ts +228 -133
  28. package/src/config-triggers.test.ts +49 -0
  29. package/src/config.test.ts +317 -2
  30. package/src/config.ts +420 -40
  31. package/src/context.test.ts +136 -0
  32. package/src/context.ts +115 -0
  33. package/src/daemon.ts +17 -16
  34. package/src/doctor.test.ts +9 -7
  35. package/src/launchd.test.ts +1 -1
  36. package/src/launchd.ts +6 -6
  37. package/src/mcp-http.ts +75 -21
  38. package/src/mcp-install.test.ts +125 -0
  39. package/src/mcp-install.ts +60 -0
  40. package/src/mcp-tools.ts +34 -96
  41. package/src/module-config.ts +109 -0
  42. package/src/oauth.test.ts +345 -57
  43. package/src/oauth.ts +155 -35
  44. package/src/published.test.ts +2 -2
  45. package/src/routes.ts +209 -33
  46. package/src/routing.test.ts +817 -300
  47. package/src/routing.ts +204 -202
  48. package/src/scopes.test.ts +294 -0
  49. package/src/scopes.ts +253 -0
  50. package/src/scribe-env.test.ts +49 -0
  51. package/src/scribe-env.ts +33 -0
  52. package/src/server.ts +73 -9
  53. package/src/services-manifest.test.ts +140 -0
  54. package/src/services-manifest.ts +99 -0
  55. package/src/systemd.ts +3 -3
  56. package/src/token-store.ts +42 -9
  57. package/src/transcription-worker.test.ts +864 -0
  58. package/src/transcription-worker.ts +501 -0
  59. package/src/triggers.test.ts +191 -1
  60. package/src/triggers.ts +17 -2
  61. package/src/vault.test.ts +693 -77
  62. package/src/version.test.ts +1 -1
  63. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  64. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  65. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  66. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  67. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  68. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  69. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  70. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  71. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  72. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  73. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  74. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  75. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  76. package/religions-abrahamic-filter.png +0 -0
  77. package/religions-buddhism-v2.png +0 -0
  78. package/religions-buddhism.png +0 -0
  79. package/religions-final.png +0 -0
  80. package/religions-v1.png +0 -0
  81. package/religions-v2.png +0 -0
  82. package/religions-zen.png +0 -0
  83. package/web/README.md +0 -73
  84. package/web/bun.lock +0 -827
  85. package/web/eslint.config.js +0 -23
  86. package/web/index.html +0 -15
  87. package/web/package.json +0 -36
  88. package/web/public/favicon.svg +0 -1
  89. package/web/public/icons.svg +0 -24
  90. package/web/src/App.tsx +0 -149
  91. package/web/src/Graph.tsx +0 -200
  92. package/web/src/NoteView.tsx +0 -155
  93. package/web/src/Sidebar.tsx +0 -186
  94. package/web/src/api.ts +0 -21
  95. package/web/src/index.css +0 -50
  96. package/web/src/main.tsx +0 -10
  97. package/web/src/types.ts +0 -37
  98. package/web/src/utils.ts +0 -107
  99. package/web/tsconfig.app.json +0 -25
  100. package/web/tsconfig.json +0 -7
  101. package/web/tsconfig.node.json +0 -24
  102. 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
- * 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.
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
- * 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
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 { handleUnifiedMcp, handleScopedMcp } from "./mcp-http.ts";
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, 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.
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?: string): string {
76
+ function mcpWwwAuthenticate(req: Request, vaultName: string): string {
73
77
  const base = getBaseUrl(req);
74
- const prefix = vaultName ? `/vaults/${vaultName}` : "";
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?: string,
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
- // OAuth discovery endpoints (no auth required).
165
+ // ---------------------------------------------------------------------
166
+ // OAuth discovery — RFC 8414 §3.1 / RFC 9728 §3 path-insertion form.
119
167
  //
120
- // RFC 8414 §3.1 and RFC 9728 §3 specify the discovery URL shape when an
121
- // authorization server (or protected resource) has a path component `/p`:
168
+ // For a resource at `/vault/<name>/mcp`, the spec-mandated metadata URLs
169
+ // are
122
170
  //
123
- // Path-insertion (spec-mandated):
124
- // <host>/.well-known/<metadata-type>/p
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
- // Strict clients including Claude Code's MCP OAuth SDK probe only the
129
- // path-insertion form. Lax clients try path-append. We serve both so any
130
- // conformant probe hits a live endpoint. Unscoped root forms
131
- // (`/.well-known/oauth-*`) are the third accepted shape, and the
132
- // path-append branch for scoped discovery lives further down alongside the
133
- // other `/vaults/{name}/*` routing.
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\/vaults\/([^/]+)(?:\/mcp)?$/,
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, `/vaults/${vaultName}/mcp`, `/vaults/${vaultName}`);
195
+ return handleProtectedResource(req, vaultName);
143
196
  }
144
197
  const authServerInsert = path.match(
145
- /^\/\.well-known\/oauth-authorization-server\/vaults\/([^/]+)(?:\/mcp)?$/,
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
- if (path === "/.well-known/oauth-protected-resource") {
156
- return handleProtectedResource(req);
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
- // Unified MCP (all vaults, global auth)
212
- if (path === "/mcp" || path.startsWith("/mcp/")) {
213
- const auth = authenticateGlobalRequest(req);
214
- if ("error" in auth) return withMcpChallenge(auth.error, req);
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
- // List vaults requires auth
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
- // Backward-compatible: /api/* routes to default vault
275
- if (path.startsWith("/api/")) {
276
- const defaultVault = resolveDefaultVault();
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
- // Vault-scoped routes: /vaults/{name}/...
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
- // Backward compat: /vaults/{name}/public/:noteId → /view/:noteId
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(`/vaults/${vaultName}/view/${vaultPublicMatch[1]}`, req.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 — serves notes as HTML (auth-aware, vault-scoped, supports ID or path)
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
- // Vault-scoped OAuth endpoints (no auth — these ARE the auth)
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
- // PR #111: handleToken now requires the vault name so it can (a) pin the
367
- // OAuth code to the issuing vault (prevents cross-vault code replay) and
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
- // Vault-scoped discovery endpoints. PR #111: the protected-resource
373
- // advertises a vault-scoped authorization server (`${base}/vaults/${name}`),
374
- // and the vault-scoped authorization-server metadata returns endpoints
375
- // scoped to `/vaults/${name}/oauth/*` so tokens mint against this vault's
376
- // DB. Keeps the RFC 9728 → RFC 8414 chain coherent end-to-end.
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
- // Auth: per-vault key OR global key.
389
- // The auth check is shared between the scoped MCP branch and the scoped
390
- // /api/* branches, so we can't unconditionally attach the MCP-only
391
- // WWW-Authenticate challenge here — we pass the challenge back only when
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
- // Per-vault scoped MCP
394
+ // MCP (per-vault, single-vault session).
401
395
  if (isScopedMcp) {
402
396
  return handleScopedMcp(req, vaultName, auth);
403
397
  }
404
398
 
405
- // Bare /vaults/{name} — single-vault root. Returns name, description,
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, (desc) => {
440
- vaultConfig.description = desc;
442
+ return handleVault(req, store, vaultConfig, () => {
441
443
  writeVaultConfig(vaultConfig);
442
444
  });
443
445
  }