@openparachute/vault 0.7.2 → 0.7.3-rc.10
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/README.md +5 -3
- package/core/src/attachment/policy.test.ts +66 -0
- package/core/src/attachment/policy.ts +131 -0
- package/core/src/attachment/tickets.test.ts +45 -0
- package/core/src/attachment/tickets.ts +117 -0
- package/core/src/attachment-tickets-tool.test.ts +286 -0
- package/core/src/conformance.ts +2 -1
- package/core/src/contract-typed-index.test.ts +4 -3
- package/core/src/core.test.ts +607 -11
- package/core/src/display-title.test.ts +190 -0
- package/core/src/embedding/chunker.test.ts +97 -0
- package/core/src/embedding/chunker.ts +180 -0
- package/core/src/embedding/provider.ts +108 -0
- package/core/src/embedding/staleness.test.ts +87 -0
- package/core/src/embedding/staleness.ts +83 -0
- package/core/src/embedding/vector-codec.test.ts +99 -0
- package/core/src/embedding/vector-codec.ts +60 -0
- package/core/src/embedding/vectors.test.ts +163 -0
- package/core/src/embedding/vectors.ts +135 -0
- package/core/src/expand.ts +11 -3
- package/core/src/indexed-fields.test.ts +9 -3
- package/core/src/indexed-fields.ts +9 -1
- package/core/src/lede.test.ts +96 -0
- package/core/src/mcp-semantic-search.test.ts +160 -0
- package/core/src/mcp.ts +405 -10
- package/core/src/notes.semantic-search.test.ts +131 -0
- package/core/src/notes.ts +319 -7
- package/core/src/query-warnings.ts +40 -0
- package/core/src/schema-defaults.ts +85 -1
- package/core/src/schema-v27-note-vectors.test.ts +178 -0
- package/core/src/schema.ts +81 -1
- package/core/src/search-fts-v25.test.ts +9 -1
- package/core/src/search-query.test.ts +42 -0
- package/core/src/search-query.ts +27 -0
- package/core/src/search-title-boost.test.ts +125 -0
- package/core/src/seed-packs.test.ts +117 -1
- package/core/src/seed-packs.ts +217 -1
- package/core/src/store.semantic-search.test.ts +236 -0
- package/core/src/store.ts +162 -5
- package/core/src/tag-schemas.ts +27 -12
- package/core/src/types.ts +63 -1
- package/core/src/vault-projection.ts +48 -0
- package/package.json +6 -1
- package/src/add-pack.test.ts +32 -0
- package/src/attachment-tickets.test.ts +350 -0
- package/src/attachment-tickets.ts +264 -0
- package/src/auth.ts +8 -2
- package/src/cli.ts +5 -1
- package/src/contract-errors.test.ts +2 -1
- package/src/contract-honest-queries.test.ts +88 -0
- package/src/contract-search.test.ts +41 -0
- package/src/embedding/capability.test.ts +33 -0
- package/src/embedding/capability.ts +34 -0
- package/src/embedding/external-api.test.ts +154 -0
- package/src/embedding/external-api.ts +144 -0
- package/src/embedding/onnx-transformers.test.ts +113 -0
- package/src/embedding/onnx-transformers.ts +141 -0
- package/src/embedding/select.test.ts +99 -0
- package/src/embedding/select.ts +92 -0
- package/src/embedding-worker.test.ts +300 -0
- package/src/embedding-worker.ts +226 -0
- package/src/mcp-http.ts +13 -1
- package/src/mcp-tools.ts +49 -14
- package/src/onboarding-seed.test.ts +64 -0
- package/src/routes.ts +173 -92
- package/src/routing.ts +17 -0
- package/src/scribe-discovery.test.ts +76 -1
- package/src/scribe-discovery.ts +28 -9
- package/src/semantic-search-routes.test.ts +161 -0
- package/src/server.ts +21 -2
- package/src/vault-embeddings-capability.test.ts +82 -0
- package/src/vault-store-embedding-wiring.test.ts +69 -0
- package/src/vault-store.ts +53 -2
- package/src/vault.test.ts +62 -13
package/src/server.ts
CHANGED
|
@@ -19,7 +19,8 @@ import { readVaultConfig, readGlobalConfig, writeGlobalConfig, writeVaultConfig,
|
|
|
19
19
|
import { existsSync, rmSync } from "fs";
|
|
20
20
|
import { migrateVaultKeys } from "./token-store.ts";
|
|
21
21
|
import { resolveFirstBootVaultName, reservedNameSquatWarnings } from "./vault-name.ts";
|
|
22
|
-
import { getVaultStore, getVaultNameForStore } from "./vault-store.ts";
|
|
22
|
+
import { getVaultStore, getVaultNameForStore, getSharedEmbeddingProvider } from "./vault-store.ts";
|
|
23
|
+
import { EmbeddingWorker, registerEmbeddingHook } from "./embedding-worker.ts";
|
|
23
24
|
import { seedOnboardingNotesBestEffort } from "./onboarding-seed.ts";
|
|
24
25
|
import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
25
26
|
import { registerTriggers } from "./triggers.ts";
|
|
@@ -220,6 +221,22 @@ if (providerName === "transcribe-cpp") {
|
|
|
220
221
|
}
|
|
221
222
|
}
|
|
222
223
|
|
|
224
|
+
// Embedding worker (semantic search MVP — EXPERIMENTAL). Unlike
|
|
225
|
+
// transcription (no-op without a discoverable scribe), a provider is
|
|
226
|
+
// ALWAYS resolved here — the bundled floor tier (`onnx-transformers.ts`)
|
|
227
|
+
// is the zero-config default, so a fresh install gets working semantic
|
|
228
|
+
// search with no operator action. The event hook embeds on every note
|
|
229
|
+
// create/update (a no-op edit makes zero provider calls — see
|
|
230
|
+
// `embedding-worker.ts`); the sweep drains the backfill for pre-existing
|
|
231
|
+
// notes and catches anything a dropped dispatch left behind.
|
|
232
|
+
const embeddingWorker = new EmbeddingWorker({
|
|
233
|
+
provider: getSharedEmbeddingProvider(),
|
|
234
|
+
vaultList: () => listVaults(),
|
|
235
|
+
getStore: (name: string) => getVaultStore(name),
|
|
236
|
+
});
|
|
237
|
+
registerEmbeddingHook(defaultHookRegistry, embeddingWorker, (store) => getVaultNameForStore(store as never));
|
|
238
|
+
embeddingWorker.start();
|
|
239
|
+
|
|
223
240
|
if (process.env.VAULT_AUTH_TOKEN?.trim()) {
|
|
224
241
|
console.log("[auth] VAULT_AUTH_TOKEN set — server-wide operator bearer active");
|
|
225
242
|
}
|
|
@@ -599,7 +616,9 @@ async function shutdown(signal: string): Promise<void> {
|
|
|
599
616
|
),
|
|
600
617
|
),
|
|
601
618
|
);
|
|
602
|
-
// Then drain hooks + stop the transcription
|
|
619
|
+
// Then drain hooks + stop the transcription/embedding workers in
|
|
620
|
+
// parallel.
|
|
621
|
+
embeddingWorker.stop();
|
|
603
622
|
await Promise.all([
|
|
604
623
|
defaultHookRegistry.drain(),
|
|
605
624
|
transcriptionWorker?.stop() ?? Promise.resolve(),
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/vault — `embeddings` capability field (semantic search MVP,
|
|
3
|
+
* EXPERIMENTAL). This tests the WIRING (routes.ts's `handleVault` →
|
|
4
|
+
* `resolveEmbeddingCap` → the response body) — `resolveEmbeddingCapability`
|
|
5
|
+
* itself is unit-tested in `src/embedding/capability.test.ts`.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
8
|
+
import { Database } from "bun:sqlite";
|
|
9
|
+
import { BunStore } from "./vault-store.ts";
|
|
10
|
+
import { handleVault } from "./routes.ts";
|
|
11
|
+
import type { EmbeddingCapability } from "./embedding/capability.ts";
|
|
12
|
+
|
|
13
|
+
let db: Database;
|
|
14
|
+
let store: BunStore;
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
db = new Database(":memory:");
|
|
18
|
+
store = new BunStore(db);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
db.close();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const BASE = "http://localhost/api";
|
|
26
|
+
|
|
27
|
+
describe("GET /api/vault embeddings capability", () => {
|
|
28
|
+
test("surfaces enabled:true + provider/model when the injected resolver reports available", async () => {
|
|
29
|
+
const cfg = { name: "default" };
|
|
30
|
+
const resolveEmbeddingCap = async (): Promise<EmbeddingCapability> => ({
|
|
31
|
+
enabled: true,
|
|
32
|
+
provider: "onnx-transformers",
|
|
33
|
+
model: "Xenova/bge-small-en-v1.5",
|
|
34
|
+
});
|
|
35
|
+
const res = await handleVault(
|
|
36
|
+
new Request(`${BASE}/vault`, { method: "GET" }),
|
|
37
|
+
store,
|
|
38
|
+
cfg as any,
|
|
39
|
+
undefined,
|
|
40
|
+
undefined, // resolveCapability (transcription) — keep default
|
|
41
|
+
undefined, // tagScope — keep default
|
|
42
|
+
resolveEmbeddingCap,
|
|
43
|
+
);
|
|
44
|
+
expect(res.status).toBe(200);
|
|
45
|
+
const body = (await res.json()) as any;
|
|
46
|
+
expect(body.embeddings).toEqual({
|
|
47
|
+
enabled: true,
|
|
48
|
+
provider: "onnx-transformers",
|
|
49
|
+
model: "Xenova/bge-small-en-v1.5",
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("surfaces enabled:false with no provider/model when the injected resolver reports unavailable", async () => {
|
|
54
|
+
const cfg = { name: "default" };
|
|
55
|
+
const resolveEmbeddingCap = async (): Promise<EmbeddingCapability> => ({ enabled: false });
|
|
56
|
+
const res = await handleVault(
|
|
57
|
+
new Request(`${BASE}/vault`, { method: "GET" }),
|
|
58
|
+
store,
|
|
59
|
+
cfg as any,
|
|
60
|
+
undefined,
|
|
61
|
+
undefined,
|
|
62
|
+
undefined,
|
|
63
|
+
resolveEmbeddingCap,
|
|
64
|
+
);
|
|
65
|
+
const body = (await res.json()) as any;
|
|
66
|
+
expect(body.embeddings).toEqual({ enabled: false });
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("the production default resolves the shared embedding provider (zero-config -> the bundled floor, enabled:true)", async () => {
|
|
70
|
+
const cfg = { name: "default" };
|
|
71
|
+
// No resolver override — exercises the real default wiring
|
|
72
|
+
// (getSharedEmbeddingProvider() -> resolveEmbeddingCapability()).
|
|
73
|
+
// Zero-config env -> the bundled onnx-transformers floor, which is
|
|
74
|
+
// optimistically available() before any real model load — see
|
|
75
|
+
// src/embedding/onnx-transformers.ts's "lazy-fail, not crash" doc.
|
|
76
|
+
const res = await handleVault(new Request(`${BASE}/vault`, { method: "GET" }), store, cfg as any);
|
|
77
|
+
expect(res.status).toBe(200);
|
|
78
|
+
const body = (await res.json()) as any;
|
|
79
|
+
expect(body.embeddings.enabled).toBe(true);
|
|
80
|
+
expect(body.embeddings.provider).toBe("onnx-transformers");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end wiring test for the `EMBEDDINGS_ENABLED=false` off switch's
|
|
3
|
+
* error hint (review nano flag on #602): `getVaultStore` should thread
|
|
4
|
+
* "explicitly disabled" through to `Store.semanticSearch`'s
|
|
5
|
+
* `semantic_unavailable` hint, not the generic provider-setup message —
|
|
6
|
+
* see `core/src/store.ts`'s `embeddingDisabledReason` doc comment for why
|
|
7
|
+
* core can't determine this on its own. Unit coverage for the underlying
|
|
8
|
+
* `Store` behavior lives in `core/src/store.semantic-search.test.ts`; this
|
|
9
|
+
* file exercises the REAL `src/vault-store.ts` call site.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, test, expect, beforeEach, afterEach } from "bun:test";
|
|
12
|
+
import { mkdirSync, rmSync } from "fs";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import { tmpdir } from "os";
|
|
15
|
+
import { QueryError } from "../core/src/query-operators.ts";
|
|
16
|
+
import {
|
|
17
|
+
getVaultStore,
|
|
18
|
+
clearVaultStoreCache,
|
|
19
|
+
resetSharedEmbeddingProviderForTests,
|
|
20
|
+
} from "./vault-store.ts";
|
|
21
|
+
|
|
22
|
+
let tmpHome: string;
|
|
23
|
+
let prevHome: string | undefined;
|
|
24
|
+
let prevEnabled: string | undefined;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
tmpHome = join(tmpdir(), `vault-embed-wiring-${Date.now()}-${Math.random().toString(36).slice(2)}`);
|
|
28
|
+
mkdirSync(join(tmpHome, "vault", "data"), { recursive: true });
|
|
29
|
+
prevHome = process.env.PARACHUTE_HOME;
|
|
30
|
+
prevEnabled = process.env.EMBEDDINGS_ENABLED;
|
|
31
|
+
process.env.PARACHUTE_HOME = tmpHome;
|
|
32
|
+
clearVaultStoreCache();
|
|
33
|
+
resetSharedEmbeddingProviderForTests();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
if (prevHome === undefined) delete process.env.PARACHUTE_HOME;
|
|
38
|
+
else process.env.PARACHUTE_HOME = prevHome;
|
|
39
|
+
if (prevEnabled === undefined) delete process.env.EMBEDDINGS_ENABLED;
|
|
40
|
+
else process.env.EMBEDDINGS_ENABLED = prevEnabled;
|
|
41
|
+
clearVaultStoreCache();
|
|
42
|
+
resetSharedEmbeddingProviderForTests();
|
|
43
|
+
rmSync(tmpHome, { recursive: true, force: true });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe("getVaultStore — EMBEDDINGS_ENABLED=false hint wiring", () => {
|
|
47
|
+
test("store.embeddingDisabledReason is set, and semanticSearch's hint names the off switch", async () => {
|
|
48
|
+
process.env.EMBEDDINGS_ENABLED = "false";
|
|
49
|
+
const store = getVaultStore("test");
|
|
50
|
+
expect(store.embeddingDisabledReason).toBe("semantic search is disabled by EMBEDDINGS_ENABLED=false");
|
|
51
|
+
|
|
52
|
+
let caught: unknown;
|
|
53
|
+
try {
|
|
54
|
+
await store.semanticSearch("anything");
|
|
55
|
+
} catch (e) {
|
|
56
|
+
caught = e;
|
|
57
|
+
}
|
|
58
|
+
expect(caught).toBeInstanceOf(QueryError);
|
|
59
|
+
expect((caught as QueryError).error_type).toBe("semantic_unavailable");
|
|
60
|
+
expect((caught as QueryError).hint).toBe("semantic search is disabled by EMBEDDINGS_ENABLED=false");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("no reason set when EMBEDDINGS_ENABLED is unset — the store still has a (bundled-floor) provider", async () => {
|
|
64
|
+
delete process.env.EMBEDDINGS_ENABLED;
|
|
65
|
+
const store = getVaultStore("test");
|
|
66
|
+
expect(store.embeddingDisabledReason).toBeUndefined();
|
|
67
|
+
expect(store.embeddingProvider).toBeDefined();
|
|
68
|
+
});
|
|
69
|
+
});
|
package/src/vault-store.ts
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
import { SqliteStore } from "../core/src/store.ts";
|
|
9
9
|
import { defaultHookRegistry } from "../core/src/hooks.ts";
|
|
10
10
|
import type { Store } from "../core/src/types.ts";
|
|
11
|
+
import type { EmbeddingProvider } from "../core/src/embedding/provider.ts";
|
|
12
|
+
import { buildEmbeddingProvider, embeddingsExplicitlyDisabled } from "./embedding/select.ts";
|
|
11
13
|
import { openVaultDb } from "./db.ts";
|
|
12
14
|
|
|
13
15
|
export { SqliteStore as BunStore };
|
|
@@ -20,14 +22,63 @@ const stores = new Map<string, SqliteStore>();
|
|
|
20
22
|
* resolve the vault's assets directory from the store handle. */
|
|
21
23
|
const storeToVault = new WeakMap<SqliteStore, string>();
|
|
22
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The one shared `EmbeddingProvider` instance for this process (semantic
|
|
27
|
+
* search MVP, EXPERIMENTAL). Unlike `defaultTranscriptionProvider()`
|
|
28
|
+
* (resolved fresh per call — transcription providers are cheap stateless
|
|
29
|
+
* wrappers), this is memoized deliberately: the bundled floor tier
|
|
30
|
+
* (`onnx-transformers.ts`) lazy-loads a ~34MB ONNX model on first use, and
|
|
31
|
+
* every vault this server hosts should share that ONE loaded model rather
|
|
32
|
+
* than each vault's `Store` triggering its own separate load. Resolved
|
|
33
|
+
* from `process.env` on first access (so `.env` loaded via
|
|
34
|
+
* `loadEnvFile()` in `server.ts` is visible) and reused for every
|
|
35
|
+
* subsequent vault opened this process.
|
|
36
|
+
*
|
|
37
|
+
* `undefined` is a legitimate, cacheable resolution — `EMBEDDINGS_ENABLED=
|
|
38
|
+
* false` (the off switch, see `select.ts`) means "no provider," not
|
|
39
|
+
* "not resolved yet." A separate `resolved` flag (rather than null-
|
|
40
|
+
* checking the provider itself) distinguishes the two, so an explicitly-
|
|
41
|
+
* disabled vault doesn't re-run `buildEmbeddingProvider()` on every call.
|
|
42
|
+
*/
|
|
43
|
+
let sharedEmbeddingProvider: EmbeddingProvider | undefined;
|
|
44
|
+
let sharedEmbeddingProviderResolved = false;
|
|
45
|
+
|
|
46
|
+
/** The shared embedding provider (see `sharedEmbeddingProvider`'s doc comment). `undefined` when `EMBEDDINGS_ENABLED=false`. */
|
|
47
|
+
export function getSharedEmbeddingProvider(): EmbeddingProvider | undefined {
|
|
48
|
+
if (!sharedEmbeddingProviderResolved) {
|
|
49
|
+
sharedEmbeddingProvider = buildEmbeddingProvider();
|
|
50
|
+
sharedEmbeddingProviderResolved = true;
|
|
51
|
+
}
|
|
52
|
+
return sharedEmbeddingProvider;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Test-only: force a fresh provider on the next `getSharedEmbeddingProvider()` call. */
|
|
56
|
+
export function resetSharedEmbeddingProviderForTests(): void {
|
|
57
|
+
sharedEmbeddingProvider = undefined;
|
|
58
|
+
sharedEmbeddingProviderResolved = false;
|
|
59
|
+
}
|
|
60
|
+
|
|
23
61
|
/** Get or create a store for a vault. */
|
|
24
62
|
export function getVaultStore(name: string): SqliteStore {
|
|
25
63
|
let store = stores.get(name);
|
|
26
64
|
if (!store) {
|
|
27
65
|
const db = openVaultDb(name);
|
|
28
66
|
// Share the process-wide hook registry so features can register
|
|
29
|
-
// handlers once at startup and have them fire for every vault.
|
|
30
|
-
|
|
67
|
+
// handlers once at startup and have them fire for every vault. Same
|
|
68
|
+
// for the embedding provider (see `getSharedEmbeddingProvider`'s doc
|
|
69
|
+
// comment) — every vault's semantic search resolves the SAME provider
|
|
70
|
+
// instance. `embeddingDisabledReason` is only ever set here (core can't
|
|
71
|
+
// read env vars itself — see `Store.embeddingDisabledReason`'s doc
|
|
72
|
+
// comment), so `semanticSearch`'s no-provider hint can say "explicitly
|
|
73
|
+
// disabled" instead of generic provider-setup instructions when that's
|
|
74
|
+
// actually why.
|
|
75
|
+
store = new SqliteStore(db, {
|
|
76
|
+
hooks: defaultHookRegistry,
|
|
77
|
+
embeddingProvider: getSharedEmbeddingProvider(),
|
|
78
|
+
embeddingDisabledReason: embeddingsExplicitlyDisabled()
|
|
79
|
+
? "semantic search is disabled by EMBEDDINGS_ENABLED=false"
|
|
80
|
+
: undefined,
|
|
81
|
+
});
|
|
31
82
|
stores.set(name, store);
|
|
32
83
|
storeToVault.set(store, name);
|
|
33
84
|
}
|
package/src/vault.test.ts
CHANGED
|
@@ -272,9 +272,9 @@ describe("metadata", async () => {
|
|
|
272
272
|
expect(published[0].content).toBe("Published");
|
|
273
273
|
});
|
|
274
274
|
|
|
275
|
-
test("notes without metadata return
|
|
275
|
+
test("notes without metadata return metadata: {} (vault V1.1 — not an absent key)", async () => {
|
|
276
276
|
const note = await store.createNote("Plain note");
|
|
277
|
-
expect(note.metadata).
|
|
277
|
+
expect(note.metadata).toEqual({});
|
|
278
278
|
});
|
|
279
279
|
|
|
280
280
|
test("creates link with metadata", async () => {
|
|
@@ -2139,6 +2139,22 @@ describe("HTTP /notes", async () => {
|
|
|
2139
2139
|
expect(body[0]).not.toHaveProperty("content");
|
|
2140
2140
|
expect(body[0]).toHaveProperty("byteSize");
|
|
2141
2141
|
expect(body[0]).toHaveProperty("preview");
|
|
2142
|
+
expect(body[0]).toHaveProperty("displayTitle");
|
|
2143
|
+
});
|
|
2144
|
+
|
|
2145
|
+
// Title axis (ratified 2026-07-17) — displayTitle on the REST lean shape.
|
|
2146
|
+
test("GET /notes lean shape carries the computed displayTitle", async () => {
|
|
2147
|
+
await store.createNote("# Meeting Notes\nagenda: budget", { path: "meeting" });
|
|
2148
|
+
const res = await handleNotes(mkReq("GET", "/notes"), store, "");
|
|
2149
|
+
const body = await res.json() as any[];
|
|
2150
|
+
expect(body[0].displayTitle).toBe("Meeting Notes");
|
|
2151
|
+
});
|
|
2152
|
+
|
|
2153
|
+
test("GET /notes lean shape reports null displayTitle for an empty note", async () => {
|
|
2154
|
+
await store.createNote("", { path: "empty" });
|
|
2155
|
+
const res = await handleNotes(mkReq("GET", "/notes"), store, "");
|
|
2156
|
+
const body = await res.json() as any[];
|
|
2157
|
+
expect(body[0].displayTitle).toBeNull();
|
|
2142
2158
|
});
|
|
2143
2159
|
|
|
2144
2160
|
test("GET /notes?include_content=true returns full notes", async () => {
|
|
@@ -6609,8 +6625,12 @@ describe("stateless MCP transport", async () => {
|
|
|
6609
6625
|
expect(toolNames).not.toContain("merge-tags");
|
|
6610
6626
|
// Admin tools (vault#376) are hidden too
|
|
6611
6627
|
expect(toolNames).not.toContain("manage-token");
|
|
6612
|
-
//
|
|
6613
|
-
expect(toolNames
|
|
6628
|
+
// request-attachment-download is read-tier (upload is write-tier).
|
|
6629
|
+
expect(toolNames).toContain("request-attachment-download");
|
|
6630
|
+
expect(toolNames).not.toContain("request-attachment-upload");
|
|
6631
|
+
// Read tier is exactly 6 tools (doctor added by the re-tier;
|
|
6632
|
+
// request-attachment-download added by the attachment-tickets design).
|
|
6633
|
+
expect(toolNames.length).toBe(6);
|
|
6614
6634
|
|
|
6615
6635
|
closeAllStores();
|
|
6616
6636
|
});
|
|
@@ -6891,15 +6911,15 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
|
|
|
6891
6911
|
return names;
|
|
6892
6912
|
}
|
|
6893
6913
|
|
|
6894
|
-
test("vault:read sees exactly the
|
|
6914
|
+
test("vault:read sees exactly the 6 read tools (doctor moved admin → read; request-attachment-download is read-tier)", async () => {
|
|
6895
6915
|
const names = await listToolNames(["vault:read"]);
|
|
6896
6916
|
expect(new Set(names)).toEqual(
|
|
6897
|
-
new Set(["query-notes", "list-tags", "find-path", "vault-info", "doctor"]),
|
|
6917
|
+
new Set(["query-notes", "list-tags", "find-path", "vault-info", "doctor", "request-attachment-download"]),
|
|
6898
6918
|
);
|
|
6899
|
-
expect(names.length).toBe(
|
|
6919
|
+
expect(names.length).toBe(6);
|
|
6900
6920
|
});
|
|
6901
6921
|
|
|
6902
|
-
test("vault:read + vault:write sees the
|
|
6922
|
+
test("vault:read + vault:write sees the 10 read+write tools (tag-schema tools moved write → admin; request-attachment-upload is write-tier)", async () => {
|
|
6903
6923
|
const names = await listToolNames(["vault:read", "vault:write"]);
|
|
6904
6924
|
expect(new Set(names)).toEqual(
|
|
6905
6925
|
new Set([
|
|
@@ -6911,9 +6931,11 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
|
|
|
6911
6931
|
"create-note",
|
|
6912
6932
|
"update-note",
|
|
6913
6933
|
"delete-note",
|
|
6934
|
+
"request-attachment-upload",
|
|
6935
|
+
"request-attachment-download",
|
|
6914
6936
|
]),
|
|
6915
6937
|
);
|
|
6916
|
-
expect(names.length).toBe(
|
|
6938
|
+
expect(names.length).toBe(10);
|
|
6917
6939
|
expect(names).not.toContain("manage-token");
|
|
6918
6940
|
// Re-tier (this PR): update-tag/delete-tag/rename-tag/merge-tags are now
|
|
6919
6941
|
// admin-tier — structure/taxonomy curation, not content authorship.
|
|
@@ -6925,7 +6947,7 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
|
|
|
6925
6947
|
expect(names).toContain("delete-note");
|
|
6926
6948
|
});
|
|
6927
6949
|
|
|
6928
|
-
test("vault:admin sees all
|
|
6950
|
+
test("vault:admin sees all 16 tools including manage-token + prune-schema + the tag-schema tools + both attachment-ticket tools", async () => {
|
|
6929
6951
|
const names = await listToolNames(["vault:read", "vault:write", "vault:admin"]);
|
|
6930
6952
|
expect(names).toContain("manage-token");
|
|
6931
6953
|
expect(names).toContain("prune-schema");
|
|
@@ -6934,10 +6956,12 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
|
|
|
6934
6956
|
expect(names).toContain("delete-tag");
|
|
6935
6957
|
expect(names).toContain("rename-tag");
|
|
6936
6958
|
expect(names).toContain("merge-tags");
|
|
6937
|
-
expect(names
|
|
6959
|
+
expect(names).toContain("request-attachment-upload");
|
|
6960
|
+
expect(names).toContain("request-attachment-download");
|
|
6961
|
+
expect(names.length).toBe(16);
|
|
6938
6962
|
});
|
|
6939
6963
|
|
|
6940
|
-
test("legacy-derived full token sees all
|
|
6964
|
+
test("legacy-derived full token sees all 16 tools (back-compat)", async () => {
|
|
6941
6965
|
const { handleScopedMcp } = await import("./mcp-http.ts");
|
|
6942
6966
|
const { writeVaultConfig } = await import("./config.ts");
|
|
6943
6967
|
const { closeAllStores } = await import("./vault-store.ts");
|
|
@@ -6970,7 +6994,7 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
|
|
|
6970
6994
|
} as any);
|
|
6971
6995
|
const body = await res.json() as any;
|
|
6972
6996
|
const names: string[] = body.result.tools.map((t: any) => t.name);
|
|
6973
|
-
expect(names.length).toBe(
|
|
6997
|
+
expect(names.length).toBe(16);
|
|
6974
6998
|
expect(names).toContain("manage-token");
|
|
6975
6999
|
expect(names).toContain("prune-schema");
|
|
6976
7000
|
expect(names).toContain("doctor");
|
|
@@ -7602,6 +7626,31 @@ describe("extractApiKey", () => {
|
|
|
7602
7626
|
expect(extractApiKey(req)).toBe("pvt_abc123");
|
|
7603
7627
|
});
|
|
7604
7628
|
|
|
7629
|
+
// RFC 7235: the auth-scheme token is case-insensitive (V1.4). A client
|
|
7630
|
+
// sending `bearer`/`BEARER`/`BeArEr` must authenticate identically to
|
|
7631
|
+
// one sending the canonical `Bearer` — only the credentials that follow
|
|
7632
|
+
// the scheme are case-sensitive, and those come through verbatim.
|
|
7633
|
+
test("extracts from a lowercase `bearer` scheme (RFC 7235 case-insensitivity)", () => {
|
|
7634
|
+
const req = new Request("http://localhost/api/notes", {
|
|
7635
|
+
headers: { Authorization: "bearer pvt_abc123" },
|
|
7636
|
+
});
|
|
7637
|
+
expect(extractApiKey(req)).toBe("pvt_abc123");
|
|
7638
|
+
});
|
|
7639
|
+
|
|
7640
|
+
test("extracts from a mixed-case `BeArEr` scheme", () => {
|
|
7641
|
+
const req = new Request("http://localhost/api/notes", {
|
|
7642
|
+
headers: { Authorization: "BeArEr pvt_abc123" },
|
|
7643
|
+
});
|
|
7644
|
+
expect(extractApiKey(req)).toBe("pvt_abc123");
|
|
7645
|
+
});
|
|
7646
|
+
|
|
7647
|
+
test("the token itself stays case-sensitive — only the scheme is folded", () => {
|
|
7648
|
+
const req = new Request("http://localhost/api/notes", {
|
|
7649
|
+
headers: { Authorization: "bearer PvT_MixedCase123" },
|
|
7650
|
+
});
|
|
7651
|
+
expect(extractApiKey(req)).toBe("PvT_MixedCase123");
|
|
7652
|
+
});
|
|
7653
|
+
|
|
7605
7654
|
test("extracts from X-API-Key header", () => {
|
|
7606
7655
|
const req = new Request("http://localhost/api/notes", {
|
|
7607
7656
|
headers: { "X-API-Key": "pvk_xyz789" },
|