@openparachute/vault 0.6.0-rc.1 → 0.6.1
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 +32 -7
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- 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 +172 -22
- package/core/src/mcp.ts +254 -34
- package/core/src/notes.ts +172 -48
- 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/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +87 -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/content-range-routes.test.ts +178 -0
- 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/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- 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-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- 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 +1331 -110
- package/src/mirror-routes.ts +787 -30
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +763 -122
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +121 -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-BPgyIjR7.js +61 -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
|
@@ -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
|
+
}
|
package/src/mcp-install.test.ts
CHANGED
|
@@ -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");
|
package/src/mcp-install.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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:
|