@openparachute/vault 0.6.4-rc.9 → 0.6.4

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/core/src/notes.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  type QueryHashInputs,
20
20
  } from "./cursor.js";
21
21
  import { getIndexedField, releaseField } from "./indexed-fields.js";
22
+ import { stripTagHash } from "./tag-hierarchy.js";
22
23
 
23
24
  let idCounter = 0;
24
25
 
@@ -686,8 +687,14 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
686
687
  // rides idx_note_tags_tag, produces each note id at most once, and lets
687
688
  // the whole query drop DISTINCT. See the 2026-06-10 perf measurements.
688
689
  if (opts.tags && opts.tags.length > 0) {
690
+ // Canonical-bare-tag guard (vault#XXX) backstop for direct-core callers
691
+ // that bypass BunSqliteStore.normalizeQueryTags (the store normalizes +
692
+ // hierarchy-expands before reaching here; this protects the raw noteOps
693
+ // entry point and tests). `_tagsExpanded`, when present, was already built
694
+ // from bare names by the store, so prefer it; otherwise strip the literal
695
+ // tags. No-op on already-bare input.
689
696
  const tagSets: string[][] = (opts as QueryOpts & { _tagsExpanded?: string[][] })._tagsExpanded
690
- ?? opts.tags.map((t) => [t]);
697
+ ?? opts.tags.map((t) => [stripTagHash(t)]);
691
698
  const match = opts.tagMatch ?? "all";
692
699
  if (match === "any") {
693
700
  // Flatten all expanded sets and dedupe — a note tagged with any one
@@ -710,9 +717,11 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
710
717
  }
711
718
  }
712
719
 
713
- // Exclude tags
720
+ // Exclude tags — bare-tag guard backstop (see tags block above).
714
721
  if (opts.excludeTags && opts.excludeTags.length > 0) {
715
- for (const tag of opts.excludeTags) {
722
+ for (const rawTag of opts.excludeTags) {
723
+ const tag = stripTagHash(rawTag);
724
+ if (tag === "") continue;
716
725
  conditions.push(`NOT EXISTS (SELECT 1 FROM note_tags ex WHERE ex.note_id = n.id AND ex.tag_name = ?)`);
717
726
  params.push(tag);
718
727
  }
@@ -1189,11 +1198,18 @@ export function searchNotes(
1189
1198
  const limit = typeof opts?.limit === "number" ? opts.limit : 50;
1190
1199
 
1191
1200
  if (opts?.tags && opts.tags.length > 0) {
1201
+ // Canonical-bare-tag guard backstop (vault#XXX) for direct-core callers.
1202
+ const searchTags = opts.tags.map(stripTagHash).filter((t) => t !== "");
1203
+ if (searchTags.length === 0) {
1204
+ // All tag filters collapsed to empty — fall through to the untagged
1205
+ // search path below (no tag constraint).
1206
+ opts = { ...opts, tags: undefined };
1207
+ } else {
1192
1208
  try {
1193
1209
  // Tag membership as a semijoin — same rationale as queryNotes: a
1194
1210
  // `JOIN note_tags` multiplies rows for multi-tagged notes and forced
1195
1211
  // DISTINCT over full rows. The FTS join itself is 1:1 on rowid.
1196
- const tagPlaceholders = opts.tags.map(() => "?").join(", ");
1212
+ const tagPlaceholders = searchTags.map(() => "?").join(", ");
1197
1213
  const rows = db.prepare(`
1198
1214
  SELECT n.* FROM notes n
1199
1215
  JOIN notes_fts fts ON fts.rowid = n.rowid
@@ -1201,11 +1217,12 @@ export function searchNotes(
1201
1217
  AND n.id IN (SELECT note_id FROM note_tags WHERE tag_name IN (${tagPlaceholders}))
1202
1218
  ORDER BY rank
1203
1219
  LIMIT ?
1204
- `).all(query, ...opts.tags, limit) as NoteRow[];
1220
+ `).all(query, ...searchTags, limit) as NoteRow[];
1205
1221
  return notesWithTags(db, rows);
1206
1222
  } catch {
1207
1223
  return [];
1208
1224
  }
1225
+ }
1209
1226
  }
1210
1227
 
1211
1228
  try {
@@ -1236,7 +1253,15 @@ export function tagNote(db: Database, noteId: string, tags: string[]): void {
1236
1253
  const insertTag = db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)");
1237
1254
  const insertNoteTag = db.prepare("INSERT OR IGNORE INTO note_tags (note_id, tag_name) VALUES (?, ?)");
1238
1255
 
1239
- for (const tag of tags) {
1256
+ // Canonical-bare-tag guard (vault#XXX): strip any leading `#` so the
1257
+ // `#`-decorated form a client may pass (the agent module stored
1258
+ // `#agent/message/inbound` verbatim) lands on the same bare row everyone
1259
+ // else queries. This is the single write chokepoint — createNote /
1260
+ // updateNote / batch / MCP add-tags / REST / import / transcript all funnel
1261
+ // through store.tagNote → here.
1262
+ for (const raw of tags) {
1263
+ const tag = stripTagHash(raw);
1264
+ if (tag === "") continue;
1240
1265
  insertTag.run(tag);
1241
1266
  insertNoteTag.run(noteId, tag);
1242
1267
  }
@@ -1244,7 +1269,10 @@ export function tagNote(db: Database, noteId: string, tags: string[]): void {
1244
1269
 
1245
1270
  export function untagNote(db: Database, noteId: string, tags: string[]): void {
1246
1271
  const stmt = db.prepare("DELETE FROM note_tags WHERE note_id = ? AND tag_name = ?");
1247
- for (const tag of tags) {
1272
+ // Mirror tagNote's normalization so removing `#tag` deletes the bare row.
1273
+ for (const raw of tags) {
1274
+ const tag = stripTagHash(raw);
1275
+ if (tag === "") continue;
1248
1276
  stmt.run(noteId, tag);
1249
1277
  }
1250
1278
  }
@@ -1359,6 +1387,11 @@ export type RenameTagResult =
1359
1387
  * after the cascade returns.
1360
1388
  */
1361
1389
  export function renameTag(db: Database, oldName: string, newName: string): RenameTagResult {
1390
+ // Normalize the TARGET so a rename can never create a `#`-prefixed tag. The
1391
+ // SOURCE (`oldName`) is left LITERAL on purpose — it's the transitional escape
1392
+ // hatch that lets the `#legacy/*` → `legacy/*` data migration find the
1393
+ // `#`-prefixed rows. (Renaming TO a `#`-name is the thing we're preventing.)
1394
+ newName = stripTagHash(newName);
1362
1395
  if (oldName === newName) {
1363
1396
  const exists = db.prepare("SELECT 1 FROM tags WHERE name = ?").get(oldName);
1364
1397
  return exists
@@ -1727,6 +1760,9 @@ export function mergeTags(
1727
1760
  sources: string[],
1728
1761
  target: string,
1729
1762
  ): { merged: Record<string, number>; target: string } {
1763
+ // Normalize the TARGET so a merge can never create a `#`-prefixed tag. SOURCES
1764
+ // stay LITERAL so `#legacy/*` rows can be merged away (same carve-out as rename).
1765
+ target = stripTagHash(target);
1730
1766
  // Dedup + drop target-in-sources (self-merge is a no-op).
1731
1767
  const uniqueSources = Array.from(new Set(sources)).filter((s) => s !== target);
1732
1768
 
@@ -1991,15 +2027,18 @@ export function createNotes(db: Database, inputs: BulkNoteInput[]): Note[] {
1991
2027
  export function batchTag(db: Database, noteIds: string[], tags: string[]): number {
1992
2028
  const insertTag = db.prepare("INSERT OR IGNORE INTO tags (name) VALUES (?)");
1993
2029
  const insertNoteTag = db.prepare("INSERT OR IGNORE INTO note_tags (note_id, tag_name) VALUES (?, ?)");
2030
+ // Canonical-bare-tag guard (vault#XXX) — batchTag has its own SQL (does NOT
2031
+ // funnel through tagNote), so it strips leading `#` independently.
2032
+ const bareTags = tags.map(stripTagHash).filter((t) => t !== "");
1994
2033
  let count = 0;
1995
2034
 
1996
2035
  db.exec("BEGIN");
1997
2036
  try {
1998
- for (const tag of tags) {
2037
+ for (const tag of bareTags) {
1999
2038
  insertTag.run(tag);
2000
2039
  }
2001
2040
  for (const noteId of noteIds) {
2002
- for (const tag of tags) {
2041
+ for (const tag of bareTags) {
2003
2042
  insertNoteTag.run(noteId, tag);
2004
2043
  count++;
2005
2044
  }
@@ -2015,12 +2054,15 @@ export function batchTag(db: Database, noteIds: string[], tags: string[]): numbe
2015
2054
 
2016
2055
  export function batchUntag(db: Database, noteIds: string[], tags: string[]): number {
2017
2056
  const stmt = db.prepare("DELETE FROM note_tags WHERE note_id = ? AND tag_name = ?");
2057
+ // Mirror batchTag's bare-tag normalization so removing `#tag` deletes the
2058
+ // bare row.
2059
+ const bareTags = tags.map(stripTagHash).filter((t) => t !== "");
2018
2060
  let count = 0;
2019
2061
 
2020
2062
  db.exec("BEGIN");
2021
2063
  try {
2022
2064
  for (const noteId of noteIds) {
2023
- for (const tag of tags) {
2065
+ for (const tag of bareTags) {
2024
2066
  stmt.run(noteId, tag);
2025
2067
  count++;
2026
2068
  }
package/core/src/store.ts CHANGED
@@ -16,6 +16,7 @@ import { HookRegistry } from "./hooks.js";
16
16
  import {
17
17
  loadTagHierarchy,
18
18
  getTagExpansion,
19
+ stripTagHash,
19
20
  TAG_CONFIG_PREFIX,
20
21
  DEFAULT_TAG_NAME,
21
22
  DEFAULT_TAG_EXPAND_MODE,
@@ -28,6 +29,10 @@ import {
28
29
  type ResolvedSchemas,
29
30
  type ValidationStatus,
30
31
  } from "./schema-defaults.js";
32
+ import {
33
+ countConformanceViolations,
34
+ type ConformanceReport,
35
+ } from "./conformance.js";
31
36
 
32
37
  /**
33
38
  * bun:sqlite-backed Store implementation. Internally everything is
@@ -247,8 +252,30 @@ export class BunSqliteStore implements Store {
247
252
  );
248
253
  }
249
254
 
255
+ /**
256
+ * Canonical-bare-tag guard (vault#XXX) for the QUERY path. Strip any leading
257
+ * `#` from `tags` / `excludeTags` BEFORE hierarchy expansion so a
258
+ * `#agent/message`-form query matches a bare-stored `agent/message` row (and
259
+ * vice-versa). This is what makes the data migration non-breaking — every
260
+ * old `#`-decorated query keeps working, mapping onto the same bare rows.
261
+ * Runs before `expandQueryTags` so hierarchy resolution / `_default` collapse
262
+ * see the bare names. Empty-after-strip entries are dropped.
263
+ */
264
+ private normalizeQueryTags(opts: QueryOpts): QueryOpts {
265
+ let next = opts;
266
+ if (opts.tags && opts.tags.length > 0) {
267
+ const tags = opts.tags.map(stripTagHash).filter((t) => t !== "");
268
+ next = { ...next, tags };
269
+ }
270
+ if (opts.excludeTags && opts.excludeTags.length > 0) {
271
+ const excludeTags = opts.excludeTags.map(stripTagHash).filter((t) => t !== "");
272
+ next = { ...next, excludeTags };
273
+ }
274
+ return next;
275
+ }
276
+
250
277
  async queryNotes(opts: QueryOpts): Promise<Note[]> {
251
- return noteOps.queryNotes(this.db, this.expandQueryTags(opts));
278
+ return noteOps.queryNotes(this.db, this.expandQueryTags(this.normalizeQueryTags(opts)));
252
279
  }
253
280
 
254
281
  async queryNotesPaged(opts: QueryOpts): Promise<QueryNotesPage> {
@@ -258,7 +285,11 @@ export class BunSqliteStore implements Store {
258
285
  // descendant set → different rows match → caller should restart). The
259
286
  // alternative — hash the expanded set — would silently keep returning
260
287
  // stale results from a hierarchy snapshot the caller never saw.
261
- return noteOps.queryNotesPaged(this.db, this.expandQueryTags(opts));
288
+ //
289
+ // Bare-tag normalization runs first (before the hash is taken inside
290
+ // queryNotesPaged) so a `#tag`-form page-1 and a bare `tag`-form follow-up
291
+ // resolve to the same cursor query_hash.
292
+ return noteOps.queryNotesPaged(this.db, this.expandQueryTags(this.normalizeQueryTags(opts)));
262
293
  }
263
294
 
264
295
  /**
@@ -328,6 +359,11 @@ export class BunSqliteStore implements Store {
328
359
  }
329
360
 
330
361
  async searchNotes(query: string, opts?: { tags?: string[]; limit?: number; expand?: TagExpandMode }): Promise<Note[]> {
362
+ // Canonical-bare-tag guard (vault#XXX): strip leading `#` from search tag
363
+ // filters before expansion, so `#manual` and `manual` resolve identically.
364
+ if (opts?.tags && opts.tags.length > 0) {
365
+ opts = { ...opts, tags: opts.tags.map(stripTagHash).filter((t) => t !== "") };
366
+ }
331
367
  // Same tag-expansion treatment as queryNotes, along the SAME `expand` axis
332
368
  // (vault tag `expand` axis) — searching `#manual` should match notes
333
369
  // tagged with any descendant under "subtypes", any `manual/*` under
@@ -626,6 +662,18 @@ export class BunSqliteStore implements Store {
626
662
  parent_names?: string[] | null;
627
663
  },
628
664
  ) {
665
+ // Canonical-bare-tag guard (vault#XXX) for the SCHEMA path. Strip leading
666
+ // `#` from the tag NAME being upserted and from every `parent_names` entry,
667
+ // so the `tags` rows and the inheritance graph stay bare — matching the
668
+ // bare-stored note_tags. A `#foo` schema with `parent_names: ["#bar"]`
669
+ // becomes `foo` / `["bar"]`, so `tag:bar` still expands to `foo`.
670
+ tag = stripTagHash(tag);
671
+ if (patch.parent_names != null) {
672
+ patch = {
673
+ ...patch,
674
+ parent_names: patch.parent_names.map(stripTagHash).filter((p) => p !== ""),
675
+ };
676
+ }
629
677
  // Snapshot the prior indexed-field set BEFORE the write so the diff below
630
678
  // sees what this tag declared going in. Only needed when `fields` changes.
631
679
  const priorRecord =
@@ -716,6 +764,21 @@ export class BunSqliteStore implements Store {
716
764
  return result;
717
765
  }
718
766
 
767
+ /**
768
+ * Conformance check (vault#283) — count how many EXISTING notes carrying
769
+ * `tag` (descendants included) would violate the PROPOSED field spec, so a
770
+ * tightening edit (strict / required / narrowed enum / changed type) can
771
+ * warn the operator BEFORE save. Pure read — no mutation. See
772
+ * core/src/conformance.ts.
773
+ */
774
+ async countTagConformance(
775
+ tag: string,
776
+ proposedFields: Record<string, tagSchemaOps.TagFieldSchema>,
777
+ opts?: { sampleLimit?: number },
778
+ ): Promise<ConformanceReport> {
779
+ return countConformanceViolations(this.db, tag, proposedFields, opts);
780
+ }
781
+
719
782
  // ---- Batch Wikilink Sync ----
720
783
 
721
784
  /**
@@ -27,6 +27,32 @@
27
27
 
28
28
  import { Database } from "bun:sqlite";
29
29
 
30
+ /**
31
+ * Canonical-bare-tag guard (vault#XXX). Strip any leading `#` (one or more)
32
+ * from a tag value so a `#`-prefixed tag can never be stored or queried
33
+ * out-of-band. Tags are stored BARE by convention (`agent/message/inbound`,
34
+ * not `#agent/message/inbound`); a client that passes the `#`-decorated form —
35
+ * as the agent module did, persisting `#agent/message/inbound` literally — must
36
+ * land on the same canonical row everyone else queries.
37
+ *
38
+ * Deliberately MINIMAL: this is NOT `normalizeTagValue` (portable-md.ts). It
39
+ * does NOT lowercase, slug-validate, or otherwise transform the value — casing
40
+ * and structure are preserved. The ONLY job is to remove the stray leading `#`.
41
+ *
42
+ * Idempotent: `#tag` / `##tag` / `tag` all collapse to `tag`. Only LEADING `#`
43
+ * is stripped — a `#` mid-string (e.g. `c#`) is left intact. Whitespace is
44
+ * trimmed first so a `" #tag"` from a sloppy client also normalizes. Applied at
45
+ * the canonical chokepoints (note-tag write, query/exclude filters, tag-schema
46
+ * name + parent_names) so MCP, REST, and direct-core callers all converge.
47
+ */
48
+ export function stripTagHash(tag: string): string {
49
+ // Strip any LEADING run of `#`/whitespace (handles `#tag`, `##tag`, ` #tag`,
50
+ // and `# tag` with a space after the hash), then trim the tail. A `#`
51
+ // mid-string (`c#`) is untouched; a degenerate `# #` collapses to "" (the
52
+ // write-path empty-tag gate drops it).
53
+ return tag.replace(/^[#\s]+/, "").trim();
54
+ }
55
+
30
56
  export interface TagHierarchy {
31
57
  /** tag → set of immediate child tags (those that declared `tag` as a parent). */
32
58
  childrenOf: Map<string, Set<string>>;
package/core/src/types.ts CHANGED
@@ -3,12 +3,14 @@ import type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } f
3
3
  import type { PrunedField } from "./indexed-fields.js";
4
4
  import type { TagExpandMode } from "./tag-hierarchy.js";
5
5
  import type { ValidationStatus } from "./schema-defaults.js";
6
+ import type { ConformanceReport } from "./conformance.js";
6
7
 
7
8
  // ---- Re-exports ----
8
9
 
9
10
  export type { TagFieldSchema, TagRelationship, TagRelationshipMap, TagRecord } from "./tag-schemas.js";
10
11
  export type { PrunedField } from "./indexed-fields.js";
11
12
  export type { TagExpandMode } from "./tag-hierarchy.js";
13
+ export type { ConformanceReport } from "./conformance.js";
12
14
 
13
15
  // ---- Note ----
14
16
 
@@ -413,6 +415,19 @@ export interface Store {
413
415
  },
414
416
  ): Promise<TagRecord>;
415
417
 
418
+ /**
419
+ * Conformance check (vault#283) — count existing notes carrying `tag`
420
+ * (descendants included) that would violate the PROPOSED field spec, so a
421
+ * tightening edit (strict / required / narrowed enum / changed type) can
422
+ * warn before save. Pure read. `proposedFields` is the full merged field
423
+ * map the operator intends to save; only those fields are checked.
424
+ */
425
+ countTagConformance(
426
+ tag: string,
427
+ proposedFields: Record<string, TagFieldSchema>,
428
+ opts?: { sampleLimit?: number },
429
+ ): Promise<ConformanceReport>;
430
+
416
431
  // Schema validation (post-v17: backed by `tags.fields` only — the
417
432
  // standalone note_schemas + schema_mappings subsystem retired in v17, see
418
433
  // vault#267). Post vault#270 the resolver walks `parent_names` so a note's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.6.4-rc.9",
3
+ "version": "0.6.4",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Canonical-bare-tag guard (vault#XXX) — REST layer. Confirms the PUT
3
+ * /tags/:name route normalizes the upserted tag NAME (so the partial-merge read
4
+ * + write land on the bare row) and leaves the rename SOURCE-name lookup literal
5
+ * (the migration escape hatch — a #-prefixed legacy tag must still be
6
+ * rename-able away). The query + note-tag write paths normalize in the store and
7
+ * are covered by core/src/bare-tag-guard.test.ts.
8
+ */
9
+
10
+ import { describe, it, expect, beforeEach } from "bun:test";
11
+ import { Database } from "bun:sqlite";
12
+ import { SqliteStore } from "../core/src/store.ts";
13
+ import { initSchema } from "../core/src/schema.ts";
14
+ import { handleTags } from "./routes.ts";
15
+
16
+ let store: SqliteStore;
17
+ let db: Database;
18
+
19
+ beforeEach(() => {
20
+ db = new Database(":memory:");
21
+ initSchema(db);
22
+ store = new SqliteStore(db);
23
+ });
24
+
25
+ function put(name: string, body: unknown): Promise<Response> {
26
+ const req = new Request(`http://localhost/api/tags/${encodeURIComponent(name)}`, {
27
+ method: "PUT",
28
+ body: JSON.stringify(body),
29
+ });
30
+ return handleTags(req, store, `/${encodeURIComponent(name)}`);
31
+ }
32
+
33
+ describe("PUT /tags/:name — bare-tag guard", () => {
34
+ it("PUT /tags/%23foo with parent_names [#bar] stores the bare row + bare parents", async () => {
35
+ const res = await put("#foo", { parent_names: ["#bar"], description: "child" });
36
+ expect(res.status).toBe(200);
37
+
38
+ const record = await store.getTagRecord("foo");
39
+ expect(record).not.toBeNull();
40
+ expect(record!.parent_names).toEqual(["bar"]);
41
+ expect(await store.getTagRecord("#foo")).toBeNull();
42
+ });
43
+
44
+ it("a #-decorated PUT preserves the existing bare record's fields (merge correctness)", async () => {
45
+ await put("thing", { description: "first" });
46
+ await put("#thing", { parent_names: [] });
47
+ const record = await store.getTagRecord("thing");
48
+ expect(record!.description).toBe("first");
49
+ });
50
+ });
51
+
52
+ describe("POST /tags/:name/rename — source name stays literal", () => {
53
+ it("rename #agent/message → agent/message finds the literal #-prefixed row", async () => {
54
+ db.prepare("INSERT OR IGNORE INTO tags (name) VALUES ('#agent/message')").run();
55
+ const note = await store.createNote("legacy inbound");
56
+ db.prepare("INSERT INTO note_tags (note_id, tag_name) VALUES (?, '#agent/message')").run(note.id);
57
+
58
+ const sub = "/" + encodeURIComponent("#agent/message") + "/rename";
59
+ const req = new Request(`http://localhost/api/tags${sub}`, {
60
+ method: "POST",
61
+ body: JSON.stringify({ new_name: "agent/message" }),
62
+ });
63
+ const res = await handleTags(req, store, sub);
64
+ expect(res.status).toBe(200);
65
+ const body = (await res.json()) as { renamed?: number };
66
+ expect(body.renamed).toBeGreaterThan(0);
67
+
68
+ const tags = await store.listTags();
69
+ expect(tags.map((t) => t.name)).not.toContain("#agent/message");
70
+ expect(tags.map((t) => t.name)).toContain("agent/message");
71
+ });
72
+ });
package/src/cli.ts CHANGED
@@ -1664,7 +1664,7 @@ function cmdRemove(args: string[]) {
1664
1664
  configDirty = true;
1665
1665
  console.log(
1666
1666
  ` Last vault removed — wrote auto_create: false to ${GLOBAL_CONFIG_PATH} so the` +
1667
- ` server won't auto-recreate "default" on next boot. Create a vault with:` +
1667
+ ` server won't auto-create a first vault on next boot. Create one with:` +
1668
1668
  ` parachute-vault create <name>`,
1669
1669
  );
1670
1670
  }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Integration tests for the first-boot vault creation gate introduced in
3
+ * vault#478 Part 2 — server creates a vault ONLY when PARACHUTE_VAULT_NAME
4
+ * is explicitly set.
5
+ *
6
+ * Each test spawns a real server subprocess against a fresh PARACHUTE_HOME
7
+ * tmpdir (the stop-signal.test.ts pattern) and probes over HTTP so the
8
+ * actual Bun.serve boot path is exercised, not a mocked code path.
9
+ *
10
+ * Port assignments (chosen to avoid collisions with the running daemon on
11
+ * 1940 and stop-signal.test.ts on 19404):
12
+ * 19410 — test 1: PARACHUTE_VAULT_NAME unset → zero vaults
13
+ * 19411 — test 2: PARACHUTE_VAULT_NAME=testvault → vault created
14
+ *
15
+ * NOTE: the unit-level assertions for resolveFirstBootVaultName (source:
16
+ * "default" / "env" / "env-invalid") already live in vault-name.test.ts.
17
+ * These tests cover the SERVER-LEVEL behaviour — whether a vault is actually
18
+ * created on disk — so they are intentionally non-duplicative.
19
+ */
20
+
21
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
22
+ import { mkdtempSync, rmSync, mkdirSync, existsSync } from "fs";
23
+ import { tmpdir } from "os";
24
+ import { join, resolve } from "path";
25
+ import { waitForHealthy } from "./health.ts";
26
+ import { listVaults } from "./config.ts";
27
+
28
+ const SERVER_PATH = resolve(import.meta.dir, "server.ts");
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Test 1 — PARACHUTE_VAULT_NAME unset
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const PORT_NO_ENV = 19_410;
35
+ let tmpHomeNoEnv: string;
36
+
37
+ beforeAll(() => {
38
+ tmpHomeNoEnv = mkdtempSync(join(tmpdir(), "vault-first-boot-no-env-"));
39
+ mkdirSync(join(tmpHomeNoEnv, "vault"), { recursive: true });
40
+ });
41
+
42
+ afterAll(() => {
43
+ rmSync(tmpHomeNoEnv, { recursive: true, force: true });
44
+ });
45
+
46
+ describe("first-boot: PARACHUTE_VAULT_NAME unset → zero vaults", () => {
47
+ test("server is healthy AND no vault was auto-created", async () => {
48
+ const proc = Bun.spawn({
49
+ cmd: ["bun", SERVER_PATH],
50
+ env: {
51
+ ...process.env,
52
+ PARACHUTE_HOME: tmpHomeNoEnv,
53
+ PORT: String(PORT_NO_ENV),
54
+ // Must be unset — do NOT inherit from the test runner's shell.
55
+ PARACHUTE_VAULT_NAME: undefined as unknown as string,
56
+ SCRIBE_URL: "",
57
+ },
58
+ stdout: "pipe",
59
+ stderr: "pipe",
60
+ });
61
+
62
+ try {
63
+ const health = await waitForHealthy(PORT_NO_ENV, { totalMs: 15_000 });
64
+ expect(health.status).toBe("healthy");
65
+
66
+ // Verify zero vaults were created inside the tmp home.
67
+ const originalHome = process.env.PARACHUTE_HOME;
68
+ try {
69
+ process.env.PARACHUTE_HOME = tmpHomeNoEnv;
70
+ const vaults = listVaults();
71
+ expect(vaults).toHaveLength(0);
72
+ expect(vaults).not.toContain("default");
73
+ } finally {
74
+ if (originalHome === undefined) delete process.env.PARACHUTE_HOME;
75
+ else process.env.PARACHUTE_HOME = originalHome;
76
+ }
77
+
78
+ // Also assert no vault.yaml was written (belt-and-suspenders).
79
+ // Path structure: <PARACHUTE_HOME>/vault/data/<name>/vault.yaml
80
+ const defaultVaultConfig = join(tmpHomeNoEnv, "vault", "data", "default", "vault.yaml");
81
+ expect(existsSync(defaultVaultConfig)).toBe(false);
82
+ } finally {
83
+ if (!proc.killed) proc.kill();
84
+ await proc.exited;
85
+ }
86
+ }, 25_000);
87
+ });
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // Test 2 — PARACHUTE_VAULT_NAME=testvault → vault created
91
+ // ---------------------------------------------------------------------------
92
+
93
+ const PORT_WITH_ENV = 19_411;
94
+ let tmpHomeWithEnv: string;
95
+
96
+ // We need separate beforeAll/afterAll scopes for the two server lifetimes.
97
+ // Bun:test describe blocks share the outer beforeAll/afterAll, so we use
98
+ // module-level variables and a nested describe with its own lifecycle hooks.
99
+ describe("first-boot: PARACHUTE_VAULT_NAME=testvault → vault created", () => {
100
+ let proc: ReturnType<typeof Bun.spawn>;
101
+
102
+ beforeAll(async () => {
103
+ tmpHomeWithEnv = mkdtempSync(join(tmpdir(), "vault-first-boot-with-env-"));
104
+ mkdirSync(join(tmpHomeWithEnv, "vault"), { recursive: true });
105
+
106
+ proc = Bun.spawn({
107
+ cmd: ["bun", SERVER_PATH],
108
+ env: {
109
+ ...process.env,
110
+ PARACHUTE_HOME: tmpHomeWithEnv,
111
+ PORT: String(PORT_WITH_ENV),
112
+ PARACHUTE_VAULT_NAME: "testvault",
113
+ SCRIBE_URL: "",
114
+ },
115
+ stdout: "pipe",
116
+ stderr: "pipe",
117
+ });
118
+
119
+ const health = await waitForHealthy(PORT_WITH_ENV, { totalMs: 15_000 });
120
+ if (health.status !== "healthy") {
121
+ proc.kill();
122
+ throw new Error(`server failed to become healthy: ${health.status} (${health.error ?? ""})`);
123
+ }
124
+ });
125
+
126
+ afterAll(async () => {
127
+ if (proc && !proc.killed) proc.kill();
128
+ await proc?.exited;
129
+ rmSync(tmpHomeWithEnv, { recursive: true, force: true });
130
+ });
131
+
132
+ test('vault "testvault" was created (not "default")', () => {
133
+ const originalHome = process.env.PARACHUTE_HOME;
134
+ try {
135
+ process.env.PARACHUTE_HOME = tmpHomeWithEnv;
136
+ const vaults = listVaults();
137
+ expect(vaults).toContain("testvault");
138
+ expect(vaults).not.toContain("default");
139
+ expect(vaults).toHaveLength(1);
140
+ } finally {
141
+ if (originalHome === undefined) delete process.env.PARACHUTE_HOME;
142
+ else process.env.PARACHUTE_HOME = originalHome;
143
+ }
144
+ });
145
+
146
+ test("vault config dir exists at the correct path", () => {
147
+ // Path structure: <PARACHUTE_HOME>/vault/data/<name>/vault.yaml
148
+ const vaultConfigPath = join(tmpHomeWithEnv, "vault", "data", "testvault", "vault.yaml");
149
+ expect(existsSync(vaultConfigPath)).toBe(true);
150
+
151
+ // "default" must NOT have been created.
152
+ const defaultConfigPath = join(tmpHomeWithEnv, "vault", "data", "default", "vault.yaml");
153
+ expect(existsSync(defaultConfigPath)).toBe(false);
154
+ });
155
+ });