@openparachute/vault 0.2.1 → 0.2.3
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/routing.test.ts +183 -0
- package/src/routing.ts +37 -1
- 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.3] — 2026-04-17
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **OAuth discovery endpoints now served at RFC-compliant path-insertion URLs (`/.well-known/oauth-authorization-server/{path}`) in addition to the existing path-append form.** Restores Claude Code's MCP OAuth SDK compatibility, which follows RFC 8414 §3.1 and RFC 9728 §3 strictly and probes only the path-insertion shape. Before 0.2.3, the SDK's AS-metadata fetch 404'd, leaving it without a `registration_endpoint` and cascading into a 404 on the `/register` fallback. Both scoped forms now work: `/.well-known/oauth-authorization-server/vaults/<name>` and the longer `/.well-known/oauth-authorization-server/vaults/<name>/mcp`; same shapes on `/.well-known/oauth-protected-resource/...`. Path-append routes (`/vaults/<name>/.well-known/<type>`) are unchanged so lax clients keep working.
|
|
12
|
+
|
|
13
|
+
## [0.2.2] — 2026-04-17
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **`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.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- **`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.
|
|
22
|
+
|
|
7
23
|
## [0.2.1] — 2026-04-17
|
|
8
24
|
|
|
9
25
|
### Fixed
|
|
@@ -83,5 +99,7 @@ First tagged public release. Ships the auth, backup, and onboarding surface the
|
|
|
83
99
|
- **`core/src/test-preload.ts`** isolates `PARACHUTE_HOME` for tests so `bun test` never touches a user's real `~/.parachute/`.
|
|
84
100
|
- Test suite at release cut: **538 passing / 0 failing / 3 skipped** across 22 files (541 tests total).
|
|
85
101
|
|
|
102
|
+
[0.2.3]: https://github.com/ParachuteComputer/parachute-vault/releases/tag/v0.2.3
|
|
103
|
+
[0.2.2]: https://github.com/ParachuteComputer/parachute-vault/releases/tag/v0.2.2
|
|
86
104
|
[0.2.1]: https://github.com/ParachuteComputer/parachute-vault/releases/tag/v0.2.1
|
|
87
105
|
[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/routing.test.ts
CHANGED
|
@@ -476,3 +476,186 @@ describe("MCP 401 WWW-Authenticate challenge (RFC 9728)", () => {
|
|
|
476
476
|
);
|
|
477
477
|
});
|
|
478
478
|
});
|
|
479
|
+
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
// RFC 8414 §3.1 / RFC 9728 §3 path-insertion discovery.
|
|
482
|
+
//
|
|
483
|
+
// For a resource at `/vaults/<name>/mcp`, the spec-mandated metadata URLs are
|
|
484
|
+
// /.well-known/oauth-authorization-server/vaults/<name>[/mcp]
|
|
485
|
+
// /.well-known/oauth-protected-resource/vaults/<name>[/mcp]
|
|
486
|
+
// rather than the path-append form
|
|
487
|
+
// /vaults/<name>/.well-known/<type>
|
|
488
|
+
// that PR #111 also ships. Strict clients (including Claude Code's MCP OAuth
|
|
489
|
+
// SDK) probe only the path-insertion form; lax clients try path-append. We
|
|
490
|
+
// serve both so any conformant probe hits a live endpoint.
|
|
491
|
+
// ---------------------------------------------------------------------------
|
|
492
|
+
|
|
493
|
+
describe("path-insertion OAuth discovery (RFC 8414 §3.1 / RFC 9728 §3)", () => {
|
|
494
|
+
test("/.well-known/oauth-authorization-server/vaults/<name> returns vault-scoped AS metadata", async () => {
|
|
495
|
+
createVault("journal");
|
|
496
|
+
const path = "/.well-known/oauth-authorization-server/vaults/journal";
|
|
497
|
+
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
498
|
+
expect(res.status).toBe(200);
|
|
499
|
+
const body = (await res.json()) as {
|
|
500
|
+
issuer: string;
|
|
501
|
+
authorization_endpoint: string;
|
|
502
|
+
token_endpoint: string;
|
|
503
|
+
registration_endpoint: string;
|
|
504
|
+
};
|
|
505
|
+
// All four endpoints must be vault-scoped — otherwise Claude Code's
|
|
506
|
+
// registration_endpoint falls back to root `/register` and cascades 404.
|
|
507
|
+
expect(body.issuer).toBe("http://localhost:1940/vaults/journal");
|
|
508
|
+
expect(body.authorization_endpoint).toBe("http://localhost:1940/vaults/journal/oauth/authorize");
|
|
509
|
+
expect(body.token_endpoint).toBe("http://localhost:1940/vaults/journal/oauth/token");
|
|
510
|
+
expect(body.registration_endpoint).toBe("http://localhost:1940/vaults/journal/oauth/register");
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
test("/.well-known/oauth-authorization-server/vaults/<name>/mcp (longer form) also returns AS metadata", async () => {
|
|
514
|
+
// Aaron's log shows Claude Code probes this longer form too; cheap to
|
|
515
|
+
// support since it resolves to the same AS for the same vault.
|
|
516
|
+
createVault("journal");
|
|
517
|
+
const path = "/.well-known/oauth-authorization-server/vaults/journal/mcp";
|
|
518
|
+
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
519
|
+
expect(res.status).toBe(200);
|
|
520
|
+
const body = (await res.json()) as { issuer: string; registration_endpoint: string };
|
|
521
|
+
expect(body.issuer).toBe("http://localhost:1940/vaults/journal");
|
|
522
|
+
expect(body.registration_endpoint).toBe("http://localhost:1940/vaults/journal/oauth/register");
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
test("/.well-known/oauth-protected-resource/vaults/<name> returns vault-scoped PRM", async () => {
|
|
526
|
+
createVault("journal");
|
|
527
|
+
const path = "/.well-known/oauth-protected-resource/vaults/journal";
|
|
528
|
+
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
529
|
+
expect(res.status).toBe(200);
|
|
530
|
+
const body = (await res.json()) as { resource: string; authorization_servers: string[] };
|
|
531
|
+
expect(body.resource).toBe("http://localhost:1940/vaults/journal/mcp");
|
|
532
|
+
expect(body.authorization_servers).toEqual(["http://localhost:1940/vaults/journal"]);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test("/.well-known/oauth-protected-resource/vaults/<name>/mcp (longer form) also returns PRM", async () => {
|
|
536
|
+
createVault("journal");
|
|
537
|
+
const path = "/.well-known/oauth-protected-resource/vaults/journal/mcp";
|
|
538
|
+
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
539
|
+
expect(res.status).toBe(200);
|
|
540
|
+
const body = (await res.json()) as { resource: string };
|
|
541
|
+
expect(body.resource).toBe("http://localhost:1940/vaults/journal/mcp");
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
test("path-insertion and path-append forms return identical metadata", async () => {
|
|
545
|
+
// The coherence guarantee: a client that follows either spec shape MUST
|
|
546
|
+
// land on the same AS config. If these drift, a mixed-toolchain deploy
|
|
547
|
+
// (CLI using one form, daemon using the other) would mint tokens
|
|
548
|
+
// against inconsistent endpoints.
|
|
549
|
+
createVault("journal");
|
|
550
|
+
|
|
551
|
+
// AS metadata
|
|
552
|
+
const insertAsPath = "/.well-known/oauth-authorization-server/vaults/journal";
|
|
553
|
+
const appendAsPath = "/vaults/journal/.well-known/oauth-authorization-server";
|
|
554
|
+
const insertAsRes = await route(new Request(`http://localhost:1940${insertAsPath}`), insertAsPath);
|
|
555
|
+
const appendAsRes = await route(new Request(`http://localhost:1940${appendAsPath}`), appendAsPath);
|
|
556
|
+
expect(await insertAsRes.json()).toEqual(await appendAsRes.json());
|
|
557
|
+
|
|
558
|
+
// PRM
|
|
559
|
+
const insertPrmPath = "/.well-known/oauth-protected-resource/vaults/journal";
|
|
560
|
+
const appendPrmPath = "/vaults/journal/.well-known/oauth-protected-resource";
|
|
561
|
+
const insertPrmRes = await route(new Request(`http://localhost:1940${insertPrmPath}`), insertPrmPath);
|
|
562
|
+
const appendPrmRes = await route(new Request(`http://localhost:1940${appendPrmPath}`), appendPrmPath);
|
|
563
|
+
expect(await insertPrmRes.json()).toEqual(await appendPrmRes.json());
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("unknown vault in path-insertion URL returns 404, not boilerplate metadata", async () => {
|
|
567
|
+
// Don't leak metadata for phantom vaults. The equivalent path-append
|
|
568
|
+
// route also 404s when the vault doesn't exist (`readVaultConfig` miss
|
|
569
|
+
// at the vault-scoped routes branch); path-insertion must match.
|
|
570
|
+
createVault("journal");
|
|
571
|
+
for (const path of [
|
|
572
|
+
"/.well-known/oauth-authorization-server/vaults/nonexistent",
|
|
573
|
+
"/.well-known/oauth-authorization-server/vaults/nonexistent/mcp",
|
|
574
|
+
"/.well-known/oauth-protected-resource/vaults/nonexistent",
|
|
575
|
+
"/.well-known/oauth-protected-resource/vaults/nonexistent/mcp",
|
|
576
|
+
]) {
|
|
577
|
+
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
578
|
+
expect(res.status).toBe(404);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("x-forwarded-* headers propagate into the generated metadata URLs", async () => {
|
|
583
|
+
// Same contract as the WWW-Authenticate challenge and the root/append
|
|
584
|
+
// discovery endpoints: metadata must match the public-facing origin so
|
|
585
|
+
// a Cloudflare Tunnel / Tailscale Funnel deployment doesn't advertise
|
|
586
|
+
// internal localhost:1940 URLs.
|
|
587
|
+
createVault("journal");
|
|
588
|
+
const path = "/.well-known/oauth-authorization-server/vaults/journal";
|
|
589
|
+
const res = await route(
|
|
590
|
+
new Request(`http://127.0.0.1:1940${path}`, {
|
|
591
|
+
headers: {
|
|
592
|
+
"x-forwarded-host": "vault.example.com",
|
|
593
|
+
"x-forwarded-proto": "https",
|
|
594
|
+
},
|
|
595
|
+
}),
|
|
596
|
+
path,
|
|
597
|
+
);
|
|
598
|
+
expect(res.status).toBe(200);
|
|
599
|
+
const body = (await res.json()) as { issuer: string; registration_endpoint: string };
|
|
600
|
+
expect(body.issuer).toBe("https://vault.example.com/vaults/journal");
|
|
601
|
+
expect(body.registration_endpoint).toBe(
|
|
602
|
+
"https://vault.example.com/vaults/journal/oauth/register",
|
|
603
|
+
);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
test("end-to-end flow: WWW-Authenticate → PRM → AS metadata → registration_endpoint is live", async () => {
|
|
607
|
+
// The actual Claude-Code bug: on 401, follow the challenge to the PRM,
|
|
608
|
+
// then follow PRM.authorization_servers[0] to the AS metadata (via
|
|
609
|
+
// path-insertion), then hit the `registration_endpoint`. Every hop
|
|
610
|
+
// must resolve — before the fix, the AS-metadata-via-path-insertion
|
|
611
|
+
// step 404'd and the SDK fell back to `/register` which also 404'd.
|
|
612
|
+
createVault("journal");
|
|
613
|
+
|
|
614
|
+
// Step 1: unauthenticated MCP → 401 + WWW-Authenticate.
|
|
615
|
+
const mcpRes = await route(
|
|
616
|
+
new Request("http://localhost:1940/vaults/journal/mcp"),
|
|
617
|
+
"/vaults/journal/mcp",
|
|
618
|
+
);
|
|
619
|
+
expect(mcpRes.status).toBe(401);
|
|
620
|
+
const challenge = mcpRes.headers.get("WWW-Authenticate")!;
|
|
621
|
+
const prmUrl = challenge.match(/resource_metadata="([^"]+)"/)![1];
|
|
622
|
+
|
|
623
|
+
// Step 2: fetch PRM. The challenge points at the path-append form, but
|
|
624
|
+
// a strict client might also try path-insertion — both must work.
|
|
625
|
+
// Follow the advertised URL (path-append in this case) and note the
|
|
626
|
+
// authorization_servers pointer.
|
|
627
|
+
const prmPath = new URL(prmUrl).pathname;
|
|
628
|
+
const prmRes = await route(new Request(`http://localhost:1940${prmPath}`), prmPath);
|
|
629
|
+
expect(prmRes.status).toBe(200);
|
|
630
|
+
const prm = (await prmRes.json()) as { authorization_servers: string[] };
|
|
631
|
+
const asBase = prm.authorization_servers[0]; // "http://localhost:1940/vaults/journal"
|
|
632
|
+
|
|
633
|
+
// Step 3: strict-client path-insertion probe for AS metadata.
|
|
634
|
+
const asBasePath = new URL(asBase).pathname; // "/vaults/journal"
|
|
635
|
+
const asInsertPath = `/.well-known/oauth-authorization-server${asBasePath}`;
|
|
636
|
+
const asRes = await route(
|
|
637
|
+
new Request(`http://localhost:1940${asInsertPath}`),
|
|
638
|
+
asInsertPath,
|
|
639
|
+
);
|
|
640
|
+
// This was the 404 before the fix — the reason Claude Code's SDK gave
|
|
641
|
+
// up and cascade-404'd on `/register`.
|
|
642
|
+
expect(asRes.status).toBe(200);
|
|
643
|
+
const asMeta = (await asRes.json()) as { registration_endpoint: string };
|
|
644
|
+
|
|
645
|
+
// Step 4: the advertised registration_endpoint must be live (POST-only).
|
|
646
|
+
const regPath = new URL(asMeta.registration_endpoint).pathname;
|
|
647
|
+
const regRes = await route(
|
|
648
|
+
new Request(`http://localhost:1940${regPath}`, {
|
|
649
|
+
method: "POST",
|
|
650
|
+
headers: { "Content-Type": "application/json" },
|
|
651
|
+
body: JSON.stringify({
|
|
652
|
+
client_name: "Test",
|
|
653
|
+
redirect_uris: ["https://example.com/cb"],
|
|
654
|
+
}),
|
|
655
|
+
}),
|
|
656
|
+
regPath,
|
|
657
|
+
);
|
|
658
|
+
// Successful DCR is 201; anything but 404 proves the endpoint is wired.
|
|
659
|
+
expect(regRes.status).toBe(201);
|
|
660
|
+
});
|
|
661
|
+
});
|
package/src/routing.ts
CHANGED
|
@@ -115,7 +115,43 @@ export async function route(
|
|
|
115
115
|
path: string,
|
|
116
116
|
clientIp?: string,
|
|
117
117
|
): Promise<Response> {
|
|
118
|
-
// OAuth discovery endpoints (no auth required)
|
|
118
|
+
// OAuth discovery endpoints (no auth required).
|
|
119
|
+
//
|
|
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`:
|
|
122
|
+
//
|
|
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>
|
|
127
|
+
//
|
|
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.
|
|
134
|
+
const protectedResourceInsert = path.match(
|
|
135
|
+
/^\/\.well-known\/oauth-protected-resource\/vaults\/([^/]+)(?:\/mcp)?$/,
|
|
136
|
+
);
|
|
137
|
+
if (protectedResourceInsert) {
|
|
138
|
+
const vaultName = protectedResourceInsert[1];
|
|
139
|
+
if (!readVaultConfig(vaultName)) {
|
|
140
|
+
return Response.json({ error: "Vault not found", vault: vaultName }, { status: 404 });
|
|
141
|
+
}
|
|
142
|
+
return handleProtectedResource(req, `/vaults/${vaultName}/mcp`, `/vaults/${vaultName}`);
|
|
143
|
+
}
|
|
144
|
+
const authServerInsert = path.match(
|
|
145
|
+
/^\/\.well-known\/oauth-authorization-server\/vaults\/([^/]+)(?:\/mcp)?$/,
|
|
146
|
+
);
|
|
147
|
+
if (authServerInsert) {
|
|
148
|
+
const vaultName = authServerInsert[1];
|
|
149
|
+
if (!readVaultConfig(vaultName)) {
|
|
150
|
+
return Response.json({ error: "Vault not found", vault: vaultName }, { status: 404 });
|
|
151
|
+
}
|
|
152
|
+
return handleAuthorizationServer(req, vaultName);
|
|
153
|
+
}
|
|
154
|
+
|
|
119
155
|
if (path === "/.well-known/oauth-protected-resource") {
|
|
120
156
|
return handleProtectedResource(req);
|
|
121
157
|
}
|
|
@@ -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
|
+
});
|