@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.
- package/.parachute/module.json +14 -3
- package/core/src/mcp.ts +20 -0
- package/core/src/schema.ts +45 -1
- package/core/src/store.ts +66 -19
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +27 -1
- package/package.json +1 -1
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/cli.ts +45 -18
- package/src/config.test.ts +27 -0
- package/src/config.ts +87 -0
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/routes.ts +192 -78
- package/src/routing.test.ts +64 -0
- package/src/routing.ts +48 -1
- package/src/server.ts +49 -3
- 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/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/vault-create.test.ts +35 -1
- 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 +194 -0
- package/web/ui/dist/assets/index-CGL256oe.js +60 -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-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,
|
|
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 };
|
|
@@ -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
|
+
});
|
package/src/vault-store.ts
CHANGED
|
@@ -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
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
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
|
+
|