@openparachute/vault 0.3.3 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +133 -0
  3. package/core/src/core.test.ts +2990 -92
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +413 -68
  6. package/core/src/notes.ts +693 -42
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +331 -0
  11. package/core/src/schema.ts +467 -11
  12. package/core/src/store.ts +262 -8
  13. package/core/src/tag-hierarchy.ts +171 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +96 -7
  16. package/core/src/vault-projection.ts +309 -0
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +360 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +173 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +322 -57
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +307 -0
  30. package/src/hub-jwt.ts +88 -0
  31. package/src/init.test.ts +216 -0
  32. package/src/mcp-http.ts +33 -29
  33. package/src/mcp-install.ts +1 -1
  34. package/src/mcp-tools.ts +318 -19
  35. package/src/module-config.ts +1 -1
  36. package/src/oauth.test.ts +345 -0
  37. package/src/oauth.ts +85 -14
  38. package/src/owner-auth.ts +57 -1
  39. package/src/prompt.ts +6 -5
  40. package/src/routes.ts +796 -61
  41. package/src/routing.test.ts +466 -1
  42. package/src/routing.ts +106 -24
  43. package/src/scopes.test.ts +66 -8
  44. package/src/scopes.ts +163 -37
  45. package/src/server.ts +24 -2
  46. package/src/services-manifest.test.ts +20 -0
  47. package/src/services-manifest.ts +9 -2
  48. package/src/stop-signal.test.ts +85 -0
  49. package/src/storage.test.ts +92 -0
  50. package/src/tag-scope.ts +118 -0
  51. package/src/token-store.test.ts +47 -0
  52. package/src/token-store.ts +128 -13
  53. package/src/tokens-routes.test.ts +727 -0
  54. package/src/tokens-routes.ts +392 -0
  55. package/src/transcription-worker.test.ts +5 -0
  56. package/src/triggers.ts +1 -1
  57. package/src/two-factor.ts +2 -2
  58. package/src/vault-create.test.ts +193 -0
  59. package/src/vault-name.test.ts +123 -0
  60. package/src/vault-name.ts +80 -0
  61. package/src/vault.test.ts +1626 -183
  62. package/tsconfig.json +8 -1
  63. package/.claude/settings.local.json +0 -8
  64. package/.dockerignore +0 -8
  65. package/.env.example +0 -9
  66. package/CHANGELOG.md +0 -175
  67. package/CLAUDE.md +0 -125
  68. package/Caddyfile +0 -3
  69. package/Dockerfile +0 -22
  70. package/bun.lock +0 -219
  71. package/bunfig.toml +0 -2
  72. package/deploy/parachute-vault.service +0 -20
  73. package/docker-compose.yml +0 -50
  74. package/docs/HTTP_API.md +0 -434
  75. package/docs/auth-model.md +0 -340
  76. package/fly.toml +0 -24
  77. package/package/package.json +0 -32
  78. package/railway.json +0 -14
  79. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  80. package/scripts/migrate-audio-to-opus.ts +0 -499
@@ -0,0 +1,392 @@
1
+ /**
2
+ * REST handlers for `/vault/<name>/tokens`.
3
+ *
4
+ * The endpoint is admin-gated upstream — `route()` checks
5
+ * `hasScopeForVault(auth.scopes, vaultName, "admin")` before dispatching here,
6
+ * so any caller reaching this module already holds vault:admin (broad or
7
+ * narrowed). POST mints a new pvt_* token with caller-narrowed scopes and
8
+ * returns the plaintext exactly once. GET lists existing tokens (metadata
9
+ * only — no plaintext, no hash). DELETE revokes by display id (`t_…`); a
10
+ * non-existent id returns 404; an id that resolves to a different
11
+ * vault's binding returns **403** (per #257 spec — silent 200 on
12
+ * cross-vault revoke would let an operator believe a token was
13
+ * revoked while it kept working from its owning vault).
14
+ *
15
+ * Scope narrowing is enforced as a strict subset check via
16
+ * `validateMintedScopes` — defense-in-depth even with the admin gate, so a
17
+ * future relaxation of the gate cannot accidentally permit privilege
18
+ * escalation. Cross-vault scopes (`vault:<other>:<verb>`) are rejected with
19
+ * the same path.
20
+ *
21
+ * Tag-allowlist narrowing (per patterns/tag-scoped-tokens.md) follows the
22
+ * same defense-in-depth shape: the requested allowlist must be a subset of
23
+ * the minter's. A `null` minter allowlist is the universe — any allowlist
24
+ * may be granted. Each tag must be an existing root-tag name (no `/`) —
25
+ * sub-tags are reached via the `_tags/<name>` hierarchy at enforcement time,
26
+ * not at mint time.
27
+ */
28
+
29
+ import type { Database } from "bun:sqlite";
30
+ import type { SqliteStore } from "../core/src/store.ts";
31
+ import {
32
+ generateToken,
33
+ createToken,
34
+ normalizePermission,
35
+ type TokenPermission,
36
+ } from "./token-store.ts";
37
+ import {
38
+ validateMintedScopes,
39
+ parseScopes,
40
+ hasScope,
41
+ SCOPE_WRITE,
42
+ SCOPE_ADMIN,
43
+ } from "./scopes.ts";
44
+
45
+ interface MintRequestBody {
46
+ label?: string;
47
+ /** Either an array (preferred) or a space-separated OAuth-style string. */
48
+ scopes?: string[];
49
+ scope?: string;
50
+ /** ISO-8601 future timestamp, or null/omitted for never-expiring. */
51
+ expires_at?: string | null;
52
+ /**
53
+ * Optional tag-allowlist. Each entry must be an existing root-tag name
54
+ * (no `/`). When omitted or null, the token is unscoped (current behavior).
55
+ * The minted allowlist must be a subset of the caller's allowlist.
56
+ */
57
+ tags?: string[] | null;
58
+ }
59
+
60
+ function badRequest(message: string, extra?: Record<string, unknown>): Response {
61
+ return Response.json({ error: "Bad Request", message, ...extra }, { status: 400 });
62
+ }
63
+
64
+ function methodNotAllowed(): Response {
65
+ return Response.json({ error: "Method not allowed" }, { status: 405 });
66
+ }
67
+
68
+ function permissionForScopes(scopes: string[]): TokenPermission {
69
+ return hasScope(scopes, SCOPE_WRITE) || hasScope(scopes, SCOPE_ADMIN) ? "full" : "read";
70
+ }
71
+
72
+ export async function handleTokens(
73
+ req: Request,
74
+ store: SqliteStore,
75
+ vaultName: string,
76
+ callerScopes: string[],
77
+ callerScopedTags: string[] | null,
78
+ subpath: string,
79
+ ): Promise<Response> {
80
+ if (subpath === "" || subpath === "/") {
81
+ if (req.method === "GET") return listHandler(store.db, vaultName);
82
+ if (req.method === "POST") return mintHandler(req, store, vaultName, callerScopes, callerScopedTags);
83
+ return methodNotAllowed();
84
+ }
85
+ const idMatch = subpath.match(/^\/([^/]+)$/);
86
+ if (idMatch && idMatch[1]) {
87
+ if (req.method === "DELETE") return revokeHandler(store.db, vaultName, idMatch[1]);
88
+ return methodNotAllowed();
89
+ }
90
+ return Response.json({ error: "Not found" }, { status: 404 });
91
+ }
92
+
93
+ async function mintHandler(
94
+ req: Request,
95
+ store: SqliteStore,
96
+ vaultName: string,
97
+ callerScopes: string[],
98
+ callerScopedTags: string[] | null,
99
+ ): Promise<Response> {
100
+ let body: MintRequestBody;
101
+ try {
102
+ const raw = await req.text();
103
+ body = raw.length === 0 ? {} : (JSON.parse(raw) as MintRequestBody);
104
+ } catch {
105
+ return badRequest("invalid JSON body");
106
+ }
107
+
108
+ let requested: string[];
109
+ if (Array.isArray(body.scopes)) {
110
+ requested = body.scopes.filter((s): s is string => typeof s === "string" && s.length > 0);
111
+ } else if (typeof body.scope === "string") {
112
+ requested = parseScopes(body.scope);
113
+ } else {
114
+ // Default: full scope set. Admin caller is required to reach this code,
115
+ // so granting full scope is within their power; explicit narrowing is
116
+ // available via `scopes` / `scope` for least-privilege deployments.
117
+ requested = ["vault:read", "vault:write", "vault:admin"];
118
+ }
119
+ if (requested.length === 0) {
120
+ return badRequest("at least one scope required");
121
+ }
122
+
123
+ const validation = validateMintedScopes(requested, vaultName, callerScopes);
124
+ if (!validation.ok) {
125
+ return Response.json(
126
+ { error: "Bad Request", message: "scope rejected", rejected: validation.rejected },
127
+ { status: 400 },
128
+ );
129
+ }
130
+
131
+ // Tag-allowlist narrowing. `tags === undefined` or `null` means unscoped
132
+ // (no filtering). When provided, every entry must (a) be a non-empty
133
+ // string, (b) contain no `/` (root tags only — sub-tags reach via the
134
+ // `_tags/<name>` hierarchy at enforcement time), (c) exist in the vault's
135
+ // tag list, and (d) fall within the caller's own allowlist when the caller
136
+ // is themselves tag-scoped (null caller scope = universe = anything OK).
137
+ //
138
+ // Privilege-escalation guard: a tag-scoped minter must NOT be able to
139
+ // produce a `null`-allowlist token (the universe is broader than any
140
+ // finite allowlist). We reject the omission with a clear 403 rather than
141
+ // silently inheriting — explicit > implicit at a security boundary.
142
+ let scopedTags: string[] | null = null;
143
+ if ((body.tags === undefined || body.tags === null) && callerScopedTags !== null) {
144
+ return Response.json(
145
+ {
146
+ error: "Forbidden",
147
+ error_type: "tag_scope_violation",
148
+ message: `minter is tag-scoped (${callerScopedTags.join(", ")}) — request must include an explicit "tags" array within that allowlist; an unscoped token cannot be minted from a scoped one`,
149
+ minter_scoped_tags: callerScopedTags,
150
+ },
151
+ { status: 403 },
152
+ );
153
+ }
154
+ if (body.tags !== undefined && body.tags !== null) {
155
+ if (!Array.isArray(body.tags)) {
156
+ return badRequest("tags must be an array of strings or null");
157
+ }
158
+ if (body.tags.length === 0) {
159
+ return badRequest("tags must be a non-empty array (omit the field, or pass null, for an unscoped token)");
160
+ }
161
+ const cleaned: string[] = [];
162
+ for (const t of body.tags) {
163
+ if (typeof t !== "string" || t.length === 0) {
164
+ return badRequest("each tag must be a non-empty string");
165
+ }
166
+ if (t.includes("/")) {
167
+ return badRequest(
168
+ `tag "${t}" must be a root-tag name (no path separators). Sub-tags inherit via the _tags/<name> hierarchy at enforcement time.`,
169
+ );
170
+ }
171
+ cleaned.push(t);
172
+ }
173
+ // Dedupe while preserving caller-supplied order.
174
+ const seen = new Set<string>();
175
+ const deduped: string[] = [];
176
+ for (const t of cleaned) {
177
+ if (!seen.has(t)) {
178
+ seen.add(t);
179
+ deduped.push(t);
180
+ }
181
+ }
182
+ // Existence check against the vault's tag list. The pattern doc names
183
+ // list-tags as the source of truth — keeps the CLI/SPA picker and the
184
+ // server in agreement.
185
+ const known = new Set((await store.listTags()).map((t) => t.name));
186
+ const unknown = deduped.filter((t) => !known.has(t));
187
+ if (unknown.length > 0) {
188
+ return badRequest(
189
+ `unknown tag(s): ${unknown.join(", ")} — must be existing root-tag names in vault '${vaultName}'`,
190
+ { unknown_tags: unknown },
191
+ );
192
+ }
193
+ // Subset rule: if the caller is tag-scoped, every requested tag must be
194
+ // in the caller's allowlist. Null caller = universe.
195
+ if (callerScopedTags !== null) {
196
+ const callerSet = new Set(callerScopedTags);
197
+ const escalating = deduped.filter((t) => !callerSet.has(t));
198
+ if (escalating.length > 0) {
199
+ return Response.json(
200
+ {
201
+ error: "Forbidden",
202
+ error_type: "tag_scope_violation",
203
+ message: `cannot mint a token with tag(s) outside the minter's allowlist: ${escalating.join(", ")}`,
204
+ rejected_tags: escalating,
205
+ minter_scoped_tags: callerScopedTags,
206
+ },
207
+ { status: 403 },
208
+ );
209
+ }
210
+ }
211
+ scopedTags = deduped;
212
+ }
213
+
214
+ let expiresAt: string | null = null;
215
+ if (body.expires_at !== undefined && body.expires_at !== null) {
216
+ if (typeof body.expires_at !== "string") {
217
+ return badRequest("expires_at must be an ISO-8601 string or null");
218
+ }
219
+ const t = Date.parse(body.expires_at);
220
+ if (Number.isNaN(t)) {
221
+ return badRequest("expires_at is not a valid ISO-8601 timestamp");
222
+ }
223
+ if (t <= Date.now()) {
224
+ return badRequest("expires_at must be in the future");
225
+ }
226
+ expiresAt = new Date(t).toISOString();
227
+ }
228
+
229
+ const label = typeof body.label === "string" && body.label.length > 0 ? body.label : "API token";
230
+ const permission = permissionForScopes(requested);
231
+
232
+ const { fullToken } = generateToken();
233
+ const created = createToken(store.db, fullToken, {
234
+ label,
235
+ permission,
236
+ scopes: requested,
237
+ scoped_tags: scopedTags,
238
+ expires_at: expiresAt,
239
+ // Per-vault binding (v16): tokens minted via /vault/<name>/tokens are
240
+ // pinned to that vault. authenticateVaultRequest rejects them at any
241
+ // other vault. NULL would be legacy / server-wide; reserved for the
242
+ // YAML-import path and explicit --all CLI mints (see vault#257).
243
+ vault_name: vaultName,
244
+ });
245
+
246
+ // Display id mirrors `listTokens`: `t_` + first 12 chars of the SHA-256
247
+ // hash payload (the part after the `sha256:` prefix). Stable across reads.
248
+ const id = `t_${created.token_hash.slice(7, 19)}`;
249
+
250
+ return Response.json(
251
+ {
252
+ id,
253
+ token: fullToken,
254
+ label: created.label,
255
+ permission: created.permission,
256
+ scopes: requested,
257
+ scoped_tags: scopedTags,
258
+ vault_name: created.vault_name,
259
+ expires_at: created.expires_at,
260
+ created_at: created.created_at,
261
+ },
262
+ { status: 201 },
263
+ );
264
+ }
265
+
266
+ function listHandler(db: Database, vaultName: string): Response {
267
+ // Direct SELECT (rather than reusing `listTokens`) so we can include the
268
+ // `scopes` and `scoped_tags` columns without changing the existing
269
+ // CLI-facing shape that goes through `listTokens`.
270
+ //
271
+ // Per-vault filter (v16): rows where vault_name matches THIS vault, plus
272
+ // legacy server-wide rows (vault_name IS NULL). The latter authenticate
273
+ // cross-vault by design; the operator should see them in any vault's
274
+ // admin UI to revoke. Tokens bound to a different vault never appear
275
+ // here — this is the SPA-side fix for vault#257.
276
+ const rows = db.prepare(`
277
+ SELECT token_hash, label, permission, scopes, scoped_tags, vault_name, expires_at, created_at, last_used_at
278
+ FROM tokens
279
+ WHERE vault_name = ? OR vault_name IS NULL
280
+ ORDER BY created_at DESC
281
+ `).all(vaultName) as {
282
+ token_hash: string;
283
+ label: string;
284
+ permission: string;
285
+ scopes: string | null;
286
+ scoped_tags: string | null;
287
+ vault_name: string | null;
288
+ expires_at: string | null;
289
+ created_at: string;
290
+ last_used_at: string | null;
291
+ }[];
292
+
293
+ return Response.json({
294
+ tokens: rows.map((r) => ({
295
+ id: `t_${r.token_hash.slice(7, 19)}`,
296
+ label: r.label,
297
+ permission: normalizePermission(r.permission),
298
+ scopes: parseScopes(r.scopes),
299
+ scoped_tags: parseScopedTagsJSON(r.scoped_tags),
300
+ vault_name: r.vault_name,
301
+ expires_at: r.expires_at,
302
+ created_at: r.created_at,
303
+ last_used_at: r.last_used_at,
304
+ })),
305
+ });
306
+ }
307
+
308
+ /**
309
+ * Defensive parser for the `tokens.scoped_tags` JSON column. Mirrors the
310
+ * shape used by `token-store.ts#parseScopedTags`: collapse anything that
311
+ * isn't a non-empty array of strings to `null` (the unscoped sentinel) so a
312
+ * corrupt row can't masquerade as a scoped token in the listing.
313
+ */
314
+ function parseScopedTagsJSON(raw: string | null): string[] | null {
315
+ if (!raw) return null;
316
+ try {
317
+ const parsed = JSON.parse(raw);
318
+ if (!Array.isArray(parsed) || parsed.length === 0) return null;
319
+ const cleaned = parsed.filter((s): s is string => typeof s === "string" && s.length > 0);
320
+ return cleaned.length > 0 ? cleaned : null;
321
+ } catch {
322
+ return null;
323
+ }
324
+ }
325
+
326
+ function revokeHandler(db: Database, vaultName: string, id: string): Response {
327
+ // Per-vault scope (v16, per #257 spec): pre-check the row's vault binding
328
+ // before deleting so we can distinguish three cases:
329
+ //
330
+ // - row missing entirely → 404 (the id never existed in this vault's DB)
331
+ // - row exists, vault_name = <other> → 403 with descriptive error
332
+ // - row exists, vault_name = <this> OR NULL → DELETE + 200
333
+ //
334
+ // The non-leakage argument for "always 200" doesn't hold given that GET
335
+ // /vault/<name>/tokens already exposes the full vault-scoped + legacy-NULL
336
+ // listing — so existence isn't being protected. 403 over silent 200 is the
337
+ // operator-debuggable shape: clicking "revoke" and seeing success while the
338
+ // token still works (because it's bound to a different vault) is the worst
339
+ // UX. Tell the operator which vault owns it.
340
+ //
341
+ // Ambiguous prefix matches: the pre-check uses the same prefix shape as the
342
+ // DELETE, so a prefix that hits multiple rows is treated as "found" if any
343
+ // row matches the calling vault. The 12-char hex prefix collision space is
344
+ // large enough that organic ambiguity is effectively impossible, and
345
+ // security-significant ambiguity would require a chosen-prefix attack
346
+ // against SHA-256.
347
+ let row: { vault_name: string | null } | null;
348
+ if (id.startsWith("t_")) {
349
+ const hashPrefix = id.slice(2);
350
+ row = db.prepare(`
351
+ SELECT vault_name FROM tokens
352
+ WHERE token_hash LIKE ?
353
+ LIMIT 1
354
+ `).get(`sha256:${hashPrefix}%`) as { vault_name: string | null } | null;
355
+ } else {
356
+ row = db.prepare(`
357
+ SELECT vault_name FROM tokens
358
+ WHERE token_hash = ?
359
+ LIMIT 1
360
+ `).get(id) as { vault_name: string | null } | null;
361
+ }
362
+
363
+ if (!row) {
364
+ return Response.json({ error: "Not found", message: "no token with that id" }, { status: 404 });
365
+ }
366
+
367
+ if (row.vault_name !== null && row.vault_name !== vaultName) {
368
+ return Response.json(
369
+ {
370
+ error: "Forbidden",
371
+ message: `token belongs to vault '${row.vault_name}', not '${vaultName}'; revoke it from that vault's admin surface`,
372
+ },
373
+ { status: 403 },
374
+ );
375
+ }
376
+
377
+ if (id.startsWith("t_")) {
378
+ const hashPrefix = id.slice(2);
379
+ db.prepare(`
380
+ DELETE FROM tokens
381
+ WHERE token_hash LIKE ?
382
+ AND (vault_name = ? OR vault_name IS NULL)
383
+ `).run(`sha256:${hashPrefix}%`, vaultName);
384
+ } else {
385
+ db.prepare(`
386
+ DELETE FROM tokens
387
+ WHERE token_hash = ?
388
+ AND (vault_name = ? OR vault_name IS NULL)
389
+ `).run(id, vaultName);
390
+ }
391
+ return Response.json({ revoked: true });
392
+ }
@@ -126,8 +126,13 @@ describe("transcription worker", () => {
126
126
  });
127
127
 
128
128
  test("no placeholder: replaces full body when stub is set", async () => {
129
+ // Voice memos have a path in production, so the empty-content path is
130
+ // legitimate (paired with the stub marker, the worker fills the body
131
+ // when the transcript lands). The Store's empty-note invariant (#213)
132
+ // requires content OR path; this is the path-only case.
129
133
  await store.createNote("", {
130
134
  id: "n3",
135
+ path: "memos/n3",
131
136
  metadata: { transcribe_stub: true },
132
137
  });
133
138
  seedAudio("memos/c.webm");
package/src/triggers.ts CHANGED
@@ -132,7 +132,7 @@ function saveAudioToAssets(
132
132
  : contentType.includes("mp4") ? ".m4a"
133
133
  : ".ogg"; // default to ogg
134
134
 
135
- const date = new Date().toISOString().split("T")[0];
135
+ const date = new Date().toISOString().split("T")[0]!;
136
136
  const dir = join(assetsRoot, date);
137
137
  mkdirSync(dir, { recursive: true });
138
138
 
package/src/two-factor.ts CHANGED
@@ -148,7 +148,7 @@ function randomBackupCode(): string {
148
148
  const bytes = crypto.getRandomValues(new Uint8Array(BACKUP_CODE_LENGTH));
149
149
  let out = "";
150
150
  for (let i = 0; i < BACKUP_CODE_LENGTH; i++) {
151
- out += alphabet[bytes[i] % alphabet.length];
151
+ out += alphabet[bytes[i]! % alphabet.length];
152
152
  }
153
153
  return out;
154
154
  }
@@ -208,7 +208,7 @@ async function doVerifyAndConsume(normalized: string): Promise<boolean> {
208
208
 
209
209
  for (let i = 0; i < hashes.length; i++) {
210
210
  try {
211
- if (await Bun.password.verify(normalized, hashes[i])) {
211
+ if (await Bun.password.verify(normalized, hashes[i]!)) {
212
212
  // Consume: splice from the snapshot we verified against and persist.
213
213
  config.backup_codes = hashes.filter((_, j) => j !== i);
214
214
  writeGlobalConfig(config);
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Integration tests for `parachute-vault create <name> [--json]`.
3
+ *
4
+ * The `--json` mode is the contract the hub orchestrator parses: stdout
5
+ * carries a single JSON object with name/token/paths/set_as_default. These
6
+ * tests spawn the CLI in a temp `PARACHUTE_HOME` so the create lands on a
7
+ * fresh, isolated vault tree and we can assert the on-disk artifacts the
8
+ * payload claims to have written.
9
+ */
10
+
11
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
12
+ import { resolve } from "path";
13
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from "fs";
14
+ import { tmpdir } from "os";
15
+ import { join } from "path";
16
+
17
+ const CLI = resolve(import.meta.dir, "cli.ts");
18
+
19
+ function runCli(
20
+ args: string[],
21
+ env: Record<string, string>,
22
+ ): { exitCode: number; stdout: string; stderr: string } {
23
+ const proc = Bun.spawnSync({
24
+ cmd: ["bun", CLI, ...args],
25
+ stdout: "pipe",
26
+ stderr: "pipe",
27
+ env: { ...process.env, ...env },
28
+ });
29
+ return {
30
+ exitCode: proc.exitCode ?? -1,
31
+ stdout: new TextDecoder().decode(proc.stdout),
32
+ stderr: new TextDecoder().decode(proc.stderr),
33
+ };
34
+ }
35
+
36
+ let home: string;
37
+
38
+ beforeEach(() => {
39
+ home = mkdtempSync(join(tmpdir(), "vault-create-test-"));
40
+ });
41
+
42
+ afterEach(() => {
43
+ rmSync(home, { recursive: true, force: true });
44
+ });
45
+
46
+ describe("vault create --json", () => {
47
+ test("emits parseable JSON with name, token, paths, set_as_default=true on first vault", () => {
48
+ const { exitCode, stdout, stderr } = runCli(
49
+ ["create", "myvault", "--json"],
50
+ { PARACHUTE_HOME: home },
51
+ );
52
+ expect(exitCode).toBe(0);
53
+ expect(stderr).toBe("");
54
+
55
+ // stdout must be exactly one JSON object — single-line, parseable.
56
+ // Asserting line count first so a regression that prints a banner above
57
+ // the JSON fails with "expected 1 line, got 2" rather than the much
58
+ // less actionable "JSON parse error".
59
+ const lines = stdout.trim().split("\n");
60
+ expect(lines).toHaveLength(1);
61
+ const payload = JSON.parse(lines[0]!);
62
+ expect(payload.name).toBe("myvault");
63
+ expect(payload.token).toMatch(/^pvt_/);
64
+ expect(payload.set_as_default).toBe(true);
65
+ expect(payload.paths.vault_dir).toBe(join(home, "vault", "data", "myvault"));
66
+ expect(payload.paths.vault_db).toBe(join(home, "vault", "data", "myvault", "vault.db"));
67
+ expect(payload.paths.vault_config).toBe(join(home, "vault", "data", "myvault", "vault.yaml"));
68
+
69
+ // Sanity: the on-disk artifacts the payload describes actually exist.
70
+ expect(existsSync(payload.paths.vault_dir)).toBe(true);
71
+ expect(existsSync(payload.paths.vault_db)).toBe(true);
72
+ expect(existsSync(payload.paths.vault_config)).toBe(true);
73
+ });
74
+
75
+ test("set_as_default=false when another vault already holds the default slot", () => {
76
+ runCli(["create", "first", "--json"], { PARACHUTE_HOME: home });
77
+ const { exitCode, stdout } = runCli(
78
+ ["create", "second", "--json"],
79
+ { PARACHUTE_HOME: home },
80
+ );
81
+ expect(exitCode).toBe(0);
82
+ const payload = JSON.parse(stdout.trim());
83
+ expect(payload.name).toBe("second");
84
+ expect(payload.set_as_default).toBe(false);
85
+ });
86
+
87
+ test("--json works regardless of flag position (before or after name)", () => {
88
+ const { exitCode, stdout } = runCli(
89
+ ["create", "--json", "before"],
90
+ { PARACHUTE_HOME: home },
91
+ );
92
+ expect(exitCode).toBe(0);
93
+ const payload = JSON.parse(stdout.trim());
94
+ expect(payload.name).toBe("before");
95
+ });
96
+
97
+ test("invalid name in --json mode still errors on stderr (not stdout) and exits non-zero", () => {
98
+ const { exitCode, stdout, stderr } = runCli(
99
+ ["create", "Bad Name", "--json"],
100
+ { PARACHUTE_HOME: home },
101
+ );
102
+ expect(exitCode).not.toBe(0);
103
+ expect(stdout).toBe("");
104
+ expect(stderr).toContain("letters, numbers");
105
+ });
106
+
107
+ test("duplicate name in --json mode errors on stderr and exits non-zero", () => {
108
+ runCli(["create", "dup", "--json"], { PARACHUTE_HOME: home });
109
+ const { exitCode, stdout, stderr } = runCli(
110
+ ["create", "dup", "--json"],
111
+ { PARACHUTE_HOME: home },
112
+ );
113
+ expect(exitCode).not.toBe(0);
114
+ expect(stdout).toBe("");
115
+ expect(stderr).toContain("already exists");
116
+ });
117
+ });
118
+
119
+ /**
120
+ * Regression tests for #208: `vault create` was not updating
121
+ * `~/.parachute/services.json`, so vaults created after init were invisible
122
+ * to the hub well-known endpoint and to paraclaw's attach picker. cmdCreate
123
+ * now re-registers the parachute-vault entry with the full vault list every
124
+ * time. The default vault must sort first because the hub uses `paths[0]`
125
+ * as the canonical mount for `.well-known/parachute.json`.
126
+ */
127
+ describe("vault create — services.json registration (#208)", () => {
128
+ function readServices(): { name: string; paths: string[]; port: number }[] {
129
+ const raw = readFileSync(join(home, "services.json"), "utf-8");
130
+ return JSON.parse(raw).services;
131
+ }
132
+
133
+ test("create-first-vault registers parachute-vault entry with /vault/<name>", () => {
134
+ const { exitCode } = runCli(["create", "first", "--json"], {
135
+ PARACHUTE_HOME: home,
136
+ });
137
+ expect(exitCode).toBe(0);
138
+
139
+ const services = readServices();
140
+ const vault = services.find((s) => s.name === "parachute-vault");
141
+ expect(vault).toBeDefined();
142
+ expect(vault!.paths).toEqual(["/vault/first"]);
143
+ });
144
+
145
+ test("create-additional-vault grows the paths array (default stays first)", () => {
146
+ runCli(["create", "alpha", "--json"], { PARACHUTE_HOME: home });
147
+ runCli(["create", "beta", "--json"], { PARACHUTE_HOME: home });
148
+
149
+ const vault = readServices().find((s) => s.name === "parachute-vault");
150
+ expect(vault).toBeDefined();
151
+ // alpha is the default (created first), so it must lead. beta follows.
152
+ expect(vault!.paths).toEqual(["/vault/alpha", "/vault/beta"]);
153
+ });
154
+
155
+ test("create-multiple preserves default-first ordering across N vaults", () => {
156
+ runCli(["create", "one", "--json"], { PARACHUTE_HOME: home });
157
+ runCli(["create", "two", "--json"], { PARACHUTE_HOME: home });
158
+ runCli(["create", "three", "--json"], { PARACHUTE_HOME: home });
159
+
160
+ const vault = readServices().find((s) => s.name === "parachute-vault");
161
+ expect(vault).toBeDefined();
162
+ expect(vault!.paths[0]).toBe("/vault/one");
163
+ expect(vault!.paths.slice(1).sort()).toEqual([
164
+ "/vault/three",
165
+ "/vault/two",
166
+ ]);
167
+ expect(vault!.paths).toHaveLength(3);
168
+ });
169
+
170
+ test("--json mode keeps stdout parseable even when services.json is updated", () => {
171
+ // Regression guard: warnings from upsertService must go to stderr, not
172
+ // stdout — otherwise the hub orchestrator's JSON.parse(stdout) breaks.
173
+ const { stdout } = runCli(["create", "clean", "--json"], {
174
+ PARACHUTE_HOME: home,
175
+ });
176
+ expect(() => JSON.parse(stdout.trim())).not.toThrow();
177
+ });
178
+ });
179
+
180
+ describe("vault create (human mode unchanged)", () => {
181
+ test("prints multi-line human output without --json", () => {
182
+ const { exitCode, stdout } = runCli(
183
+ ["create", "human"],
184
+ { PARACHUTE_HOME: home },
185
+ );
186
+ expect(exitCode).toBe(0);
187
+ expect(stdout).toContain('Vault "human" created.');
188
+ expect(stdout).toContain("API token:");
189
+ expect(stdout).toContain("Save this");
190
+ // Human output should NOT be valid JSON.
191
+ expect(() => JSON.parse(stdout.trim())).toThrow();
192
+ });
193
+ });