@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
package/src/vault-create.test.ts
CHANGED
|
@@ -10,7 +10,15 @@
|
|
|
10
10
|
|
|
11
11
|
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
12
12
|
import { resolve } from "path";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
mkdtempSync,
|
|
15
|
+
rmSync,
|
|
16
|
+
existsSync,
|
|
17
|
+
readFileSync,
|
|
18
|
+
mkdirSync,
|
|
19
|
+
writeFileSync,
|
|
20
|
+
symlinkSync,
|
|
21
|
+
} from "fs";
|
|
14
22
|
import { tmpdir } from "os";
|
|
15
23
|
import { join } from "path";
|
|
16
24
|
|
|
@@ -20,11 +28,18 @@ function runCli(
|
|
|
20
28
|
args: string[],
|
|
21
29
|
env: Record<string, string>,
|
|
22
30
|
): { exitCode: number; stdout: string; stderr: string } {
|
|
31
|
+
// Hermetic: don't inherit the dev/CI box's PARACHUTE_HUB_ORIGIN. A leaked
|
|
32
|
+
// origin makes `detectHubPresence`'s rule-1 (configured-origin) short-circuit
|
|
33
|
+
// to "hub present" with no probe, flipping the no-hub guidance copy to the
|
|
34
|
+
// "admin wizard" variant — a CI flake when the runner env has it set. Tests
|
|
35
|
+
// that genuinely want a hub origin pass it explicitly via `env`.
|
|
36
|
+
const baseEnv: Record<string, string | undefined> = { ...process.env };
|
|
37
|
+
delete baseEnv.PARACHUTE_HUB_ORIGIN;
|
|
23
38
|
const proc = Bun.spawnSync({
|
|
24
39
|
cmd: ["bun", CLI, ...args],
|
|
25
40
|
stdout: "pipe",
|
|
26
41
|
stderr: "pipe",
|
|
27
|
-
env: { ...
|
|
42
|
+
env: { ...baseEnv, ...env },
|
|
28
43
|
});
|
|
29
44
|
return {
|
|
30
45
|
exitCode: proc.exitCode ?? -1,
|
|
@@ -33,6 +48,23 @@ function runCli(
|
|
|
33
48
|
};
|
|
34
49
|
}
|
|
35
50
|
|
|
51
|
+
/** Path to a vault's per-vault mirror-config file inside a temp PARACHUTE_HOME. */
|
|
52
|
+
function mirrorConfigPath(home: string, name: string): string {
|
|
53
|
+
return join(home, "vault", "data", name, "mirror-config.yaml");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Path to a vault's internal mirror git working tree. */
|
|
57
|
+
function mirrorRepoPath(home: string, name: string): string {
|
|
58
|
+
return join(home, "vault", "data", name, "mirror");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Write a minimal config.yaml carrying a `default_mirror` knob value. */
|
|
62
|
+
function writeDefaultMirrorKnob(home: string, value: "internal" | "off"): void {
|
|
63
|
+
const dir = join(home, "vault");
|
|
64
|
+
mkdirSync(dir, { recursive: true });
|
|
65
|
+
writeFileSync(join(dir, "config.yaml"), `port: 1940\ndefault_mirror: ${value}\n`);
|
|
66
|
+
}
|
|
67
|
+
|
|
36
68
|
let home: string;
|
|
37
69
|
|
|
38
70
|
beforeEach(() => {
|
|
@@ -60,13 +92,15 @@ describe("vault create --json", () => {
|
|
|
60
92
|
expect(lines).toHaveLength(1);
|
|
61
93
|
const payload = JSON.parse(lines[0]!);
|
|
62
94
|
expect(payload.name).toBe("myvault");
|
|
63
|
-
// vault#
|
|
64
|
-
// hub's admin-vaults.ts requires still holds (`token`
|
|
65
|
-
//
|
|
66
|
-
//
|
|
95
|
+
// vault#442: default auth is per-user OAuth — `create` does NOT mint a
|
|
96
|
+
// token. The contract hub's admin-vaults.ts requires still holds (`token`
|
|
97
|
+
// is a string); it's the empty string and `token_guidance` carries the
|
|
98
|
+
// OAuth-first connect path (the hub SPA handles the empty-token case and
|
|
99
|
+
// mints admin via its own session-cookie path).
|
|
67
100
|
expect(typeof payload.token).toBe("string");
|
|
68
101
|
expect(payload.token).toBe("");
|
|
69
|
-
expect(payload.token_guidance).toContain("No token
|
|
102
|
+
expect(payload.token_guidance).toContain("No token minted");
|
|
103
|
+
expect(payload.token_guidance).toContain("per-user OAuth");
|
|
70
104
|
expect(payload.set_as_default).toBe(true);
|
|
71
105
|
expect(payload.paths.vault_dir).toBe(join(home, "vault", "data", "myvault"));
|
|
72
106
|
expect(payload.paths.vault_db).toBe(join(home, "vault", "data", "myvault", "vault.db"));
|
|
@@ -107,7 +141,47 @@ describe("vault create --json", () => {
|
|
|
107
141
|
);
|
|
108
142
|
expect(exitCode).not.toBe(0);
|
|
109
143
|
expect(stdout).toBe("");
|
|
110
|
-
expect(stderr).toContain("
|
|
144
|
+
expect(stderr).toContain("lowercase alphanumeric");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("UPPERCASE vault name is rejected (security review — audience case-drift)", () => {
|
|
148
|
+
// An uppercase name would flip the JWT audience case (vault.<Name> vs
|
|
149
|
+
// vault.<name>) and drift from hub/init lowercasing. cmdCreate must
|
|
150
|
+
// reject it the same way `init` does.
|
|
151
|
+
const { exitCode, stdout, stderr } = runCli(
|
|
152
|
+
["create", "MyVault", "--json"],
|
|
153
|
+
{ PARACHUTE_HOME: home },
|
|
154
|
+
);
|
|
155
|
+
expect(exitCode).not.toBe(0);
|
|
156
|
+
expect(stdout).toBe("");
|
|
157
|
+
expect(stderr).toContain("lowercase");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("reserved names (list/new/assets/admin) are rejected at create", () => {
|
|
161
|
+
// Consolidated reserved set (2026-06-09 hub-module-boundary B2). Before
|
|
162
|
+
// the consolidation cmdCreate hardcoded only "list" — `admin` could enter
|
|
163
|
+
// through `create` and capture the daemon-level /vault/admin mount.
|
|
164
|
+
for (const reserved of ["list", "new", "assets", "admin"]) {
|
|
165
|
+
const { exitCode, stdout, stderr } = runCli(
|
|
166
|
+
["create", reserved, "--json"],
|
|
167
|
+
{ PARACHUTE_HOME: home },
|
|
168
|
+
);
|
|
169
|
+
expect(exitCode).not.toBe(0);
|
|
170
|
+
expect(stdout).toBe("");
|
|
171
|
+
expect(stderr).toContain("reserved");
|
|
172
|
+
// The vault must not have been created.
|
|
173
|
+
expect(existsSync(join(home, "vault", "data", reserved))).toBe(false);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("near-misses of reserved names (adminx, admin2) create fine", () => {
|
|
178
|
+
for (const name of ["adminx", "admin2"]) {
|
|
179
|
+
const { exitCode, stdout } = runCli(["create", name, "--json"], {
|
|
180
|
+
PARACHUTE_HOME: home,
|
|
181
|
+
});
|
|
182
|
+
expect(exitCode).toBe(0);
|
|
183
|
+
expect(JSON.parse(stdout.trim()).name).toBe(name);
|
|
184
|
+
}
|
|
111
185
|
});
|
|
112
186
|
|
|
113
187
|
test("duplicate name in --json mode errors on stderr and exits non-zero", () => {
|
|
@@ -122,6 +196,102 @@ describe("vault create --json", () => {
|
|
|
122
196
|
});
|
|
123
197
|
});
|
|
124
198
|
|
|
199
|
+
/**
|
|
200
|
+
* vault#442: default to per-user OAuth — `create` must NOT auto-mint or bake in
|
|
201
|
+
* a shared `vault:<name>:admin` token. Token-minting is explicit opt-in
|
|
202
|
+
* (`--mint`) and scope-narrow (read/write, NEVER admin); `--token <bearer>` is
|
|
203
|
+
* the paste path. These tests pin the behavioral contract.
|
|
204
|
+
*/
|
|
205
|
+
describe("vault create — OAuth-first auth (vault#442)", () => {
|
|
206
|
+
test("default create does NOT mint a token — empty token + OAuth guidance (--json)", () => {
|
|
207
|
+
const { exitCode, stdout } = runCli(["create", "oauthy", "--json"], {
|
|
208
|
+
PARACHUTE_HOME: home,
|
|
209
|
+
});
|
|
210
|
+
expect(exitCode).toBe(0);
|
|
211
|
+
const payload = JSON.parse(stdout.trim());
|
|
212
|
+
// No token baked in — OAuth on first connect.
|
|
213
|
+
expect(payload.token).toBe("");
|
|
214
|
+
expect(payload.token_guidance).toContain("No token minted");
|
|
215
|
+
expect(payload.token_guidance).toContain("per-user OAuth");
|
|
216
|
+
// Never the admin-mint failure copy.
|
|
217
|
+
expect(payload.token_guidance).not.toContain("No token issued");
|
|
218
|
+
expect(payload.token_guidance).not.toContain("admin");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("default create leads the human summary with the OAuth connect command", () => {
|
|
222
|
+
const { exitCode, stdout } = runCli(["create", "connectme"], {
|
|
223
|
+
PARACHUTE_HOME: home,
|
|
224
|
+
});
|
|
225
|
+
expect(exitCode).toBe(0);
|
|
226
|
+
expect(stdout).toContain(
|
|
227
|
+
"Connect your AI: claude mcp add --transport http parachute-connectme",
|
|
228
|
+
);
|
|
229
|
+
expect(stdout).toContain("no token needed");
|
|
230
|
+
// Scope-narrow opt-in pointer, never admin.
|
|
231
|
+
expect(stdout).toContain("parachute auth mint-token --scope vault:connectme:read");
|
|
232
|
+
expect(stdout).not.toContain("vault:connectme:admin");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("--scope admin is rejected from the create flow (admin never mintable here)", () => {
|
|
236
|
+
const { exitCode, stdout, stderr } = runCli(
|
|
237
|
+
["create", "noadmin", "--mint", "--scope", "admin", "--json"],
|
|
238
|
+
{ PARACHUTE_HOME: home },
|
|
239
|
+
);
|
|
240
|
+
expect(exitCode).not.toBe(0);
|
|
241
|
+
expect(stdout).toBe("");
|
|
242
|
+
expect(stderr).toContain('--scope must be "read" or "write"');
|
|
243
|
+
// The vault must not have been created (rejected before createVault).
|
|
244
|
+
expect(existsSync(join(home, "vault", "data", "noadmin", "vault.db"))).toBe(false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("--mint and --token are mutually exclusive", () => {
|
|
248
|
+
const { exitCode, stderr } = runCli(
|
|
249
|
+
["create", "conflict", "--mint", "--token", "abc.def.ghi", "--json"],
|
|
250
|
+
{ PARACHUTE_HOME: home },
|
|
251
|
+
);
|
|
252
|
+
expect(exitCode).not.toBe(0);
|
|
253
|
+
expect(stderr).toContain("mutually exclusive");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("--token <bearer> paste path surfaces the supplied bearer (no mint attempted)", () => {
|
|
257
|
+
const { exitCode, stdout } = runCli(
|
|
258
|
+
["create", "pasted", "--token", "header.auth.bearer", "--json"],
|
|
259
|
+
{ PARACHUTE_HOME: home },
|
|
260
|
+
);
|
|
261
|
+
expect(exitCode).toBe(0);
|
|
262
|
+
const payload = JSON.parse(stdout.trim());
|
|
263
|
+
// The pasted bearer is surfaced verbatim — vault never minted one.
|
|
264
|
+
expect(payload.token).toBe("header.auth.bearer");
|
|
265
|
+
expect(payload.token_guidance).toContain("--token");
|
|
266
|
+
// No admin scope, no mint-failure copy.
|
|
267
|
+
expect(payload.token_guidance).not.toContain("No token issued");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("--mint (no hub reachable) opts in but mints scope-narrow read, never admin", () => {
|
|
271
|
+
// In this sandbox there's no hub/operator.token, so the mint can't complete
|
|
272
|
+
// — but the request is scope-narrow read by default and must NEVER ask for
|
|
273
|
+
// an admin grant. We assert the create still succeeds and the guidance is
|
|
274
|
+
// the standalone path (the scope requested is read, per
|
|
275
|
+
// mintBootstrapCredential).
|
|
276
|
+
//
|
|
277
|
+
// Point the hub-presence probe at a guaranteed-closed port so the test is
|
|
278
|
+
// deterministic regardless of whether a real hub happens to be running on
|
|
279
|
+
// the dev box's 1939 (#445 added a live `/health` probe to branch the
|
|
280
|
+
// no-operator-token copy).
|
|
281
|
+
const { exitCode, stdout } = runCli(
|
|
282
|
+
["create", "wantmint", "--mint", "--json"],
|
|
283
|
+
{ PARACHUTE_HOME: home, PARACHUTE_HUB_PORT: "59399" },
|
|
284
|
+
);
|
|
285
|
+
expect(exitCode).toBe(0);
|
|
286
|
+
const payload = JSON.parse(stdout.trim());
|
|
287
|
+
// No hub here → no token, and the standalone guidance asks for NO admin
|
|
288
|
+
// grant (the #445 hub-present "admin wizard" copy is gated out by the dead
|
|
289
|
+
// probe port above).
|
|
290
|
+
expect(payload.token).toBe("");
|
|
291
|
+
expect(payload.token_guidance).not.toContain("admin");
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
125
295
|
/**
|
|
126
296
|
* Regression tests for #208: `vault create` was not updating
|
|
127
297
|
* `~/.parachute/services.json`, so vaults created after init were invisible
|
|
@@ -191,11 +361,169 @@ describe("vault create (human mode)", () => {
|
|
|
191
361
|
);
|
|
192
362
|
expect(exitCode).toBe(0);
|
|
193
363
|
expect(stdout).toContain('Vault "human" created.');
|
|
194
|
-
// vault#
|
|
195
|
-
//
|
|
196
|
-
|
|
197
|
-
expect(stdout).toContain("
|
|
364
|
+
// vault#442: default auth is per-user OAuth — NO token is minted, even when
|
|
365
|
+
// a hub would have been reachable. The human output leads with the OAuth
|
|
366
|
+
// connect command and never prints an "API token:" line.
|
|
367
|
+
expect(stdout).toContain("No token minted");
|
|
368
|
+
expect(stdout).toContain("Connect your AI: claude mcp add --transport http parachute-human");
|
|
369
|
+
expect(stdout).not.toContain("API token:");
|
|
370
|
+
// The old admin auto-mint failure copy must NOT fire on a default create.
|
|
371
|
+
expect(stdout).not.toContain("No token issued");
|
|
198
372
|
// Human output should NOT be valid JSON.
|
|
199
373
|
expect(() => JSON.parse(stdout.trim())).toThrow();
|
|
200
374
|
});
|
|
201
375
|
});
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Create-time default backup posture: new vaults default to an internal live
|
|
379
|
+
* mirror (local git backup of the markdown projection). The History preset
|
|
380
|
+
* `{enabled:true, location:internal, sync_mode:events, auto_commit:true,
|
|
381
|
+
* auto_push:false}` is written to `data/<vault>/mirror-config.yaml` and (when
|
|
382
|
+
* git is present) the internal mirror dir is git-bootstrapped. The behavior is
|
|
383
|
+
* controlled by the server-wide `default_mirror` knob (default `internal`) and
|
|
384
|
+
* overridable per-create by `--no-mirror`.
|
|
385
|
+
*
|
|
386
|
+
* Critically: create-time ONLY — already-created vaults are NOT retroactively
|
|
387
|
+
* migrated, and the git-less box stays a successful create (best-effort
|
|
388
|
+
* bootstrap, config still written, actionable log).
|
|
389
|
+
*/
|
|
390
|
+
describe("vault create — default internal live mirror", () => {
|
|
391
|
+
test("default (no knob) → History-preset mirror config written + git-bootstrapped", () => {
|
|
392
|
+
const { exitCode } = runCli(["create", "backed", "--json"], {
|
|
393
|
+
PARACHUTE_HOME: home,
|
|
394
|
+
});
|
|
395
|
+
expect(exitCode).toBe(0);
|
|
396
|
+
|
|
397
|
+
// Mirror config exists with the exact History preset.
|
|
398
|
+
const cfgPath = mirrorConfigPath(home, "backed");
|
|
399
|
+
expect(existsSync(cfgPath)).toBe(true);
|
|
400
|
+
const cfg = readFileSync(cfgPath, "utf-8");
|
|
401
|
+
expect(cfg).toContain("enabled: true");
|
|
402
|
+
expect(cfg).toContain("location: internal");
|
|
403
|
+
expect(cfg).toContain("sync_mode: events");
|
|
404
|
+
expect(cfg).toContain("auto_commit: true");
|
|
405
|
+
expect(cfg).toContain("auto_push: false");
|
|
406
|
+
|
|
407
|
+
// Git present on the test host → the internal mirror dir is a real repo
|
|
408
|
+
// with the seed commit (bootstrap ran). counts/usage reflect it: the
|
|
409
|
+
// mirror working tree exists under the vault data dir.
|
|
410
|
+
const repo = mirrorRepoPath(home, "backed");
|
|
411
|
+
expect(existsSync(join(repo, ".git"))).toBe(true);
|
|
412
|
+
const log = Bun.spawnSync({
|
|
413
|
+
cmd: ["git", "-C", repo, "log", "--oneline"],
|
|
414
|
+
stdout: "pipe",
|
|
415
|
+
});
|
|
416
|
+
expect(new TextDecoder().decode(log.stdout)).toContain("initial mirror bootstrap");
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
test("default_mirror: off knob → no mirror config written", () => {
|
|
420
|
+
writeDefaultMirrorKnob(home, "off");
|
|
421
|
+
const { exitCode } = runCli(["create", "bare", "--json"], {
|
|
422
|
+
PARACHUTE_HOME: home,
|
|
423
|
+
});
|
|
424
|
+
expect(exitCode).toBe(0);
|
|
425
|
+
|
|
426
|
+
// Vault is created…
|
|
427
|
+
expect(existsSync(join(home, "vault", "data", "bare", "vault.db"))).toBe(true);
|
|
428
|
+
// …but NO mirror config + NO mirror dir.
|
|
429
|
+
expect(existsSync(mirrorConfigPath(home, "bare"))).toBe(false);
|
|
430
|
+
expect(existsSync(mirrorRepoPath(home, "bare"))).toBe(false);
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
test("default_mirror: internal knob (explicit) → mirror config written", () => {
|
|
434
|
+
writeDefaultMirrorKnob(home, "internal");
|
|
435
|
+
const { exitCode } = runCli(["create", "explicit", "--json"], {
|
|
436
|
+
PARACHUTE_HOME: home,
|
|
437
|
+
});
|
|
438
|
+
expect(exitCode).toBe(0);
|
|
439
|
+
expect(existsSync(mirrorConfigPath(home, "explicit"))).toBe(true);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
test("--no-mirror → no mirror config even when knob is internal", () => {
|
|
443
|
+
writeDefaultMirrorKnob(home, "internal");
|
|
444
|
+
const { exitCode } = runCli(["create", "optout", "--no-mirror", "--json"], {
|
|
445
|
+
PARACHUTE_HOME: home,
|
|
446
|
+
});
|
|
447
|
+
expect(exitCode).toBe(0);
|
|
448
|
+
|
|
449
|
+
expect(existsSync(join(home, "vault", "data", "optout", "vault.db"))).toBe(true);
|
|
450
|
+
expect(existsSync(mirrorConfigPath(home, "optout"))).toBe(false);
|
|
451
|
+
expect(existsSync(mirrorRepoPath(home, "optout"))).toBe(false);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("--no-mirror in human mode keeps the rest of the create working", () => {
|
|
455
|
+
const { exitCode, stdout } = runCli(["create", "humanbare", "--no-mirror"], {
|
|
456
|
+
PARACHUTE_HOME: home,
|
|
457
|
+
});
|
|
458
|
+
expect(exitCode).toBe(0);
|
|
459
|
+
expect(stdout).toContain('Vault "humanbare" created.');
|
|
460
|
+
expect(existsSync(mirrorConfigPath(home, "humanbare"))).toBe(false);
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("git missing → vault still creates, config written, mirror NOT bootstrapped, actionable log, no crash", () => {
|
|
464
|
+
// Simulate a git-less server: spawn the CLI with a PATH that resolves
|
|
465
|
+
// `bun` (so the test can run) but NOT `git`. `bootstrapInternalMirror`'s
|
|
466
|
+
// `Bun.which("git")` preflight then returns null → friendly,
|
|
467
|
+
// best-effort failure. The vault create MUST still succeed.
|
|
468
|
+
const bunBin = Bun.which("bun");
|
|
469
|
+
expect(bunBin).toBeTruthy();
|
|
470
|
+
const fakeBin = mkdtempSync(join(tmpdir(), "vault-create-nogit-bin-"));
|
|
471
|
+
symlinkSync(bunBin!, join(fakeBin, "bun"));
|
|
472
|
+
try {
|
|
473
|
+
const proc = Bun.spawnSync({
|
|
474
|
+
cmd: [bunBin!, CLI, "create", "nogit", "--json"],
|
|
475
|
+
stdout: "pipe",
|
|
476
|
+
stderr: "pipe",
|
|
477
|
+
// Replace PATH entirely so `git` is unresolvable — only our fake bin
|
|
478
|
+
// (bun only) is on the path.
|
|
479
|
+
env: { ...process.env, PARACHUTE_HOME: home, PATH: fakeBin },
|
|
480
|
+
});
|
|
481
|
+
const exitCode = proc.exitCode ?? -1;
|
|
482
|
+
const stdout = new TextDecoder().decode(proc.stdout);
|
|
483
|
+
const stderr = new TextDecoder().decode(proc.stderr);
|
|
484
|
+
|
|
485
|
+
// No crash — the vault create succeeds on a git-less box.
|
|
486
|
+
expect(exitCode).toBe(0);
|
|
487
|
+
// The vault itself was created.
|
|
488
|
+
expect(existsSync(join(home, "vault", "data", "nogit", "vault.db"))).toBe(true);
|
|
489
|
+
// The mirror CONFIG was still written (intent persists for when git
|
|
490
|
+
// lands later).
|
|
491
|
+
expect(existsSync(mirrorConfigPath(home, "nogit"))).toBe(true);
|
|
492
|
+
// But the mirror was NOT git-bootstrapped (no .git — git was absent).
|
|
493
|
+
expect(existsSync(join(mirrorRepoPath(home, "nogit"), ".git"))).toBe(false);
|
|
494
|
+
// And the operator got an actionable, clear log on stderr.
|
|
495
|
+
expect(stderr).toContain("local git backup configured but not yet active");
|
|
496
|
+
expect(stderr).toContain("Install git");
|
|
497
|
+
// JSON stdout stays clean + parseable (the note went to stderr).
|
|
498
|
+
const payload = JSON.parse(stdout.trim());
|
|
499
|
+
expect(payload.name).toBe("nogit");
|
|
500
|
+
} finally {
|
|
501
|
+
rmSync(fakeBin, { recursive: true, force: true });
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
test("EXISTING vault (created before this change) is NOT auto-migrated on a later create", () => {
|
|
506
|
+
// Simulate a vault that predates the default-mirror behavior: create it
|
|
507
|
+
// with the knob OFF so it gets no mirror config (stand-in for "created by
|
|
508
|
+
// an older vault build").
|
|
509
|
+
writeDefaultMirrorKnob(home, "off");
|
|
510
|
+
const first = runCli(["create", "legacy", "--json"], { PARACHUTE_HOME: home });
|
|
511
|
+
expect(first.exitCode).toBe(0);
|
|
512
|
+
expect(existsSync(mirrorConfigPath(home, "legacy"))).toBe(false);
|
|
513
|
+
|
|
514
|
+
// Now flip the knob ON and create a SECOND, different vault. The act of
|
|
515
|
+
// creating "fresh" (which DOES get a mirror) must not retroactively write
|
|
516
|
+
// a mirror config onto the pre-existing "legacy" vault — create-time only,
|
|
517
|
+
// never a sweep over existing vaults.
|
|
518
|
+
writeDefaultMirrorKnob(home, "internal");
|
|
519
|
+
const second = runCli(["create", "fresh", "--json"], { PARACHUTE_HOME: home });
|
|
520
|
+
expect(second.exitCode).toBe(0);
|
|
521
|
+
|
|
522
|
+
// The new vault is backed…
|
|
523
|
+
expect(existsSync(mirrorConfigPath(home, "fresh"))).toBe(true);
|
|
524
|
+
// …but the pre-existing vault is left exactly as it was — no surprise
|
|
525
|
+
// mirror config, no surprise disk-doubling mirror repo.
|
|
526
|
+
expect(existsSync(mirrorConfigPath(home, "legacy"))).toBe(false);
|
|
527
|
+
expect(existsSync(mirrorRepoPath(home, "legacy"))).toBe(false);
|
|
528
|
+
});
|
|
529
|
+
});
|
package/src/vault-name.test.ts
CHANGED
|
@@ -7,7 +7,13 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { describe, test, expect } from "bun:test";
|
|
10
|
-
import {
|
|
10
|
+
import {
|
|
11
|
+
RESERVED_VAULT_NAMES,
|
|
12
|
+
decideInitVaultName,
|
|
13
|
+
reservedNameSquatWarnings,
|
|
14
|
+
resolveFirstBootVaultName,
|
|
15
|
+
validateVaultName,
|
|
16
|
+
} from "./vault-name.ts";
|
|
11
17
|
|
|
12
18
|
describe("validateVaultName", () => {
|
|
13
19
|
describe("accepts", () => {
|
|
@@ -69,12 +75,29 @@ describe("validateVaultName", () => {
|
|
|
69
75
|
}
|
|
70
76
|
});
|
|
71
77
|
|
|
72
|
-
|
|
73
|
-
|
|
78
|
+
// The consolidated reserved set (2026-06-09 hub-module-boundary B2):
|
|
79
|
+
// `list` (legacy), `new` + `assets` (hub SPA route collisions), `admin`
|
|
80
|
+
// (the daemon-level /vault/admin multi-vault mount). Kept in lockstep
|
|
81
|
+
// with hub's RESERVED_VAULT_NAMES.
|
|
82
|
+
test.each(["list", "new", "assets", "admin"])("reserved name '%s'", (name) => {
|
|
83
|
+
const result = validateVaultName(name);
|
|
74
84
|
expect(result.ok).toBe(false);
|
|
75
85
|
if (!result.ok) expect(result.error).toContain("reserved");
|
|
76
86
|
});
|
|
77
87
|
|
|
88
|
+
test("the exported set carries exactly the four consolidated names", () => {
|
|
89
|
+
expect([...RESERVED_VAULT_NAMES].sort()).toEqual(["admin", "assets", "list", "new"]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test.each(["adminx", "admin2", "newer", "asset", "listing"])(
|
|
93
|
+
"near-miss '%s' is NOT reserved",
|
|
94
|
+
(name) => {
|
|
95
|
+
const result = validateVaultName(name);
|
|
96
|
+
expect(result.ok).toBe(true);
|
|
97
|
+
if (result.ok) expect(result.name).toBe(name);
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
|
|
78
101
|
test("single character (below 2-char min)", () => {
|
|
79
102
|
const result = validateVaultName("a");
|
|
80
103
|
expect(result.ok).toBe(false);
|
|
@@ -95,6 +118,41 @@ describe("validateVaultName", () => {
|
|
|
95
118
|
});
|
|
96
119
|
});
|
|
97
120
|
|
|
121
|
+
describe("reservedNameSquatWarnings", () => {
|
|
122
|
+
test("fires for a squatted 'admin' vault, naming the shadowing + recovery", () => {
|
|
123
|
+
const warnings = reservedNameSquatWarnings(["default", "admin"]);
|
|
124
|
+
expect(warnings).toHaveLength(1);
|
|
125
|
+
expect(warnings[0]).toContain('vault "admin"');
|
|
126
|
+
expect(warnings[0]).toContain("shadowed");
|
|
127
|
+
expect(warnings[0]).toContain("/vault/admin/*");
|
|
128
|
+
// Recovery procedure (no rename command exists): export → create →
|
|
129
|
+
// import → remove.
|
|
130
|
+
expect(warnings[0]).toContain("export");
|
|
131
|
+
expect(warnings[0]).toContain("create <newname>");
|
|
132
|
+
expect(warnings[0]).toContain("import");
|
|
133
|
+
expect(warnings[0]).toContain("remove admin --yes");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("fires once per squatted name — admin + new + assets all warned", () => {
|
|
137
|
+
const warnings = reservedNameSquatWarnings(["admin", "new", "assets", "ok"]);
|
|
138
|
+
expect(warnings).toHaveLength(3);
|
|
139
|
+
expect(warnings.join("\n")).toContain('vault "admin"');
|
|
140
|
+
expect(warnings.join("\n")).toContain('vault "new"');
|
|
141
|
+
expect(warnings.join("\n")).toContain('vault "assets"');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("silent for clean vault lists and near-misses", () => {
|
|
145
|
+
expect(reservedNameSquatWarnings([])).toEqual([]);
|
|
146
|
+
expect(reservedNameSquatWarnings(["default", "work", "adminx", "admin2"])).toEqual([]);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("silent for 'list' (reserved for consistency, but not shadowed)", () => {
|
|
150
|
+
// `/vault/list/*` still routes per-vault — list is reserved at create
|
|
151
|
+
// time but a legacy squatter keeps working, so no scary boot warning.
|
|
152
|
+
expect(reservedNameSquatWarnings(["list"])).toEqual([]);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
98
156
|
describe("decideInitVaultName", () => {
|
|
99
157
|
test("--vault-name=aaron resolves to name 'aaron'", () => {
|
|
100
158
|
const d = decideInitVaultName(["--vault-name", "aaron"], { isTTY: true });
|
package/src/vault-name.ts
CHANGED
|
@@ -7,34 +7,82 @@
|
|
|
7
7
|
* rejected up front.
|
|
8
8
|
*
|
|
9
9
|
* Rule: lowercase alphanumeric + hyphens or underscores, 2–32 chars, with
|
|
10
|
-
* `list` reserved. Used by the `init` prompt,
|
|
11
|
-
* the `PARACHUTE_VAULT_NAME` env var at server
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* have minted.
|
|
10
|
+
* `list` / `new` / `assets` / `admin` reserved. Used by the `init` prompt,
|
|
11
|
+
* the `--vault-name` flag, the `PARACHUTE_VAULT_NAME` env var at server
|
|
12
|
+
* first-boot, and (since the 2026-06-09 hub-module-boundary migration B2)
|
|
13
|
+
* `cmdCreate` — every name-minting edge shares this one validator.
|
|
15
14
|
*/
|
|
16
15
|
|
|
17
16
|
const VAULT_NAME_RE = /^[a-z0-9_-]+$/;
|
|
18
17
|
const VAULT_NAME_MIN_LEN = 2;
|
|
19
18
|
const VAULT_NAME_MAX_LEN = 32;
|
|
20
19
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
/**
|
|
21
|
+
* THE reserved vault-name set — kept in lockstep with hub's
|
|
22
|
+
* `RESERVED_VAULT_NAMES` (parachute-hub/src/vault-name.ts, B2h of the
|
|
23
|
+
* 2026-06-09 hub-module-boundary migration). A vault under any of these
|
|
24
|
+
* names would have its URL surface captured by a reserved route:
|
|
25
|
+
*
|
|
26
|
+
* - `list` — legacy `/vaults/list` discovery-endpoint collision; the
|
|
27
|
+
* routes have since moved under `/vault/<name>/`, but consistency with
|
|
28
|
+
* the historical reservation is cheap.
|
|
29
|
+
* - `new` — collides with `/vault/new`, the hub SPA's create route.
|
|
30
|
+
* - `assets` — collides with `/vault/assets/*`, the hub SPA's bundle.
|
|
31
|
+
* - `admin` — collides with `/vault/admin`, the daemon-level mount for
|
|
32
|
+
* vault's own multi-vault admin surface (B3). Both the hub's route and
|
|
33
|
+
* this daemon's own routing dispatch `/vault/admin/*` before the
|
|
34
|
+
* per-vault branch, so a vault named `admin` would be fully shadowed.
|
|
35
|
+
*/
|
|
36
|
+
export const RESERVED_VAULT_NAMES: ReadonlySet<string> = new Set([
|
|
25
37
|
"list",
|
|
38
|
+
"new",
|
|
39
|
+
"assets",
|
|
40
|
+
"admin",
|
|
26
41
|
]);
|
|
27
42
|
|
|
43
|
+
/**
|
|
44
|
+
* The subset of reserved names whose data plane is SHADOWED by reserved
|
|
45
|
+
* routes when a vault squats them (created before the reservation landed).
|
|
46
|
+
* `list` is reserved for historical consistency only — `/vault/list/*`
|
|
47
|
+
* still routes per-vault — so it's excluded here.
|
|
48
|
+
*/
|
|
49
|
+
const SHADOWED_RESERVED_NAMES = ["admin", "new", "assets"] as const;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build boot-time warnings for vaults squatting a shadowed reserved name.
|
|
53
|
+
* Pure (takes the vault list, returns warning strings) so server boot can
|
|
54
|
+
* `console.warn` each and tests can pin the copy without booting a server.
|
|
55
|
+
*
|
|
56
|
+
* Recovery procedure is spelled out because no rename command exists:
|
|
57
|
+
* export → create under a new name → import → remove the squatter.
|
|
58
|
+
*/
|
|
59
|
+
export function reservedNameSquatWarnings(vaults: readonly string[]): string[] {
|
|
60
|
+
const warnings: string[] = [];
|
|
61
|
+
for (const reserved of SHADOWED_RESERVED_NAMES) {
|
|
62
|
+
if (!vaults.includes(reserved)) continue;
|
|
63
|
+
warnings.push(
|
|
64
|
+
`[reserved-name] vault "${reserved}" exists but "${reserved}" is a reserved name — ` +
|
|
65
|
+
`its data plane (/vault/${reserved}/*) is shadowed by reserved hub/daemon routes, so its ` +
|
|
66
|
+
`MCP, REST, and admin surfaces are unreachable. Recover by renaming it (no rename ` +
|
|
67
|
+
`command exists): parachute-vault export <dir> --vault ${reserved} → ` +
|
|
68
|
+
`parachute-vault create <newname> → parachute-vault import <dir> --vault <newname> → ` +
|
|
69
|
+
`parachute-vault remove ${reserved} --yes`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return warnings;
|
|
73
|
+
}
|
|
74
|
+
|
|
28
75
|
export type VaultNameValidation =
|
|
29
76
|
| { ok: true; name: string }
|
|
30
77
|
| { ok: false; error: string };
|
|
31
78
|
|
|
32
79
|
/**
|
|
33
80
|
* Validate a vault name. Accepts lowercase alphanumeric + hyphens or
|
|
34
|
-
* underscores, 2–32 chars
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
81
|
+
* underscores, 2–32 chars, none of `RESERVED_VAULT_NAMES`. Trims
|
|
82
|
+
* surrounding whitespace before checking. The one gate used by the env
|
|
83
|
+
* var, the `--vault-name` flag, hub's first-boot wizard, AND `cmdCreate`
|
|
84
|
+
* (consolidated 2026-06-09 — cmdCreate previously carried its own inline
|
|
85
|
+
* charset + `"list"` check that had drifted from this set).
|
|
38
86
|
*/
|
|
39
87
|
export function validateVaultName(raw: string): VaultNameValidation {
|
|
40
88
|
const name = raw.trim();
|
|
@@ -54,7 +102,7 @@ export function validateVaultName(raw: string): VaultNameValidation {
|
|
|
54
102
|
"vault names must be lowercase alphanumeric with hyphens or underscores. Try again.",
|
|
55
103
|
};
|
|
56
104
|
}
|
|
57
|
-
if (
|
|
105
|
+
if (RESERVED_VAULT_NAMES.has(name)) {
|
|
58
106
|
return { ok: false, error: `"${name}" is a reserved vault name.` };
|
|
59
107
|
}
|
|
60
108
|
return { ok: true, name };
|