@openparachute/vault 0.5.3-rc.3 → 0.6.0

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 (41) hide show
  1. package/.parachute/module.json +14 -3
  2. package/core/src/mcp.ts +20 -0
  3. package/core/src/schema.ts +45 -1
  4. package/core/src/store.ts +66 -19
  5. package/core/src/tag-expand-axis.test.ts +301 -0
  6. package/core/src/tag-hierarchy.ts +80 -0
  7. package/core/src/triggers-store.test.ts +100 -0
  8. package/core/src/triggers-store.ts +165 -0
  9. package/core/src/types.ts +27 -1
  10. package/package.json +1 -1
  11. package/src/admin-spa.test.ts +100 -10
  12. package/src/admin-spa.ts +48 -3
  13. package/src/auto-transcribe.test.ts +51 -0
  14. package/src/auto-transcribe.ts +24 -6
  15. package/src/cli.ts +45 -18
  16. package/src/config.test.ts +27 -0
  17. package/src/config.ts +87 -0
  18. package/src/live-match.test.ts +198 -0
  19. package/src/live-match.ts +310 -0
  20. package/src/routes.ts +192 -78
  21. package/src/routing.test.ts +64 -0
  22. package/src/routing.ts +48 -1
  23. package/src/server.ts +49 -3
  24. package/src/subscribe.test.ts +588 -0
  25. package/src/subscribe.ts +248 -0
  26. package/src/subscriptions.ts +295 -0
  27. package/src/tag-expand-routes.test.ts +45 -0
  28. package/src/triggers-api.test.ts +533 -0
  29. package/src/triggers-api.ts +295 -0
  30. package/src/triggers.ts +93 -7
  31. package/src/vault-create.test.ts +35 -1
  32. package/src/vault-name.test.ts +61 -3
  33. package/src/vault-name.ts +62 -14
  34. package/src/vault-remove.test.ts +187 -0
  35. package/src/vault-store.ts +10 -3
  36. package/src/vault.test.ts +194 -0
  37. package/web/ui/dist/assets/index-CGL256oe.js +60 -0
  38. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  39. package/web/ui/dist/index.html +2 -2
  40. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  41. package/web/ui/dist/assets/index-DJL6Az--.js +0 -60
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, the `--vault-name` flag, and
11
- * the `PARACHUTE_VAULT_NAME` env var at server first-boot. `cmdCreate`
12
- * keeps its own (slightly more permissive, legacy) regex for backward
13
- * compattightening it would reject names existing users may already
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
- const RESERVED_NAMES = new Set([
22
- // Collides with the `/vaults/list` discovery endpoint historically; the
23
- // routes have since moved under `/vault/<name>/`, but `cmdCreate` still
24
- // rejects "list" and consistency is cheap.
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. Trims surrounding whitespace before checking.
35
- * `cmdCreate` keeps its own (legacy-permissive) regex; this validator is
36
- * the strict gate used by the env var, the `--vault-name` flag, and
37
- * hub's first-boot wizard.
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 (RESERVED_NAMES.has(name)) {
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 };
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Integration tests for `parachute-vault remove <name> --yes` — the cmdRemove
3
+ * hygiene from the 2026-06-09 hub-module-boundary migration (B1's CLI-side
4
+ * improvements):
5
+ *
6
+ * 1. services.json refresh — remove re-runs `selfRegister` (the same
7
+ * refresh cmdCreate does, #208) so the deleted vault's `/vault/<name>`
8
+ * path drops out of the parachute-vault row immediately instead of
9
+ * going stale until the next server boot.
10
+ * 2. last-vault marker — removing the LAST vault writes
11
+ * `auto_create: false` into the global config so the server boot's
12
+ * auto-create-default does NOT silently resurrect a fresh `default`
13
+ * (with fresh credentials). Fresh installs (no config.yaml at all)
14
+ * still auto-create — the Docker first-run path is preserved.
15
+ *
16
+ * Spawns the CLI in a temp PARACHUTE_HOME (never the operator's real
17
+ * ~/.parachute). In-process `readGlobalConfig` reads resolve PARACHUTE_HOME
18
+ * per call, so pointing the env var at the temp home inside a test is safe.
19
+ */
20
+
21
+ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
22
+ import { resolve } from "path";
23
+ import { mkdtempSync, rmSync, existsSync, readFileSync } from "fs";
24
+ import { tmpdir } from "os";
25
+ import { join } from "path";
26
+ import { bootAutoCreateAllowed, readGlobalConfig } from "./config.ts";
27
+ import type { GlobalConfig } from "./config.ts";
28
+
29
+ const CLI = resolve(import.meta.dir, "cli.ts");
30
+
31
+ function runCli(
32
+ args: string[],
33
+ env: Record<string, string>,
34
+ ): { exitCode: number; stdout: string; stderr: string } {
35
+ // Hermetic: don't inherit the dev/CI box's PARACHUTE_HUB_ORIGIN (same
36
+ // posture as vault-create.test.ts — a leaked origin flips guidance copy).
37
+ const baseEnv: Record<string, string | undefined> = { ...process.env };
38
+ delete baseEnv.PARACHUTE_HUB_ORIGIN;
39
+ const proc = Bun.spawnSync({
40
+ cmd: ["bun", CLI, ...args],
41
+ stdout: "pipe",
42
+ stderr: "pipe",
43
+ env: { ...baseEnv, ...env },
44
+ });
45
+ return {
46
+ exitCode: proc.exitCode ?? -1,
47
+ stdout: new TextDecoder().decode(proc.stdout),
48
+ stderr: new TextDecoder().decode(proc.stderr),
49
+ };
50
+ }
51
+
52
+ /** Read the global config from the test home via the real parser. The
53
+ * config path resolves `process.env.PARACHUTE_HOME` per call, so a scoped
54
+ * override + restore is all it takes — no module re-import dance. */
55
+ function readGlobalConfigFrom(home: string): GlobalConfig {
56
+ const prior = process.env.PARACHUTE_HOME;
57
+ process.env.PARACHUTE_HOME = home;
58
+ try {
59
+ return readGlobalConfig();
60
+ } finally {
61
+ if (prior === undefined) delete process.env.PARACHUTE_HOME;
62
+ else process.env.PARACHUTE_HOME = prior;
63
+ }
64
+ }
65
+
66
+ function readServices(home: string): { name: string; paths: string[] }[] {
67
+ const raw = readFileSync(join(home, "services.json"), "utf-8");
68
+ return JSON.parse(raw).services;
69
+ }
70
+
71
+ let home: string;
72
+
73
+ beforeEach(() => {
74
+ home = mkdtempSync(join(tmpdir(), "vault-remove-test-"));
75
+ });
76
+
77
+ afterEach(() => {
78
+ rmSync(home, { recursive: true, force: true });
79
+ });
80
+
81
+ describe("vault remove — services.json refresh", () => {
82
+ test("removing a vault drops its /vault/<name> path immediately", () => {
83
+ runCli(["create", "alpha", "--json"], { PARACHUTE_HOME: home });
84
+ runCli(["create", "beta", "--json"], { PARACHUTE_HOME: home });
85
+ expect(readServices(home).find((s) => s.name === "parachute-vault")!.paths).toEqual([
86
+ "/vault/alpha",
87
+ "/vault/beta",
88
+ ]);
89
+
90
+ const { exitCode, stdout } = runCli(["remove", "beta", "--yes"], {
91
+ PARACHUTE_HOME: home,
92
+ });
93
+ expect(exitCode).toBe(0);
94
+ expect(stdout).toContain('Vault "beta" removed.');
95
+
96
+ // The stale-until-next-boot path is gone NOW — the hub's well-known
97
+ // fan-out stops advertising the deleted vault without a restart.
98
+ const vault = readServices(home).find((s) => s.name === "parachute-vault");
99
+ expect(vault).toBeDefined();
100
+ expect(vault!.paths).toEqual(["/vault/alpha"]);
101
+ // The vault data itself is gone too.
102
+ expect(existsSync(join(home, "vault", "data", "beta"))).toBe(false);
103
+ });
104
+
105
+ test("removing the DEFAULT vault promotes the survivor and reorders paths", () => {
106
+ runCli(["create", "alpha", "--json"], { PARACHUTE_HOME: home }); // default
107
+ runCli(["create", "beta", "--json"], { PARACHUTE_HOME: home });
108
+
109
+ const { exitCode, stdout } = runCli(["remove", "alpha", "--yes"], {
110
+ PARACHUTE_HOME: home,
111
+ });
112
+ expect(exitCode).toBe(0);
113
+ expect(stdout).toContain('Default vault is now "beta".');
114
+
115
+ const config = readGlobalConfigFrom(home);
116
+ expect(config.default_vault).toBe("beta");
117
+ // selfRegister ran AFTER the default promotion, so paths[0] is the new
118
+ // default (the hub reads paths[0] as the canonical mount).
119
+ const vault = readServices(home).find((s) => s.name === "parachute-vault");
120
+ expect(vault!.paths).toEqual(["/vault/beta"]);
121
+ });
122
+ });
123
+
124
+ describe("vault remove — last-vault auto_create marker", () => {
125
+ test("removing the last vault writes auto_create: false + boot honors it", () => {
126
+ runCli(["create", "solo", "--json"], { PARACHUTE_HOME: home });
127
+
128
+ const { exitCode, stdout } = runCli(["remove", "solo", "--yes"], {
129
+ PARACHUTE_HOME: home,
130
+ });
131
+ expect(exitCode).toBe(0);
132
+ expect(stdout).toContain("auto_create: false");
133
+
134
+ // The marker is in the YAML and the real parser reads it back.
135
+ const yaml = readFileSync(join(home, "vault", "config.yaml"), "utf-8");
136
+ expect(yaml).toMatch(/^auto_create: false$/m);
137
+ const config = readGlobalConfigFrom(home);
138
+ expect(config.auto_create).toBe(false);
139
+
140
+ // The boot gate (server.ts auto-create branch) honors the marker — no
141
+ // resurrection of a freshly-credentialed "default".
142
+ expect(bootAutoCreateAllowed(config)).toBe(false);
143
+
144
+ // services.json was still refreshed: with zero vaults the row falls back
145
+ // to the manifest's canonical paths — the SAME row a subsequent boot's
146
+ // selfRegister writes, so CLI-remove and boot agree on the zero-vault
147
+ // registration shape (and the hub still sees the module as installed).
148
+ const vault = readServices(home).find((s) => s.name === "parachute-vault");
149
+ expect(vault).toBeDefined();
150
+ expect(vault!.paths).toEqual(["/vault/default"]);
151
+ });
152
+
153
+ test("removing a NON-last vault does not write the marker", () => {
154
+ runCli(["create", "alpha", "--json"], { PARACHUTE_HOME: home });
155
+ runCli(["create", "beta", "--json"], { PARACHUTE_HOME: home });
156
+ runCli(["remove", "beta", "--yes"], { PARACHUTE_HOME: home });
157
+
158
+ const yaml = readFileSync(join(home, "vault", "config.yaml"), "utf-8");
159
+ expect(yaml).not.toContain("auto_create");
160
+ const config = readGlobalConfigFrom(home);
161
+ expect(config.auto_create).toBeUndefined();
162
+ expect(bootAutoCreateAllowed(config)).toBe(true);
163
+ });
164
+
165
+ test("fresh install (no config.yaml at all) still allows boot auto-create", () => {
166
+ // Docker first-run: PARACHUTE_HOME exists but no vault/config.yaml has
167
+ // ever been written. readGlobalConfig returns defaults — no marker —
168
+ // and the boot auto-create proceeds.
169
+ expect(existsSync(join(home, "vault", "config.yaml"))).toBe(false);
170
+ const config = readGlobalConfigFrom(home);
171
+ expect(config.auto_create).toBeUndefined();
172
+ expect(bootAutoCreateAllowed(config)).toBe(true);
173
+ });
174
+
175
+ test("remove without --yes is a dry-run: no marker, no services change", () => {
176
+ runCli(["create", "solo", "--json"], { PARACHUTE_HOME: home });
177
+ const before = readServices(home).find((s) => s.name === "parachute-vault")!.paths;
178
+
179
+ const { exitCode, stdout } = runCli(["remove", "solo"], { PARACHUTE_HOME: home });
180
+ expect(exitCode).toBe(0);
181
+ expect(stdout).toContain("To confirm");
182
+
183
+ expect(existsSync(join(home, "vault", "data", "solo"))).toBe(true);
184
+ expect(readGlobalConfigFrom(home).auto_create).toBeUndefined();
185
+ expect(readServices(home).find((s) => s.name === "parachute-vault")!.paths).toEqual(before);
186
+ });
187
+ });
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { SqliteStore } from "../core/src/store.ts";
9
9
  import { defaultHookRegistry } from "../core/src/hooks.ts";
10
+ import type { Store } from "../core/src/types.ts";
10
11
  import { openVaultDb } from "./db.ts";
11
12
 
12
13
  export { SqliteStore as BunStore };
@@ -33,9 +34,15 @@ export function getVaultStore(name: string): SqliteStore {
33
34
  return store;
34
35
  }
35
36
 
36
- /** Look up the vault name for a previously-opened store. */
37
- export function getVaultNameForStore(store: SqliteStore): string | undefined {
38
- return storeToVault.get(store);
37
+ /**
38
+ * Look up the vault name for a previously-opened store. Accepts the `Store`
39
+ * interface (not just the concrete `SqliteStore`) so hook handlers — which
40
+ * receive `Store` from the dispatcher — can resolve the vault. The WeakMap is
41
+ * keyed on the concrete instance the dispatcher passes through unchanged, so
42
+ * the lookup still hits.
43
+ */
44
+ export function getVaultNameForStore(store: Store): string | undefined {
45
+ return storeToVault.get(store as SqliteStore);
39
46
  }
40
47
 
41
48
  /**
package/src/vault.test.ts CHANGED
@@ -1692,6 +1692,56 @@ describe("HTTP /notes", async () => {
1692
1692
  expect(body).toHaveLength(1);
1693
1693
  });
1694
1694
 
1695
+ // ---- search path honors the `expand` axis (vault tag `expand` axis) ----
1696
+ //
1697
+ // Corpus: all three notes share the FTS term "fox". Tags separate the two
1698
+ // axes — `person` is a declared subtype of `entity` (parent_names) but NOT
1699
+ // name-prefixed; `entity/archived` is name-prefixed but NOT a subtype. So
1700
+ // search(tag=entity) returns DIFFERENT sets per `expand` mode, proving the
1701
+ // search branch threads it (regression for the "validated then dropped" bug).
1702
+ async function seedSearchAxisCorpus() {
1703
+ await store.upsertTagRecord("entity", { description: "entity root" });
1704
+ await store.upsertTagRecord("person", { parent_names: ["entity"] });
1705
+ await store.upsertTagRecord("entity/archived", {});
1706
+ await store.createNote("fox literal", { tags: ["entity"], path: "s-entity" });
1707
+ await store.createNote("fox subtype", { tags: ["person"], path: "s-person" });
1708
+ await store.createNote("fox filed", { tags: ["entity/archived"], path: "s-archived" });
1709
+ }
1710
+
1711
+ test("GET /notes?search=fox&tag=entity — absent expand ≡ subtypes (descendants, no namespaced sibling)", async () => {
1712
+ await seedSearchAxisCorpus();
1713
+ const absent = await (await handleNotes(mkReq("GET", "/notes?search=fox&tag=entity&include_content=true"), store, "")).json() as any[];
1714
+ const sub = await (await handleNotes(mkReq("GET", "/notes?search=fox&tag=entity&expand=subtypes&include_content=true"), store, "")).json() as any[];
1715
+ const absentSet = new Set(absent.map((n) => n.content));
1716
+ expect(new Set(sub.map((n) => n.content))).toEqual(absentSet);
1717
+ // entity (literal) + person (subtype); NOT entity/archived.
1718
+ expect(absentSet).toEqual(new Set(["fox literal", "fox subtype"]));
1719
+ });
1720
+
1721
+ test("GET /notes?search=fox&tag=entity&expand=namespace — lexical tag/* only, NOT subtype sibling", async () => {
1722
+ await seedSearchAxisCorpus();
1723
+ const res = await handleNotes(mkReq("GET", "/notes?search=fox&tag=entity&expand=namespace&include_content=true"), store, "");
1724
+ const body = await res.json() as any[];
1725
+ // entity (literal) + entity/archived (name-prefixed); NOT person (subtype).
1726
+ expect(new Set(body.map((n) => n.content))).toEqual(new Set(["fox literal", "fox filed"]));
1727
+ });
1728
+
1729
+ test("GET /notes?search=fox&tag=entity&expand=exact — literal tag only", async () => {
1730
+ await seedSearchAxisCorpus();
1731
+ const res = await handleNotes(mkReq("GET", "/notes?search=fox&tag=entity&expand=exact&include_content=true"), store, "");
1732
+ const body = await res.json() as any[];
1733
+ expect(body.map((n) => n.content)).toEqual(["fox literal"]);
1734
+ });
1735
+
1736
+ test("GET /notes?search=...&expand=bogus → 400 INVALID_QUERY (search branch validates too)", async () => {
1737
+ await store.createNote("fox here");
1738
+ const res = await handleNotes(mkReq("GET", "/notes?search=fox&expand=bogus"), store, "");
1739
+ expect(res.status).toBe(400);
1740
+ const body = await res.json() as any;
1741
+ expect(body.code).toBe("INVALID_QUERY");
1742
+ expect(body.error).toContain("expand");
1743
+ });
1744
+
1695
1745
  test("GET /notes?has_tags=false returns only untagged notes", async () => {
1696
1746
  await store.createNote("tagged", { tags: ["x"], path: "t" });
1697
1747
  await store.createNote("plain", { path: "p" });
@@ -6065,3 +6115,147 @@ describe("handleVault: audio_retention", async () => {
6065
6115
  });
6066
6116
  });
6067
6117
 
6118
+ describe("handleVault: auto_transcribe (per-vault)", async () => {
6119
+ function mkVaultReq(method: string, body?: unknown): Request {
6120
+ const init: RequestInit = { method };
6121
+ if (body !== undefined) {
6122
+ init.body = JSON.stringify(body);
6123
+ init.headers = { "Content-Type": "application/json" };
6124
+ }
6125
+ return new Request(`${BASE}/vault`, init);
6126
+ }
6127
+
6128
+ test("GET reflects the per-vault auto_transcribe.enabled when set", async () => {
6129
+ const cfg = { name: "vaultA", auto_transcribe: { enabled: false } };
6130
+ const res = await handleVault(mkReq("GET", "/vault"), store, cfg as any);
6131
+ expect(res.status).toBe(200);
6132
+ const body = await res.json() as any;
6133
+ // The vault's OWN value wins over global — per-vault → global → true.
6134
+ expect(body.config.auto_transcribe.enabled).toBe(false);
6135
+ });
6136
+
6137
+ test("GET reflects per-vault true override even if global is off", async () => {
6138
+ const cfg = { name: "vaultA", auto_transcribe: { enabled: true } };
6139
+ const res = await handleVault(mkReq("GET", "/vault"), store, cfg as any);
6140
+ const body = await res.json() as any;
6141
+ expect(body.config.auto_transcribe.enabled).toBe(true);
6142
+ });
6143
+
6144
+ test("PATCH writes auto_transcribe to THIS vault's config object (per-vault)", async () => {
6145
+ const cfg: { name: string; auto_transcribe?: { enabled?: boolean } } = { name: "vaultA" };
6146
+ let persisted = 0;
6147
+ const res = await handleVault(
6148
+ mkVaultReq("PATCH", { config: { auto_transcribe: { enabled: true } } }),
6149
+ store,
6150
+ cfg as any,
6151
+ () => { persisted++; },
6152
+ );
6153
+ expect(res.status).toBe(200);
6154
+ const body = await res.json() as any;
6155
+ expect(body.config.auto_transcribe.enabled).toBe(true);
6156
+ // Persisted onto the per-vault config object (writeVaultConfig path),
6157
+ // NOT a server-wide global — this is the field the worker reads per-vault.
6158
+ expect(cfg.auto_transcribe?.enabled).toBe(true);
6159
+ expect(persisted).toBe(1);
6160
+
6161
+ // GET round-trips the persisted per-vault value.
6162
+ const getRes = await handleVault(mkReq("GET", "/vault"), store, cfg as any);
6163
+ const getBody = await getRes.json() as any;
6164
+ expect(getBody.config.auto_transcribe.enabled).toBe(true);
6165
+ });
6166
+
6167
+ test("enabling vault X does NOT affect vault Y (genuinely per-vault)", async () => {
6168
+ const vaultX: { name: string; auto_transcribe?: { enabled?: boolean } } = { name: "vaultX" };
6169
+ const vaultY: { name: string; auto_transcribe?: { enabled?: boolean } } = { name: "vaultY" };
6170
+
6171
+ // Link scribe to X only.
6172
+ await handleVault(
6173
+ mkVaultReq("PATCH", { config: { auto_transcribe: { enabled: true } } }),
6174
+ store,
6175
+ vaultX as any,
6176
+ () => {},
6177
+ );
6178
+
6179
+ expect(vaultX.auto_transcribe?.enabled).toBe(true);
6180
+ // Y is untouched — no global toggle was flipped, so Y still has no
6181
+ // per-vault override (the old global-write behavior would have moved Y too).
6182
+ expect(vaultY.auto_transcribe).toBeUndefined();
6183
+ });
6184
+
6185
+ test("PATCH accepts auto_transcribe.enabled=false", async () => {
6186
+ const cfg: { name: string; auto_transcribe?: { enabled?: boolean } } = {
6187
+ name: "vaultA",
6188
+ auto_transcribe: { enabled: true },
6189
+ };
6190
+ const res = await handleVault(
6191
+ mkVaultReq("PATCH", { config: { auto_transcribe: { enabled: false } } }),
6192
+ store,
6193
+ cfg as any,
6194
+ () => {},
6195
+ );
6196
+ expect(res.status).toBe(200);
6197
+ const body = await res.json() as any;
6198
+ expect(body.config.auto_transcribe.enabled).toBe(false);
6199
+ expect(cfg.auto_transcribe?.enabled).toBe(false);
6200
+ });
6201
+
6202
+ test("PATCH rejects a non-boolean enabled with 400 and does not mutate or persist", async () => {
6203
+ const cfg: { name: string; auto_transcribe?: { enabled?: boolean } } = {
6204
+ name: "vaultA",
6205
+ auto_transcribe: { enabled: true },
6206
+ };
6207
+ let persisted = 0;
6208
+ const res = await handleVault(
6209
+ mkVaultReq("PATCH", { config: { auto_transcribe: { enabled: "yes" } } }),
6210
+ store,
6211
+ cfg as any,
6212
+ () => { persisted++; },
6213
+ );
6214
+ expect(res.status).toBe(400);
6215
+ const body = await res.json() as any;
6216
+ expect(body.error).toBe("invalid_auto_transcribe");
6217
+ // Unchanged — the bad write never landed.
6218
+ expect(cfg.auto_transcribe?.enabled).toBe(true);
6219
+ expect(persisted).toBe(0);
6220
+ });
6221
+
6222
+ test("PATCH rejects auto_transcribe missing enabled with 400", async () => {
6223
+ const cfg = { name: "vaultA" } as { name: string };
6224
+ let persisted = 0;
6225
+ const res = await handleVault(
6226
+ mkVaultReq("PATCH", { config: { auto_transcribe: {} } }),
6227
+ store,
6228
+ cfg as any,
6229
+ () => { persisted++; },
6230
+ );
6231
+ expect(res.status).toBe(400);
6232
+ const body = await res.json() as any;
6233
+ expect(body.error).toBe("invalid_auto_transcribe");
6234
+ expect(persisted).toBe(0);
6235
+ });
6236
+
6237
+ test("auto_transcribe and audio_retention can be set in one PATCH (single persist)", async () => {
6238
+ const cfg: { name: string; audio_retention?: string; auto_transcribe?: { enabled?: boolean } } = {
6239
+ name: "vaultA",
6240
+ };
6241
+ let persisted = 0;
6242
+ const res = await handleVault(
6243
+ mkVaultReq("PATCH", {
6244
+ config: { audio_retention: "until_transcribed", auto_transcribe: { enabled: true } },
6245
+ }),
6246
+ store,
6247
+ cfg as any,
6248
+ () => { persisted++; },
6249
+ );
6250
+ expect(res.status).toBe(200);
6251
+ const body = await res.json() as any;
6252
+ expect(body.config.audio_retention).toBe("until_transcribed");
6253
+ expect(body.config.auto_transcribe.enabled).toBe(true);
6254
+ expect(cfg.audio_retention).toBe("until_transcribed");
6255
+ expect(cfg.auto_transcribe?.enabled).toBe(true);
6256
+ // Both fields persisted in one writeVaultConfig call.
6257
+ expect(persisted).toBe(1);
6258
+ });
6259
+
6260
+ });
6261
+