@openparachute/vault 0.6.0-rc.1 → 0.6.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.
- package/.parachute/module.json +14 -3
- package/README.md +7 -7
- package/core/src/core.test.ts +279 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +97 -2
- package/core/src/mcp.ts +201 -33
- package/core/src/notes.ts +44 -8
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/schema.ts +58 -11
- package/core/src/store.ts +69 -22
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/tag-schemas.ts +61 -46
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +68 -4
- package/core/src/vault-projection.ts +20 -0
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -3
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auth-hub-jwt.test.ts +8 -1
- package/src/auth-status.ts +2 -2
- package/src/auth.test.ts +39 -3
- package/src/auth.ts +31 -2
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +434 -140
- package/src/config.test.ts +109 -0
- package/src/config.ts +157 -10
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/init-summary.test.ts +120 -5
- package/src/init-summary.ts +67 -25
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/mcp-tools.ts +80 -7
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +463 -1
- package/src/mirror-routes.ts +474 -4
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +696 -121
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +113 -5
- package/src/scopes.ts +1 -1
- package/src/server.ts +66 -4
- package/src/storage.test.ts +162 -0
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/tag-scope.ts +68 -1
- package/src/token-store.ts +7 -7
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +340 -12
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +1353 -62
- package/web/ui/dist/assets/index-CGL256oe.js +60 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
package/src/hub-jwt.test.ts
CHANGED
|
@@ -14,7 +14,14 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test";
|
|
16
16
|
import { generateKeyPair, exportJWK, SignJWT } from "jose";
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
resetJwksCache,
|
|
19
|
+
resetRevocationCache,
|
|
20
|
+
validateHubJwt,
|
|
21
|
+
looksLikeJwt,
|
|
22
|
+
getHubOrigin,
|
|
23
|
+
getJwksOrigin,
|
|
24
|
+
} from "./hub-jwt.ts";
|
|
18
25
|
|
|
19
26
|
interface Keypair {
|
|
20
27
|
privateKey: CryptoKey;
|
|
@@ -113,6 +120,7 @@ async function signJwt(kp: Keypair, opts: SignOpts): Promise<string> {
|
|
|
113
120
|
let fixture: JwksFixture;
|
|
114
121
|
let kp: Keypair;
|
|
115
122
|
let prevHubOrigin: string | undefined;
|
|
123
|
+
let prevJwksOrigin: string | undefined;
|
|
116
124
|
|
|
117
125
|
beforeAll(async () => {
|
|
118
126
|
fixture = startJwksFixture();
|
|
@@ -124,12 +132,19 @@ afterAll(() => {
|
|
|
124
132
|
fixture.stop();
|
|
125
133
|
if (prevHubOrigin === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
126
134
|
else process.env.PARACHUTE_HUB_ORIGIN = prevHubOrigin;
|
|
135
|
+
if (prevJwksOrigin === undefined) delete process.env.PARACHUTE_HUB_JWKS_ORIGIN;
|
|
136
|
+
else process.env.PARACHUTE_HUB_JWKS_ORIGIN = prevJwksOrigin;
|
|
127
137
|
});
|
|
128
138
|
|
|
129
139
|
beforeEach(() => {
|
|
130
|
-
// Each test sets its own origin for clarity.
|
|
140
|
+
// Each test sets its own origin for clarity. Post-vault#464 the JWKS *fetch*
|
|
141
|
+
// origin is resolved separately from the iss-validation origin, so point the
|
|
142
|
+
// JWKS fetch at the fixture too — otherwise the guard would read keys from
|
|
143
|
+
// the loopback default (no JWKS server there) and every case would fail.
|
|
131
144
|
prevHubOrigin = process.env.PARACHUTE_HUB_ORIGIN;
|
|
145
|
+
prevJwksOrigin = process.env.PARACHUTE_HUB_JWKS_ORIGIN;
|
|
132
146
|
process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
|
|
147
|
+
process.env.PARACHUTE_HUB_JWKS_ORIGIN = fixture.origin;
|
|
133
148
|
fixture.setUnreachable(false);
|
|
134
149
|
fixture.setKeys([kp]);
|
|
135
150
|
resetJwksCache();
|
|
@@ -153,6 +168,64 @@ describe("looksLikeJwt", () => {
|
|
|
153
168
|
});
|
|
154
169
|
});
|
|
155
170
|
|
|
171
|
+
describe("origin resolvers — iss/jwks split (vault#464)", () => {
|
|
172
|
+
test("getHubOrigin honors PARACHUTE_HUB_ORIGIN (iss-validation origin)", () => {
|
|
173
|
+
process.env.PARACHUTE_HUB_ORIGIN = "https://vault.example.com/";
|
|
174
|
+
expect(getHubOrigin()).toBe("https://vault.example.com");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("getHubOrigin falls back to loopback when unset", () => {
|
|
178
|
+
delete process.env.PARACHUTE_HUB_ORIGIN;
|
|
179
|
+
expect(getHubOrigin()).toBe("http://127.0.0.1:1939");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("getJwksOrigin defaults to loopback (no env override)", () => {
|
|
183
|
+
delete process.env.PARACHUTE_HUB_JWKS_ORIGIN;
|
|
184
|
+
expect(getJwksOrigin()).toBe("http://127.0.0.1:1939");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test("getJwksOrigin honors PARACHUTE_HUB_JWKS_ORIGIN and strips trailing slash", () => {
|
|
188
|
+
process.env.PARACHUTE_HUB_JWKS_ORIGIN = "http://10.0.0.5:1939/";
|
|
189
|
+
expect(getJwksOrigin()).toBe("http://10.0.0.5:1939");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("jwks fetch is decoupled from the iss origin: keys served ONLY at the jwks origin still validate a token whose iss is the (separate) public origin", async () => {
|
|
193
|
+
// Mirrors the vault#464 deploy shape: iss + revocation live at the public
|
|
194
|
+
// origin (the default `fixture` here), while the JWKS is reachable only at
|
|
195
|
+
// a SEPARATE jwks origin (a second fixture standing in for loopback). The
|
|
196
|
+
// guard must fetch keys from the jwks origin, not the iss origin.
|
|
197
|
+
const jwksOnly = startJwksFixture();
|
|
198
|
+
jwksOnly.setKeys([kp]);
|
|
199
|
+
// The public iss origin (default fixture) serves revocation but NOT keys —
|
|
200
|
+
// if the guard fetched JWKS from here, verification would fail "no key".
|
|
201
|
+
fixture.setKeys([]);
|
|
202
|
+
try {
|
|
203
|
+
process.env.PARACHUTE_HUB_ORIGIN = fixture.origin; // iss + revocation
|
|
204
|
+
process.env.PARACHUTE_HUB_JWKS_ORIGIN = jwksOnly.origin; // keys only
|
|
205
|
+
resetJwksCache();
|
|
206
|
+
const token = await signJwt(kp, { iss: fixture.origin });
|
|
207
|
+
const claims = await validateHubJwt(token);
|
|
208
|
+
expect(claims.sub).toBe("user-1");
|
|
209
|
+
} finally {
|
|
210
|
+
jwksOnly.stop();
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("token whose iss does NOT match the iss origin is rejected even when keys resolve at the jwks origin", async () => {
|
|
215
|
+
const jwksOnly = startJwksFixture();
|
|
216
|
+
jwksOnly.setKeys([kp]);
|
|
217
|
+
try {
|
|
218
|
+
process.env.PARACHUTE_HUB_ORIGIN = fixture.origin;
|
|
219
|
+
process.env.PARACHUTE_HUB_JWKS_ORIGIN = jwksOnly.origin;
|
|
220
|
+
resetJwksCache();
|
|
221
|
+
const token = await signJwt(kp, { iss: "https://attacker.example" });
|
|
222
|
+
await expect(validateHubJwt(token)).rejects.toThrow(/verification failed/);
|
|
223
|
+
} finally {
|
|
224
|
+
jwksOnly.stop();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
156
229
|
describe("validateHubJwt — happy path", () => {
|
|
157
230
|
test("valid JWT with correct iss → claims surface", async () => {
|
|
158
231
|
const token = await signJwt(kp, { iss: fixture.origin, scope: "vault:work:read vault:work:write" });
|
package/src/hub-jwt.ts
CHANGED
|
@@ -23,13 +23,18 @@ import {
|
|
|
23
23
|
const DEFAULT_HUB_LOOPBACK = "http://127.0.0.1:1939";
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* Resolve the hub origin used to
|
|
26
|
+
* Resolve the hub origin used to validate the token's `iss` claim. Strips a
|
|
27
27
|
* trailing slash so we get a single canonical form.
|
|
28
28
|
*
|
|
29
29
|
* Order: env var → loopback fallback. We deliberately don't read
|
|
30
30
|
* `~/.parachute/services.json` — the hub is the dispatcher, not a registered
|
|
31
31
|
* service in that file. If a deployment exposes the hub on a non-default
|
|
32
32
|
* origin, the env var is the contract.
|
|
33
|
+
*
|
|
34
|
+
* `parachute expose` pins `PARACHUTE_HUB_ORIGIN` to the PUBLIC FQDN so the
|
|
35
|
+
* `iss` we validate against matches what the hub stamps on the tokens it
|
|
36
|
+
* mints — keep using this origin for iss-validation. The JWKS *fetch* origin
|
|
37
|
+
* is resolved separately by `getJwksOrigin()`; see vault#464.
|
|
33
38
|
*/
|
|
34
39
|
export function getHubOrigin(): string {
|
|
35
40
|
const env = process.env.PARACHUTE_HUB_ORIGIN?.replace(/\/$/, "");
|
|
@@ -37,12 +42,44 @@ export function getHubOrigin(): string {
|
|
|
37
42
|
return DEFAULT_HUB_LOOPBACK;
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the origin used to FETCH the hub's JWKS — kept distinct from
|
|
47
|
+
* `getHubOrigin()` (the iss-validation origin) per vault#464.
|
|
48
|
+
*
|
|
49
|
+
* Vault is co-located with its hub (the hub supervises vault on the same box,
|
|
50
|
+
* the common deploy). After `parachute expose --cloudflare`, `getHubOrigin()`
|
|
51
|
+
* is the public Cloudflare FQDN. If we fetched JWKS from that public origin we
|
|
52
|
+
* would hairpin out through the tunnel and back to the same box — a round-trip
|
|
53
|
+
* that times out (hard fail under Docker NAT-loopback, slow/flaky on a real
|
|
54
|
+
* VPS) and 401s the first MCP connect after expose. So we always read keys
|
|
55
|
+
* from the LOCAL hub on loopback instead.
|
|
56
|
+
*
|
|
57
|
+
* Order: `PARACHUTE_HUB_JWKS_ORIGIN` override → loopback default. The override
|
|
58
|
+
* exists for the rare non-co-located case — a vault running on a DIFFERENT box
|
|
59
|
+
* than its hub sets `PARACHUTE_HUB_JWKS_ORIGIN` to the hub's reachable
|
|
60
|
+
* internal address. Trailing-slash-stripped, matching `getHubOrigin()`.
|
|
61
|
+
*/
|
|
62
|
+
export function getJwksOrigin(): string {
|
|
63
|
+
const env = process.env.PARACHUTE_HUB_JWKS_ORIGIN?.replace(/\/$/, "");
|
|
64
|
+
if (env && env.length > 0) return env;
|
|
65
|
+
return DEFAULT_HUB_LOOPBACK;
|
|
66
|
+
}
|
|
67
|
+
|
|
40
68
|
// Process-wide guard. The resolver form lets tests flip
|
|
41
|
-
// `PARACHUTE_HUB_ORIGIN` between cases — the lib
|
|
42
|
-
// `validateHubJwt` and `resetJwksCache` call so the
|
|
43
|
-
// without a server restart. JWKS cache (5min/30s
|
|
44
|
-
// guard, shared across requests.
|
|
45
|
-
|
|
69
|
+
// `PARACHUTE_HUB_ORIGIN` / `PARACHUTE_HUB_JWKS_ORIGIN` between cases — the lib
|
|
70
|
+
// re-resolves on every `validateHubJwt` and `resetJwksCache` call so the
|
|
71
|
+
// env-var change picks up without a server restart. JWKS cache (5min/30s
|
|
72
|
+
// defaults) lives inside the guard, shared across requests.
|
|
73
|
+
//
|
|
74
|
+
// The iss/jwks split (vault#464): `hubOrigin` validates the token's `iss`
|
|
75
|
+
// (public FQDN post-expose, via PARACHUTE_HUB_ORIGIN); `jwksOrigin` fetches
|
|
76
|
+
// the keys from the local hub (loopback by default, via
|
|
77
|
+
// PARACHUTE_HUB_JWKS_ORIGIN). Co-located vault never egresses to read its own
|
|
78
|
+
// hub's keys, so no tunnel hairpin.
|
|
79
|
+
const guard = createScopeGuard({
|
|
80
|
+
hubOrigin: () => getHubOrigin(),
|
|
81
|
+
jwksOrigin: () => getJwksOrigin(),
|
|
82
|
+
});
|
|
46
83
|
|
|
47
84
|
/**
|
|
48
85
|
* Verify a presented JWT against the hub's JWKS. Throws `HubJwtError` on any
|
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,66 @@ 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, standalone)", () => {
|
|
179
|
+
const out = buildInitSummaryLines({
|
|
180
|
+
...baseInput,
|
|
181
|
+
addMcp: false,
|
|
182
|
+
addToken: true,
|
|
183
|
+
apiKey: undefined,
|
|
184
|
+
noTokenGuidance: "No token issued — hub unreachable.",
|
|
185
|
+
hubPresent: false,
|
|
186
|
+
}).join("\n");
|
|
187
|
+
|
|
188
|
+
test("surfaces the no-token-issued guidance + recovery", () => {
|
|
189
|
+
expect(out).toContain("No token issued");
|
|
190
|
+
expect(out).toContain("parachute-vault mcp-install");
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("standalone framing — points at bringing a hub up / VAULT_AUTH_TOKEN", () => {
|
|
194
|
+
expect(out).toContain("Once a hub is running");
|
|
195
|
+
expect(out).toContain("VAULT_AUTH_TOKEN");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("does NOT claim the vault is reachable (no hub present)", () => {
|
|
199
|
+
expect(out).not.toContain("Your vault is still reachable");
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// #445: opted into a token, none minted, but a HUB IS PRESENT. The vault is
|
|
204
|
+
// reachable via the hub's browser OAuth flow even with no header-auth token,
|
|
205
|
+
// so the standalone "isn't reachable" framing would be false here.
|
|
206
|
+
describe("MCP=N + token=Y, no token minted, but hub present (#445)", () => {
|
|
207
|
+
const out = buildInitSummaryLines({
|
|
208
|
+
...baseInput,
|
|
209
|
+
addMcp: false,
|
|
210
|
+
addToken: true,
|
|
211
|
+
apiKey: undefined,
|
|
212
|
+
noTokenGuidance: "No token yet — the hub's admin wizard mints it.",
|
|
213
|
+
hubPresent: true,
|
|
214
|
+
}).join("\n");
|
|
215
|
+
|
|
216
|
+
test("affirms the vault is still reachable via the hub's OAuth flow", () => {
|
|
217
|
+
expect(out).toContain("Your vault is still reachable");
|
|
218
|
+
expect(out).toContain("sign-in (OAuth)");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("frames a header-auth token as optional (scripts / non-OAuth clients)", () => {
|
|
222
|
+
expect(out).toContain("only needed for scripts");
|
|
223
|
+
expect(out).toContain("parachute-vault mcp-install");
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("does NOT print the standalone 'Once a hub is running' / VAULT_AUTH_TOKEN copy", () => {
|
|
227
|
+
expect(out).not.toContain("Once a hub is running");
|
|
228
|
+
expect(out).not.toContain("VAULT_AUTH_TOKEN");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("never claims the vault isn't reachable by any client", () => {
|
|
232
|
+
expect(out).not.toContain("isn't reachable by any client");
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
121
236
|
test("always prints Config: and Server: lines", () => {
|
|
122
237
|
for (const [addMcp, addToken] of [
|
|
123
238
|
[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
|
|
@@ -19,66 +26,101 @@ export type InitSummaryInput = {
|
|
|
19
26
|
* undefined, so they know why and how to make the vault reachable.
|
|
20
27
|
*/
|
|
21
28
|
noTokenGuidance?: string | undefined;
|
|
29
|
+
/**
|
|
30
|
+
* Whether a hub is present on this host (live `/health` probe or a
|
|
31
|
+
* configured hub origin — see `detectHubPresence`). Branches the
|
|
32
|
+
* opted-into-a-token-but-none-minted copy: under a hub the vault is reachable
|
|
33
|
+
* via the hub's browser OAuth flow even with no header-auth token, so the
|
|
34
|
+
* old "your vault isn't reachable by any client" framing is false. #445.
|
|
35
|
+
* Undefined → treat as the conservative standalone case.
|
|
36
|
+
*/
|
|
37
|
+
hubPresent?: boolean | undefined;
|
|
22
38
|
};
|
|
23
39
|
|
|
24
40
|
/**
|
|
25
41
|
* Build the post-install summary lines for `vault init`, branched on the
|
|
26
|
-
* (addMcp, addToken, apiKey) decision matrix.
|
|
27
|
-
*
|
|
28
|
-
* is
|
|
42
|
+
* (addMcp, addToken, apiKey) decision matrix.
|
|
43
|
+
*
|
|
44
|
+
* vault#442: the DEFAULT is per-user OAuth — no token is minted, and the
|
|
45
|
+
* Claude Code MCP entry is written without a baked bearer (browser sign-in on
|
|
46
|
+
* first connect). A token is minted only on explicit opt-in (`addToken`), and
|
|
47
|
+
* then scope-narrow. Branches:
|
|
29
48
|
*
|
|
30
|
-
* addMcp,
|
|
31
|
-
* addMcp,
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* !addMcp, !
|
|
49
|
+
* addMcp, !apiKey → OAuth-first: connect, sign in on first use
|
|
50
|
+
* addMcp, addToken, apiKey → token baked into claude.json + printed
|
|
51
|
+
* addMcp, !addToken, apiKey → token baked into claude.json, hint
|
|
52
|
+
* !addMcp, addToken, apiKey → token printed prominently
|
|
53
|
+
* !addMcp, addToken, !apiKey → opted into a token but no hub reachable
|
|
54
|
+
* !addMcp, !addToken → OAuth-first: add Claude Code later
|
|
35
55
|
*/
|
|
36
56
|
export function buildInitSummaryLines(input: InitSummaryInput): string[] {
|
|
37
|
-
const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl, noTokenGuidance } = input;
|
|
57
|
+
const { addMcp, addToken, apiKey, configDir, bindHost, port, mcpUrl, vaultName, noTokenGuidance, hubPresent } = input;
|
|
38
58
|
const lines: string[] = [];
|
|
39
59
|
lines.push("");
|
|
40
60
|
lines.push("---");
|
|
41
61
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (addMcp && addToken && apiKey) {
|
|
62
|
+
if (addMcp && apiKey && addToken) {
|
|
45
63
|
lines.push("");
|
|
46
64
|
lines.push(`Your API token: ${apiKey}`);
|
|
47
65
|
lines.push(` - Baked into ~/.claude.json for Claude Code ✓`);
|
|
48
66
|
lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
|
|
49
67
|
lines.push(` - Won't be shown again — save it now.`);
|
|
50
|
-
} else if (addMcp &&
|
|
68
|
+
} else if (addMcp && apiKey && !addToken) {
|
|
51
69
|
lines.push("");
|
|
52
70
|
lines.push(
|
|
53
71
|
"Token in ~/.claude.json; run `parachute-vault mcp-install` later if you need one for other clients.",
|
|
54
72
|
);
|
|
73
|
+
} else if (addMcp && !apiKey) {
|
|
74
|
+
// vault#442 default: OAuth-first. The MCP entry is wired without a bearer —
|
|
75
|
+
// Claude Code signs in via browser OAuth on first connect. No token needed.
|
|
76
|
+
lines.push("");
|
|
77
|
+
lines.push("Connect your AI — no token needed, you'll sign in on first use:");
|
|
78
|
+
lines.push(` Claude Code is already wired in (~/.claude.json) — just start a session.`);
|
|
79
|
+
lines.push(` Other clients: claude mcp add --transport http parachute-vault ${mcpUrl}`);
|
|
80
|
+
lines.push(` Need a header-auth token for a script? parachute auth mint-token --scope vault:${vaultName}:read`);
|
|
55
81
|
} else if (!addMcp && addToken && apiKey) {
|
|
56
82
|
lines.push("");
|
|
57
83
|
lines.push(`Your API token: ${apiKey}`);
|
|
58
84
|
lines.push(` - Paste into your other MCP client's config, or use as Authorization: Bearer <token>`);
|
|
59
85
|
lines.push(` - Won't be shown again — save it now.`);
|
|
60
|
-
} else if (
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
// the recovery paths.
|
|
86
|
+
} else if (!addMcp && addToken && !apiKey) {
|
|
87
|
+
// Explicitly opted into a token but none was minted (vault#282 Stage 2 —
|
|
88
|
+
// vault no longer mints local pvt_* tokens). Surface why + recovery.
|
|
64
89
|
lines.push("");
|
|
65
90
|
lines.push(
|
|
66
91
|
noTokenGuidance ??
|
|
67
92
|
"No token issued — no hub was reachable to mint a hub JWT.",
|
|
68
93
|
);
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
94
|
+
if (hubPresent) {
|
|
95
|
+
// A hub IS present — the vault is already reachable via the hub's
|
|
96
|
+
// browser OAuth flow / web UI. A header-auth token is optional, only for
|
|
97
|
+
// non-OAuth clients + scripts. The "isn't reachable" framing is false
|
|
98
|
+
// here (#445).
|
|
99
|
+
lines.push(
|
|
100
|
+
" Your vault is still reachable — clients connect through the hub's browser",
|
|
101
|
+
);
|
|
102
|
+
lines.push(
|
|
103
|
+
" sign-in (OAuth); a header-auth token is only needed for scripts / non-OAuth",
|
|
104
|
+
);
|
|
105
|
+
lines.push(
|
|
106
|
+
" clients. Run `parachute-vault mcp-install` to mint + wire one when you want it.",
|
|
107
|
+
);
|
|
108
|
+
} else {
|
|
109
|
+
lines.push(
|
|
110
|
+
" Once a hub is running, run `parachute-vault mcp-install` to mint + wire a token,",
|
|
111
|
+
);
|
|
112
|
+
lines.push(
|
|
113
|
+
" or set VAULT_AUTH_TOKEN for an operator-channel bearer.",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
75
116
|
} else if (!addMcp && !addToken) {
|
|
117
|
+
// OAuth-first, but the operator skipped wiring Claude Code too.
|
|
76
118
|
lines.push("");
|
|
77
119
|
lines.push(
|
|
78
|
-
"
|
|
120
|
+
"Skipped the Claude Code MCP entry. Add it anytime — it uses per-user OAuth, no token needed:",
|
|
79
121
|
);
|
|
80
122
|
lines.push(
|
|
81
|
-
"
|
|
123
|
+
" parachute-vault mcp-install",
|
|
82
124
|
);
|
|
83
125
|
}
|
|
84
126
|
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Predicate-parity tests for the live matcher (live-query SSE).
|
|
3
|
+
*
|
|
4
|
+
* The load-bearing invariant: for any supported query, the set of notes the
|
|
5
|
+
* snapshot SQL (`store.queryNotes`) returns MUST equal the set the in-process
|
|
6
|
+
* `buildLiveMatcher` accepts over the same corpus. Each `it` seeds a corpus,
|
|
7
|
+
* runs both evaluators for a query shape, and asserts the id-sets are equal.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
11
|
+
import { Database } from "bun:sqlite";
|
|
12
|
+
import { SqliteStore } from "../core/src/store.ts";
|
|
13
|
+
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
14
|
+
import type { QueryOpts } from "../core/src/types.ts";
|
|
15
|
+
import { buildLiveMatcher } from "./live-match.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Declare metadata fields as `indexed: true` via the update-tag MCP tool —
|
|
19
|
+
* the only path that populates the `indexed_fields` table + reconciles the
|
|
20
|
+
* generated `meta_<field>` columns the snapshot operator queries need.
|
|
21
|
+
* `store.upsertTagSchema` alone records the schema but NOT the index.
|
|
22
|
+
*/
|
|
23
|
+
async function declareIndexed(tag: string, fields: Record<string, { type: string; indexed: boolean }>) {
|
|
24
|
+
const tools = generateMcpTools(store);
|
|
25
|
+
const updateTag = tools.find((t) => t.name === "update-tag")!;
|
|
26
|
+
await updateTag.execute({ tag, fields });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
let db: Database;
|
|
30
|
+
let store: SqliteStore;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
db = new Database(":memory:");
|
|
34
|
+
store = new SqliteStore(db);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
db.close();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
/** Build the parity assertion: snapshot id-set === live-matcher id-set. */
|
|
42
|
+
async function assertParity(opts: QueryOpts): Promise<Set<string>> {
|
|
43
|
+
const snapshot = await store.queryNotes(opts);
|
|
44
|
+
const snapshotIds = new Set(snapshot.map((n) => n.id));
|
|
45
|
+
|
|
46
|
+
const all = await store.queryNotes({ limit: 100000 });
|
|
47
|
+
const matcher = await buildLiveMatcher(store, opts);
|
|
48
|
+
const liveIds = new Set(all.filter((n) => matcher.match(n)).map((n) => n.id));
|
|
49
|
+
|
|
50
|
+
expect([...liveIds].sort()).toEqual([...snapshotIds].sort());
|
|
51
|
+
return snapshotIds;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe("live-match — predicate parity with the query engine", () => {
|
|
55
|
+
it("tags (single, no hierarchy)", async () => {
|
|
56
|
+
await store.createNote("a", { tags: ["chat"] });
|
|
57
|
+
await store.createNote("b", { tags: ["chat", "x"] });
|
|
58
|
+
await store.createNote("c", { tags: ["other"] });
|
|
59
|
+
await store.createNote("d", {});
|
|
60
|
+
const ids = await assertParity({ tags: ["chat"] });
|
|
61
|
+
expect(ids.size).toBe(2);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("tags 'all' (AND) across multiple tags", async () => {
|
|
65
|
+
await store.createNote("a", { tags: ["chat", "x"] });
|
|
66
|
+
await store.createNote("b", { tags: ["chat"] });
|
|
67
|
+
await store.createNote("c", { tags: ["x"] });
|
|
68
|
+
const ids = await assertParity({ tags: ["chat", "x"], tagMatch: "all" });
|
|
69
|
+
expect(ids.size).toBe(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("tags 'any' (OR) across multiple tags", async () => {
|
|
73
|
+
await store.createNote("a", { tags: ["chat"] });
|
|
74
|
+
await store.createNote("b", { tags: ["x"] });
|
|
75
|
+
await store.createNote("c", { tags: ["other"] });
|
|
76
|
+
const ids = await assertParity({ tags: ["chat", "x"], tagMatch: "any" });
|
|
77
|
+
expect(ids.size).toBe(2);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("tags with descendant/hierarchy expansion", async () => {
|
|
81
|
+
// Declare voice as a child of manual via parent_names.
|
|
82
|
+
await store.upsertTagRecord("manual", { description: "manual root" });
|
|
83
|
+
await store.upsertTagRecord("voice", { parent_names: ["manual"] });
|
|
84
|
+
await store.createNote("root", { tags: ["manual"] });
|
|
85
|
+
await store.createNote("child", { tags: ["voice"] });
|
|
86
|
+
await store.createNote("unrelated", { tags: ["other"] });
|
|
87
|
+
// Query for #manual should match notes tagged #voice (a descendant).
|
|
88
|
+
const ids = await assertParity({ tags: ["manual"] });
|
|
89
|
+
expect(ids.has("nonexistent")).toBe(false);
|
|
90
|
+
// Both root + child match.
|
|
91
|
+
const notes = await store.queryNotes({ tags: ["manual"] });
|
|
92
|
+
expect(notes.length).toBe(2);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("excludeTags (raw, no expansion — mirrors engine)", async () => {
|
|
96
|
+
await store.createNote("a", { tags: ["chat"] });
|
|
97
|
+
await store.createNote("b", { tags: ["chat", "muted"] });
|
|
98
|
+
await assertParity({ tags: ["chat"], excludeTags: ["muted"] });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("path (case-insensitive exact)", async () => {
|
|
102
|
+
await store.createNote("a", { path: "Channels/general" });
|
|
103
|
+
await store.createNote("b", { path: "Channels/random" });
|
|
104
|
+
await assertParity({ path: "channels/general" });
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("pathPrefix", async () => {
|
|
108
|
+
await store.createNote("a", { path: "Channels/general" });
|
|
109
|
+
await store.createNote("b", { path: "Channels/random" });
|
|
110
|
+
await store.createNote("c", { path: "Other/thing" });
|
|
111
|
+
const ids = await assertParity({ pathPrefix: "Channels/" });
|
|
112
|
+
expect(ids.size).toBe(2);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("pathPrefix (mixed-case — parity with the engine's CI LIKE) (N1)", async () => {
|
|
116
|
+
await store.createNote("a", { path: "Channels/general" });
|
|
117
|
+
await store.createNote("b", { path: "Channels/random" });
|
|
118
|
+
await store.createNote("c", { path: "Other/thing" });
|
|
119
|
+
// Lower-case prefix must still match the title-case paths, same as the
|
|
120
|
+
// engine's `LIKE 'channels/%'` (ASCII case-insensitive).
|
|
121
|
+
const ids = await assertParity({ pathPrefix: "channels/" });
|
|
122
|
+
expect(ids.size).toBe(2);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("hasTags true/false (M1 — presence parity)", async () => {
|
|
126
|
+
await store.createNote("tagged", { tags: ["x"] });
|
|
127
|
+
await store.createNote("bare", {});
|
|
128
|
+
const has = await assertParity({ hasTags: true });
|
|
129
|
+
expect(has.size).toBe(1);
|
|
130
|
+
const none = await assertParity({ hasTags: false });
|
|
131
|
+
expect(none.size).toBe(1);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("hasTags is ignored when a tag filter is also present (engine parity)", async () => {
|
|
135
|
+
// queryNotes drops hasTags when `tags` is set (the tag filter already
|
|
136
|
+
// constrains to tagged notes); the matcher must mirror that exactly.
|
|
137
|
+
await store.createNote("a", { tags: ["chat"] });
|
|
138
|
+
await store.createNote("b", { tags: ["other"] });
|
|
139
|
+
await assertParity({ tags: ["chat"], hasTags: false });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("extension (default md + explicit)", async () => {
|
|
143
|
+
await store.createNote("md1", { path: "n1" });
|
|
144
|
+
await store.createNote("csv1", { path: "n2", extension: "csv" });
|
|
145
|
+
await assertParity({ extension: "csv" });
|
|
146
|
+
await assertParity({ extension: "md" });
|
|
147
|
+
await assertParity({ extension: ["csv", "yaml"] });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("metadata operators (indexed field)", () => {
|
|
151
|
+
beforeEach(async () => {
|
|
152
|
+
// Declare `channel` + `count` indexed so the snapshot operator path works.
|
|
153
|
+
await declareIndexed("msg", {
|
|
154
|
+
channel: { type: "string", indexed: true },
|
|
155
|
+
count: { type: "integer", indexed: true },
|
|
156
|
+
});
|
|
157
|
+
await store.createNote("g1", { tags: ["msg"], metadata: { channel: "general", count: 5 } });
|
|
158
|
+
await store.createNote("g2", { tags: ["msg"], metadata: { channel: "general", count: 10 } });
|
|
159
|
+
await store.createNote("r1", { tags: ["msg"], metadata: { channel: "random", count: 1 } });
|
|
160
|
+
await store.createNote("n1", { tags: ["msg"] }); // no channel/count
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("eq", async () => {
|
|
164
|
+
const ids = await assertParity({ tags: ["msg"], metadata: { channel: { eq: "general" } } });
|
|
165
|
+
expect(ids.size).toBe(2);
|
|
166
|
+
});
|
|
167
|
+
it("ne (absent field passes)", async () => {
|
|
168
|
+
await assertParity({ tags: ["msg"], metadata: { channel: { ne: "general" } } });
|
|
169
|
+
});
|
|
170
|
+
it("gt / gte / lt / lte", async () => {
|
|
171
|
+
await assertParity({ tags: ["msg"], metadata: { count: { gt: 5 } } });
|
|
172
|
+
await assertParity({ tags: ["msg"], metadata: { count: { gte: 5 } } });
|
|
173
|
+
await assertParity({ tags: ["msg"], metadata: { count: { lt: 5 } } });
|
|
174
|
+
await assertParity({ tags: ["msg"], metadata: { count: { lte: 5 } } });
|
|
175
|
+
});
|
|
176
|
+
it("in / not_in", async () => {
|
|
177
|
+
await assertParity({ tags: ["msg"], metadata: { channel: { in: ["general", "random"] } } });
|
|
178
|
+
await assertParity({ tags: ["msg"], metadata: { channel: { not_in: ["random"] } } });
|
|
179
|
+
});
|
|
180
|
+
it("exists true/false", async () => {
|
|
181
|
+
await assertParity({ tags: ["msg"], metadata: { channel: { exists: true } } });
|
|
182
|
+
await assertParity({ tags: ["msg"], metadata: { channel: { exists: false } } });
|
|
183
|
+
});
|
|
184
|
+
it("combined: tag + metadata operator (the channel case)", async () => {
|
|
185
|
+
const ids = await assertParity({
|
|
186
|
+
tags: ["msg"],
|
|
187
|
+
metadata: { channel: { eq: "general" }, count: { gte: 10 } },
|
|
188
|
+
});
|
|
189
|
+
expect(ids.size).toBe(1);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("metadata primitive exact-match (shorthand)", async () => {
|
|
194
|
+
await store.createNote("a", { tags: ["t"], metadata: { kind: "note" } });
|
|
195
|
+
await store.createNote("b", { tags: ["t"], metadata: { kind: "task" } });
|
|
196
|
+
await assertParity({ tags: ["t"], metadata: { kind: "note" } });
|
|
197
|
+
});
|
|
198
|
+
});
|