@openparachute/vault 0.6.0 → 0.6.2-rc.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/README.md +31 -6
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- package/core/src/links.ts +76 -21
- package/core/src/mcp.ts +53 -1
- package/core/src/notes.ts +128 -40
- package/core/src/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +30 -1
- package/package.json +1 -1
- package/src/cli.ts +90 -25
- package/src/content-range-routes.test.ts +178 -0
- package/src/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- package/src/init-summary.test.ts +125 -125
- package/src/init-summary.ts +89 -54
- package/src/init.test.ts +128 -0
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-remote-guard.test.ts +269 -0
- package/src/mirror-remote-guard.ts +273 -0
- package/src/mirror-routes.test.ts +1118 -46
- package/src/mirror-routes.ts +405 -32
- package/src/routes.ts +69 -3
- package/src/routing.ts +8 -0
- package/src/vault.test.ts +56 -0
- package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-CGL256oe.js +0 -60
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression pins for the 2026-06-10 query-perf work:
|
|
3
|
+
*
|
|
4
|
+
* 1. Plain `{field: value}` metadata equality on an INDEXED field now routes
|
|
5
|
+
* through the generated column as an index prefilter, keeping the
|
|
6
|
+
* original json_extract clause as a residual predicate. Results must be
|
|
7
|
+
* IDENTICAL to the JSON-scan path — property-tested here by giving every
|
|
8
|
+
* note twin fields (`*_idx` indexed, `*_raw` not) carrying the SAME
|
|
9
|
+
* value, then asserting every probe returns the same ids through both.
|
|
10
|
+
*
|
|
11
|
+
* 2. Tag membership is a semijoin (no `JOIN note_tags` + `DISTINCT n.*`),
|
|
12
|
+
* so a note matched by SEVERAL tags in one query must still appear once.
|
|
13
|
+
*
|
|
14
|
+
* 3. `getLinksHydratedForNotes` (the batched include_links path) must agree
|
|
15
|
+
* with per-note `getLinksHydrated`, including self-loops and links whose
|
|
16
|
+
* endpoints are both on the page.
|
|
17
|
+
*/
|
|
18
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
19
|
+
import { Database } from "bun:sqlite";
|
|
20
|
+
import { SqliteStore } from "./store.js";
|
|
21
|
+
import { queryNotes } from "./notes.js";
|
|
22
|
+
import { getLinksHydrated, getLinksHydratedForNotes } from "./links.js";
|
|
23
|
+
|
|
24
|
+
let db: Database;
|
|
25
|
+
let store: SqliteStore;
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
db = new Database(":memory:");
|
|
29
|
+
store = new SqliteStore(db);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe("plain metadata equality: indexed routing is result-identical to the JSON scan", () => {
|
|
33
|
+
/**
|
|
34
|
+
* Edge values exercised through BOTH paths. Some of these never match
|
|
35
|
+
* (today's documented scan behavior — e.g. a JS number binds as its JSON
|
|
36
|
+
* text and SQLite won't equate INTEGER 5 with TEXT '5'); the pin is that
|
|
37
|
+
* indexed routing reproduces the scan's verdict EXACTLY, match or not.
|
|
38
|
+
*/
|
|
39
|
+
const STORED_VALUES: unknown[] = [
|
|
40
|
+
"pending",
|
|
41
|
+
"",
|
|
42
|
+
"5", // numeric-looking string
|
|
43
|
+
5, // JSON number — affinity edge case vs "5"
|
|
44
|
+
0,
|
|
45
|
+
-1.5,
|
|
46
|
+
true,
|
|
47
|
+
false,
|
|
48
|
+
null,
|
|
49
|
+
"naïve-Ünïcode ✨",
|
|
50
|
+
{ nested: { deep: 1 } },
|
|
51
|
+
["a", "b"],
|
|
52
|
+
undefined, // field absent from metadata
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
const PROBES: unknown[] = [
|
|
56
|
+
"pending",
|
|
57
|
+
"",
|
|
58
|
+
"5",
|
|
59
|
+
5,
|
|
60
|
+
0,
|
|
61
|
+
-1.5,
|
|
62
|
+
true,
|
|
63
|
+
false,
|
|
64
|
+
null,
|
|
65
|
+
"naïve-Ünïcode ✨",
|
|
66
|
+
"missing-everywhere",
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
async function seedTwinFieldFixture(sqliteType: "string" | "integer") {
|
|
70
|
+
// `status_idx` is indexed (string) via a tag schema; `status_raw` is not.
|
|
71
|
+
await store.upsertTagRecord("thing", {
|
|
72
|
+
fields: { status_idx: { type: sqliteType, indexed: true } },
|
|
73
|
+
});
|
|
74
|
+
for (let i = 0; i < STORED_VALUES.length; i++) {
|
|
75
|
+
const v = STORED_VALUES[i];
|
|
76
|
+
const metadata: Record<string, unknown> = { seq: i };
|
|
77
|
+
if (v !== undefined) {
|
|
78
|
+
metadata.status_idx = v;
|
|
79
|
+
metadata.status_raw = v;
|
|
80
|
+
}
|
|
81
|
+
await store.createNote(`note ${i}`, { tags: ["thing"], metadata });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
for (const sqliteType of ["string", "integer"] as const) {
|
|
86
|
+
it(`every probe matches the same notes through the indexed and scan paths (${sqliteType} column)`, async () => {
|
|
87
|
+
await seedTwinFieldFixture(sqliteType);
|
|
88
|
+
for (const probe of PROBES) {
|
|
89
|
+
const viaIndexed = queryNotes(db, { metadata: { status_idx: probe } }).map((n) => n.id);
|
|
90
|
+
const viaScan = queryNotes(db, { metadata: { status_raw: probe } }).map((n) => n.id);
|
|
91
|
+
expect(viaIndexed).toEqual(viaScan);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
it("string probe on the indexed field actually matches (sanity: not vacuous)", async () => {
|
|
97
|
+
await seedTwinFieldFixture("string");
|
|
98
|
+
const hits = queryNotes(db, { metadata: { status_idx: "pending" } });
|
|
99
|
+
expect(hits.length).toBe(1);
|
|
100
|
+
expect(hits[0]!.metadata?.seq).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("indexed plain equality agrees with the operator form for string values", async () => {
|
|
104
|
+
await seedTwinFieldFixture("string");
|
|
105
|
+
for (const probe of ["pending", "", "missing-everywhere", "naïve-Ünïcode ✨"]) {
|
|
106
|
+
const plain = queryNotes(db, { metadata: { status_idx: probe } }).map((n) => n.id);
|
|
107
|
+
const operator = queryNotes(db, { metadata: { status_idx: { eq: probe } } }).map((n) => n.id);
|
|
108
|
+
expect(plain).toEqual(operator);
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("plain equality on a non-indexed field still works (no FIELD_NOT_INDEXED error)", async () => {
|
|
113
|
+
await store.createNote("a", { metadata: { color: "blue" } });
|
|
114
|
+
await store.createNote("b", { metadata: { color: "red" } });
|
|
115
|
+
const hits = queryNotes(db, { metadata: { color: "blue" } });
|
|
116
|
+
expect(hits.length).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("tag semijoin: no duplicate rows without DISTINCT", () => {
|
|
121
|
+
it("a note carrying several matching tags appears once under tag_match=any", async () => {
|
|
122
|
+
await store.upsertTagRecord("capture/voice", { parent_names: ["capture"] });
|
|
123
|
+
await store.upsertTagRecord("capture/text", { parent_names: ["capture"] });
|
|
124
|
+
// Tagged with the parent AND two children — the old JOIN produced 3 rows.
|
|
125
|
+
const note = await store.createNote("multi", {
|
|
126
|
+
tags: ["capture", "capture/voice", "capture/text"],
|
|
127
|
+
});
|
|
128
|
+
await store.createNote("single", { tags: ["capture/voice"] });
|
|
129
|
+
|
|
130
|
+
const viaExpansion = await store.queryNotes({ tags: ["capture"] });
|
|
131
|
+
expect(viaExpansion.map((n) => n.id).filter((id) => id === note.id).length).toBe(1);
|
|
132
|
+
expect(viaExpansion.length).toBe(2);
|
|
133
|
+
|
|
134
|
+
const viaAny = await store.queryNotes({
|
|
135
|
+
tags: ["capture", "capture/voice", "capture/text"],
|
|
136
|
+
tagMatch: "any",
|
|
137
|
+
});
|
|
138
|
+
expect(viaAny.map((n) => n.id).filter((id) => id === note.id).length).toBe(1);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("tag_match=all still requires every input tag", async () => {
|
|
142
|
+
const both = await store.createNote("both", { tags: ["a", "b"] });
|
|
143
|
+
await store.createNote("only-a", { tags: ["a"] });
|
|
144
|
+
const hits = await store.queryNotes({ tags: ["a", "b"], tagMatch: "all" });
|
|
145
|
+
expect(hits.map((n) => n.id)).toEqual([both.id]);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("getLinksHydratedForNotes agrees with per-note getLinksHydrated", () => {
|
|
150
|
+
it("matches per-note hydration across self-loops, shared links, and isolated notes", async () => {
|
|
151
|
+
const a = await store.createNote("a", { tags: ["x"] });
|
|
152
|
+
const b = await store.createNote("b", { tags: ["y"], metadata: { k: 1 } });
|
|
153
|
+
const c = await store.createNote("c", { path: "Notes/c" });
|
|
154
|
+
const lonely = await store.createNote("lonely");
|
|
155
|
+
|
|
156
|
+
await store.createLink(a.id, b.id, "mentions"); // both endpoints on page
|
|
157
|
+
await store.createLink(b.id, a.id, "replies"); // reverse direction
|
|
158
|
+
await store.createLink(a.id, a.id, "self"); // self-loop
|
|
159
|
+
await store.createLink(c.id, a.id, "references"); // inbound from page note
|
|
160
|
+
await store.createLink(b.id, c.id, "cites", { via: "test" });
|
|
161
|
+
|
|
162
|
+
const pageIds = [a.id, b.id, c.id, lonely.id];
|
|
163
|
+
const batch = getLinksHydratedForNotes(db, pageIds);
|
|
164
|
+
|
|
165
|
+
for (const id of pageIds) {
|
|
166
|
+
const single = getLinksHydrated(db, id);
|
|
167
|
+
const batched = batch.get(id)!;
|
|
168
|
+
const key = (l: { sourceId: string; targetId: string; relationship: string }) =>
|
|
169
|
+
`${l.sourceId}→${l.targetId}:${l.relationship}`;
|
|
170
|
+
// Same link set per note…
|
|
171
|
+
expect(batched.map(key).sort()).toEqual(single.map(key).sort());
|
|
172
|
+
// …ordered newest-first like the single-note SQL.
|
|
173
|
+
const stamps = batched.map((l) => l.createdAt);
|
|
174
|
+
expect([...stamps].sort().reverse()).toEqual(stamps);
|
|
175
|
+
// …with identical hydrated summaries (path, tags, metadata).
|
|
176
|
+
const summaries = (ls: typeof single) =>
|
|
177
|
+
new Map(ls.map((l) => [key(l), JSON.stringify([l.sourceNote, l.targetNote])]));
|
|
178
|
+
expect(summaries(batched)).toEqual(summaries(single));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// The self-loop appears once in a's list (not duplicated by the
|
|
182
|
+
// source/target double-walk), matching the single-note query.
|
|
183
|
+
const selfLoops = batch.get(a.id)!.filter((l) => l.sourceId === a.id && l.targetId === a.id);
|
|
184
|
+
expect(selfLoops.length).toBe(1);
|
|
185
|
+
|
|
186
|
+
expect(batch.get(lonely.id)).toEqual([]);
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("v22 keyset index", () => {
|
|
191
|
+
it("exists after initSchema and covers (updated_at, id)", () => {
|
|
192
|
+
const row = db
|
|
193
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_notes_updated'")
|
|
194
|
+
.get();
|
|
195
|
+
expect(row).toBeTruthy();
|
|
196
|
+
const plan = db
|
|
197
|
+
.prepare(
|
|
198
|
+
`EXPLAIN QUERY PLAN
|
|
199
|
+
SELECT n.id FROM notes n
|
|
200
|
+
WHERE (n.updated_at > ? OR (n.updated_at = ? AND n.id > ?))
|
|
201
|
+
ORDER BY n.updated_at ASC, n.id ASC LIMIT 10`,
|
|
202
|
+
)
|
|
203
|
+
.all("2025-01-01", "2025-01-01", "x") as { detail: string }[];
|
|
204
|
+
const details = plan.map((r) => r.detail).join(" | ");
|
|
205
|
+
expect(details).toContain("idx_notes_updated");
|
|
206
|
+
expect(details).not.toContain("TEMP B-TREE");
|
|
207
|
+
});
|
|
208
|
+
});
|
package/core/src/schema.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { Database } from "bun:sqlite";
|
|
|
2
2
|
import { normalizePath } from "./paths.js";
|
|
3
3
|
import { rebuildIndexes } from "./indexed-fields.js";
|
|
4
4
|
|
|
5
|
-
export const SCHEMA_VERSION =
|
|
5
|
+
export const SCHEMA_VERSION = 22;
|
|
6
6
|
|
|
7
7
|
export const SCHEMA_SQL = `
|
|
8
8
|
-- Notes: the universal record.
|
|
@@ -474,6 +474,11 @@ export function initSchema(db: Database): void {
|
|
|
474
474
|
// above, so this is a defensive confirmation hook for upgrading vaults.
|
|
475
475
|
migrateToV21(db);
|
|
476
476
|
|
|
477
|
+
// Migrate v21 → v22: composite index notes(updated_at, id) backing cursor
|
|
478
|
+
// keyset pagination and date_filter on updated_at — both were full table
|
|
479
|
+
// scans. See the 2026-06-10 query-perf measurements.
|
|
480
|
+
migrateToV22(db);
|
|
481
|
+
|
|
477
482
|
// Rebuild any generated columns + indexes declared in indexed_fields.
|
|
478
483
|
// No-op for a fresh vault; idempotent on existing vaults.
|
|
479
484
|
rebuildIndexes(db);
|
|
@@ -1121,6 +1126,30 @@ function migrateToV21(db: Database): void {
|
|
|
1121
1126
|
`);
|
|
1122
1127
|
}
|
|
1123
1128
|
|
|
1129
|
+
/**
|
|
1130
|
+
* Migrate v21 → v22: composite B-tree on notes(updated_at, id).
|
|
1131
|
+
*
|
|
1132
|
+
* Two query shapes ride it (2026-06-10 perf measurements — both were full
|
|
1133
|
+
* table scans + temp-B-tree sorts before):
|
|
1134
|
+
*
|
|
1135
|
+
* 1. Cursor keyset pagination (vault#313): the predicate
|
|
1136
|
+
* `updated_at > ? OR (updated_at = ? AND id > ?)` with
|
|
1137
|
+
* `ORDER BY updated_at ASC, id ASC` matches the composite key exactly,
|
|
1138
|
+
* so a "since last checked" poll seeks straight to the watermark and
|
|
1139
|
+
* streams in order — no scan, no sort.
|
|
1140
|
+
* 2. `date_filter: { field: "updated_at" }` range queries (incremental
|
|
1141
|
+
* rebuild flows, vault#285 1.5).
|
|
1142
|
+
*
|
|
1143
|
+
* Lives here (not SCHEMA_SQL) following the idx_tokens_vault_name precedent:
|
|
1144
|
+
* migrations run after every column-shape change, so the index statement
|
|
1145
|
+
* never races an older table shape. Fresh vaults reach this through the
|
|
1146
|
+
* same initSchema path — CREATE INDEX IF NOT EXISTS is idempotent.
|
|
1147
|
+
*/
|
|
1148
|
+
function migrateToV22(db: Database): void {
|
|
1149
|
+
if (!hasTable(db, "notes")) return;
|
|
1150
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_notes_updated ON notes(updated_at, id)");
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1124
1153
|
function hasTable(db: Database, name: string): boolean {
|
|
1125
1154
|
const row = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(name);
|
|
1126
1155
|
return !!row;
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
buildMcpEntryPlan,
|
|
58
58
|
chooseHubOrigin,
|
|
59
59
|
chooseMcpUrl,
|
|
60
|
+
DEFAULT_HUB_LOOPBACK_PORT,
|
|
60
61
|
detectHubPresence,
|
|
61
62
|
detectInstallContext,
|
|
62
63
|
mintHubJwt,
|
|
@@ -258,21 +259,60 @@ switch (command) {
|
|
|
258
259
|
// Command implementations
|
|
259
260
|
// ---------------------------------------------------------------------------
|
|
260
261
|
|
|
262
|
+
/**
|
|
263
|
+
* Resolve the origin to use for the web setup wizard link (`<origin>/admin/setup`).
|
|
264
|
+
*
|
|
265
|
+
* The wizard is served by the HUB, not by vault, so the loopback fallback must
|
|
266
|
+
* target the hub's fixed loopback port (1939 / $PARACHUTE_HUB_PORT) — NOT
|
|
267
|
+
* vault's listen port. `chooseHubOrigin` returns vault's loopback as its
|
|
268
|
+
* fallback, so we only reuse it when a real (env / expose-state) hub origin is
|
|
269
|
+
* configured; otherwise we synthesize the hub's loopback URL.
|
|
270
|
+
*
|
|
271
|
+
* `vaultPort` is vault's listen port — passed only so `chooseHubOrigin`'s
|
|
272
|
+
* loopback branch is well-formed; we discard that loopback URL in favor of the
|
|
273
|
+
* hub-port one.
|
|
274
|
+
*/
|
|
275
|
+
function resolveHubOriginForWizard(vaultPort: number): string {
|
|
276
|
+
const { url, source } = chooseHubOrigin(vaultPort);
|
|
277
|
+
if (source === "loopback") {
|
|
278
|
+
// Guard against a non-numeric PARACHUTE_HUB_PORT producing
|
|
279
|
+
// `http://127.0.0.1:NaN` — mirror detectHubPresence's Number.isFinite guard.
|
|
280
|
+
const envPort = process.env.PARACHUTE_HUB_PORT
|
|
281
|
+
? Number(process.env.PARACHUTE_HUB_PORT)
|
|
282
|
+
: undefined;
|
|
283
|
+
const hubPort = Number.isFinite(envPort) ? (envPort as number) : DEFAULT_HUB_LOOPBACK_PORT;
|
|
284
|
+
return `http://127.0.0.1:${hubPort}`;
|
|
285
|
+
}
|
|
286
|
+
return url;
|
|
287
|
+
}
|
|
288
|
+
|
|
261
289
|
async function cmdInit(args: string[] = []) {
|
|
262
290
|
ensureConfigDirSync();
|
|
263
291
|
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
292
|
+
// Writing the Claude Code MCP config (~/.claude.json) is now OPT-IN
|
|
293
|
+
// (2026-06-23). init's primary job is to get the operator to the hub's
|
|
294
|
+
// web setup wizard and SURFACE the self-serve connection info (connector
|
|
295
|
+
// URL + a ready-to-paste `claude mcp add` command), NOT to silently write
|
|
296
|
+
// a config file as a side effect of setup. The site no longer claims
|
|
297
|
+
// "Claude Code is auto-configured," so the install code stops doing it by
|
|
298
|
+
// default.
|
|
299
|
+
//
|
|
300
|
+
// Opt in with --configure-claude-code (aliases --mcp-install, --mcp) to
|
|
301
|
+
// have init write the entry for you. --no-mcp is retained as the explicit
|
|
302
|
+
// "definitely don't" form (and wins if both are passed — safer default).
|
|
303
|
+
// The standalone `parachute-vault mcp-install` command is unchanged — it
|
|
304
|
+
// remains the canonical explicit opt-in path.
|
|
268
305
|
//
|
|
269
|
-
// --token / --no-token
|
|
270
|
-
//
|
|
271
|
-
//
|
|
306
|
+
// --token / --no-token control whether init ALSO mints + surfaces a
|
|
307
|
+
// header-auth API token (for pasting into non-OAuth MCP clients, scripts,
|
|
308
|
+
// or curl). Default stays off.
|
|
272
309
|
//
|
|
273
310
|
// --vault-name <name> skips the name prompt for non-interactive installs
|
|
274
311
|
// (validated up front; exits non-zero on invalid input).
|
|
275
|
-
const flagMcpOn =
|
|
312
|
+
const flagMcpOn =
|
|
313
|
+
args.includes("--configure-claude-code") ||
|
|
314
|
+
args.includes("--mcp-install") ||
|
|
315
|
+
args.includes("--mcp");
|
|
276
316
|
const flagMcpOff = args.includes("--no-mcp");
|
|
277
317
|
const flagTokenOn = args.includes("--token");
|
|
278
318
|
const flagTokenOff = args.includes("--no-token");
|
|
@@ -507,19 +547,28 @@ async function cmdInit(args: string[] = []) {
|
|
|
507
547
|
const bindHost = resolveBindHostname(process.env);
|
|
508
548
|
console.log(` Listening on http://${bindHost}:${globalConfig.port || DEFAULT_PORT}`);
|
|
509
549
|
|
|
510
|
-
// 7.
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
550
|
+
// 7. Optionally write the Claude Code MCP config (~/.claude.json). This is
|
|
551
|
+
// OPT-IN as of 2026-06-23 (see the flag-parsing note above). init's job is
|
|
552
|
+
// to point the operator at the web wizard + surface the self-serve connect
|
|
553
|
+
// info; it does NOT write ~/.claude.json by default. Resolution:
|
|
554
|
+
// --no-mcp → false (explicit "don't")
|
|
555
|
+
// --configure-claude-code / --mcp → true (explicit opt-in)
|
|
556
|
+
// TTY, no flag → ask (default NO — opt-in, not -out)
|
|
557
|
+
// non-TTY, no flag → false (no silent side effect in
|
|
558
|
+
// piped installs; the connect info is
|
|
559
|
+
// printed for copy-paste instead)
|
|
514
560
|
let addMcp: boolean;
|
|
515
561
|
if (flagMcpOff) {
|
|
516
562
|
addMcp = false;
|
|
517
563
|
} else if (flagMcpOn) {
|
|
518
564
|
addMcp = true;
|
|
519
565
|
} else if (process.stdin.isTTY) {
|
|
520
|
-
addMcp = await confirm(
|
|
566
|
+
addMcp = await confirm(
|
|
567
|
+
"Also write the Claude Code MCP config now (~/.claude.json)? (you can always copy-paste the command below later)",
|
|
568
|
+
false,
|
|
569
|
+
);
|
|
521
570
|
} else {
|
|
522
|
-
addMcp =
|
|
571
|
+
addMcp = false; // non-interactive: no silent ~/.claude.json write
|
|
523
572
|
}
|
|
524
573
|
|
|
525
574
|
// 7b. Mint an API token for the header-auth / script use case? (Codex,
|
|
@@ -583,14 +632,22 @@ async function cmdInit(args: string[] = []) {
|
|
|
583
632
|
if (!apiKey) {
|
|
584
633
|
console.log(` No token baked in — you'll sign in via OAuth on first connect.`);
|
|
585
634
|
}
|
|
586
|
-
} else {
|
|
587
|
-
console.log(" Skipped adding MCP to ~/.claude.json.");
|
|
588
|
-
console.log(" Run `parachute-vault mcp-install` later if you want it.");
|
|
589
635
|
}
|
|
636
|
+
// No else: when the operator didn't opt in, the init summary below surfaces
|
|
637
|
+
// the connector URL + a copy-paste `claude mcp add` command instead of a
|
|
638
|
+
// "skipped" line — that's the self-serve path.
|
|
590
639
|
|
|
591
640
|
// 8. Summary
|
|
592
641
|
const port = globalConfig.port || DEFAULT_PORT;
|
|
593
|
-
|
|
642
|
+
// Connector URL surfaced for self-serve copy-paste. Hub-origin / expose-state
|
|
643
|
+
// aware (chooseMcpUrl), so it's the URL a remote Claude Code / other client
|
|
644
|
+
// actually reaches, not a bare loopback. The init-summary prints a
|
|
645
|
+
// ready-to-paste `claude mcp add ...` built from this.
|
|
646
|
+
const { url: connectorUrl } = chooseMcpUrl(defaultVault, port);
|
|
647
|
+
// Web setup wizard lives on the hub at <hub-origin>/admin/setup. Resolve the
|
|
648
|
+
// hub origin the same way (env / expose-state / loopback); the loopback form
|
|
649
|
+
// points at the co-located hub's fixed port (1939), not vault's listen port.
|
|
650
|
+
const wizardUrl = `${resolveHubOriginForWizard(port)}/admin/setup`;
|
|
594
651
|
// Probe whether a hub is present so the summary's "opted into a token but
|
|
595
652
|
// none minted" copy reflects reality: under a hub the vault is reachable via
|
|
596
653
|
// browser OAuth even with no header-auth token (#445). Only matters for the
|
|
@@ -603,7 +660,8 @@ async function cmdInit(args: string[] = []) {
|
|
|
603
660
|
configDir: CONFIG_DIR,
|
|
604
661
|
bindHost,
|
|
605
662
|
port,
|
|
606
|
-
mcpUrl,
|
|
663
|
+
mcpUrl: connectorUrl,
|
|
664
|
+
wizardUrl,
|
|
607
665
|
vaultName: defaultVault,
|
|
608
666
|
noTokenGuidance: credentialGuidance,
|
|
609
667
|
hubPresent,
|
|
@@ -3714,12 +3772,19 @@ data, and debugging.
|
|
|
3714
3772
|
── Standard use ───────────────────────────────────────────────────────
|
|
3715
3773
|
|
|
3716
3774
|
Setup:
|
|
3717
|
-
parachute-vault init [--
|
|
3718
|
-
[--autostart|--no-autostart]
|
|
3719
|
-
Set up everything (one command, idempotent).
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3775
|
+
parachute-vault init [--configure-claude-code|--no-mcp] [--token|--no-token]
|
|
3776
|
+
[--vault-name <name>] [--autostart|--no-autostart]
|
|
3777
|
+
Set up everything (one command, idempotent). init's
|
|
3778
|
+
job is to get you to the web setup wizard and surface
|
|
3779
|
+
your connector URL + a ready-to-paste \`claude mcp add\`
|
|
3780
|
+
command — it does NOT write the Claude Code MCP config
|
|
3781
|
+
(~/.claude.json) by default. Pass --configure-claude-code
|
|
3782
|
+
(alias --mcp-install / --mcp) to opt in and have init
|
|
3783
|
+
write that entry for you (per-user OAuth — no baked token;
|
|
3784
|
+
sign in on first connect); --no-mcp is the explicit "don't".
|
|
3785
|
+
The standalone \`parachute-vault mcp-install\` command remains
|
|
3786
|
+
the canonical way to wire Claude Code anytime.
|
|
3787
|
+
--token opts into ALSO minting a scope-narrow
|
|
3723
3788
|
header-auth token (vault:<name>:read) for non-OAuth clients /
|
|
3724
3789
|
scripts; --no-token (the default) skips minting entirely.
|
|
3725
3790
|
--vault-name skips the prompt and names the vault
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* REST face of content range / pagination (bounded reads for large notes).
|
|
3
|
+
*
|
|
4
|
+
* Exercises all four GET shapes that accept `content_offset` /
|
|
5
|
+
* `content_length`:
|
|
6
|
+
* - GET /notes?id=… (single, folded into the collection route)
|
|
7
|
+
* - GET /notes/:idOrPath (single point read)
|
|
8
|
+
* - GET /notes?… (structured list)
|
|
9
|
+
* - GET /notes?search=… (full-text list)
|
|
10
|
+
*
|
|
11
|
+
* Slice mechanics (codepoint boundaries, reassembly invariant) are pinned
|
|
12
|
+
* in core/src/content-range.test.ts; this suite covers the HTTP wiring:
|
|
13
|
+
* param parsing, 400s, per-shape application, and the no-params
|
|
14
|
+
* regression. Fully sandboxed — in-memory SQLite, no daemon.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
18
|
+
import { Database } from "bun:sqlite";
|
|
19
|
+
import { BunStore } from "./vault-store.ts";
|
|
20
|
+
import { handleNotes } from "./routes.ts";
|
|
21
|
+
|
|
22
|
+
let db: Database;
|
|
23
|
+
let store: BunStore;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
db = new Database(":memory:");
|
|
27
|
+
store = new BunStore(db);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
afterEach(() => {
|
|
31
|
+
db.close();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const BASE = "http://localhost/api";
|
|
35
|
+
|
|
36
|
+
function get(path: string): Promise<Response> {
|
|
37
|
+
return handleNotes(new Request(`${BASE}/notes${path}`, { method: "GET" }), store, pathSub(path));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Extract the handleNotes subpath ("/<id>" for point reads, "" otherwise). */
|
|
41
|
+
function pathSub(path: string): string {
|
|
42
|
+
const m = path.match(/^\/([^?/]+)/);
|
|
43
|
+
return m ? `/${m[1]}` : "";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("REST content range — single note", () => {
|
|
47
|
+
it("GET /notes?id=… honors content_length and adds the range fields", async () => {
|
|
48
|
+
const note = await store.createNote("0123456789");
|
|
49
|
+
const res = await get(`?id=${note.id}&content_length=4`);
|
|
50
|
+
expect(res.status).toBe(200);
|
|
51
|
+
const body: any = await res.json();
|
|
52
|
+
expect(body.content).toBe("0123");
|
|
53
|
+
expect(body.content_offset).toBe(0);
|
|
54
|
+
expect(body.content_total_length).toBe(10);
|
|
55
|
+
expect(body.content_next_offset).toBe(4);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("GET /notes/:id honors content_offset (tail read to the end)", async () => {
|
|
59
|
+
const note = await store.createNote("hello world", { path: "tail-note" });
|
|
60
|
+
const res = await get(`/${note.id}?content_offset=6`);
|
|
61
|
+
expect(res.status).toBe(200);
|
|
62
|
+
const body: any = await res.json();
|
|
63
|
+
expect(body.content).toBe("world");
|
|
64
|
+
expect(body.content_total_length).toBe(11);
|
|
65
|
+
expect(body.content_next_offset).toBeNull();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("paged loop over REST reassembles multi-byte content byte-identically", async () => {
|
|
69
|
+
const content = "ab\u{1F600}cd 你好 ".repeat(20).trim();
|
|
70
|
+
const note = await store.createNote(content);
|
|
71
|
+
let offset = 0;
|
|
72
|
+
let assembled = "";
|
|
73
|
+
for (;;) {
|
|
74
|
+
const res = await get(`/${note.id}?content_offset=${offset}&content_length=16`);
|
|
75
|
+
expect(res.status).toBe(200);
|
|
76
|
+
const body: any = await res.json();
|
|
77
|
+
expect(Buffer.byteLength(body.content, "utf8")).toBeLessThanOrEqual(16);
|
|
78
|
+
assembled += body.content;
|
|
79
|
+
if (body.content_next_offset === null) break;
|
|
80
|
+
offset = body.content_next_offset;
|
|
81
|
+
}
|
|
82
|
+
expect(assembled).toBe(content);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("offset past end → 200 with empty slice and null next_offset", async () => {
|
|
86
|
+
const note = await store.createNote("abc");
|
|
87
|
+
const res = await get(`?id=${note.id}&content_offset=500`);
|
|
88
|
+
expect(res.status).toBe(200);
|
|
89
|
+
const body: any = await res.json();
|
|
90
|
+
expect(body.content).toBe("");
|
|
91
|
+
expect(body.content_next_offset).toBeNull();
|
|
92
|
+
expect(body.content_total_length).toBe(3);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("include_content=false + range params → 400 INVALID_QUERY", async () => {
|
|
96
|
+
const note = await store.createNote("abc");
|
|
97
|
+
const res = await get(`?id=${note.id}&include_content=false&content_length=8`);
|
|
98
|
+
expect(res.status).toBe(400);
|
|
99
|
+
const body: any = await res.json();
|
|
100
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
101
|
+
expect(body.error).toContain("include_content");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("zero / sub-minimum / malformed budgets → 400 INVALID_QUERY", async () => {
|
|
105
|
+
const note = await store.createNote("abc");
|
|
106
|
+
for (const qs of ["content_length=0", "content_length=2", "content_length=abc", "content_offset=-1"]) {
|
|
107
|
+
const res = await get(`?id=${note.id}&${qs}`);
|
|
108
|
+
expect(res.status).toBe(400);
|
|
109
|
+
const body: any = await res.json();
|
|
110
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("no range params → response shape unchanged (regression)", async () => {
|
|
115
|
+
const note = await store.createNote("plain body", { path: "plain" });
|
|
116
|
+
const res = await get(`/${note.id}`);
|
|
117
|
+
expect(res.status).toBe(200);
|
|
118
|
+
const body: any = await res.json();
|
|
119
|
+
expect(body.content).toBe("plain body");
|
|
120
|
+
expect("content_total_length" in body).toBe(false);
|
|
121
|
+
expect("content_next_offset" in body).toBe(false);
|
|
122
|
+
expect("content_offset" in body).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe("REST content range — list shapes", () => {
|
|
127
|
+
it("structured list with include_content=true applies the window per note", async () => {
|
|
128
|
+
await store.createNote("first body first body", { tags: ["paged"] });
|
|
129
|
+
await store.createNote("second body second body", { tags: ["paged"] });
|
|
130
|
+
const res = await get(`?tag=paged&include_content=true&content_length=6`);
|
|
131
|
+
expect(res.status).toBe(200);
|
|
132
|
+
const body: any[] = await res.json();
|
|
133
|
+
expect(body.length).toBe(2);
|
|
134
|
+
for (const n of body) {
|
|
135
|
+
expect(Buffer.byteLength(n.content, "utf8")).toBeLessThanOrEqual(6);
|
|
136
|
+
expect(typeof n.content_total_length).toBe("number");
|
|
137
|
+
expect(n.content_next_offset).toBe(6);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("structured list on the lean default + range params → 400", async () => {
|
|
142
|
+
await store.createNote("body", { tags: ["paged"] });
|
|
143
|
+
const res = await get(`?tag=paged&content_length=8`);
|
|
144
|
+
expect(res.status).toBe(400);
|
|
145
|
+
const body: any = await res.json();
|
|
146
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
147
|
+
expect(body.error).toContain("include_content");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("search list with include_content=true applies the window per note", async () => {
|
|
151
|
+
await store.createNote("the quick brown fox jumps over the lazy dog");
|
|
152
|
+
const res = await get(`?search=fox&include_content=true&content_length=9`);
|
|
153
|
+
expect(res.status).toBe(200);
|
|
154
|
+
const body: any[] = await res.json();
|
|
155
|
+
expect(body.length).toBe(1);
|
|
156
|
+
expect(Buffer.byteLength(body[0].content, "utf8")).toBeLessThanOrEqual(9);
|
|
157
|
+
expect(body[0].content_total_length).toBe(
|
|
158
|
+
Buffer.byteLength("the quick brown fox jumps over the lazy dog", "utf8"),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("search list on the lean default + range params → 400", async () => {
|
|
163
|
+
await store.createNote("the quick brown fox");
|
|
164
|
+
const res = await get(`?search=fox&content_length=8`);
|
|
165
|
+
expect(res.status).toBe(400);
|
|
166
|
+
const body: any = await res.json();
|
|
167
|
+
expect(body.code).toBe("INVALID_QUERY");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("list without range params → no range fields injected (regression)", async () => {
|
|
171
|
+
await store.createNote("body here", { tags: ["paged"] });
|
|
172
|
+
const res = await get(`?tag=paged&include_content=true`);
|
|
173
|
+
expect(res.status).toBe(200);
|
|
174
|
+
const body: any[] = await res.json();
|
|
175
|
+
expect("content_total_length" in body[0]).toBe(false);
|
|
176
|
+
expect("content_next_offset" in body[0]).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
});
|