@openparachute/vault 0.5.1 → 0.5.2-rc.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/core/src/core.test.ts +183 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +77 -0
- package/core/src/mcp.ts +130 -22
- package/core/src/notes.ts +36 -0
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/schema.ts +7 -4
- package/core/src/store.ts +1 -1
- package/core/src/tag-schemas.ts +59 -44
- package/core/src/types.ts +31 -3
- package/package.json +1 -1
- package/src/auth.test.ts +37 -1
- package/src/auth.ts +29 -0
- package/src/cli.ts +286 -68
- package/src/config.test.ts +16 -0
- package/src/config.ts +39 -0
- package/src/init-summary.test.ts +77 -5
- package/src/init-summary.ts +37 -19
- package/src/mcp-tools.ts +60 -6
- package/src/routes.ts +486 -53
- package/src/routing.test.ts +185 -0
- package/src/routing.ts +32 -2
- package/src/server.ts +7 -0
- package/src/storage.test.ts +162 -0
- package/src/tag-scope.ts +68 -1
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +298 -11
- package/src/vault.test.ts +1064 -7
package/src/init-summary.test.ts
CHANGED
|
@@ -13,6 +13,7 @@ const baseInput = {
|
|
|
13
13
|
bindHost: "127.0.0.1",
|
|
14
14
|
port: 1940,
|
|
15
15
|
mcpUrl: "http://127.0.0.1:1940/vault/default/mcp",
|
|
16
|
+
vaultName: "default",
|
|
16
17
|
};
|
|
17
18
|
|
|
18
19
|
function lines(addMcp: boolean, addToken: boolean, apiKey: string | undefined) {
|
|
@@ -96,16 +97,70 @@ describe("buildInitSummaryLines", () => {
|
|
|
96
97
|
});
|
|
97
98
|
});
|
|
98
99
|
|
|
99
|
-
|
|
100
|
+
// vault#442: the DEFAULT init path — MCP wired, NO token minted (per-user
|
|
101
|
+
// OAuth). The summary must LEAD with the OAuth connect path, never mint, and
|
|
102
|
+
// never surface the old "no token issued" failure copy.
|
|
103
|
+
describe("MCP=Y + no token (vault#442 OAuth default)", () => {
|
|
104
|
+
const out = lines(true, false, undefined).join("\n");
|
|
105
|
+
|
|
106
|
+
test("leads with the OAuth connect message — no token needed", () => {
|
|
107
|
+
expect(out).toContain("no token needed, you'll sign in on first use");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("tells the user Claude Code is already wired in", () => {
|
|
111
|
+
expect(out).toContain("Claude Code is already wired in");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("shows the OAuth `claude mcp add` command for other clients", () => {
|
|
115
|
+
expect(out).toContain(
|
|
116
|
+
"claude mcp add --transport http parachute-vault http://127.0.0.1:1940/vault/default/mcp",
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("offers the scope-narrow opt-in mint for scripts (full vault:<name>:read, never admin)", () => {
|
|
121
|
+
// Must be the three-segment named-resource form the hub mint-token model
|
|
122
|
+
// requires — a bare `vault:read` would mint a malformed scope (vault#443).
|
|
123
|
+
expect(out).toContain("parachute auth mint-token --scope vault:default:read");
|
|
124
|
+
expect(out).not.toContain("--scope vault:read ");
|
|
125
|
+
expect(out).not.toMatch(/--scope vault:read$/m);
|
|
126
|
+
expect(out).not.toContain("vault:admin");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("does NOT print or imply any minted token", () => {
|
|
130
|
+
expect(out).not.toContain("Your API token:");
|
|
131
|
+
expect(out).not.toContain("Baked into ~/.claude.json");
|
|
132
|
+
expect(out).not.toContain("Authorization: Bearer");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("does NOT surface the old no-token-issued failure copy", () => {
|
|
136
|
+
expect(out).not.toContain("No token issued");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("threads a non-default vault name into the mint-token scope", () => {
|
|
140
|
+
const out2 = buildInitSummaryLines({
|
|
141
|
+
...baseInput,
|
|
142
|
+
vaultName: "journal",
|
|
143
|
+
mcpUrl: "http://127.0.0.1:1940/vault/journal/mcp",
|
|
144
|
+
addMcp: true,
|
|
145
|
+
addToken: false,
|
|
146
|
+
apiKey: undefined,
|
|
147
|
+
}).join("\n");
|
|
148
|
+
expect(out2).toContain("parachute auth mint-token --scope vault:journal:read");
|
|
149
|
+
expect(out2).not.toContain("vault:default:read");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe("MCP=N + token=N (OAuth default, Claude Code not wired)", () => {
|
|
100
154
|
const out = lines(false, false, undefined).join("\n");
|
|
101
155
|
|
|
102
|
-
test("
|
|
103
|
-
expect(out).toContain("
|
|
156
|
+
test("frames skipping the MCP entry as OAuth-first, not 'unreachable'", () => {
|
|
157
|
+
expect(out).toContain("uses per-user OAuth, no token needed");
|
|
158
|
+
expect(out).not.toContain("your vault isn't reachable by any client");
|
|
104
159
|
});
|
|
105
160
|
|
|
106
|
-
test("points to
|
|
161
|
+
test("points to mcp-install (no token-minting framing)", () => {
|
|
107
162
|
expect(out).toContain("parachute-vault mcp-install");
|
|
108
|
-
expect(out).toContain("mints a hub JWT");
|
|
163
|
+
expect(out).not.toContain("mints a hub JWT");
|
|
109
164
|
});
|
|
110
165
|
|
|
111
166
|
test("does not print any token", () => {
|
|
@@ -118,6 +173,23 @@ describe("buildInitSummaryLines", () => {
|
|
|
118
173
|
});
|
|
119
174
|
});
|
|
120
175
|
|
|
176
|
+
// Explicit opt-in but no hub reachable to mint (vault#282 Stage 2 path,
|
|
177
|
+
// reached only when the operator passes --token without a hub).
|
|
178
|
+
describe("MCP=N + token=Y but no hub (opt-in mint failed)", () => {
|
|
179
|
+
const out = buildInitSummaryLines({
|
|
180
|
+
...baseInput,
|
|
181
|
+
addMcp: false,
|
|
182
|
+
addToken: true,
|
|
183
|
+
apiKey: undefined,
|
|
184
|
+
noTokenGuidance: "No token issued — hub unreachable.",
|
|
185
|
+
}).join("\n");
|
|
186
|
+
|
|
187
|
+
test("surfaces the no-token-issued guidance + recovery", () => {
|
|
188
|
+
expect(out).toContain("No token issued");
|
|
189
|
+
expect(out).toContain("parachute-vault mcp-install");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
121
193
|
test("always prints Config: and Server: lines", () => {
|
|
122
194
|
for (const [addMcp, addToken] of [
|
|
123
195
|
[true, true],
|
package/src/init-summary.ts
CHANGED
|
@@ -12,6 +12,13 @@ export type InitSummaryInput = {
|
|
|
12
12
|
bindHost: string;
|
|
13
13
|
port: number;
|
|
14
14
|
mcpUrl: string;
|
|
15
|
+
/**
|
|
16
|
+
* The default vault's name — used to emit the three-segment
|
|
17
|
+
* `vault:<vaultName>:read` scope in the OAuth-first mint-token suggestion
|
|
18
|
+
* (the hub mint-token model requires the named-resource form;
|
|
19
|
+
* a bare `vault:read` would mint a malformed scope). vault#442/#443.
|
|
20
|
+
*/
|
|
21
|
+
vaultName: string;
|
|
15
22
|
/**
|
|
16
23
|
* Guidance from the bootstrap-credential step when no token could be issued
|
|
17
24
|
* (standalone install, no hub reachable — vault#282 Stage 2). Surfaced when
|
|
@@ -23,44 +30,54 @@ export type InitSummaryInput = {
|
|
|
23
30
|
|
|
24
31
|
/**
|
|
25
32
|
* Build the post-install summary lines for `vault init`, branched on the
|
|
26
|
-
* (addMcp, addToken, apiKey) decision matrix.
|
|
27
|
-
*
|
|
28
|
-
* is
|
|
33
|
+
* (addMcp, addToken, apiKey) decision matrix.
|
|
34
|
+
*
|
|
35
|
+
* vault#442: the DEFAULT is per-user OAuth — no token is minted, and the
|
|
36
|
+
* Claude Code MCP entry is written without a baked bearer (browser sign-in on
|
|
37
|
+
* first connect). A token is minted only on explicit opt-in (`addToken`), and
|
|
38
|
+
* then scope-narrow. Branches:
|
|
29
39
|
*
|
|
30
|
-
* addMcp,
|
|
31
|
-
* addMcp,
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* !addMcp, !
|
|
40
|
+
* addMcp, !apiKey → OAuth-first: connect, sign in on first use
|
|
41
|
+
* addMcp, addToken, apiKey → token baked into claude.json + printed
|
|
42
|
+
* addMcp, !addToken, apiKey → token baked into claude.json, hint
|
|
43
|
+
* !addMcp, addToken, apiKey → token printed prominently
|
|
44
|
+
* !addMcp, addToken, !apiKey → opted into a token but no hub reachable
|
|
45
|
+
* !addMcp, !addToken → OAuth-first: add Claude Code later
|
|
35
46
|
*/
|
|
36
47
|
export function buildInitSummaryLines(input: InitSummaryInput): string[] {
|
|
37
|
-
const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl, noTokenGuidance } = input;
|
|
48
|
+
const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl, vaultName, noTokenGuidance } = input;
|
|
38
49
|
const lines: string[] = [];
|
|
39
50
|
lines.push("");
|
|
40
51
|
lines.push("---");
|
|
41
52
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (addMcp && addToken && apiKey) {
|
|
53
|
+
if (addMcp && apiKey && addToken) {
|
|
45
54
|
lines.push("");
|
|
46
55
|
lines.push(`Your API token: ${apiKey}`);
|
|
47
56
|
lines.push(` - Baked into ~/.claude.json for Claude Code ✓`);
|
|
48
57
|
lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
|
|
49
58
|
lines.push(` - Won't be shown again — save it now.`);
|
|
50
|
-
} else if (addMcp &&
|
|
59
|
+
} else if (addMcp && apiKey && !addToken) {
|
|
51
60
|
lines.push("");
|
|
52
61
|
lines.push(
|
|
53
62
|
"Token in ~/.claude.json; run `parachute-vault mcp-install` later if you need one for other clients.",
|
|
54
63
|
);
|
|
64
|
+
} else if (addMcp && !apiKey) {
|
|
65
|
+
// vault#442 default: OAuth-first. The MCP entry is wired without a bearer —
|
|
66
|
+
// Claude Code signs in via browser OAuth on first connect. No token needed.
|
|
67
|
+
lines.push("");
|
|
68
|
+
lines.push("Connect your AI — no token needed, you'll sign in on first use:");
|
|
69
|
+
lines.push(` Claude Code is already wired in (~/.claude.json) — just start a session.`);
|
|
70
|
+
lines.push(` Other clients: claude mcp add --transport http parachute-vault ${mcpUrl}`);
|
|
71
|
+
lines.push(` Need a header-auth token for a script? parachute auth mint-token --scope vault:${vaultName}:read`);
|
|
55
72
|
} else if (!addMcp && addToken && apiKey) {
|
|
56
73
|
lines.push("");
|
|
57
74
|
lines.push(`Your API token: ${apiKey}`);
|
|
58
75
|
lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
|
|
59
76
|
lines.push(` - Won't be shown again — save it now.`);
|
|
60
|
-
} else if (
|
|
61
|
-
//
|
|
62
|
-
// Stage 2 — vault no longer mints local pvt_* tokens). Surface
|
|
63
|
-
// the recovery paths.
|
|
77
|
+
} else if (!addMcp && addToken && !apiKey) {
|
|
78
|
+
// Explicitly opted into a token but no hub was reachable to mint one
|
|
79
|
+
// (vault#282 Stage 2 — vault no longer mints local pvt_* tokens). Surface
|
|
80
|
+
// why and the recovery paths.
|
|
64
81
|
lines.push("");
|
|
65
82
|
lines.push(
|
|
66
83
|
noTokenGuidance ??
|
|
@@ -73,12 +90,13 @@ export function buildInitSummaryLines(input: InitSummaryInput): string[] {
|
|
|
73
90
|
" or set VAULT_AUTH_TOKEN for an operator-channel bearer.",
|
|
74
91
|
);
|
|
75
92
|
} else if (!addMcp && !addToken) {
|
|
93
|
+
// OAuth-first, but the operator skipped wiring Claude Code too.
|
|
76
94
|
lines.push("");
|
|
77
95
|
lines.push(
|
|
78
|
-
"
|
|
96
|
+
"Skipped the Claude Code MCP entry. Add it anytime — it uses per-user OAuth, no token needed:",
|
|
79
97
|
);
|
|
80
98
|
lines.push(
|
|
81
|
-
"
|
|
99
|
+
" parachute-vault mcp-install",
|
|
82
100
|
);
|
|
83
101
|
}
|
|
84
102
|
|
package/src/mcp-tools.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
9
9
|
import type { McpToolDef } from "../core/src/mcp.ts";
|
|
10
|
+
import type { Note } from "../core/src/types.ts";
|
|
10
11
|
import {
|
|
11
12
|
buildVaultProjection,
|
|
12
13
|
projectionToMarkdown,
|
|
@@ -18,6 +19,7 @@ import { hasScopeForVault, parseScopes, validateMintedScopes } from "./scopes.ts
|
|
|
18
19
|
import type { AuthResult } from "./auth.ts";
|
|
19
20
|
import {
|
|
20
21
|
expandTokenTagScope,
|
|
22
|
+
filterHydratedLinksByTagScope,
|
|
21
23
|
noteWithinTagScope,
|
|
22
24
|
tagsWithinScope,
|
|
23
25
|
} from "./tag-scope.ts";
|
|
@@ -117,11 +119,30 @@ export function generateScopedMcpTools(
|
|
|
117
119
|
callerBearer?: string | null,
|
|
118
120
|
): McpToolDef[] {
|
|
119
121
|
const store = getVaultStore(vaultName);
|
|
120
|
-
|
|
122
|
+
|
|
123
|
+
// Tag-scope confidentiality (security review): when the session is
|
|
124
|
+
// tag-scoped, build an expand-visibility predicate so `query-notes`'s
|
|
125
|
+
// `expand_links` inlining can't embed out-of-scope note content. The
|
|
126
|
+
// predicate reads from a SHARED holder that `applyTagScopeWrappers`
|
|
127
|
+
// populates with the resolved allowlist before core's execute runs the
|
|
128
|
+
// (synchronous) expansion — so by the time core calls `isVisible(note)`
|
|
129
|
+
// the allowlist is ready. Core stays scope-unaware: it only receives the
|
|
130
|
+
// plain closure. Unscoped sessions pass no predicate (unchanged path).
|
|
131
|
+
const scoped = Boolean(auth?.scoped_tags && auth.scoped_tags.length > 0);
|
|
132
|
+
const allowedHolder: { value: Set<string> | null } = { value: null };
|
|
133
|
+
const rawTags = scoped ? auth!.scoped_tags : null;
|
|
134
|
+
const expandVisibility = scoped
|
|
135
|
+
? (note: Note) => noteWithinTagScope(note, allowedHolder.value, rawTags)
|
|
136
|
+
: undefined;
|
|
137
|
+
|
|
138
|
+
const tools = generateMcpTools(
|
|
139
|
+
store,
|
|
140
|
+
expandVisibility ? { expandVisibility } : undefined,
|
|
141
|
+
);
|
|
121
142
|
|
|
122
143
|
overrideVaultInfo(tools, vaultName, auth);
|
|
123
144
|
applyTagDependencyGuards(tools, vaultName);
|
|
124
|
-
applyTagScopeWrappers(tools, vaultName, auth);
|
|
145
|
+
applyTagScopeWrappers(tools, vaultName, auth, allowedHolder);
|
|
125
146
|
|
|
126
147
|
// manage-token is server-only (needs token-store + auth context), so it
|
|
127
148
|
// lives here rather than in core. Always appended to the surface; the
|
|
@@ -181,6 +202,7 @@ function applyTagScopeWrappers(
|
|
|
181
202
|
tools: McpToolDef[],
|
|
182
203
|
vaultName: string,
|
|
183
204
|
auth: AuthResult | undefined,
|
|
205
|
+
allowedHolder?: { value: Set<string> | null },
|
|
184
206
|
): void {
|
|
185
207
|
if (!auth || !auth.scoped_tags || auth.scoped_tags.length === 0) return;
|
|
186
208
|
const store = getVaultStore(vaultName);
|
|
@@ -188,12 +210,40 @@ function applyTagScopeWrappers(
|
|
|
188
210
|
let allowedPromise: Promise<Set<string> | null> | null = null;
|
|
189
211
|
const getAllowed = (): Promise<Set<string> | null> => {
|
|
190
212
|
if (!allowedPromise) {
|
|
191
|
-
allowedPromise = expandTokenTagScope(store, auth.scoped_tags)
|
|
213
|
+
allowedPromise = expandTokenTagScope(store, auth.scoped_tags).then((a) => {
|
|
214
|
+
// Publish the resolved allowlist into the shared holder so the
|
|
215
|
+
// expand-visibility predicate (wired in generateScopedMcpTools and
|
|
216
|
+
// baked into the query-notes expand context) sees the same set.
|
|
217
|
+
// The query-notes wrapper awaits getAllowed() before calling the
|
|
218
|
+
// core execute that runs expansion, so the holder is populated in
|
|
219
|
+
// time. Security review: closes the expand_links content leak.
|
|
220
|
+
if (allowedHolder) allowedHolder.value = a;
|
|
221
|
+
return a;
|
|
222
|
+
});
|
|
192
223
|
}
|
|
193
224
|
return allowedPromise;
|
|
194
225
|
};
|
|
195
226
|
const rawTags = auth.scoped_tags;
|
|
196
227
|
|
|
228
|
+
// Scrub a returned note's hydrated `links` array (present when the caller
|
|
229
|
+
// set `include_links`) so out-of-scope NEIGHBOR summaries (id/path/tags)
|
|
230
|
+
// don't leak — symmetric with the REST `include_links` fix. Mutates in
|
|
231
|
+
// place and returns the note for chaining. No-op when `links` is absent.
|
|
232
|
+
//
|
|
233
|
+
// Ordering invariant: reading `allowedHolder.value` here is safe ONLY
|
|
234
|
+
// because every wrapper that calls scrubNoteLinks first does
|
|
235
|
+
// `await getAllowed()` (which populates the holder) before `orig(params)`
|
|
236
|
+
// and before this scrub runs. So by the time we read `holder.value` it is
|
|
237
|
+
// the resolved allowlist, never the initial `null`. The `?? null` fallback
|
|
238
|
+
// is the unscoped/holder-absent path; `filterHydratedLinksByTagScope` then
|
|
239
|
+
// keys off `rawTags` (non-null here) for the actual scope check.
|
|
240
|
+
const scrubNoteLinks = (n: any): any => {
|
|
241
|
+
if (n && Array.isArray(n.links)) {
|
|
242
|
+
n.links = filterHydratedLinksByTagScope(n.links, allowedHolder?.value ?? null, rawTags);
|
|
243
|
+
}
|
|
244
|
+
return n;
|
|
245
|
+
};
|
|
246
|
+
|
|
197
247
|
wrapReadTool(tools, "query-notes", async (orig, params) => {
|
|
198
248
|
const allowed = await getAllowed();
|
|
199
249
|
const result = await orig(params);
|
|
@@ -203,7 +253,9 @@ function applyTagScopeWrappers(
|
|
|
203
253
|
// - `{notes, next_cursor}` (cursor mode, vault#313)
|
|
204
254
|
// - `{...note}` with `id`+`tags` (single-note by id)
|
|
205
255
|
if (Array.isArray(result)) {
|
|
206
|
-
return result
|
|
256
|
+
return result
|
|
257
|
+
.filter((n: any) => noteWithinTagScope(n, allowed, rawTags))
|
|
258
|
+
.map(scrubNoteLinks);
|
|
207
259
|
}
|
|
208
260
|
if (
|
|
209
261
|
result &&
|
|
@@ -214,13 +266,15 @@ function applyTagScopeWrappers(
|
|
|
214
266
|
) {
|
|
215
267
|
const r = result as { notes: any[]; next_cursor: string | null };
|
|
216
268
|
return {
|
|
217
|
-
notes: r.notes
|
|
269
|
+
notes: r.notes
|
|
270
|
+
.filter((n: any) => noteWithinTagScope(n, allowed, rawTags))
|
|
271
|
+
.map(scrubNoteLinks),
|
|
218
272
|
next_cursor: r.next_cursor,
|
|
219
273
|
};
|
|
220
274
|
}
|
|
221
275
|
if (result && typeof result === "object" && "id" in result && "tags" in result) {
|
|
222
276
|
return noteWithinTagScope(result as any, allowed, rawTags)
|
|
223
|
-
? result
|
|
277
|
+
? scrubNoteLinks(result)
|
|
224
278
|
: { error: "Note not found", id: (result as any).id };
|
|
225
279
|
}
|
|
226
280
|
return result;
|