@openparachute/vault 0.2.0 → 0.2.2
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 +18 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/cli.ts +13 -0
- package/src/daemon.ts +9 -0
- package/src/launchd.test.ts +78 -0
- package/src/oauth.ts +9 -1
- package/src/routing.test.ts +131 -0
- package/src/routing.ts +52 -4
- package/src/version.test.ts +65 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,22 @@ All notable changes to Parachute Vault are documented here.
|
|
|
4
4
|
|
|
5
5
|
This project loosely follows [Keep a Changelog](https://keepachangelog.com) and [Semantic Versioning](https://semver.org).
|
|
6
6
|
|
|
7
|
+
## [0.2.2] — 2026-04-17
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **`start.sh` daemon wrapper no longer crashes on user shell profiles that reference unbound variables.** The generated wrapper ran `source ~/.zprofile` and `source ~/.zshrc` under `set -u`, so a zsh plugin framework or any conditional profile setup that touched an unset variable would abort the wrapper with exit 1. The `2>/dev/null` redirect swallowed the error, launchd saw repeated exit 1s, and the daemon silently refused to start with an empty `vault.err`. The wrapper now brackets the profile-source lines with `set +u` / `set -u` so -u is only active for code the wrapper owns. Run `parachute vault init` once on 0.2.2 to rewrite `~/.parachute/start.sh` — the rewrite is idempotent.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **`parachute --version` / `parachute -v` / `parachute version`** print the installed package version to stdout. Works at the root and with the `vault` prefix (`parachute vault --version`, etc.). Reads from the installed `package.json` at module load, not a hardcoded string.
|
|
16
|
+
|
|
17
|
+
## [0.2.1] — 2026-04-17
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- OAuth discovery now works against Claude Code's MCP SDK (and any other strict RFC 9728 client): 401 responses from the MCP endpoint carry a `WWW-Authenticate: Bearer resource_metadata="…"` header pointing at the scoped or unscoped protected-resource metadata document, matching the URL the client actually hit. Previously, clients with no pointer fell back to probing the root `/.well-known/oauth-protected-resource`, got `resource: <base>/mcp`, and rejected any connection to `/vaults/<name>/mcp` as a resource mismatch.
|
|
22
|
+
|
|
7
23
|
## [0.2.0] — 2026-04-17
|
|
8
24
|
|
|
9
25
|
First tagged public release. Ships the auth, backup, and onboarding surface the project needs for first-wave users.
|
|
@@ -77,4 +93,6 @@ First tagged public release. Ships the auth, backup, and onboarding surface the
|
|
|
77
93
|
- **`core/src/test-preload.ts`** isolates `PARACHUTE_HOME` for tests so `bun test` never touches a user's real `~/.parachute/`.
|
|
78
94
|
- Test suite at release cut: **538 passing / 0 failing / 3 skipped** across 22 files (541 tests total).
|
|
79
95
|
|
|
96
|
+
[0.2.2]: https://github.com/ParachuteComputer/parachute-vault/releases/tag/v0.2.2
|
|
97
|
+
[0.2.1]: https://github.com/ParachuteComputer/parachute-vault/releases/tag/v0.2.1
|
|
80
98
|
[0.2.0]: https://github.com/ParachuteComputer/parachute-vault/releases/tag/v0.2.0
|
package/README.md
CHANGED
|
@@ -172,6 +172,7 @@ parachute vault init # one-command setup (idempotent — s
|
|
|
172
172
|
parachute vault status # check what's running
|
|
173
173
|
parachute vault doctor # diagnose install/config issues (see Troubleshooting)
|
|
174
174
|
parachute vault url # print the local server URL (for scripts)
|
|
175
|
+
parachute --version # print the installed version (aliases: -v, version)
|
|
175
176
|
parachute vault uninstall # remove daemon + MCP entry; keeps user data
|
|
176
177
|
parachute vault uninstall --wipe # ...and also remove vaults, .env, config.yaml, logs
|
|
177
178
|
parachute vault uninstall --yes --wipe # scripted destructive wipe (prints an audit line)
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -19,6 +19,10 @@
|
|
|
19
19
|
import { resolve } from "path";
|
|
20
20
|
import { homedir } from "os";
|
|
21
21
|
import { existsSync, readFileSync, writeFileSync, rmSync, mkdirSync } from "fs";
|
|
22
|
+
// JSON import — resolved at module load, works for both dev runs
|
|
23
|
+
// (`bun src/cli.ts …`) and the published package (`bunx @openparachute/vault`)
|
|
24
|
+
// because package.json ships at the root next to src/.
|
|
25
|
+
import pkg from "../package.json" with { type: "json" };
|
|
22
26
|
import {
|
|
23
27
|
ensureConfigDirSync,
|
|
24
28
|
readVaultConfig,
|
|
@@ -180,6 +184,14 @@ switch (command) {
|
|
|
180
184
|
case "-h":
|
|
181
185
|
usage();
|
|
182
186
|
break;
|
|
187
|
+
case "version":
|
|
188
|
+
case "--version":
|
|
189
|
+
case "-v":
|
|
190
|
+
// Intentionally minimal — just the version string on stdout. Scripts
|
|
191
|
+
// (and `parachute vault doctor` in a future check) rely on this being
|
|
192
|
+
// a bare-number line; anything else belongs in `vault status`.
|
|
193
|
+
console.log(pkg.version);
|
|
194
|
+
break;
|
|
183
195
|
default:
|
|
184
196
|
console.error(`Unknown command: ${command}`);
|
|
185
197
|
usage();
|
|
@@ -1976,6 +1988,7 @@ Setup:
|
|
|
1976
1988
|
config.yaml, and daemon logs (vault.log, vault.err).
|
|
1977
1989
|
--yes skips prompts (DANGEROUS with --wipe: no confirmation).
|
|
1978
1990
|
parachute vault url Print the local server URL (for scripts)
|
|
1991
|
+
parachute --version Print the installed version (alias: -v, version)
|
|
1979
1992
|
|
|
1980
1993
|
Vaults:
|
|
1981
1994
|
parachute vault create <name> Create a new vault
|
package/src/daemon.ts
CHANGED
|
@@ -55,8 +55,17 @@ export function generateWrapper(opts: {
|
|
|
55
55
|
set -u
|
|
56
56
|
|
|
57
57
|
# Source user shell profile for PATH (needed for parakeet-mlx, ffmpeg, etc.)
|
|
58
|
+
# Temporarily disable -u around these: user rc files routinely reference
|
|
59
|
+
# unbound variables (zsh plugin frameworks, conditional setups), and a bare
|
|
60
|
+
# \`set -u\` source would crash the wrapper with exit 1 and leave vault.err
|
|
61
|
+
# empty because of the 2>/dev/null below — launchd would respawn silently
|
|
62
|
+
# until it gave up. Keep the stderr redirect so expected "command not found"
|
|
63
|
+
# noise from incomplete setups doesn't fill vault.err; to debug silent
|
|
64
|
+
# wrapper failures, run \`bash -x ~/.parachute/start.sh\` by hand.
|
|
65
|
+
set +u
|
|
58
66
|
[ -f "$HOME/.zprofile" ] && source "$HOME/.zprofile" 2>/dev/null
|
|
59
67
|
[ -f "$HOME/.zshrc" ] && source "$HOME/.zshrc" 2>/dev/null
|
|
68
|
+
set -u
|
|
60
69
|
|
|
61
70
|
if [ -f "${envPath}" ]; then
|
|
62
71
|
set -a
|
package/src/launchd.test.ts
CHANGED
|
@@ -78,6 +78,84 @@ describe("generateWrapper", () => {
|
|
|
78
78
|
rmSync(dir, { recursive: true, force: true });
|
|
79
79
|
}
|
|
80
80
|
});
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// The incident this guards against (0.2.2): sourcing the user's ~/.zshrc or
|
|
84
|
+
// ~/.zprofile under `set -u` crashes the wrapper if any line in the rc file
|
|
85
|
+
// references an unbound variable — which is routine in zsh plugin frameworks
|
|
86
|
+
// and half-configured setups. The 2>/dev/null redirect swallowed the error
|
|
87
|
+
// so vault.err stayed empty and launchd silently gave up after repeated
|
|
88
|
+
// exit 1s. The fix brackets the profile-source lines with `set +u` / `set -u`
|
|
89
|
+
// so strict-unset-vars only applies to code the wrapper itself owns.
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
test("brackets profile sourcing with set +u / set -u to survive user rc files", () => {
|
|
93
|
+
const wrapper = generateWrapper({ bunPath: "/bin/bun" });
|
|
94
|
+
// Textual shape check — quickest canary.
|
|
95
|
+
const lines = wrapper.split("\n");
|
|
96
|
+
const zprofileIdx = lines.findIndex((l) => l.includes(".zprofile"));
|
|
97
|
+
const zshrcIdx = lines.findIndex((l) => l.includes(".zshrc"));
|
|
98
|
+
expect(zprofileIdx).toBeGreaterThan(-1);
|
|
99
|
+
expect(zshrcIdx).toBeGreaterThan(-1);
|
|
100
|
+
// Both source lines must be sandwiched between a `set +u` and a `set -u`.
|
|
101
|
+
// Walk back for `set +u` and forward for `set -u` from whichever source
|
|
102
|
+
// line comes first / last so ordering stays flexible.
|
|
103
|
+
const firstIdx = Math.min(zprofileIdx, zshrcIdx);
|
|
104
|
+
const lastIdx = Math.max(zprofileIdx, zshrcIdx);
|
|
105
|
+
const preceding = lines.slice(0, firstIdx).reverse().find((l) => l.trim().startsWith("set "));
|
|
106
|
+
const following = lines.slice(lastIdx + 1).find((l) => l.trim().startsWith("set "));
|
|
107
|
+
expect(preceding?.trim()).toBe("set +u");
|
|
108
|
+
expect(following?.trim()).toBe("set -u");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("surviving profile source under set -u: running the generated wrapper with a rc file that trips set -u does not abort before reaching the pointer-file logic", async () => {
|
|
112
|
+
// The integration proof. Build a fake HOME where ~/.zshrc expands an
|
|
113
|
+
// unbound variable ($UNSET_IN_TEST), point the wrapper at it via HOME,
|
|
114
|
+
// and expect the wrapper to exit on the "server path not configured"
|
|
115
|
+
// path (exit 1 from the explicit check) rather than on the zshrc crash
|
|
116
|
+
// (exit 1 from set -u). The signal we compare on is the stderr message:
|
|
117
|
+
// the pointer-missing branch prints a specific error; a set-u crash
|
|
118
|
+
// prints zsh's own "parameter not set" message and no vault-branded
|
|
119
|
+
// text at all.
|
|
120
|
+
//
|
|
121
|
+
// Skipping stderr here would let a regressed wrapper silently exit 1
|
|
122
|
+
// and still pass the test, so we assert on the presence of the
|
|
123
|
+
// pointer-missing message.
|
|
124
|
+
const dir = mkdtempSync(join(tmpdir(), "vault-wrapper-setu-"));
|
|
125
|
+
try {
|
|
126
|
+
const fakeHome = join(dir, "home");
|
|
127
|
+
writeFileSync(join(dir, "mkdir.marker"), ""); // ensure dir exists
|
|
128
|
+
await $`mkdir -p ${fakeHome}`.quiet();
|
|
129
|
+
// A ~/.zshrc that blows up under set -u.
|
|
130
|
+
writeFileSync(join(fakeHome, ".zshrc"), 'echo "$UNSET_IN_TEST"\n');
|
|
131
|
+
// Wrapper with explicit env/pointer paths that do NOT exist. We do not
|
|
132
|
+
// pass PARACHUTE_VAULT_SERVER_PATH either. So the wrapper should take
|
|
133
|
+
// the "no server path configured" branch and exit 1 with the branded
|
|
134
|
+
// message — but only if it survives the zshrc source.
|
|
135
|
+
const wrapper = generateWrapper({
|
|
136
|
+
bunPath: "/bin/echo", // won't be reached; a safe no-op if it is
|
|
137
|
+
serverPathFile: join(dir, "nonexistent-pointer"),
|
|
138
|
+
envPath: join(dir, "nonexistent.env"),
|
|
139
|
+
});
|
|
140
|
+
const path = join(dir, "start.sh");
|
|
141
|
+
writeFileSync(path, wrapper);
|
|
142
|
+
// Override HOME so the wrapper sources our crafted zshrc.
|
|
143
|
+
// Clear PARACHUTE_VAULT_SERVER_PATH in case the test runner has it set.
|
|
144
|
+
const result = await $`HOME=${fakeHome} PARACHUTE_VAULT_SERVER_PATH= bash ${path}`
|
|
145
|
+
.quiet()
|
|
146
|
+
.nothrow();
|
|
147
|
+
const stderr = result.stderr.toString();
|
|
148
|
+
// Positive: we reached the branded pointer-missing branch.
|
|
149
|
+
expect(stderr).toMatch(/parachute-vault: server path not configured/);
|
|
150
|
+
// Negative: we did NOT crash in zshrc with a zsh/bash unbound-variable
|
|
151
|
+
// message. Catches a future regression where someone drops the
|
|
152
|
+
// `set +u` bracket.
|
|
153
|
+
expect(stderr).not.toMatch(/UNSET_IN_TEST: unbound variable/);
|
|
154
|
+
expect(result.exitCode).toBe(1); // the branded branch
|
|
155
|
+
} finally {
|
|
156
|
+
rmSync(dir, { recursive: true, force: true });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
81
159
|
});
|
|
82
160
|
|
|
83
161
|
describe("generatePlist", () => {
|
package/src/oauth.ts
CHANGED
|
@@ -44,7 +44,15 @@ export interface AuthorizePostOptions {
|
|
|
44
44
|
// Helpers
|
|
45
45
|
// ---------------------------------------------------------------------------
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Public-facing base URL of the server. Honors `x-forwarded-*` headers so a
|
|
49
|
+
* Cloudflare Tunnel / Tailscale Funnel / reverse-proxied deployment advertises
|
|
50
|
+
* the right external origin in discovery documents (RFC 8414, RFC 9728).
|
|
51
|
+
*
|
|
52
|
+
* Exported so the router can build `WWW-Authenticate` challenge headers that
|
|
53
|
+
* point at the same origin as the `/.well-known/*` metadata documents.
|
|
54
|
+
*/
|
|
55
|
+
export function getBaseUrl(req: Request): string {
|
|
48
56
|
const forwardedHost = req.headers.get("x-forwarded-host");
|
|
49
57
|
const forwardedProto = req.headers.get("x-forwarded-proto");
|
|
50
58
|
if (forwardedHost) {
|
package/src/routing.test.ts
CHANGED
|
@@ -345,3 +345,134 @@ describe("single-vault auto-default", () => {
|
|
|
345
345
|
expect(res.status).toBe(401); // reached per-vault auth
|
|
346
346
|
});
|
|
347
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
|
+
});
|
package/src/routing.ts
CHANGED
|
@@ -49,8 +49,49 @@ import {
|
|
|
49
49
|
handleAuthorizeGet,
|
|
50
50
|
handleAuthorizePost,
|
|
51
51
|
handleToken,
|
|
52
|
+
getBaseUrl,
|
|
52
53
|
} from "./oauth.ts";
|
|
53
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Decorate a 401 response from the MCP endpoint with the RFC 9728 challenge
|
|
57
|
+
* header pointing at the matching protected-resource metadata document.
|
|
58
|
+
*
|
|
59
|
+
* An MCP-capable OAuth client that receives a plain 401 has no structured way
|
|
60
|
+
* to discover which authorization server to use, and SDKs that follow RFC 9728
|
|
61
|
+
* (including Claude Code's) default to probing the *root* `/.well-known/oauth-
|
|
62
|
+
* protected-resource`. That document advertises `resource: <base>/mcp` — which
|
|
63
|
+
* then fails the SDK's strict resource-URL match when the client is actually
|
|
64
|
+
* connecting to `/vaults/{name}/mcp`. The `WWW-Authenticate` header tells the
|
|
65
|
+
* client exactly which metadata document applies to the endpoint it just hit,
|
|
66
|
+
* closing the mismatch.
|
|
67
|
+
*
|
|
68
|
+
* Scoped calls pass `vaultName`; unscoped omits it. Other 401-emitting
|
|
69
|
+
* endpoints (`/api/*`, `/vaults`, `/health` when authenticated) are not MCP
|
|
70
|
+
* resources and intentionally do not carry this header.
|
|
71
|
+
*/
|
|
72
|
+
function mcpWwwAuthenticate(req: Request, vaultName?: string): string {
|
|
73
|
+
const base = getBaseUrl(req);
|
|
74
|
+
const prefix = vaultName ? `/vaults/${vaultName}` : "";
|
|
75
|
+
return `Bearer resource_metadata="${base}${prefix}/.well-known/oauth-protected-resource"`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Clone a 401 Response and attach the `WWW-Authenticate` challenge header.
|
|
80
|
+
* The auth module returns a fully-baked `Response`, and headers on a consumed
|
|
81
|
+
* `Response` can't be mutated in place; cloning is the cheap path.
|
|
82
|
+
*/
|
|
83
|
+
async function withMcpChallenge(
|
|
84
|
+
res: Response,
|
|
85
|
+
req: Request,
|
|
86
|
+
vaultName?: string,
|
|
87
|
+
): Promise<Response> {
|
|
88
|
+
if (res.status !== 401) return res;
|
|
89
|
+
const body = await res.text();
|
|
90
|
+
const headers = new Headers(res.headers);
|
|
91
|
+
headers.set("WWW-Authenticate", mcpWwwAuthenticate(req, vaultName));
|
|
92
|
+
return new Response(body, { status: 401, headers });
|
|
93
|
+
}
|
|
94
|
+
|
|
54
95
|
/**
|
|
55
96
|
* Check if a /view request has a valid API key (header or ?key= query param).
|
|
56
97
|
* Returns true if authenticated, false if not. Never rejects — unauthenticated
|
|
@@ -134,7 +175,7 @@ export async function route(
|
|
|
134
175
|
// Unified MCP (all vaults, global auth)
|
|
135
176
|
if (path === "/mcp" || path.startsWith("/mcp/")) {
|
|
136
177
|
const auth = authenticateGlobalRequest(req);
|
|
137
|
-
if ("error" in auth) return auth.error;
|
|
178
|
+
if ("error" in auth) return withMcpChallenge(auth.error, req);
|
|
138
179
|
return handleUnifiedMcp(req, auth);
|
|
139
180
|
}
|
|
140
181
|
|
|
@@ -308,13 +349,20 @@ export async function route(
|
|
|
308
349
|
return handleAuthorizationServer(req, vaultName);
|
|
309
350
|
}
|
|
310
351
|
|
|
311
|
-
// Auth: per-vault key OR global key
|
|
352
|
+
// Auth: per-vault key OR global key.
|
|
353
|
+
// The auth check is shared between the scoped MCP branch and the scoped
|
|
354
|
+
// /api/* branches, so we can't unconditionally attach the MCP-only
|
|
355
|
+
// WWW-Authenticate challenge here — we pass the challenge back only when
|
|
356
|
+
// the failing request was actually targeting /vaults/{name}/mcp.
|
|
312
357
|
const store = getVaultStore(vaultName);
|
|
313
358
|
const auth = authenticateVaultRequest(req, vaultConfig, store.db);
|
|
314
|
-
|
|
359
|
+
const isScopedMcp = subpath === "/mcp" || subpath.startsWith("/mcp/");
|
|
360
|
+
if ("error" in auth) {
|
|
361
|
+
return isScopedMcp ? withMcpChallenge(auth.error, req, vaultName) : auth.error;
|
|
362
|
+
}
|
|
315
363
|
|
|
316
364
|
// Per-vault scoped MCP
|
|
317
|
-
if (
|
|
365
|
+
if (isScopedMcp) {
|
|
318
366
|
return handleScopedMcp(req, vaultName, auth);
|
|
319
367
|
}
|
|
320
368
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for `parachute --version` and its aliases.
|
|
3
|
+
*
|
|
4
|
+
* Spawns the real CLI as a subprocess so the argv-dispatch path is exercised
|
|
5
|
+
* end-to-end. Every accepted spelling must produce the exact version string
|
|
6
|
+
* from package.json on stdout, with exit code 0, and nothing else.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, test, expect } from "bun:test";
|
|
10
|
+
import { resolve } from "path";
|
|
11
|
+
import pkg from "../package.json" with { type: "json" };
|
|
12
|
+
|
|
13
|
+
const CLI = resolve(import.meta.dir, "cli.ts");
|
|
14
|
+
|
|
15
|
+
function runCli(args: string[]): {
|
|
16
|
+
exitCode: number;
|
|
17
|
+
stdout: string;
|
|
18
|
+
stderr: string;
|
|
19
|
+
} {
|
|
20
|
+
const proc = Bun.spawnSync({
|
|
21
|
+
cmd: ["bun", CLI, ...args],
|
|
22
|
+
stdout: "pipe",
|
|
23
|
+
stderr: "pipe",
|
|
24
|
+
});
|
|
25
|
+
return {
|
|
26
|
+
exitCode: proc.exitCode ?? -1,
|
|
27
|
+
stdout: new TextDecoder().decode(proc.stdout),
|
|
28
|
+
stderr: new TextDecoder().decode(proc.stderr),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("parachute version", () => {
|
|
33
|
+
// Every spelling must print the package's version and nothing else.
|
|
34
|
+
// `parachute vault --version` works via the argv parser's existing
|
|
35
|
+
// `args[0] === "vault"` branch, which shifts "vault" off and treats
|
|
36
|
+
// `--version` as the command — so the same switch case handles both
|
|
37
|
+
// root and vault-prefixed invocations.
|
|
38
|
+
for (const form of [
|
|
39
|
+
["--version"],
|
|
40
|
+
["-v"],
|
|
41
|
+
["version"],
|
|
42
|
+
["vault", "--version"],
|
|
43
|
+
["vault", "-v"],
|
|
44
|
+
["vault", "version"],
|
|
45
|
+
]) {
|
|
46
|
+
test(`parachute ${form.join(" ")} prints the package version`, () => {
|
|
47
|
+
const { exitCode, stdout, stderr } = runCli(form);
|
|
48
|
+
expect(exitCode).toBe(0);
|
|
49
|
+
// Exact match — no banner, no trailing whitespace other than the single
|
|
50
|
+
// trailing newline from console.log. Scripts will pipe this through
|
|
51
|
+
// things like `$(parachute --version)`.
|
|
52
|
+
expect(stdout).toBe(`${pkg.version}\n`);
|
|
53
|
+
// Stderr must be empty. If the dispatcher drops into the default branch
|
|
54
|
+
// it would print "Unknown command:" plus the full usage() block to
|
|
55
|
+
// stderr, which is exactly the regression this test catches.
|
|
56
|
+
expect(stderr).toBe("");
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
test("version string looks like semver (sanity check)", () => {
|
|
61
|
+
// Defense-in-depth: if someone ever replaces the JSON import with a
|
|
62
|
+
// hardcoded string, a malformed value still won't slip through.
|
|
63
|
+
expect(pkg.version).toMatch(/^\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?$/);
|
|
64
|
+
});
|
|
65
|
+
});
|