@openparachute/vault 0.3.1 → 0.4.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 (82) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +9 -5
  3. package/core/src/core.test.ts +2252 -7
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +801 -67
  6. package/core/src/note-schemas.ts +232 -0
  7. package/core/src/notes.ts +313 -35
  8. package/core/src/obsidian.ts +3 -3
  9. package/core/src/paths.ts +1 -1
  10. package/core/src/query-operators.ts +23 -7
  11. package/core/src/schema-defaults.ts +287 -0
  12. package/core/src/schema.ts +393 -9
  13. package/core/src/store.ts +248 -6
  14. package/core/src/tag-hierarchy.ts +137 -0
  15. package/core/src/tag-schemas.ts +242 -42
  16. package/core/src/types.ts +100 -6
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +231 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +144 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +384 -78
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +296 -0
  30. package/src/hub-jwt.ts +79 -0
  31. package/src/init-summary.test.ts +133 -0
  32. package/src/init-summary.ts +90 -0
  33. package/src/init.test.ts +216 -0
  34. package/src/mcp-http.ts +30 -28
  35. package/src/mcp-install.ts +1 -1
  36. package/src/mcp-tools.ts +294 -6
  37. package/src/module-config.ts +1 -1
  38. package/src/oauth.test.ts +345 -0
  39. package/src/oauth.ts +85 -14
  40. package/src/owner-auth.ts +57 -1
  41. package/src/prompt.ts +31 -14
  42. package/src/routes.ts +686 -58
  43. package/src/routing.test.ts +466 -1
  44. package/src/routing.ts +108 -24
  45. package/src/scopes.test.ts +66 -8
  46. package/src/scopes.ts +163 -37
  47. package/src/server.ts +24 -2
  48. package/src/services-manifest.test.ts +20 -0
  49. package/src/services-manifest.ts +9 -2
  50. package/src/stop-signal.test.ts +85 -0
  51. package/src/storage.test.ts +92 -0
  52. package/src/tag-scope.ts +118 -0
  53. package/src/token-store.test.ts +47 -0
  54. package/src/token-store.ts +128 -13
  55. package/src/tokens-routes.test.ts +720 -0
  56. package/src/tokens-routes.ts +392 -0
  57. package/src/transcription-worker.test.ts +5 -0
  58. package/src/triggers.ts +1 -1
  59. package/src/two-factor.ts +2 -2
  60. package/src/vault-create.test.ts +193 -0
  61. package/src/vault-name.test.ts +123 -0
  62. package/src/vault-name.ts +80 -0
  63. package/src/vault.test.ts +868 -3
  64. package/tsconfig.json +8 -1
  65. package/.claude/settings.local.json +0 -8
  66. package/.dockerignore +0 -8
  67. package/.env.example +0 -9
  68. package/CHANGELOG.md +0 -175
  69. package/CLAUDE.md +0 -125
  70. package/Caddyfile +0 -3
  71. package/Dockerfile +0 -22
  72. package/bun.lock +0 -219
  73. package/bunfig.toml +0 -2
  74. package/deploy/parachute-vault.service +0 -20
  75. package/docker-compose.yml +0 -50
  76. package/docs/HTTP_API.md +0 -434
  77. package/docs/auth-model.md +0 -340
  78. package/fly.toml +0 -24
  79. package/package/package.json +0 -32
  80. package/railway.json +0 -14
  81. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  82. package/scripts/migrate-audio-to-opus.ts +0 -499
package/src/hub-jwt.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Hub-issued JWT validation. Vault as resource server: trusts tokens that the
3
+ * hub signs against keys we fetch from the hub's `/.well-known/jwks.json`.
4
+ *
5
+ * The trust kernel — JWKS fetch + verify, issuer pin, audience strict-check,
6
+ * RFC 7519 string-or-array `aud` handling — lives in the shared
7
+ * `@openparachute/scope-guard` library so vault, scribe, and paraclaw can't
8
+ * silently drift on the worst place to drift. This file is the vault-side
9
+ * adapter: hub-origin resolution (env-var precedence + loopback fallback),
10
+ * a process-wide guard instance, and re-exports preserving the public
11
+ * surface every existing call site already imports.
12
+ *
13
+ * Vault#169 / hub-as-issuer Phase B2; vault#TBD / scope-guard adoption.
14
+ */
15
+ import {
16
+ createScopeGuard,
17
+ HubJwtError,
18
+ type HubJwtClaims,
19
+ looksLikeJwt,
20
+ type ValidateHubJwtOptions,
21
+ } from "@openparachute/scope-guard";
22
+
23
+ const DEFAULT_HUB_LOOPBACK = "http://127.0.0.1:1939";
24
+
25
+ /**
26
+ * Resolve the hub origin used to fetch JWKS and validate `iss`. Strips a
27
+ * trailing slash so we get a single canonical form.
28
+ *
29
+ * Order: env var → loopback fallback. We deliberately don't read
30
+ * `~/.parachute/services.json` — the hub is the dispatcher, not a registered
31
+ * service in that file. If a deployment exposes the hub on a non-default
32
+ * origin, the env var is the contract.
33
+ */
34
+ export function getHubOrigin(): string {
35
+ const env = process.env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
36
+ if (env && env.length > 0) return env;
37
+ return DEFAULT_HUB_LOOPBACK;
38
+ }
39
+
40
+ // Process-wide guard. The resolver form lets tests flip
41
+ // `PARACHUTE_HUB_ORIGIN` between cases — the lib re-resolves on every
42
+ // `validateHubJwt` and `resetJwksCache` call so the env-var change picks up
43
+ // without a server restart. JWKS cache (5min/30s defaults) lives inside the
44
+ // guard, shared across requests.
45
+ const guard = createScopeGuard({ hubOrigin: () => getHubOrigin() });
46
+
47
+ /**
48
+ * Verify a presented JWT against the hub's JWKS. Throws `HubJwtError` on any
49
+ * failure (bad signature, wrong issuer, expired, missing kid, JWKS
50
+ * unreachable, audience mismatch). On success returns the surfaced claims
51
+ * plus the parsed scope list.
52
+ *
53
+ * Trust model:
54
+ * - `iss` MUST equal the configured hub origin. Without this, anyone could
55
+ * mint a token against any RSA key and pass verification.
56
+ * - `aud` is strict-checked against `opts.expectedAudience` when provided
57
+ * — the resource-server backstop for per-vault binding.
58
+ *
59
+ * Scope-shape policy (e.g. "hub-issued tokens may not carry broad
60
+ * `vault:<verb>` scopes") is enforced one layer up in `authenticateHubJwt`,
61
+ * not here — this function stays focused on JWT-level concerns.
62
+ */
63
+ export async function validateHubJwt(
64
+ token: string,
65
+ opts: ValidateHubJwtOptions = {},
66
+ ): Promise<HubJwtClaims> {
67
+ return guard.validateHubJwt(token, opts);
68
+ }
69
+
70
+ /**
71
+ * Reset the cached JWKS getter. Tests use this to switch origins between
72
+ * cases; production callers shouldn't need it (origin is process-stable).
73
+ */
74
+ export function resetJwksCache(): void {
75
+ guard.resetJwksCache();
76
+ }
77
+
78
+ export { HubJwtError, looksLikeJwt };
79
+ export type { HubJwtClaims, ValidateHubJwtOptions };
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Tests for `buildInitSummaryLines` — the post-install summary printed at the
3
+ * end of `vault init`. The summary branches on the (addMcp, addToken) decision
4
+ * matrix; these tests cover all four cells plus the token surfacing /
5
+ * Bearer-example rules.
6
+ */
7
+
8
+ import { describe, test, expect } from "bun:test";
9
+ import { buildInitSummaryLines } from "./init-summary.ts";
10
+
11
+ const baseInput = {
12
+ configDir: "/tmp/parachute",
13
+ bindHost: "127.0.0.1",
14
+ port: 1940,
15
+ mcpUrl: "http://127.0.0.1:1940/vault/default/mcp",
16
+ };
17
+
18
+ function lines(addMcp: boolean, addToken: boolean, apiKey: string | undefined) {
19
+ return buildInitSummaryLines({ ...baseInput, addMcp, addToken, apiKey });
20
+ }
21
+
22
+ describe("buildInitSummaryLines", () => {
23
+ describe("MCP=Y + token=Y (most common)", () => {
24
+ const out = lines(true, true, "pvt_abc123").join("\n");
25
+
26
+ test("prints token prominently", () => {
27
+ expect(out).toContain("Your API token: pvt_abc123");
28
+ });
29
+
30
+ test("notes token is baked into ~/.claude.json", () => {
31
+ expect(out).toContain("Baked into ~/.claude.json for Claude Code");
32
+ });
33
+
34
+ test("includes save-it-now warning", () => {
35
+ expect(out).toContain("Won't be shown again — save it now.");
36
+ });
37
+
38
+ test("includes Bearer curl example", () => {
39
+ expect(out).toContain(
40
+ 'curl -H "Authorization: Bearer pvt_abc123" http://localhost:1940/api/notes',
41
+ );
42
+ });
43
+
44
+ test("Next steps mentions starting a Claude Code session", () => {
45
+ expect(out).toContain("Start a new Claude Code session");
46
+ });
47
+ });
48
+
49
+ describe("MCP=Y + token=N (MCP wired, token not surfaced)", () => {
50
+ const out = lines(true, false, "pvt_secret").join("\n");
51
+
52
+ test("does not print the token prominently", () => {
53
+ expect(out).not.toContain("pvt_secret");
54
+ });
55
+
56
+ test("does not include the 'Baked into' bullet", () => {
57
+ expect(out).not.toContain("Baked into ~/.claude.json");
58
+ });
59
+
60
+ test("includes the tokens-create-later hint", () => {
61
+ expect(out).toContain("Token in ~/.claude.json");
62
+ expect(out).toContain("parachute vault tokens create");
63
+ });
64
+
65
+ test("omits the Bearer curl example", () => {
66
+ expect(out).not.toContain("Authorization: Bearer");
67
+ });
68
+
69
+ test("still shows the Claude-Code-session next step", () => {
70
+ expect(out).toContain("Start a new Claude Code session");
71
+ });
72
+ });
73
+
74
+ describe("MCP=N + token=Y (token only)", () => {
75
+ const out = lines(false, true, "pvt_xyz").join("\n");
76
+
77
+ test("prints token prominently", () => {
78
+ expect(out).toContain("Your API token: pvt_xyz");
79
+ });
80
+
81
+ test("omits the 'Baked into' bullet (no claude.json entry written)", () => {
82
+ expect(out).not.toContain("Baked into ~/.claude.json");
83
+ });
84
+
85
+ test("includes Bearer curl example", () => {
86
+ expect(out).toContain('Authorization: Bearer pvt_xyz');
87
+ });
88
+
89
+ test("Next steps points at any local MCP client", () => {
90
+ expect(out).toContain("Point any local MCP client");
91
+ expect(out).toContain("http://127.0.0.1:1940/vault/default/mcp");
92
+ });
93
+
94
+ test("Next steps offers mcp-install as a way back", () => {
95
+ expect(out).toContain("parachute-vault mcp-install");
96
+ });
97
+ });
98
+
99
+ describe("MCP=N + token=N (unreachable)", () => {
100
+ const out = lines(false, false, undefined).join("\n");
101
+
102
+ test("warns the vault is unreachable", () => {
103
+ expect(out).toContain("your vault isn't reachable by any client");
104
+ });
105
+
106
+ test("points to both recovery paths", () => {
107
+ expect(out).toContain("parachute-vault mcp-install");
108
+ expect(out).toContain("parachute vault tokens create");
109
+ });
110
+
111
+ test("does not print any token", () => {
112
+ expect(out).not.toContain("Your API token:");
113
+ expect(out).not.toMatch(/pvt_/);
114
+ });
115
+
116
+ test("omits the Bearer curl example", () => {
117
+ expect(out).not.toContain("Authorization: Bearer");
118
+ });
119
+ });
120
+
121
+ test("always prints Config: and Server: lines", () => {
122
+ for (const [addMcp, addToken] of [
123
+ [true, true],
124
+ [true, false],
125
+ [false, true],
126
+ [false, false],
127
+ ] as const) {
128
+ const out = lines(addMcp, addToken, addMcp || addToken ? "pvt_k" : undefined).join("\n");
129
+ expect(out).toContain("Config: /tmp/parachute");
130
+ expect(out).toContain("Server: http://127.0.0.1:1940");
131
+ }
132
+ });
133
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Pure helper for `vault init`'s post-install summary. Extracted from cli.ts
3
+ * so the (addMcp, addToken) decision-matrix branches can be unit-tested
4
+ * without side-effects from importing the CLI entrypoint.
5
+ */
6
+
7
+ export type InitSummaryInput = {
8
+ addMcp: boolean;
9
+ addToken: boolean;
10
+ apiKey: string | undefined;
11
+ configDir: string;
12
+ bindHost: string;
13
+ port: number;
14
+ mcpUrl: string;
15
+ };
16
+
17
+ /**
18
+ * Build the post-install summary lines for `vault init`, branched on the
19
+ * (addMcp, addToken) decision matrix:
20
+ *
21
+ * Y, Y → token baked into claude.json + printed prominently
22
+ * Y, N → token baked into claude.json, hint about `tokens create`
23
+ * N, Y → token printed prominently, no claude.json entry
24
+ * N, N → warning: vault unreachable; both recovery paths listed
25
+ */
26
+ export function buildInitSummaryLines(input: InitSummaryInput): string[] {
27
+ const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl } = input;
28
+ const lines: string[] = [];
29
+ lines.push("");
30
+ lines.push("---");
31
+
32
+ if (addMcp && addToken && apiKey) {
33
+ lines.push("");
34
+ lines.push(`Your API token: ${apiKey}`);
35
+ lines.push(` - Baked into ~/.claude.json for Claude Code ✓`);
36
+ lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
37
+ lines.push(` - Won't be shown again — save it now.`);
38
+ } else if (addMcp && !addToken) {
39
+ lines.push("");
40
+ lines.push(
41
+ "Token in ~/.claude.json; run `parachute vault tokens create` later if you need one for other clients.",
42
+ );
43
+ } else if (!addMcp && addToken && apiKey) {
44
+ lines.push("");
45
+ lines.push(`Your API token: ${apiKey}`);
46
+ lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
47
+ lines.push(` - Won't be shown again — save it now.`);
48
+ } else if (!addMcp && !addToken) {
49
+ lines.push("");
50
+ lines.push(
51
+ "You've skipped both MCP install and token generation — your vault isn't reachable by any client.",
52
+ );
53
+ lines.push(
54
+ " Add Claude Code later with `parachute-vault mcp-install`, or mint a token with `parachute vault tokens create`.",
55
+ );
56
+ }
57
+
58
+ lines.push("");
59
+ lines.push(`Config: ${configDir}`);
60
+ lines.push(`Server: http://${bindHost}:${port}`);
61
+
62
+ lines.push("");
63
+ lines.push(`Usage examples:`);
64
+ lines.push(` curl http://localhost:${port}/health`);
65
+ if (addToken && apiKey) {
66
+ lines.push(` curl -H "Authorization: Bearer ${apiKey}" http://localhost:${port}/api/notes`);
67
+ }
68
+
69
+ lines.push("");
70
+ lines.push(`Next steps:`);
71
+ if (addMcp) {
72
+ lines.push(` - Start a new Claude Code session — your Vault is already wired in. Try:`);
73
+ lines.push(` claude "Help me set up my parachute vault"`);
74
+ lines.push(` - Or point any other local MCP client (Codex, Goose, OpenCode, Cursor,`);
75
+ lines.push(` Zed, Cline, your own agent) at:`);
76
+ lines.push(` ${mcpUrl}`);
77
+ } else if (addToken) {
78
+ lines.push(` - Point any local MCP client (Codex, Goose, OpenCode, Cursor, Zed,`);
79
+ lines.push(` Cline, your own agent) at:`);
80
+ lines.push(` ${mcpUrl}`);
81
+ lines.push(` - Or add Claude Code back anytime: parachute-vault mcp-install`);
82
+ } else {
83
+ lines.push(` - Add Claude Code: parachute-vault mcp-install`);
84
+ lines.push(` - Mint a token: parachute vault tokens create`);
85
+ }
86
+ lines.push(` - Check status: parachute-vault status`);
87
+ lines.push(` - Edit config: parachute-vault config`);
88
+
89
+ return lines;
90
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Integration tests for `parachute-vault init` flag plumbing — the cases
3
+ * where init exits early without touching the daemon, ~/.claude.json, or
4
+ * the vault filesystem. The full happy-path of init isn't run here because
5
+ * it would install a launchd agent on macOS and write into the developer's
6
+ * real ~/Library/LaunchAgents — out of scope for unit tests. The vault-name
7
+ * decision logic is fully covered by `vault-name.test.ts`.
8
+ */
9
+
10
+ import { describe, test, expect } from "bun:test";
11
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
12
+ import { tmpdir } from "os";
13
+ import { join, resolve } from "path";
14
+
15
+ const CLI = resolve(import.meta.dir, "cli.ts");
16
+
17
+ function runCli(args: string[], env: Record<string, string> = {}): {
18
+ exitCode: number;
19
+ stdout: string;
20
+ stderr: string;
21
+ } {
22
+ const proc = Bun.spawnSync({
23
+ cmd: ["bun", CLI, ...args],
24
+ stdout: "pipe",
25
+ stderr: "pipe",
26
+ env: { ...process.env, ...env },
27
+ });
28
+ return {
29
+ exitCode: proc.exitCode ?? -1,
30
+ stdout: new TextDecoder().decode(proc.stdout),
31
+ stderr: new TextDecoder().decode(proc.stderr),
32
+ };
33
+ }
34
+
35
+ describe("vault init — --vault-name validation", () => {
36
+ test("rejects --vault-name with uppercase + space and exits non-zero", () => {
37
+ const { exitCode, stderr } = runCli(["init", "--vault-name", "My Vault"]);
38
+ expect(exitCode).not.toBe(0);
39
+ expect(stderr).toContain("--vault-name:");
40
+ expect(stderr).toContain("lowercase alphanumeric");
41
+ });
42
+
43
+ test("rejects --vault-name with a slash and exits non-zero", () => {
44
+ const { exitCode, stderr } = runCli(["init", "--vault-name", "team/work"]);
45
+ expect(exitCode).not.toBe(0);
46
+ expect(stderr).toContain("lowercase alphanumeric");
47
+ });
48
+
49
+ test("rejects --vault-name with no value and exits non-zero", () => {
50
+ // `--vault-name` is the last arg → no value follows.
51
+ const { exitCode, stderr } = runCli(["init", "--vault-name"]);
52
+ expect(exitCode).not.toBe(0);
53
+ expect(stderr).toContain("requires a value");
54
+ });
55
+
56
+ test("rejects reserved name 'list' and exits non-zero", () => {
57
+ const { exitCode, stderr } = runCli(["init", "--vault-name", "list"]);
58
+ expect(exitCode).not.toBe(0);
59
+ expect(stderr).toContain("reserved");
60
+ });
61
+ });
62
+
63
+ describe("vault init — --help mentions --vault-name", () => {
64
+ test("usage text documents the new flag", () => {
65
+ const { exitCode, stdout } = runCli(["--help"]);
66
+ expect(exitCode).toBe(0);
67
+ expect(stdout).toContain("--vault-name");
68
+ });
69
+
70
+ test("usage text documents --no-autostart (#113)", () => {
71
+ const { exitCode, stdout } = runCli(["--help"]);
72
+ expect(exitCode).toBe(0);
73
+ expect(stdout).toContain("--no-autostart");
74
+ });
75
+ });
76
+
77
+ /**
78
+ * End-to-end init under an isolated $HOME / $PARACHUTE_HOME so we never touch
79
+ * the developer's real ~/.parachute or ~/Library/LaunchAgents. With
80
+ * --no-autostart, init must:
81
+ * 1. Persist `autostart: false` in config.yaml.
82
+ * 2. NOT write the daemon wrapper (start.sh / server-path).
83
+ *
84
+ * --no-mcp / --no-token avoid the ~/.claude.json side effect; HOME=tmpdir
85
+ * makes the launchd-uninstall-prior-registration call land inside the
86
+ * sandbox even on macOS (where uninstallAgent operates on
87
+ * `homedir()/Library/LaunchAgents/...`).
88
+ */
89
+ describe("vault init — --no-autostart (#113)", () => {
90
+ test("persists autostart=false and skips the daemon wrapper", () => {
91
+ const sandbox = mkdtempSync(join(tmpdir(), "vault-init-autostart-"));
92
+ try {
93
+ const parachuteHome = join(sandbox, ".parachute");
94
+ const { exitCode, stdout } = runCli(
95
+ [
96
+ "init",
97
+ "--no-autostart",
98
+ "--no-mcp",
99
+ "--no-token",
100
+ "--vault-name",
101
+ "autostarttest",
102
+ ],
103
+ { HOME: sandbox, PARACHUTE_HOME: parachuteHome },
104
+ );
105
+
106
+ expect(exitCode).toBe(0);
107
+ expect(stdout).toContain("Autostart disabled");
108
+
109
+ const configPath = join(parachuteHome, "vault", "config.yaml");
110
+ expect(existsSync(configPath)).toBe(true);
111
+ expect(readFileSync(configPath, "utf-8")).toContain("autostart: false");
112
+
113
+ // Daemon wrapper / pointer are written by installAgent /
114
+ // installSystemdService — neither should run when autostart is off.
115
+ expect(existsSync(join(parachuteHome, "vault", "start.sh"))).toBe(false);
116
+ expect(existsSync(join(parachuteHome, "vault", "server-path"))).toBe(false);
117
+ } finally {
118
+ rmSync(sandbox, { recursive: true, force: true });
119
+ }
120
+ });
121
+
122
+ test("re-running init without a flag preserves persisted autostart=false", () => {
123
+ // We can't drive the --autostart re-run end-to-end here: it calls
124
+ // installAgent() / installSystemdService() which write to launchd /
125
+ // systemd state outside the PARACHUTE_HOME sandbox, breaking test
126
+ // hermeticity. Instead verify the inverse property — that a no-flag
127
+ // re-run honors the persisted opt-out and does NOT fall back to the
128
+ // default-on. This is the actual user-facing risk (forgetting to pass
129
+ // --no-autostart on every re-run shouldn't re-enable the daemon).
130
+ const sandbox = mkdtempSync(join(tmpdir(), "vault-init-autostart-"));
131
+ try {
132
+ const parachuteHome = join(sandbox, ".parachute");
133
+ const env = { HOME: sandbox, PARACHUTE_HOME: parachuteHome };
134
+
135
+ const first = runCli(
136
+ [
137
+ "init",
138
+ "--no-autostart",
139
+ "--no-mcp",
140
+ "--no-token",
141
+ "--vault-name",
142
+ "autostarttest",
143
+ ],
144
+ env,
145
+ );
146
+ expect(first.exitCode).toBe(0);
147
+
148
+ const configPath = join(parachuteHome, "vault", "config.yaml");
149
+ expect(readFileSync(configPath, "utf-8")).toContain("autostart: false");
150
+
151
+ // No --autostart / --no-autostart on this run; init should read the
152
+ // persisted false and skip daemon install again.
153
+ const second = runCli(["init", "--no-mcp", "--no-token"], env);
154
+ expect(second.exitCode).toBe(0);
155
+ expect(second.stdout).toContain("Autostart disabled");
156
+ expect(readFileSync(configPath, "utf-8")).toContain("autostart: false");
157
+ expect(existsSync(join(parachuteHome, "vault", "start.sh"))).toBe(false);
158
+ } finally {
159
+ rmSync(sandbox, { recursive: true, force: true });
160
+ }
161
+ });
162
+ });
163
+
164
+ /**
165
+ * #210: re-running `parachute-vault init` is the documented recovery path
166
+ * for installs whose `services.json` is stale (#208 left some vaults out of
167
+ * the manifest). The recovery is implicit — init re-registers the full
168
+ * vault list every run via `buildVaultServicePaths` — so this test pins it
169
+ * down with an explicit fixture: corrupt the manifest to drop one vault,
170
+ * re-run init, expect the manifest to grow back.
171
+ */
172
+ describe("vault init — repairs stale services.json (#210)", () => {
173
+ test("re-running init rewrites services.json to include every vault on disk", () => {
174
+ const sandbox = mkdtempSync(join(tmpdir(), "vault-init-repair-"));
175
+ try {
176
+ const parachuteHome = join(sandbox, ".parachute");
177
+ const env = { HOME: sandbox, PARACHUTE_HOME: parachuteHome };
178
+
179
+ // Use `create` to bootstrap two vaults into a real, healthy state —
180
+ // this also writes the initial services.json with both vaults so we
181
+ // have a known-good baseline to corrupt.
182
+ expect(runCli(["create", "alpha", "--json"], env).exitCode).toBe(0);
183
+ expect(runCli(["create", "beta", "--json"], env).exitCode).toBe(0);
184
+
185
+ const servicesPath = join(parachuteHome, "services.json");
186
+ const baseline = JSON.parse(readFileSync(servicesPath, "utf-8"));
187
+ const baselineEntry = baseline.services.find(
188
+ (s: { name: string }) => s.name === "parachute-vault",
189
+ );
190
+ expect(baselineEntry.paths).toEqual(["/vault/alpha", "/vault/beta"]);
191
+
192
+ // Corrupt: drop beta from the manifest, mimicking the #208 state where
193
+ // an older `create` ran without the upsert.
194
+ baselineEntry.paths = ["/vault/alpha"];
195
+ writeFileSync(servicesPath, JSON.stringify(baseline, null, 2));
196
+
197
+ // Re-run init with no flags that would change vault topology. The
198
+ // sandbox env keeps launchd / ~/.claude.json side effects out of the
199
+ // dev environment.
200
+ const repair = runCli(
201
+ ["init", "--no-autostart", "--no-mcp", "--no-token"],
202
+ env,
203
+ );
204
+ expect(repair.exitCode).toBe(0);
205
+
206
+ const repaired = JSON.parse(readFileSync(servicesPath, "utf-8"));
207
+ const repairedEntry = repaired.services.find(
208
+ (s: { name: string }) => s.name === "parachute-vault",
209
+ );
210
+ // alpha is still default (created first), so it leads. beta is back.
211
+ expect(repairedEntry.paths).toEqual(["/vault/alpha", "/vault/beta"]);
212
+ } finally {
213
+ rmSync(sandbox, { recursive: true, force: true });
214
+ }
215
+ });
216
+ });
package/src/mcp-http.ts CHANGED
@@ -24,47 +24,49 @@ import {
24
24
  McpError,
25
25
  } from "@modelcontextprotocol/sdk/types.js";
26
26
  import { generateScopedMcpTools, getServerInstruction } from "./mcp-tools.ts";
27
- import { requireScope } from "./auth.ts";
28
27
  import type { AuthResult } from "./auth.ts";
29
28
  import type { McpToolDef } from "../core/src/mcp.ts";
30
- import { SCOPE_READ, SCOPE_WRITE } from "./scopes.ts";
29
+ import { hasScopeForVault } from "./scopes.ts";
30
+ import type { VaultVerb } from "./scopes.ts";
31
31
 
32
32
  /**
33
- * Required scope for each MCP tool. Tools that mutate note/tag state require
34
- * `vault:write`; pure query tools need `vault:read`. `vault-info` is listed as
35
- * read because read-only callers can fetch stats — the description-update
36
- * branch inside vault-info performs its own secondary `vault:write` check
37
- * (see `overrideVaultInfo` in mcp-tools.ts). Do not assume the outer gate
38
- * alone protects the inner branch.
33
+ * Required verb for each MCP tool. Tools that mutate note/tag state require
34
+ * write; pure query tools need read. `vault-info` is listed as read because
35
+ * read-only callers can fetch stats — the description-update branch inside
36
+ * vault-info performs its own secondary write check (see `overrideVaultInfo`
37
+ * in mcp-tools.ts). Do not assume the outer gate alone protects the inner
38
+ * branch.
39
39
  */
40
- const TOOL_REQUIRED_SCOPE: Record<string, string> = {
41
- "query-notes": SCOPE_READ,
42
- "list-tags": SCOPE_READ,
43
- "find-path": SCOPE_READ,
44
- "vault-info": SCOPE_READ,
45
- "create-note": SCOPE_WRITE,
46
- "update-note": SCOPE_WRITE,
47
- "delete-note": SCOPE_WRITE,
48
- "update-tag": SCOPE_WRITE,
49
- "delete-tag": SCOPE_WRITE,
40
+ const TOOL_REQUIRED_VERB: Record<string, VaultVerb> = {
41
+ "query-notes": "read",
42
+ "list-tags": "read",
43
+ "find-path": "read",
44
+ "synthesize-notes": "read",
45
+ "vault-info": "read",
46
+ "create-note": "write",
47
+ "update-note": "write",
48
+ "delete-note": "write",
49
+ "update-tag": "write",
50
+ "delete-tag": "write",
50
51
  };
51
52
 
52
- function requiredScopeForTool(toolName: string): string {
53
+ function requiredVerbForTool(toolName: string): VaultVerb {
53
54
  // Default-deny: unknown tools require write. Keeps accidental reads of
54
55
  // a not-yet-mapped mutation tool from slipping past.
55
- return TOOL_REQUIRED_SCOPE[toolName] ?? SCOPE_WRITE;
56
+ return TOOL_REQUIRED_VERB[toolName] ?? "write";
56
57
  }
57
58
 
58
59
  /** Handle scoped MCP at /vault/{name}/mcp (single vault). */
59
60
  export async function handleScopedMcp(req: Request, vaultName: string, auth: AuthResult): Promise<Response> {
60
61
  const instruction = getServerInstruction(vaultName);
61
- return handleMcp(req, () => generateScopedMcpTools(vaultName, auth), `parachute-vault/${vaultName}`, auth, instruction);
62
+ return handleMcp(req, () => generateScopedMcpTools(vaultName, auth), `parachute-vault/${vaultName}`, vaultName, auth, instruction);
62
63
  }
63
64
 
64
65
  async function handleMcp(
65
66
  req: Request,
66
67
  getTools: () => McpToolDef[],
67
68
  serverName: string,
69
+ vaultName: string,
68
70
  auth: AuthResult,
69
71
  instruction: string,
70
72
  ): Promise<Response> {
@@ -84,11 +86,11 @@ async function handleMcp(
84
86
  const mcpTools = getTools();
85
87
 
86
88
  // Filter the advertised tool list to what the caller's scopes actually
87
- // permit. Callers without `vault:write` don't see mutation tools at all —
88
- // matches the prior behavior of the read/full permission model but is now
89
- // driven by scope inheritance.
89
+ // permit for THIS vault. Callers without write don't see mutation tools at
90
+ // all — matches the prior behavior of the read/full permission model but
91
+ // now driven by per-vault scope inheritance.
90
92
  const visibleTools = mcpTools.filter((t) =>
91
- requireScope(auth, requiredScopeForTool(t.name)),
93
+ hasScopeForVault(auth.scopes, vaultName, requiredVerbForTool(t.name)),
92
94
  );
93
95
 
94
96
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -102,12 +104,12 @@ async function handleMcp(
102
104
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
103
105
  const { name, arguments: args } = request.params;
104
106
 
105
- const neededScope = requiredScopeForTool(name);
106
- if (!requireScope(auth, neededScope)) {
107
+ const neededVerb = requiredVerbForTool(name);
108
+ if (!hasScopeForVault(auth.scopes, vaultName, neededVerb)) {
107
109
  return {
108
110
  content: [{
109
111
  type: "text" as const,
110
- text: `Forbidden: tool '${name}' requires the '${neededScope}' scope. Granted scopes: ${auth.scopes.join(" ") || "(none)"}.`,
112
+ text: `Forbidden: tool '${name}' requires the 'vault:${neededVerb}' scope (or 'vault:${vaultName}:${neededVerb}'). Granted scopes: ${auth.scopes.join(" ") || "(none)"}.`,
111
113
  }],
112
114
  isError: true,
113
115
  };
@@ -20,7 +20,7 @@ export type McpUrlSource = "hub-origin" | "expose-state" | "loopback";
20
20
  export function chooseMcpUrl(
21
21
  vaultName: string,
22
22
  port: number,
23
- env: { PARACHUTE_HUB_ORIGIN?: string } = process.env,
23
+ env: { PARACHUTE_HUB_ORIGIN?: string | undefined } = process.env as { PARACHUTE_HUB_ORIGIN?: string },
24
24
  ): { url: string; source: McpUrlSource } {
25
25
  const hub = env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
26
26
  if (hub) {