@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
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the HTTP routing layer (src/routing.ts).
|
|
3
|
+
*
|
|
4
|
+
* Two surfaces under test here:
|
|
5
|
+
*
|
|
6
|
+
* 1. `/vaults/list` — public, unauthenticated discovery endpoint.
|
|
7
|
+
* Intended for the Daily vault-picker dropdown pre-OAuth.
|
|
8
|
+
* Must never leak anything beyond vault names; must return 404 when
|
|
9
|
+
* the operator disables discovery in config.yaml.
|
|
10
|
+
*
|
|
11
|
+
* 2. Single-vault auto-default. A user with exactly one vault (named
|
|
12
|
+
* anything — not necessarily "default") should be able to hit unscoped
|
|
13
|
+
* routes (/mcp, /api/*, /oauth/*) and have them transparently target
|
|
14
|
+
* their sole vault. Previously `/mcp` tried to look up a vault literally
|
|
15
|
+
* named "default" and failed. The coherence invariant from PR #111 says
|
|
16
|
+
* `/mcp` and `/vaults/:name/mcp` must behave identically for that vault.
|
|
17
|
+
*
|
|
18
|
+
* Uses PARACHUTE_HOME override so each test's vaults live in a tmp dir and
|
|
19
|
+
* never touch ~/.parachute.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { describe, test, expect, beforeEach, afterAll } from "bun:test";
|
|
23
|
+
import { rmSync, existsSync, mkdirSync } from "fs";
|
|
24
|
+
import { join } from "path";
|
|
25
|
+
import { tmpdir } from "os";
|
|
26
|
+
|
|
27
|
+
const testDir = join(
|
|
28
|
+
tmpdir(),
|
|
29
|
+
`vault-routing-test-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
30
|
+
);
|
|
31
|
+
process.env.PARACHUTE_HOME = testDir;
|
|
32
|
+
|
|
33
|
+
// Dynamic import after env override so modules pick up the tmp dir.
|
|
34
|
+
const { route } = await import("./routing.ts");
|
|
35
|
+
const {
|
|
36
|
+
readGlobalConfig,
|
|
37
|
+
writeGlobalConfig,
|
|
38
|
+
writeVaultConfig,
|
|
39
|
+
resolveDefaultVault,
|
|
40
|
+
listVaults,
|
|
41
|
+
} = await import("./config.ts");
|
|
42
|
+
// clearVaultStoreCache was added in #111 for exactly this kind of test
|
|
43
|
+
// that wipes its PARACHUTE_HOME between runs — it closes stores silently
|
|
44
|
+
// even when the DB files are already gone.
|
|
45
|
+
const { clearVaultStoreCache } = await import("./vault-store.ts");
|
|
46
|
+
|
|
47
|
+
function createVault(name: string, description?: string): void {
|
|
48
|
+
writeVaultConfig({
|
|
49
|
+
name,
|
|
50
|
+
api_keys: [],
|
|
51
|
+
created_at: new Date().toISOString(),
|
|
52
|
+
description,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function reset(): void {
|
|
57
|
+
clearVaultStoreCache();
|
|
58
|
+
if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
|
|
59
|
+
mkdirSync(testDir, { recursive: true });
|
|
60
|
+
mkdirSync(join(testDir, "vaults"), { recursive: true });
|
|
61
|
+
writeGlobalConfig({ port: 1940 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
reset();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterAll(() => {
|
|
69
|
+
clearVaultStoreCache();
|
|
70
|
+
if (existsSync(testDir)) rmSync(testDir, { recursive: true, force: true });
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// resolveDefaultVault — the function that powers every "unscoped" route.
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
|
|
77
|
+
describe("resolveDefaultVault", () => {
|
|
78
|
+
test("returns the configured default_vault when it exists", () => {
|
|
79
|
+
createVault("journal");
|
|
80
|
+
createVault("work");
|
|
81
|
+
writeGlobalConfig({ port: 1940, default_vault: "journal" });
|
|
82
|
+
expect(resolveDefaultVault()).toBe("journal");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("returns the sole vault when default_vault is unset", () => {
|
|
86
|
+
createVault("journal");
|
|
87
|
+
// No default_vault in global config.
|
|
88
|
+
expect(resolveDefaultVault()).toBe("journal");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("returns the sole vault even if default_vault points to a deleted one", () => {
|
|
92
|
+
// Simulates: user had two vaults, removed the one that was the default,
|
|
93
|
+
// but config.yaml still references the old name.
|
|
94
|
+
createVault("journal");
|
|
95
|
+
writeGlobalConfig({ port: 1940, default_vault: "deleted-vault" });
|
|
96
|
+
expect(resolveDefaultVault()).toBe("journal");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("returns null when multiple vaults exist and no valid default", () => {
|
|
100
|
+
createVault("journal");
|
|
101
|
+
createVault("work");
|
|
102
|
+
writeGlobalConfig({ port: 1940, default_vault: "missing" });
|
|
103
|
+
expect(resolveDefaultVault()).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("returns null when no vaults exist", () => {
|
|
107
|
+
expect(resolveDefaultVault()).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("does not special-case the name 'default' — a vault named 'journal' alone is the default", () => {
|
|
111
|
+
// This is the Aaron-acked bug: having to go to /vaults/journal/mcp
|
|
112
|
+
// when it's the only vault was confusing. Now the name doesn't matter.
|
|
113
|
+
createVault("journal");
|
|
114
|
+
expect(resolveDefaultVault()).toBe("journal");
|
|
115
|
+
expect(listVaults()).toEqual(["journal"]);
|
|
116
|
+
// And explicitly: "default" is NOT synthesized when it doesn't exist.
|
|
117
|
+
expect(resolveDefaultVault()).not.toBe("default");
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// /vaults/list — Task 1: public discovery endpoint for the Daily picker.
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
describe("GET /vaults/list (public discovery)", () => {
|
|
126
|
+
test("unauthenticated request returns 200 with the list of names", async () => {
|
|
127
|
+
createVault("journal");
|
|
128
|
+
createVault("work");
|
|
129
|
+
const req = new Request("http://localhost:1940/vaults/list");
|
|
130
|
+
const res = await route(req, "/vaults/list");
|
|
131
|
+
expect(res.status).toBe(200);
|
|
132
|
+
const body = (await res.json()) as { vaults: string[] };
|
|
133
|
+
// Order mirrors listVaults() (alphabetical from `ls`). Assert as set
|
|
134
|
+
// to avoid coupling to the shell order on exotic filesystems.
|
|
135
|
+
expect(new Set(body.vaults)).toEqual(new Set(["journal", "work"]));
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("response contains only names — no descriptions, timestamps, counts, or keys", async () => {
|
|
139
|
+
createVault("journal", "Private journal — do not expose this description");
|
|
140
|
+
const req = new Request("http://localhost:1940/vaults/list");
|
|
141
|
+
const res = await route(req, "/vaults/list");
|
|
142
|
+
const body = await res.json();
|
|
143
|
+
|
|
144
|
+
// Must be exactly { vaults: [string, ...] }. Anything else is a leak.
|
|
145
|
+
expect(Object.keys(body as object).sort()).toEqual(["vaults"]);
|
|
146
|
+
expect((body as { vaults: unknown[] }).vaults).toEqual(["journal"]);
|
|
147
|
+
|
|
148
|
+
// Defense in depth: stringify and grep for anything sensitive.
|
|
149
|
+
const dump = JSON.stringify(body);
|
|
150
|
+
expect(dump).not.toContain("Private journal");
|
|
151
|
+
expect(dump).not.toContain("description");
|
|
152
|
+
expect(dump).not.toContain("created_at");
|
|
153
|
+
expect(dump).not.toContain("api_keys");
|
|
154
|
+
expect(dump).not.toContain("key_hash");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("returns an empty list (still 200) when no vaults exist", async () => {
|
|
158
|
+
const req = new Request("http://localhost:1940/vaults/list");
|
|
159
|
+
const res = await route(req, "/vaults/list");
|
|
160
|
+
expect(res.status).toBe(200);
|
|
161
|
+
const body = (await res.json()) as { vaults: string[] };
|
|
162
|
+
expect(body.vaults).toEqual([]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("returns 404 when discovery is disabled in config", async () => {
|
|
166
|
+
createVault("journal");
|
|
167
|
+
writeGlobalConfig({ port: 1940, discovery: "disabled" });
|
|
168
|
+
const req = new Request("http://localhost:1940/vaults/list");
|
|
169
|
+
const res = await route(req, "/vaults/list");
|
|
170
|
+
expect(res.status).toBe(404);
|
|
171
|
+
const body = await res.json();
|
|
172
|
+
// Must not leak that any vaults exist.
|
|
173
|
+
const dump = JSON.stringify(body);
|
|
174
|
+
expect(dump).not.toContain("journal");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("returns 200 when discovery is explicitly enabled", async () => {
|
|
178
|
+
createVault("journal");
|
|
179
|
+
writeGlobalConfig({ port: 1940, discovery: "enabled" });
|
|
180
|
+
const req = new Request("http://localhost:1940/vaults/list");
|
|
181
|
+
const res = await route(req, "/vaults/list");
|
|
182
|
+
expect(res.status).toBe(200);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("ignores Authorization header (endpoint is public)", async () => {
|
|
186
|
+
// Even with an obviously-wrong token, the endpoint must still succeed.
|
|
187
|
+
// This catches a regression where we'd accidentally gate it behind auth.
|
|
188
|
+
createVault("journal");
|
|
189
|
+
const req = new Request("http://localhost:1940/vaults/list", {
|
|
190
|
+
headers: { Authorization: "Bearer not-a-real-token" },
|
|
191
|
+
});
|
|
192
|
+
const res = await route(req, "/vaults/list");
|
|
193
|
+
expect(res.status).toBe(200);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("rejects non-GET methods (falls through to vault-scoped 404 path)", async () => {
|
|
197
|
+
// Non-GET methods on /vaults/list fall through to the /vaults/:name
|
|
198
|
+
// matcher with name="list". Since no vault named "list" can exist
|
|
199
|
+
// (reserved name), we get a 404. This is the expected behavior —
|
|
200
|
+
// the endpoint is GET-only.
|
|
201
|
+
createVault("journal");
|
|
202
|
+
const req = new Request("http://localhost:1940/vaults/list", { method: "POST" });
|
|
203
|
+
const res = await route(req, "/vaults/list");
|
|
204
|
+
expect(res.status).toBe(404);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("discovery disabled still allows authenticated /vaults listing (they are separate concerns)", async () => {
|
|
208
|
+
// /vaults (authenticated, with metadata) is orthogonal to /vaults/list
|
|
209
|
+
// (public, names only). Disabling discovery hides the public endpoint
|
|
210
|
+
// but not the authenticated one.
|
|
211
|
+
createVault("journal");
|
|
212
|
+
writeGlobalConfig({ port: 1940, discovery: "disabled" });
|
|
213
|
+
// We can at least assert that /vaults still returns 401 (auth required)
|
|
214
|
+
// rather than 404 (disabled). Past that, auth is covered elsewhere.
|
|
215
|
+
const req = new Request("http://localhost:1940/vaults");
|
|
216
|
+
const res = await route(req, "/vaults");
|
|
217
|
+
expect(res.status).toBe(401);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
// Single-vault auto-default — Task 2: /mcp, /api/*, /oauth/* target the
|
|
223
|
+
// only vault regardless of its name.
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
describe("single-vault auto-default", () => {
|
|
227
|
+
test("one vault named 'journal' → /mcp requires auth (would error if vault not resolvable)", async () => {
|
|
228
|
+
// With one vault named "journal" and no default_vault set, /mcp must
|
|
229
|
+
// still hit the MCP handler. The handler itself requires a valid
|
|
230
|
+
// Bearer token — we assert 401 (auth failure), proving the request
|
|
231
|
+
// reached the MCP layer and did NOT bail out with "Default vault not
|
|
232
|
+
// found". Before this fix, /mcp would error because the code
|
|
233
|
+
// hardcoded the fallback name "default".
|
|
234
|
+
createVault("journal");
|
|
235
|
+
// Deliberately no default_vault — single-vault fallback should kick in.
|
|
236
|
+
const req = new Request("http://localhost:1940/mcp");
|
|
237
|
+
const res = await route(req, "/mcp");
|
|
238
|
+
expect(res.status).toBe(401);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("one vault named 'journal' → /api/notes reaches auth (not 404'd)", async () => {
|
|
242
|
+
createVault("journal");
|
|
243
|
+
const req = new Request("http://localhost:1940/api/notes");
|
|
244
|
+
const res = await route(req, "/api/notes");
|
|
245
|
+
// Unauthenticated: 401 from per-vault auth. Before the fix, it would
|
|
246
|
+
// have 404'd with "Default vault not found".
|
|
247
|
+
expect(res.status).toBe(401);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("one vault named 'journal' → /oauth/register reaches the OAuth handler (not 500'd)", async () => {
|
|
251
|
+
createVault("journal");
|
|
252
|
+
const req = new Request("http://localhost:1940/oauth/register", {
|
|
253
|
+
method: "POST",
|
|
254
|
+
headers: { "Content-Type": "application/json" },
|
|
255
|
+
body: JSON.stringify({ client_name: "test", redirect_uris: ["https://x.example/cb"] }),
|
|
256
|
+
});
|
|
257
|
+
const res = await route(req, "/oauth/register");
|
|
258
|
+
// Successful registration (201) or 400 from a validation issue —
|
|
259
|
+
// either way, we're PAST the "Default vault not configured" path.
|
|
260
|
+
expect(res.status).not.toBe(500);
|
|
261
|
+
expect([201, 400]).toContain(res.status);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("one vault named 'journal' → /vaults/journal/mcp still works identically (coherence invariant)", async () => {
|
|
265
|
+
// This is the invariant from PR #111: the scoped and unscoped MCP paths
|
|
266
|
+
// must behave identically for a single-vault deployment. Both should
|
|
267
|
+
// land on the MCP auth gate and return 401 unauthenticated.
|
|
268
|
+
createVault("journal");
|
|
269
|
+
const unscopedReq = new Request("http://localhost:1940/mcp");
|
|
270
|
+
const unscopedRes = await route(unscopedReq, "/mcp");
|
|
271
|
+
const scopedReq = new Request("http://localhost:1940/vaults/journal/mcp");
|
|
272
|
+
const scopedRes = await route(scopedReq, "/vaults/journal/mcp");
|
|
273
|
+
expect(unscopedRes.status).toBe(scopedRes.status);
|
|
274
|
+
expect(unscopedRes.status).toBe(401);
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("multiple vaults, no default_vault → /mcp returns a clear error, not a silent guess", async () => {
|
|
278
|
+
createVault("journal");
|
|
279
|
+
createVault("work");
|
|
280
|
+
// No default_vault set.
|
|
281
|
+
const req = new Request("http://localhost:1940/mcp");
|
|
282
|
+
const res = await route(req, "/mcp");
|
|
283
|
+
// We refuse to guess when multiple vaults exist. Hitting /mcp here
|
|
284
|
+
// must NOT silently target one of them. We expect a non-200 status
|
|
285
|
+
// that is not the auth gate (since resolveDefaultVault returns null,
|
|
286
|
+
// we return 401 because auth runs first, but the underlying MCP
|
|
287
|
+
// handler never runs — the previous test guarantees that). For the
|
|
288
|
+
// /api/ path, which checks the vault before auth, we get 404.
|
|
289
|
+
const apiReq = new Request("http://localhost:1940/api/notes");
|
|
290
|
+
const apiRes = await route(apiReq, "/api/notes");
|
|
291
|
+
expect(apiRes.status).toBe(404);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("multiple vaults, default_vault='journal' → /api/notes targets journal", async () => {
|
|
295
|
+
createVault("journal");
|
|
296
|
+
createVault("work");
|
|
297
|
+
writeGlobalConfig({ port: 1940, default_vault: "journal" });
|
|
298
|
+
const req = new Request("http://localhost:1940/api/notes");
|
|
299
|
+
const res = await route(req, "/api/notes");
|
|
300
|
+
// Reaches auth (401) rather than "Default vault not found" (404).
|
|
301
|
+
expect(res.status).toBe(401);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("default_vault points to a deleted vault but one other exists → fall back to the survivor", async () => {
|
|
305
|
+
createVault("journal");
|
|
306
|
+
writeGlobalConfig({ port: 1940, default_vault: "deleted-vault" });
|
|
307
|
+
// resolveDefaultVault ignores the stale pointer and returns journal.
|
|
308
|
+
expect(resolveDefaultVault()).toBe("journal");
|
|
309
|
+
// And /api/notes now routes to it — 401 from per-vault auth, not 404.
|
|
310
|
+
const req = new Request("http://localhost:1940/api/notes");
|
|
311
|
+
const res = await route(req, "/api/notes");
|
|
312
|
+
expect(res.status).toBe(401);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("default_vault points to a deleted vault and multiple others exist → error (no guessing)", async () => {
|
|
316
|
+
createVault("journal");
|
|
317
|
+
createVault("work");
|
|
318
|
+
writeGlobalConfig({ port: 1940, default_vault: "deleted-vault" });
|
|
319
|
+
expect(resolveDefaultVault()).toBeNull();
|
|
320
|
+
const req = new Request("http://localhost:1940/api/notes");
|
|
321
|
+
const res = await route(req, "/api/notes");
|
|
322
|
+
expect(res.status).toBe(404);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test("no vaults exist → /mcp returns auth error (MCP handler short-circuits on empty global config)", async () => {
|
|
326
|
+
// Edge case: /mcp runs global auth first. With no vaults and no keys,
|
|
327
|
+
// auth fails → 401. This is fine — the handler never sees the empty
|
|
328
|
+
// state. We assert we never return 500.
|
|
329
|
+
const req = new Request("http://localhost:1940/mcp");
|
|
330
|
+
const res = await route(req, "/mcp");
|
|
331
|
+
expect(res.status).not.toBe(500);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("empty config file (no default_vault) with single vault — existing deployments keep working", async () => {
|
|
335
|
+
// Migration concern: users with a config.yaml that never had
|
|
336
|
+
// default_vault set (pre-#111 deployments) should not break.
|
|
337
|
+
// Drop a minimal config.yaml that only specifies port.
|
|
338
|
+
writeGlobalConfig({ port: 1940 });
|
|
339
|
+
createVault("my-only-vault");
|
|
340
|
+
const cfg = readGlobalConfig();
|
|
341
|
+
expect(cfg.default_vault).toBeUndefined();
|
|
342
|
+
// /api/notes still routes to my-only-vault via single-vault fallback.
|
|
343
|
+
const req = new Request("http://localhost:1940/api/notes");
|
|
344
|
+
const res = await route(req, "/api/notes");
|
|
345
|
+
expect(res.status).toBe(401); // reached per-vault auth
|
|
346
|
+
});
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// RFC 9728 WWW-Authenticate challenge on MCP 401.
|
|
351
|
+
//
|
|
352
|
+
// Claude Code's MCP SDK (and any other strict RFC 9728 client) requires the
|
|
353
|
+
// server to emit `WWW-Authenticate: Bearer resource_metadata="..."` on 401
|
|
354
|
+
// so the client knows which protected-resource metadata document applies to
|
|
355
|
+
// the endpoint it just hit. Without it, clients fall back to probing the
|
|
356
|
+
// root `/.well-known/oauth-protected-resource`, get `resource: <base>/mcp`,
|
|
357
|
+
// and reject any connection to `/vaults/<name>/mcp` as a resource mismatch.
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
describe("MCP 401 WWW-Authenticate challenge (RFC 9728)", () => {
|
|
361
|
+
test("unscoped /mcp 401 carries the root protected-resource pointer", async () => {
|
|
362
|
+
createVault("journal");
|
|
363
|
+
const req = new Request("http://localhost:1940/mcp");
|
|
364
|
+
const res = await route(req, "/mcp");
|
|
365
|
+
expect(res.status).toBe(401);
|
|
366
|
+
const header = res.headers.get("WWW-Authenticate");
|
|
367
|
+
expect(header).toBe(
|
|
368
|
+
'Bearer resource_metadata="http://localhost:1940/.well-known/oauth-protected-resource"',
|
|
369
|
+
);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("scoped /vaults/{name}/mcp 401 carries the vault-scoped pointer", async () => {
|
|
373
|
+
createVault("journal");
|
|
374
|
+
const req = new Request("http://localhost:1940/vaults/journal/mcp");
|
|
375
|
+
const res = await route(req, "/vaults/journal/mcp");
|
|
376
|
+
expect(res.status).toBe(401);
|
|
377
|
+
const header = res.headers.get("WWW-Authenticate");
|
|
378
|
+
expect(header).toBe(
|
|
379
|
+
'Bearer resource_metadata="http://localhost:1940/vaults/journal/.well-known/oauth-protected-resource"',
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("challenge points at the same PRM document the server actually serves", async () => {
|
|
384
|
+
// Belt-and-braces: whatever we advertise in the header MUST line up with
|
|
385
|
+
// what `/.well-known/oauth-protected-resource` actually returns. If these
|
|
386
|
+
// drift, a conforming client will chase the pointer, fetch the PRM, then
|
|
387
|
+
// reject on resource mismatch anyway. Test both directions.
|
|
388
|
+
createVault("journal");
|
|
389
|
+
|
|
390
|
+
// Scoped: header points at /vaults/journal/.well-known/...
|
|
391
|
+
const scopedReq = new Request("http://localhost:1940/vaults/journal/mcp");
|
|
392
|
+
const scopedRes = await route(scopedReq, "/vaults/journal/mcp");
|
|
393
|
+
const scopedHeader = scopedRes.headers.get("WWW-Authenticate")!;
|
|
394
|
+
const scopedPrmUrl = scopedHeader.match(/resource_metadata="([^"]+)"/)![1];
|
|
395
|
+
// Fetch that PRM. Bypass the full URL by extracting the path.
|
|
396
|
+
const prmPath = new URL(scopedPrmUrl).pathname;
|
|
397
|
+
const prmRes = await route(new Request(`http://localhost:1940${prmPath}`), prmPath);
|
|
398
|
+
expect(prmRes.status).toBe(200);
|
|
399
|
+
const prm = (await prmRes.json()) as { resource: string };
|
|
400
|
+
expect(prm.resource).toBe("http://localhost:1940/vaults/journal/mcp");
|
|
401
|
+
|
|
402
|
+
// Unscoped: header points at root /.well-known/...
|
|
403
|
+
const unscopedReq = new Request("http://localhost:1940/mcp");
|
|
404
|
+
const unscopedRes = await route(unscopedReq, "/mcp");
|
|
405
|
+
const unscopedHeader = unscopedRes.headers.get("WWW-Authenticate")!;
|
|
406
|
+
const unscopedPrmUrl = unscopedHeader.match(/resource_metadata="([^"]+)"/)![1];
|
|
407
|
+
const unscopedPrmPath = new URL(unscopedPrmUrl).pathname;
|
|
408
|
+
const unscopedPrmRes = await route(
|
|
409
|
+
new Request(`http://localhost:1940${unscopedPrmPath}`),
|
|
410
|
+
unscopedPrmPath,
|
|
411
|
+
);
|
|
412
|
+
expect(unscopedPrmRes.status).toBe(200);
|
|
413
|
+
const unscopedPrm = (await unscopedPrmRes.json()) as { resource: string };
|
|
414
|
+
expect(unscopedPrm.resource).toBe("http://localhost:1940/mcp");
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("MCP 401 with invalid token still carries the challenge", async () => {
|
|
418
|
+
// The no-token case is one 401 code path (extractApiKey returns null);
|
|
419
|
+
// the invalid-token case is another (extractApiKey returns a string but
|
|
420
|
+
// resolveToken / validateKey all fail). Both must emit the header.
|
|
421
|
+
createVault("journal");
|
|
422
|
+
const req = new Request("http://localhost:1940/vaults/journal/mcp", {
|
|
423
|
+
headers: { Authorization: "Bearer pvt_not-a-real-token" },
|
|
424
|
+
});
|
|
425
|
+
const res = await route(req, "/vaults/journal/mcp");
|
|
426
|
+
expect(res.status).toBe(401);
|
|
427
|
+
expect(res.headers.get("WWW-Authenticate")).toBe(
|
|
428
|
+
'Bearer resource_metadata="http://localhost:1940/vaults/journal/.well-known/oauth-protected-resource"',
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("non-MCP 401s do NOT carry the challenge (spec is MCP-only)", async () => {
|
|
433
|
+
// The RFC 9728 challenge header is specific to the MCP resource; plain
|
|
434
|
+
// REST endpoints are not OAuth resources in the same sense. A spurious
|
|
435
|
+
// challenge here could confuse non-MCP clients and makes the /api
|
|
436
|
+
// surface look OAuth-gated when it is not.
|
|
437
|
+
createVault("journal");
|
|
438
|
+
|
|
439
|
+
// /api/notes (unscoped) — 401, no challenge.
|
|
440
|
+
const unscopedApi = await route(new Request("http://localhost:1940/api/notes"), "/api/notes");
|
|
441
|
+
expect(unscopedApi.status).toBe(401);
|
|
442
|
+
expect(unscopedApi.headers.get("WWW-Authenticate")).toBeNull();
|
|
443
|
+
|
|
444
|
+
// /vaults/journal/api/notes (scoped) — 401, no challenge. This is the
|
|
445
|
+
// code path that shares the auth check with the scoped MCP branch, so
|
|
446
|
+
// if we leak the header here the isScopedMcp gate has regressed.
|
|
447
|
+
const scopedApi = await route(
|
|
448
|
+
new Request("http://localhost:1940/vaults/journal/api/notes"),
|
|
449
|
+
"/vaults/journal/api/notes",
|
|
450
|
+
);
|
|
451
|
+
expect(scopedApi.status).toBe(401);
|
|
452
|
+
expect(scopedApi.headers.get("WWW-Authenticate")).toBeNull();
|
|
453
|
+
|
|
454
|
+
// /vaults (authenticated listing) — 401, no challenge.
|
|
455
|
+
const vaultsList = await route(new Request("http://localhost:1940/vaults"), "/vaults");
|
|
456
|
+
expect(vaultsList.status).toBe(401);
|
|
457
|
+
expect(vaultsList.headers.get("WWW-Authenticate")).toBeNull();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test("x-forwarded-host and x-forwarded-proto shape the challenge URL", async () => {
|
|
461
|
+
// Remote deployments behind Cloudflare Tunnel / Tailscale Funnel / any
|
|
462
|
+
// reverse proxy need the challenge URL to match the external origin,
|
|
463
|
+
// not the 127.0.0.1:1940 the server actually binds. Parallels how the
|
|
464
|
+
// /.well-known/* endpoints already honor these headers.
|
|
465
|
+
createVault("journal");
|
|
466
|
+
const req = new Request("http://127.0.0.1:1940/vaults/journal/mcp", {
|
|
467
|
+
headers: {
|
|
468
|
+
"x-forwarded-host": "vault.example.com",
|
|
469
|
+
"x-forwarded-proto": "https",
|
|
470
|
+
},
|
|
471
|
+
});
|
|
472
|
+
const res = await route(req, "/vaults/journal/mcp");
|
|
473
|
+
expect(res.status).toBe(401);
|
|
474
|
+
expect(res.headers.get("WWW-Authenticate")).toBe(
|
|
475
|
+
'Bearer resource_metadata="https://vault.example.com/vaults/journal/.well-known/oauth-protected-resource"',
|
|
476
|
+
);
|
|
477
|
+
});
|
|
478
|
+
});
|