@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
@@ -0,0 +1,310 @@
1
+ /**
2
+ * In-process query matcher for live subscriptions (live-query SSE — design
3
+ * `design/2026-06-08-live-query-sse.md`).
4
+ *
5
+ * The snapshot path evaluates a `QueryOpts` against the DB via the SQL query
6
+ * engine (`core/src/notes.ts queryNotes`). The live path can't go back to the
7
+ * DB for every mutation — the changed note is already in hand from the
8
+ * post-commit hook — so this module re-implements the *supported subset* of
9
+ * the query predicate against a single in-memory `Note`.
10
+ *
11
+ * **Predicate parity is the contract.** For any supported query, the set of
12
+ * notes the snapshot SQL returns MUST equal the set this matcher accepts.
13
+ * `subscribe.test.ts` enforces that over a seeded corpus. Every clause below
14
+ * is written to mirror the exact SQL semantics in `queryNotes`:
15
+ *
16
+ * - `tags` ("all"/"any") with descendant expansion — mirrors the per-input
17
+ * `_tagsExpanded` JOINs (each input tag matches the input OR any declared
18
+ * descendant). Expansion is resolved ONCE at subscribe time (see
19
+ * `buildLiveMatcher`) and frozen into the matcher, exactly as the engine
20
+ * freezes it for the snapshot query.
21
+ * - `excludeTags` — raw exact-name match (engine does NOT expand excludes).
22
+ * - `path` — case-insensitive exact (engine: `n.path = ? COLLATE NOCASE`).
23
+ * - `pathPrefix` — prefix (engine: `n.path LIKE prefix || '%'`).
24
+ * - `extension` — lower-cased, default "md" (engine: `LOWER(n.extension)`),
25
+ * a note with no extension is treated as "md".
26
+ * - `metadata` operator objects (eq/ne/gt/gte/lt/lte/in/not_in/exists) +
27
+ * primitive exact-match — mirrors `buildOperatorClause` / the json_extract
28
+ * shorthand. NULL-aware exactly like the SQL (`ne`/`not_in` match the
29
+ * field-absent row; `eq null` matches absence).
30
+ *
31
+ * - `hasTags` (presence) — `note.tags?.length > 0`, trivial + parity-safe.
32
+ *
33
+ * **Unsupported (rejected upstream with 400, never reach the matcher):**
34
+ * `search` (FTS) and `near` (graph BFS) — not evaluable against a single note;
35
+ * `hasLinks` — needs the `links` table (not on the in-hand note); date filters
36
+ * (`dateFrom`/`dateTo`/`dateFilter`) — parity risk + some need indexed columns;
37
+ * `cursor` — paging is meaningless for a live set. The subscribe route returns
38
+ * 400 for these BEFORE a subscription is created (see
39
+ * `unsupportedSubscriptionReason` + the route's raw-param checks).
40
+ *
41
+ * **Ignored (irrelevant to set membership):** `orderBy`, `limit`, `offset`,
42
+ * `ids`. The route strips `limit`/`offset` from the snapshot query so the
43
+ * snapshot is the COMPLETE matching set (parity with the unbounded matcher).
44
+ */
45
+
46
+ import type { Note, QueryOpts, Store } from "../core/src/types.ts";
47
+ import { SUPPORTED_OPS } from "../core/src/query-operators.ts";
48
+
49
+ const OPS_SET: ReadonlySet<string> = new Set<string>(SUPPORTED_OPS);
50
+
51
+ /**
52
+ * Frozen, pre-resolved form of a `QueryOpts` for fast per-note matching.
53
+ * Tag descendant expansion (a DB read) is done once here, not per event.
54
+ */
55
+ export interface LiveMatcher {
56
+ /** The original supported opts (for diagnostics / parity tests). */
57
+ readonly opts: QueryOpts;
58
+ /**
59
+ * Per-input-tag expanded sets: `tagSets[i]` = `{tags[i]} ∪ descendants`.
60
+ * Mirrors `_tagsExpanded` in the engine. Empty when no `tags` filter.
61
+ * A note matches under "all" iff it carries ≥1 tag from EACH set; under
62
+ * "any" iff it carries ≥1 tag from the UNION.
63
+ */
64
+ readonly tagSets: string[][];
65
+ readonly tagMatch: "all" | "any";
66
+ readonly excludeTags: string[];
67
+ match(note: Note): boolean;
68
+ }
69
+
70
+ /**
71
+ * Query shapes a live subscription can't evaluate against a single note.
72
+ * The route layer rejects these with 400 before creating a subscription.
73
+ */
74
+ export function unsupportedSubscriptionReason(opts: QueryOpts): string | null {
75
+ // `search` / `near` are parsed/handled by the notes route separately; the
76
+ // subscribe route detects them from raw query params. These guards catch the
77
+ // remaining shapes that the matcher can't faithfully evaluate against a
78
+ // single in-hand note (so snapshot and live would disagree).
79
+ if (opts.cursor) {
80
+ return "cursor pagination is not supported for live subscriptions";
81
+ }
82
+ if (opts.hasLinks !== undefined) {
83
+ return "has_links is not supported for live subscriptions (requires the links table)";
84
+ }
85
+ if (opts.dateFilter || opts.dateFrom || opts.dateTo) {
86
+ return "date filters are not supported for live subscriptions";
87
+ }
88
+ return null;
89
+ }
90
+
91
+ function metaValue(note: Note, key: string): unknown {
92
+ const m = note.metadata;
93
+ if (!m || typeof m !== "object") return undefined;
94
+ return (m as Record<string, unknown>)[key];
95
+ }
96
+
97
+ /**
98
+ * Compare two primitives the way SQLite's generated `meta_<field>` column
99
+ * would for ordering operators. Values stored via the indexed column are
100
+ * primitives; we compare numbers numerically and strings lexically, matching
101
+ * SQLite's type affinity for a column holding the JSON-extracted scalar.
102
+ */
103
+ function cmp(a: unknown, b: unknown): number | null {
104
+ if (typeof a === "number" && typeof b === "number") return a < b ? -1 : a > b ? 1 : 0;
105
+ // Booleans compare as their numeric form (SQLite stores 1/0).
106
+ const an = typeof a === "boolean" ? (a ? 1 : 0) : a;
107
+ const bn = typeof b === "boolean" ? (b ? 1 : 0) : b;
108
+ if (typeof an === "number" && typeof bn === "number") return an < bn ? -1 : an > bn ? 1 : 0;
109
+ const as = String(an);
110
+ const bs = String(bn);
111
+ return as < bs ? -1 : as > bs ? 1 : 0;
112
+ }
113
+
114
+ /** Loose equality mirroring the json_extract shorthand + operator `eq`. */
115
+ function looseEq(actual: unknown, expected: unknown): boolean {
116
+ if (actual === expected) return true;
117
+ // Numbers vs numeric strings: the engine's operator path binds the raw
118
+ // value to an indexed numeric column, so `5` and `5` match; the shorthand
119
+ // path stringifies. Normalize via string compare as a backstop.
120
+ if (actual == null || expected == null) return actual === expected;
121
+ if (typeof actual === "boolean" || typeof expected === "boolean") {
122
+ return (actual ? 1 : 0) === (expected ? 1 : 0) || String(actual) === String(expected);
123
+ }
124
+ return String(actual) === String(expected);
125
+ }
126
+
127
+ function evalOperatorObject(actual: unknown, opObj: Record<string, unknown>): boolean {
128
+ for (const [op, expected] of Object.entries(opObj)) {
129
+ switch (op) {
130
+ case "eq":
131
+ if (expected === null) {
132
+ if (actual !== undefined && actual !== null) return false;
133
+ } else if (!looseEq(actual, expected)) {
134
+ return false;
135
+ }
136
+ break;
137
+ case "ne":
138
+ // SQL: (col IS NULL OR col <> ?) — absent field passes; equal fails.
139
+ if (expected === null) {
140
+ if (actual === undefined || actual === null) return false;
141
+ } else if (actual !== undefined && actual !== null && looseEq(actual, expected)) {
142
+ return false;
143
+ }
144
+ break;
145
+ case "gt":
146
+ case "gte":
147
+ case "lt":
148
+ case "lte": {
149
+ // SQL comparison operators yield NULL (→ excluded) when the column
150
+ // is NULL/absent.
151
+ if (actual === undefined || actual === null) return false;
152
+ const c = cmp(actual, expected);
153
+ if (c === null) return false;
154
+ if (op === "gt" && !(c > 0)) return false;
155
+ if (op === "gte" && !(c >= 0)) return false;
156
+ if (op === "lt" && !(c < 0)) return false;
157
+ if (op === "lte" && !(c <= 0)) return false;
158
+ break;
159
+ }
160
+ case "in": {
161
+ if (!Array.isArray(expected)) return false;
162
+ if (expected.length === 0) return false; // engine emits `0`
163
+ if (actual === undefined || actual === null) return false;
164
+ if (!expected.some((v) => looseEq(actual, v))) return false;
165
+ break;
166
+ }
167
+ case "not_in": {
168
+ if (!Array.isArray(expected)) return false;
169
+ if (expected.length === 0) break; // engine emits `1` (no-op pass)
170
+ // SQL: (col IS NULL OR col NOT IN (...)) — absent passes.
171
+ if (actual === undefined || actual === null) break;
172
+ if (expected.some((v) => looseEq(actual, v))) return false;
173
+ break;
174
+ }
175
+ case "exists": {
176
+ const present = actual !== undefined && actual !== null;
177
+ if (expected === true && !present) return false;
178
+ if (expected === false && present) return false;
179
+ break;
180
+ }
181
+ default:
182
+ // Unknown operator — the snapshot path would have thrown
183
+ // UNKNOWN_OPERATOR at query time, so this never matches.
184
+ return false;
185
+ }
186
+ }
187
+ return true;
188
+ }
189
+
190
+ /** True iff every key of `value` is a supported operator (operator-object). */
191
+ function isOperatorObject(value: unknown): value is Record<string, unknown> {
192
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return false;
193
+ const keys = Object.keys(value as object);
194
+ if (keys.length === 0) return false;
195
+ return keys.every((k) => OPS_SET.has(k));
196
+ }
197
+
198
+ /**
199
+ * Resolve a `QueryOpts` into a frozen `LiveMatcher`, expanding tag descendants
200
+ * once via the store (the same hierarchy the snapshot query uses). Call at
201
+ * subscribe time; the returned matcher is pure + sync for per-event use.
202
+ */
203
+ export async function buildLiveMatcher(store: Store, opts: QueryOpts): Promise<LiveMatcher> {
204
+ const tags = opts.tags ?? [];
205
+ const tagMatch: "all" | "any" = opts.tagMatch ?? "all";
206
+ // Per-input expansion along the SAME axis the snapshot query uses
207
+ // (vault tag `expand` axis). `opts.expand` (default "subtypes") MUST be
208
+ // threaded through here so the live matcher and the snapshot query engine
209
+ // lower the IDENTICAL expansion — otherwise a subscription's snapshot and
210
+ // its live events would disagree on which notes match. `expandTags` already
211
+ // includes each input tag.
212
+ const tagSets: string[][] = [];
213
+ for (const t of tags) {
214
+ const set = await store.expandTags([t], opts.expand);
215
+ // Be defensive: ensure the root is present (expandTags always includes it
216
+ // for non-empty input, but the contract should not rely on it here).
217
+ set.add(t);
218
+ tagSets.push(Array.from(set));
219
+ }
220
+ const excludeTags = opts.excludeTags ?? [];
221
+
222
+ const matcher: LiveMatcher = {
223
+ opts,
224
+ tagSets,
225
+ tagMatch,
226
+ excludeTags,
227
+ match(note: Note): boolean {
228
+ return matchAgainst(note, opts, tagSets, tagMatch, excludeTags);
229
+ },
230
+ };
231
+ return matcher;
232
+ }
233
+
234
+ function matchAgainst(
235
+ note: Note,
236
+ opts: QueryOpts,
237
+ tagSets: string[][],
238
+ tagMatch: "all" | "any",
239
+ excludeTags: string[],
240
+ ): boolean {
241
+ const noteTags = note.tags ?? [];
242
+
243
+ // ---- tags ----
244
+ if (tagSets.length > 0) {
245
+ if (tagMatch === "any") {
246
+ const union = new Set(tagSets.flat());
247
+ if (!noteTags.some((t) => union.has(t))) return false;
248
+ } else {
249
+ // "all": for each input tag's expanded set, the note must carry ≥1.
250
+ for (const set of tagSets) {
251
+ if (set.length === 0) continue;
252
+ const s = new Set(set);
253
+ if (!noteTags.some((t) => s.has(t))) return false;
254
+ }
255
+ }
256
+ }
257
+
258
+ // ---- excludeTags (raw exact, no expansion — mirrors engine) ----
259
+ for (const ex of excludeTags) {
260
+ if (noteTags.includes(ex)) return false;
261
+ }
262
+
263
+ // ---- hasTags (presence) — ignored by the engine when a `tags` filter is
264
+ // also set (the tag filter already constrains to tagged notes), so mirror
265
+ // that: only apply when there's no `tags` filter. See queryNotes
266
+ // `filterByTags` short-circuit.
267
+ if (opts.hasTags !== undefined && tagSets.length === 0) {
268
+ const has = noteTags.length > 0;
269
+ if (opts.hasTags !== has) return false;
270
+ }
271
+
272
+ // ---- path (case-insensitive exact) ----
273
+ if (opts.path) {
274
+ if (!note.path || note.path.toLowerCase() !== opts.path.toLowerCase()) return false;
275
+ }
276
+
277
+ // ---- pathPrefix (case-insensitive — the engine uses `LIKE prefix || '%'`,
278
+ // and SQLite LIKE is ASCII-case-insensitive by default) ----
279
+ if (opts.pathPrefix) {
280
+ if (!note.path || !note.path.toLowerCase().startsWith(opts.pathPrefix.toLowerCase())) return false;
281
+ }
282
+
283
+ // ---- extension (lower-cased; default "md") ----
284
+ if (opts.extension !== undefined) {
285
+ const exts = Array.isArray(opts.extension) ? opts.extension : [opts.extension];
286
+ const cleaned = exts
287
+ .filter((e): e is string => typeof e === "string" && e.length > 0)
288
+ .map((e) => e.toLowerCase());
289
+ if (cleaned.length > 0) {
290
+ const noteExt = (note.extension ?? "md").toLowerCase();
291
+ if (!cleaned.includes(noteExt)) return false;
292
+ }
293
+ }
294
+
295
+ // ---- metadata ----
296
+ if (opts.metadata) {
297
+ for (const [key, value] of Object.entries(opts.metadata)) {
298
+ const actual = metaValue(note, key);
299
+ if (isOperatorObject(value)) {
300
+ if (!evalOperatorObject(actual, value)) return false;
301
+ } else {
302
+ // Primitive exact-match (json_extract shorthand). The engine compares
303
+ // the JSON-extracted scalar against the string/JSON form.
304
+ if (!looseEq(actual, value)) return false;
305
+ }
306
+ }
307
+ }
308
+
309
+ return true;
310
+ }
@@ -23,7 +23,9 @@ import {
23
23
  buildMcpEntryPlan,
24
24
  chooseHubOrigin,
25
25
  chooseMcpUrl,
26
+ detectHubPresence,
26
27
  mintHubJwt,
28
+ noOperatorTokenGuidance,
27
29
  readOperatorToken,
28
30
  removeMcpConfig,
29
31
  resolveInstallTarget,
@@ -315,6 +317,97 @@ describe("readOperatorToken", () => {
315
317
  });
316
318
  });
317
319
 
320
+ describe("detectHubPresence", () => {
321
+ let origHome: string | undefined;
322
+ let tmpHome: string;
323
+
324
+ beforeEach(() => {
325
+ origHome = process.env.PARACHUTE_HOME;
326
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "vault-hub-presence-"));
327
+ process.env.PARACHUTE_HOME = tmpHome;
328
+ });
329
+
330
+ afterEach(() => {
331
+ if (origHome === undefined) delete process.env.PARACHUTE_HOME;
332
+ else process.env.PARACHUTE_HOME = origHome;
333
+ fs.rmSync(tmpHome, { recursive: true, force: true });
334
+ });
335
+
336
+ test("a configured non-loopback hub origin counts as present (no probe)", async () => {
337
+ let probed = false;
338
+ const mockFetch: typeof fetch = async () => {
339
+ probed = true;
340
+ return new Response(null, { status: 500 });
341
+ };
342
+ const present = await detectHubPresence({
343
+ env: { PARACHUTE_HUB_ORIGIN: "https://hub.example" },
344
+ fetchImpl: mockFetch,
345
+ });
346
+ expect(present).toBe(true);
347
+ expect(probed).toBe(false); // configured origin short-circuits the probe
348
+ });
349
+
350
+ test("loopback + healthy hub (2xx /health) → present", async () => {
351
+ const calls: string[] = [];
352
+ const mockFetch: typeof fetch = async (url) => {
353
+ calls.push(String(url));
354
+ return new Response("ok", { status: 200 });
355
+ };
356
+ const present = await detectHubPresence({ env: {}, fetchImpl: mockFetch });
357
+ expect(present).toBe(true);
358
+ expect(calls).toHaveLength(1);
359
+ // Probes the hub's fixed loopback port (1939), not vault's listen port.
360
+ expect(calls[0]).toBe("http://127.0.0.1:1939/health");
361
+ });
362
+
363
+ test("loopback + no hub answering (fetch throws) → absent", async () => {
364
+ const mockFetch: typeof fetch = async () => {
365
+ throw new Error("ECONNREFUSED");
366
+ };
367
+ const present = await detectHubPresence({ env: {}, fetchImpl: mockFetch });
368
+ expect(present).toBe(false);
369
+ });
370
+
371
+ test("loopback + hub answers non-2xx → absent", async () => {
372
+ const mockFetch: typeof fetch = async () => new Response("nope", { status: 503 });
373
+ const present = await detectHubPresence({ env: {}, fetchImpl: mockFetch });
374
+ expect(present).toBe(false);
375
+ });
376
+
377
+ test("$PARACHUTE_HUB_PORT overrides the probed port (deterministic for tests)", async () => {
378
+ const calls: string[] = [];
379
+ const mockFetch: typeof fetch = async (url) => {
380
+ calls.push(String(url));
381
+ return new Response("ok", { status: 200 });
382
+ };
383
+ const present = await detectHubPresence({
384
+ env: { PARACHUTE_HUB_PORT: "59399" },
385
+ fetchImpl: mockFetch,
386
+ });
387
+ expect(present).toBe(true);
388
+ expect(calls[0]).toBe("http://127.0.0.1:59399/health");
389
+ });
390
+ });
391
+
392
+ describe("noOperatorTokenGuidance (#445)", () => {
393
+ test("hub present → non-circular 'finish in the wizard' copy", () => {
394
+ const msg = noOperatorTokenGuidance(true);
395
+ // Does NOT tell the operator to install the hub (circular — this flow ran
396
+ // *under* the hub).
397
+ expect(msg).not.toContain("Install the hub");
398
+ expect(msg).not.toContain("bun add -g @openparachute/hub");
399
+ expect(msg).toContain("admin wizard mints");
400
+ expect(msg).toContain("Nothing to do here");
401
+ });
402
+
403
+ test("hub absent → keeps the standalone install-the-hub advice", () => {
404
+ const msg = noOperatorTokenGuidance(false);
405
+ expect(msg).toContain("Install the hub");
406
+ expect(msg).toContain("bun add -g @openparachute/hub");
407
+ expect(msg).toContain("VAULT_AUTH_TOKEN");
408
+ });
409
+ });
410
+
318
411
  describe("resolveInstallTarget", () => {
319
412
  test("user scope → ~/.claude.json", () => {
320
413
  const res = resolveInstallTarget("user");
@@ -216,6 +216,112 @@ export function readOperatorToken(env: NodeJS.ProcessEnv = process.env): string
216
216
  }
217
217
  }
218
218
 
219
+ // ---------------------------------------------------------------------------
220
+ // Hub-presence probe
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /**
224
+ * Default loopback port the hub binds. Mirrors `hub-jwt.ts`'s
225
+ * `DEFAULT_HUB_LOOPBACK` (`http://127.0.0.1:1939`). When no hub origin is
226
+ * configured (the common fresh-box case), this is where a co-located hub
227
+ * answers.
228
+ */
229
+ export const DEFAULT_HUB_LOOPBACK_PORT = 1939;
230
+
231
+ /**
232
+ * Best-effort: is a hub actually present on this host *right now*?
233
+ *
234
+ * This is distinct from {@link InstallContext.hubReachable}, which only asks
235
+ * "is a non-loopback hub *origin* configured?" (env / expose-state). On a fresh
236
+ * box the hub is installed and running on loopback, but no origin is configured
237
+ * and the operator token isn't minted yet (hub mints it only when the first
238
+ * admin user is created in the web wizard). The stale standalone-era copy
239
+ * ("install the hub …") fires off `operatorTokenPresent === false` and so
240
+ * misreads that fresh-box state as "no hub". This probe lets the copy branch on
241
+ * whether a hub is genuinely absent vs. merely not-yet-bootstrapped.
242
+ *
243
+ * Signals, cheapest-first:
244
+ * 1. A configured non-loopback hub origin (`PARACHUTE_HUB_ORIGIN` /
245
+ * expose-state) → a hub origin exists, treat as present without a probe.
246
+ * 2. A live `GET http://127.0.0.1:<hubPort>/health` returning a 2xx. The
247
+ * hub binds its own fixed loopback port (1939 by default), independent of
248
+ * the vault's listen port — so the probe always targets the hub port, not
249
+ * `chooseHubOrigin`'s vault-loopback fallback. Short timeout; any error →
250
+ * not present.
251
+ *
252
+ * `port` is the hub's loopback port (defaults to `$PARACHUTE_HUB_PORT`, else
253
+ * 1939). `fetchImpl` is an injectable test seam; `timeoutMs` keeps a dead port
254
+ * from stalling init. Never throws — returns `false` on any failure.
255
+ */
256
+ export async function detectHubPresence(opts: {
257
+ port?: number;
258
+ env?: { PARACHUTE_HUB_ORIGIN?: string | undefined; PARACHUTE_HUB_PORT?: string | undefined };
259
+ fetchImpl?: typeof fetch;
260
+ timeoutMs?: number;
261
+ } = {}): Promise<boolean> {
262
+ const env =
263
+ opts.env ?? (process.env as { PARACHUTE_HUB_ORIGIN?: string; PARACHUTE_HUB_PORT?: string });
264
+ // Port precedence: explicit arg → `$PARACHUTE_HUB_PORT` → 1939. The env
265
+ // override keeps the probe deterministic for tests + non-default-port hubs,
266
+ // so it never accidentally hits an unrelated hub on the host's 1939.
267
+ const envPort = env.PARACHUTE_HUB_PORT ? Number(env.PARACHUTE_HUB_PORT) : undefined;
268
+ const hubPort =
269
+ opts.port ?? (envPort !== undefined && Number.isFinite(envPort) ? envPort : DEFAULT_HUB_LOOPBACK_PORT);
270
+ // 1. A configured hub origin (env / expose-state) is itself a present-hub
271
+ // signal — no need to probe. We pass `hubPort` purely as the loopback
272
+ // fallback arg; its only role here is the source discriminator.
273
+ const configured = chooseHubOrigin(hubPort, env);
274
+ // A stale expose-state (or a leftover PARACHUTE_HUB_ORIGIN) can
275
+ // false-positive here. Originally this only selected guidance copy, but as
276
+ // of hub#580 it ALSO gates `vault init`'s daemon registration default
277
+ // (hub present → skip autostart). The false-positive failure mode is
278
+ // therefore: a genuinely hubless box with stale hub-origin state runs init
279
+ // without a flag and silently skips registering a daemon. Narrow + accepted
280
+ // — the operator can re-run with `--autostart`, and any explicit flag or a
281
+ // persisted `config.autostart` short-circuits the probe entirely. See the
282
+ // call site in cli.ts for the persisted-value guard.
283
+ if (configured.source !== "loopback") return true;
284
+
285
+ // 2. Live health probe against the hub's fixed loopback port.
286
+ const origin = `http://127.0.0.1:${hubPort}`;
287
+ const fetchImpl = opts.fetchImpl ?? fetch;
288
+ const timeoutMs = opts.timeoutMs ?? 800;
289
+ const controller = new AbortController();
290
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
291
+ try {
292
+ const res = await fetchImpl(`${origin}/health`, { signal: controller.signal });
293
+ return res.ok;
294
+ } catch {
295
+ return false;
296
+ } finally {
297
+ clearTimeout(timer);
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Pick the operator-facing guidance for the "no operator.token present" case,
303
+ * branched on whether a hub is genuinely absent vs. merely not-yet-bootstrapped
304
+ * (#445). Extracted as a pure function so the copy is unit-testable without
305
+ * importing cli.ts (which dispatches on import).
306
+ *
307
+ * hubPresent === true → a hub is running; the operator token just hasn't
308
+ * been minted yet (it's minted when the first admin
309
+ * user is created in the hub's web wizard). The old
310
+ * "install the hub …" advice is circular here — this
311
+ * flow was spawned *by* the hub. Tell them there's
312
+ * nothing to do and to finish in the wizard.
313
+ * hubPresent === false → genuinely standalone. Keep the original advice.
314
+ */
315
+ export function noOperatorTokenGuidance(hubPresent: boolean): string {
316
+ return hubPresent
317
+ ? "No token yet — the hub's admin wizard mints the operator token when you " +
318
+ "create the first admin user. Nothing to do here; finish setup in the wizard, " +
319
+ "then run `parachute-vault mcp-install` if you want a header-auth token for scripts."
320
+ : "No token issued — no hub operator token at ~/.parachute/operator.token. " +
321
+ "Install the hub (`bun add -g @openparachute/hub` + `parachute init`) and re-run, " +
322
+ "or set VAULT_AUTH_TOKEN for an operator-channel bearer.";
323
+ }
324
+
219
325
  // ---------------------------------------------------------------------------
220
326
  // Hub mint-token client
221
327
  // ---------------------------------------------------------------------------
package/src/mcp-tools.ts CHANGED
@@ -7,6 +7,8 @@
7
7
 
8
8
  import { generateMcpTools } from "../core/src/mcp.ts";
9
9
  import type { McpToolDef } from "../core/src/mcp.ts";
10
+ import { getNoteTags } from "../core/src/notes.ts";
11
+ import type { Note } from "../core/src/types.ts";
10
12
  import {
11
13
  buildVaultProjection,
12
14
  projectionToMarkdown,
@@ -18,6 +20,7 @@ import { hasScopeForVault, parseScopes, validateMintedScopes } from "./scopes.ts
18
20
  import type { AuthResult } from "./auth.ts";
19
21
  import {
20
22
  expandTokenTagScope,
23
+ filterHydratedLinksByTagScope,
21
24
  noteWithinTagScope,
22
25
  tagsWithinScope,
23
26
  } from "./tag-scope.ts";
@@ -117,11 +120,48 @@ export function generateScopedMcpTools(
117
120
  callerBearer?: string | null,
118
121
  ): McpToolDef[] {
119
122
  const store = getVaultStore(vaultName);
120
- const tools = generateMcpTools(store);
123
+
124
+ // Tag-scope confidentiality (security review): when the session is
125
+ // tag-scoped, build an expand-visibility predicate so `query-notes`'s
126
+ // `expand_links` inlining can't embed out-of-scope note content. The
127
+ // predicate reads from a SHARED holder that `applyTagScopeWrappers`
128
+ // populates with the resolved allowlist before core's execute runs the
129
+ // (synchronous) expansion — so by the time core calls `isVisible(note)`
130
+ // the allowlist is ready. Core stays scope-unaware: it only receives the
131
+ // plain closure. Unscoped sessions pass no predicate (unchanged path).
132
+ const scoped = Boolean(auth?.scoped_tags && auth.scoped_tags.length > 0);
133
+ const allowedHolder: { value: Set<string> | null } = { value: null };
134
+ const rawTags = scoped ? auth!.scoped_tags : null;
135
+ const expandVisibility = scoped
136
+ ? (note: Note) => noteWithinTagScope(note, allowedHolder.value, rawTags)
137
+ : undefined;
138
+
139
+ // Tag-scope hop-guard for `near[]` (vault#439): a per-note predicate the
140
+ // core BFS consults so it refuses to traverse THROUGH out-of-scope notes —
141
+ // symmetric with find-path. Reads from the SAME shared `allowedHolder` the
142
+ // result-filter populates; the query-notes wrapper `await getAllowed()`s
143
+ // (which fills the holder) before core's execute runs the BFS, so the
144
+ // allowlist is ready by the time this fires. Looks up each candidate note's
145
+ // tags by id (sync, core-native). Unscoped sessions install no predicate.
146
+ const nearTraversable = scoped
147
+ ? (noteId: string) =>
148
+ noteWithinTagScope(
149
+ { id: noteId, tags: getNoteTags(store.db, noteId) } as Note,
150
+ allowedHolder.value,
151
+ rawTags,
152
+ )
153
+ : undefined;
154
+
155
+ const tools = generateMcpTools(
156
+ store,
157
+ expandVisibility || nearTraversable
158
+ ? { ...(expandVisibility ? { expandVisibility } : {}), ...(nearTraversable ? { nearTraversable } : {}) }
159
+ : undefined,
160
+ );
121
161
 
122
162
  overrideVaultInfo(tools, vaultName, auth);
123
163
  applyTagDependencyGuards(tools, vaultName);
124
- applyTagScopeWrappers(tools, vaultName, auth);
164
+ applyTagScopeWrappers(tools, vaultName, auth, allowedHolder);
125
165
 
126
166
  // manage-token is server-only (needs token-store + auth context), so it
127
167
  // lives here rather than in core. Always appended to the surface; the
@@ -181,6 +221,7 @@ function applyTagScopeWrappers(
181
221
  tools: McpToolDef[],
182
222
  vaultName: string,
183
223
  auth: AuthResult | undefined,
224
+ allowedHolder?: { value: Set<string> | null },
184
225
  ): void {
185
226
  if (!auth || !auth.scoped_tags || auth.scoped_tags.length === 0) return;
186
227
  const store = getVaultStore(vaultName);
@@ -188,12 +229,40 @@ function applyTagScopeWrappers(
188
229
  let allowedPromise: Promise<Set<string> | null> | null = null;
189
230
  const getAllowed = (): Promise<Set<string> | null> => {
190
231
  if (!allowedPromise) {
191
- allowedPromise = expandTokenTagScope(store, auth.scoped_tags);
232
+ allowedPromise = expandTokenTagScope(store, auth.scoped_tags).then((a) => {
233
+ // Publish the resolved allowlist into the shared holder so the
234
+ // expand-visibility predicate (wired in generateScopedMcpTools and
235
+ // baked into the query-notes expand context) sees the same set.
236
+ // The query-notes wrapper awaits getAllowed() before calling the
237
+ // core execute that runs expansion, so the holder is populated in
238
+ // time. Security review: closes the expand_links content leak.
239
+ if (allowedHolder) allowedHolder.value = a;
240
+ return a;
241
+ });
192
242
  }
193
243
  return allowedPromise;
194
244
  };
195
245
  const rawTags = auth.scoped_tags;
196
246
 
247
+ // Scrub a returned note's hydrated `links` array (present when the caller
248
+ // set `include_links`) so out-of-scope NEIGHBOR summaries (id/path/tags)
249
+ // don't leak — symmetric with the REST `include_links` fix. Mutates in
250
+ // place and returns the note for chaining. No-op when `links` is absent.
251
+ //
252
+ // Ordering invariant: reading `allowedHolder.value` here is safe ONLY
253
+ // because every wrapper that calls scrubNoteLinks first does
254
+ // `await getAllowed()` (which populates the holder) before `orig(params)`
255
+ // and before this scrub runs. So by the time we read `holder.value` it is
256
+ // the resolved allowlist, never the initial `null`. The `?? null` fallback
257
+ // is the unscoped/holder-absent path; `filterHydratedLinksByTagScope` then
258
+ // keys off `rawTags` (non-null here) for the actual scope check.
259
+ const scrubNoteLinks = (n: any): any => {
260
+ if (n && Array.isArray(n.links)) {
261
+ n.links = filterHydratedLinksByTagScope(n.links, allowedHolder?.value ?? null, rawTags);
262
+ }
263
+ return n;
264
+ };
265
+
197
266
  wrapReadTool(tools, "query-notes", async (orig, params) => {
198
267
  const allowed = await getAllowed();
199
268
  const result = await orig(params);
@@ -203,7 +272,9 @@ function applyTagScopeWrappers(
203
272
  // - `{notes, next_cursor}` (cursor mode, vault#313)
204
273
  // - `{...note}` with `id`+`tags` (single-note by id)
205
274
  if (Array.isArray(result)) {
206
- return result.filter((n: any) => noteWithinTagScope(n, allowed, rawTags));
275
+ return result
276
+ .filter((n: any) => noteWithinTagScope(n, allowed, rawTags))
277
+ .map(scrubNoteLinks);
207
278
  }
208
279
  if (
209
280
  result &&
@@ -214,13 +285,15 @@ function applyTagScopeWrappers(
214
285
  ) {
215
286
  const r = result as { notes: any[]; next_cursor: string | null };
216
287
  return {
217
- notes: r.notes.filter((n: any) => noteWithinTagScope(n, allowed, rawTags)),
288
+ notes: r.notes
289
+ .filter((n: any) => noteWithinTagScope(n, allowed, rawTags))
290
+ .map(scrubNoteLinks),
218
291
  next_cursor: r.next_cursor,
219
292
  };
220
293
  }
221
294
  if (result && typeof result === "object" && "id" in result && "tags" in result) {
222
295
  return noteWithinTagScope(result as any, allowed, rawTags)
223
- ? result
296
+ ? scrubNoteLinks(result)
224
297
  : { error: "Note not found", id: (result as any).id };
225
298
  }
226
299
  return result;
@@ -463,7 +536,7 @@ function resolveHubOrigin(): { url: string; source: string } {
463
536
  *
464
537
  * After the auth-unification arc (vault#403, MGT) the tool is a thin proxy to
465
538
  * hub's mint-token attenuation endpoint: it mints short-TTL HUB JWTs. The
466
- * `pvt_*` vault-DB mint infra it replaced was removed at 0.6.0 (vault#282
539
+ * `pvt_*` vault-DB mint infra it replaced was removed at 0.5.0 (vault#282
467
540
  * Stage 2 — vault is a pure hub resource-server).
468
541
  *
469
542
  * Closure-captured context: