@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.
Files changed (91) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +7 -7
  3. package/core/src/core.test.ts +279 -26
  4. package/core/src/expand-visibility.test.ts +102 -0
  5. package/core/src/expand.ts +31 -3
  6. package/core/src/indexed-fields.ts +1 -1
  7. package/core/src/link-count.test.ts +301 -0
  8. package/core/src/links.ts +97 -2
  9. package/core/src/mcp.ts +201 -33
  10. package/core/src/notes.ts +44 -8
  11. package/core/src/obsidian-alignment.test.ts +375 -0
  12. package/core/src/obsidian.ts +234 -14
  13. package/core/src/portable-md.test.ts +40 -0
  14. package/core/src/portable-md.ts +142 -16
  15. package/core/src/schema.ts +58 -11
  16. package/core/src/store.ts +69 -22
  17. package/core/src/tag-expand-axis.test.ts +301 -0
  18. package/core/src/tag-hierarchy.ts +80 -0
  19. package/core/src/tag-schemas.ts +61 -46
  20. package/core/src/triggers-store.test.ts +100 -0
  21. package/core/src/triggers-store.ts +165 -0
  22. package/core/src/types.ts +68 -4
  23. package/core/src/vault-projection.ts +20 -0
  24. package/core/src/wikilinks.ts +2 -2
  25. package/package.json +2 -3
  26. package/src/admin-spa.test.ts +100 -10
  27. package/src/admin-spa.ts +48 -3
  28. package/src/auth-hub-jwt.test.ts +8 -1
  29. package/src/auth-status.ts +2 -2
  30. package/src/auth.test.ts +39 -3
  31. package/src/auth.ts +31 -2
  32. package/src/auto-transcribe.test.ts +51 -0
  33. package/src/auto-transcribe.ts +24 -6
  34. package/src/autostart.test.ts +75 -0
  35. package/src/autostart.ts +84 -0
  36. package/src/cli.ts +434 -140
  37. package/src/config.test.ts +109 -0
  38. package/src/config.ts +157 -10
  39. package/src/export-watch.test.ts +23 -0
  40. package/src/export-watch.ts +14 -0
  41. package/src/git-preflight.test.ts +70 -0
  42. package/src/git-preflight.ts +68 -0
  43. package/src/hub-jwt.test.ts +75 -2
  44. package/src/hub-jwt.ts +43 -6
  45. package/src/init-summary.test.ts +120 -5
  46. package/src/init-summary.ts +67 -25
  47. package/src/live-match.test.ts +198 -0
  48. package/src/live-match.ts +310 -0
  49. package/src/mcp-install.test.ts +93 -0
  50. package/src/mcp-install.ts +106 -0
  51. package/src/mcp-tools.ts +80 -7
  52. package/src/mirror-config.test.ts +14 -0
  53. package/src/mirror-config.ts +11 -0
  54. package/src/mirror-import.test.ts +110 -0
  55. package/src/mirror-import.ts +71 -13
  56. package/src/mirror-manager.test.ts +51 -0
  57. package/src/mirror-manager.ts +73 -11
  58. package/src/mirror-routes.test.ts +463 -1
  59. package/src/mirror-routes.ts +474 -4
  60. package/src/oauth-discovery.test.ts +55 -0
  61. package/src/oauth-discovery.ts +24 -5
  62. package/src/routes.ts +696 -121
  63. package/src/routing.test.ts +451 -5
  64. package/src/routing.ts +113 -5
  65. package/src/scopes.ts +1 -1
  66. package/src/server.ts +66 -4
  67. package/src/storage.test.ts +162 -0
  68. package/src/subscribe.test.ts +588 -0
  69. package/src/subscribe.ts +248 -0
  70. package/src/subscriptions.ts +295 -0
  71. package/src/tag-expand-routes.test.ts +45 -0
  72. package/src/tag-scope.ts +68 -1
  73. package/src/token-store.ts +7 -7
  74. package/src/transcription-worker.test.ts +471 -5
  75. package/src/transcription-worker.ts +212 -44
  76. package/src/triggers-api.test.ts +533 -0
  77. package/src/triggers-api.ts +295 -0
  78. package/src/triggers.ts +93 -7
  79. package/src/usage.test.ts +362 -0
  80. package/src/usage.ts +318 -0
  81. package/src/vault-create.test.ts +340 -12
  82. package/src/vault-name.test.ts +61 -3
  83. package/src/vault-name.ts +62 -14
  84. package/src/vault-remove.test.ts +187 -0
  85. package/src/vault-store.ts +10 -3
  86. package/src/vault.test.ts +1353 -62
  87. package/web/ui/dist/assets/index-CGL256oe.js +60 -0
  88. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  89. package/web/ui/dist/index.html +2 -2
  90. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  91. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
@@ -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 { resetJwksCache, resetRevocationCache, validateHubJwt, looksLikeJwt } from "./hub-jwt.ts";
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 fetch JWKS and validate `iss`. Strips a
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 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() });
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
@@ -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
- describe("MCP=N + token=N (unreachable)", () => {
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("warns the vault is unreachable", () => {
103
- expect(out).toContain("your vault isn't reachable by any client");
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 the mcp-install recovery path (hub JWT)", () => {
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],
@@ -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. Post-0.6.0 the token is a
27
- * hub-issued JWT minted via operator.token; when no hub is reachable `apiKey`
28
- * is undefined even though the operator opted in (`addToken`/`addMcp`):
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, addToken, apiKey token baked into claude.json + printed
31
- * addMcp, !addToken, apiKey → token baked into claude.json, hint
32
- * !addMcp, addToken, apiKey → token printed prominently
33
- * wanted-token-but-no-hub guidance: no token issued, recovery paths
34
- * !addMcp, !addToken warning: vault unreachable; recovery paths
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
- const wantedToken = addMcp || addToken;
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 && !addToken && apiKey) {
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 (wantedToken && !apiKey) {
61
- // Opted into a token but no hub was reachable to mint one (vault#282
62
- // Stage 2 — vault no longer mints local pvt_* tokens). Surface why and
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
- lines.push(
70
- " Once a hub is running, run `parachute-vault mcp-install` to mint + wire a token,",
71
- );
72
- lines.push(
73
- " or set VAULT_AUTH_TOKEN for an operator-channel bearer.",
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
- "You've skipped both MCP install and token generationyour vault isn't reachable by any client.",
120
+ "Skipped the Claude Code MCP entry. Add it anytimeit uses per-user OAuth, no token needed:",
79
121
  );
80
122
  lines.push(
81
- " Add Claude Code later with `parachute-vault mcp-install`, which mints a hub JWT (needs a hub running).",
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
+ });