@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.
Files changed (74) hide show
  1. package/README.md +5 -3
  2. package/core/src/attachment/policy.test.ts +66 -0
  3. package/core/src/attachment/policy.ts +131 -0
  4. package/core/src/attachment/tickets.test.ts +45 -0
  5. package/core/src/attachment/tickets.ts +117 -0
  6. package/core/src/attachment-tickets-tool.test.ts +286 -0
  7. package/core/src/conformance.ts +2 -1
  8. package/core/src/contract-typed-index.test.ts +4 -3
  9. package/core/src/core.test.ts +607 -11
  10. package/core/src/display-title.test.ts +190 -0
  11. package/core/src/embedding/chunker.test.ts +97 -0
  12. package/core/src/embedding/chunker.ts +180 -0
  13. package/core/src/embedding/provider.ts +108 -0
  14. package/core/src/embedding/staleness.test.ts +87 -0
  15. package/core/src/embedding/staleness.ts +83 -0
  16. package/core/src/embedding/vector-codec.test.ts +99 -0
  17. package/core/src/embedding/vector-codec.ts +60 -0
  18. package/core/src/embedding/vectors.test.ts +163 -0
  19. package/core/src/embedding/vectors.ts +135 -0
  20. package/core/src/expand.ts +11 -3
  21. package/core/src/indexed-fields.test.ts +9 -3
  22. package/core/src/indexed-fields.ts +9 -1
  23. package/core/src/lede.test.ts +96 -0
  24. package/core/src/mcp-semantic-search.test.ts +160 -0
  25. package/core/src/mcp.ts +405 -10
  26. package/core/src/notes.semantic-search.test.ts +131 -0
  27. package/core/src/notes.ts +319 -7
  28. package/core/src/query-warnings.ts +40 -0
  29. package/core/src/schema-defaults.ts +85 -1
  30. package/core/src/schema-v27-note-vectors.test.ts +178 -0
  31. package/core/src/schema.ts +81 -1
  32. package/core/src/search-fts-v25.test.ts +9 -1
  33. package/core/src/search-query.test.ts +42 -0
  34. package/core/src/search-query.ts +27 -0
  35. package/core/src/search-title-boost.test.ts +125 -0
  36. package/core/src/seed-packs.test.ts +117 -1
  37. package/core/src/seed-packs.ts +217 -1
  38. package/core/src/store.semantic-search.test.ts +236 -0
  39. package/core/src/store.ts +162 -5
  40. package/core/src/tag-schemas.ts +27 -12
  41. package/core/src/types.ts +63 -1
  42. package/core/src/vault-projection.ts +48 -0
  43. package/package.json +6 -1
  44. package/src/add-pack.test.ts +32 -0
  45. package/src/attachment-tickets.test.ts +350 -0
  46. package/src/attachment-tickets.ts +264 -0
  47. package/src/auth.ts +8 -2
  48. package/src/cli.ts +5 -1
  49. package/src/contract-errors.test.ts +2 -1
  50. package/src/contract-honest-queries.test.ts +88 -0
  51. package/src/contract-search.test.ts +41 -0
  52. package/src/embedding/capability.test.ts +33 -0
  53. package/src/embedding/capability.ts +34 -0
  54. package/src/embedding/external-api.test.ts +154 -0
  55. package/src/embedding/external-api.ts +144 -0
  56. package/src/embedding/onnx-transformers.test.ts +113 -0
  57. package/src/embedding/onnx-transformers.ts +141 -0
  58. package/src/embedding/select.test.ts +99 -0
  59. package/src/embedding/select.ts +92 -0
  60. package/src/embedding-worker.test.ts +300 -0
  61. package/src/embedding-worker.ts +226 -0
  62. package/src/mcp-http.ts +13 -1
  63. package/src/mcp-tools.ts +49 -14
  64. package/src/onboarding-seed.test.ts +64 -0
  65. package/src/routes.ts +173 -92
  66. package/src/routing.ts +17 -0
  67. package/src/scribe-discovery.test.ts +76 -1
  68. package/src/scribe-discovery.ts +28 -9
  69. package/src/semantic-search-routes.test.ts +161 -0
  70. package/src/server.ts +21 -2
  71. package/src/vault-embeddings-capability.test.ts +82 -0
  72. package/src/vault-store-embedding-wiring.test.ts +69 -0
  73. package/src/vault-store.ts +53 -2
  74. 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 worker in parallel.
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
+ });
@@ -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
- store = new SqliteStore(db, { hooks: defaultHookRegistry });
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 undefined metadata", async () => {
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).toBeUndefined();
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
- // Read tier is exactly 5 tools (doctor added by the re-tier).
6613
- expect(toolNames.length).toBe(5);
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 5 read tools (doctor moved admin → read)", async () => {
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(5);
6919
+ expect(names.length).toBe(6);
6900
6920
  });
6901
6921
 
6902
- test("vault:read + vault:write sees the 8 read+write tools (tag-schema tools moved write → admin)", async () => {
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(8);
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 14 tools including manage-token + prune-schema + the tag-schema tools", async () => {
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.length).toBe(14);
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 14 tools (back-compat)", async () => {
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(14);
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" },