@openparachute/vault 0.7.3-rc.13 → 0.7.3-rc.2

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 (53) hide show
  1. package/README.md +3 -5
  2. package/core/src/conformance.ts +1 -2
  3. package/core/src/content-range.test.ts +0 -127
  4. package/core/src/content-range.ts +0 -100
  5. package/core/src/contract-typed-index.test.ts +3 -4
  6. package/core/src/core.test.ts +4 -521
  7. package/core/src/expand.ts +3 -11
  8. package/core/src/indexed-fields.test.ts +3 -9
  9. package/core/src/indexed-fields.ts +1 -9
  10. package/core/src/mcp.ts +10 -601
  11. package/core/src/notes.ts +4 -201
  12. package/core/src/schema-defaults.ts +1 -85
  13. package/core/src/search-fts-v25.test.ts +1 -9
  14. package/core/src/search-query.test.ts +0 -42
  15. package/core/src/search-query.ts +0 -27
  16. package/core/src/seed-packs.test.ts +1 -117
  17. package/core/src/seed-packs.ts +1 -217
  18. package/core/src/store.ts +3 -59
  19. package/core/src/tag-schemas.ts +12 -27
  20. package/core/src/types.ts +1 -7
  21. package/core/src/vault-projection.ts +0 -55
  22. package/package.json +1 -1
  23. package/src/add-pack.test.ts +0 -32
  24. package/src/auth-hub-jwt.test.ts +1 -118
  25. package/src/auth.ts +0 -64
  26. package/src/cli.ts +1 -5
  27. package/src/contract-errors.test.ts +1 -2
  28. package/src/mcp-http.ts +4 -33
  29. package/src/mcp-tools.ts +14 -61
  30. package/src/oauth-discovery.ts +0 -31
  31. package/src/onboarding-seed.test.ts +0 -64
  32. package/src/routes.ts +95 -92
  33. package/src/routing.test.ts +4 -229
  34. package/src/routing.ts +23 -152
  35. package/src/scopes.ts +0 -22
  36. package/src/server.ts +0 -7
  37. package/src/storage.test.ts +1 -200
  38. package/src/transcription-worker.test.ts +0 -151
  39. package/src/transcription-worker.ts +52 -113
  40. package/src/vault.test.ts +11 -48
  41. package/core/src/attachment/bytes-provider.ts +0 -65
  42. package/core/src/attachment/policy.test.ts +0 -66
  43. package/core/src/attachment/policy.ts +0 -131
  44. package/core/src/attachment/tickets.test.ts +0 -45
  45. package/core/src/attachment/tickets.ts +0 -117
  46. package/core/src/attachment-tickets-tool.test.ts +0 -286
  47. package/core/src/display-title.test.ts +0 -190
  48. package/core/src/lede.test.ts +0 -96
  49. package/core/src/search-title-boost.test.ts +0 -125
  50. package/src/attachment-bytes.ts +0 -68
  51. package/src/attachment-tickets.test.ts +0 -475
  52. package/src/attachment-tickets.ts +0 -340
  53. package/src/read-attachment.test.ts +0 -436
package/src/routes.ts CHANGED
@@ -42,10 +42,6 @@ import {
42
42
  type ContentRange,
43
43
  } from "../core/src/content-range.ts";
44
44
  import { attachValidationStatus, enforceStrictWrite, applySchemaDefaults } from "../core/src/mcp.ts";
45
- import {
46
- BLOCKED_ATTACHMENT_EXTENSIONS,
47
- ATTACHMENT_MIME_TYPES,
48
- } from "../core/src/attachment/policy.ts";
49
45
  import type { ValidationWarning } from "../core/src/schema-defaults.ts";
50
46
  import { logStrictBypass } from "./scopes.ts";
51
47
  import * as linkOps from "../core/src/links.ts";
@@ -143,7 +139,7 @@ import {
143
139
  type ExpandMode,
144
140
  } from "../core/src/expand.ts";
145
141
  import { join, extname, normalize } from "path";
146
- import { existsSync, mkdirSync, statSync, unlinkSync, writeFileSync } from "fs";
142
+ import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "fs";
147
143
  import { assetsDir, readGlobalConfig, readVaultConfig } from "./config.ts";
148
144
  import { shouldAutoTranscribe } from "./auto-transcribe.ts";
149
145
  // usage.ts imports `assetsDir` from config.ts (neutral ground), so this import
@@ -4444,64 +4440,97 @@ export const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
4444
4440
  */
4445
4441
  export const MAX_REQUEST_BODY_BYTES = MAX_UPLOAD_BYTES + 20 * 1024 * 1024; // 120MB
4446
4442
 
4447
- // Storage upload policy (extension blocklist + MIME lookup) now lives in
4448
- // core/src/attachment/policy.tsthe canonical source shared with the
4449
- // attachment-ticket mint/spend path (vault attachment-tickets design,
4450
- // "shared BLOCKED_EXTENSIONS"), so a blocked extension can never diverge
4451
- // between REST and tickets. See that module's doc comment for the full
4452
- // same-origin-XSS rationale and the MIME curation invariant.
4453
- const BLOCKED_EXTENSIONS = BLOCKED_ATTACHMENT_EXTENSIONS;
4454
- const MIME_TYPES = ATTACHMENT_MIME_TYPES;
4455
-
4456
- /**
4457
- * Parse a single-range `Range: bytes=a-b` header (RFC 7233 §2.1 — single
4458
- * range only, attachments-for-agents design D9, the REST twin of MCP's
4459
- * `content_offset`). Returns `null` — this function's own contract is
4460
- * "serve the full response, unranged" — for a missing header, a
4461
- * MALFORMED value, an unrecognized unit, a multi-range list
4462
- * (`bytes=0-10,20-30` ignored, not an error, per D9), or a range this
4463
- * file can't satisfy (a `start` past EOF). `start`/`end` are both
4464
- * INCLUSIVE byte offsets; `end` is clamped to `total - 1` when the
4465
- * request left it open (`bytes=500-`) or asked past EOF.
4466
- *
4467
- * IMPORTANT this `null` contract is NOT the last word for an
4468
- * unsatisfiable (syntactically-valid but out-of-bounds) range on a real
4469
- * `Bun.serve()` deployment. Live-verified against an actual socket (not
4470
- * the in-process `handleStorage()` call the test suite uses): when the
4471
- * response body is a `Bun.file()` — which `handleStorage`'s full-response
4472
- * branch below always hands back — Bun's OWN runtime transparently
4473
- * reinterprets the incoming request's `Range` header a second time and,
4474
- * for an out-of-bounds range, overrides our 200 with a native
4475
- * **416 Range Not Satisfiable**, regardless of what this function or
4476
- * `handleStorage` returned. That's RFC 7233-correct and is being KEPT,
4477
- * not fought — so in practice, `null` from an out-of-bounds `start`
4478
- * still results in a 200 from `handleStorage`'s own logic, but the byte
4479
- * that actually reaches a real client for that specific case is a 416
4480
- * courtesy of Bun itself. MALFORMED and multi-range headers are NOT
4481
- * range-shaped at all, so Bun's native layer doesn't touch them — those
4482
- * two cases genuinely serve the full 200, in-process harness and real
4483
- * socket alike.
4484
- */
4485
- export function parseByteRangeHeader(header: string | null, total: number): { start: number; end: number } | null {
4486
- if (!header || total <= 0) return null;
4487
- const match = header.match(/^bytes=(\d*)-(\d*)$/);
4488
- if (!match) return null; // malformed, unrecognized unit, or a multi-range list
4489
- const [, startRaw, endRaw] = match;
4490
- if (startRaw === "" && endRaw === "") return null;
4491
-
4492
- if (startRaw === "") {
4493
- // Suffix range: last N bytes (`bytes=-500`).
4494
- const suffixLength = Number(endRaw);
4495
- if (!Number.isSafeInteger(suffixLength) || suffixLength <= 0) return null;
4496
- return { start: Math.max(0, total - suffixLength), end: total - 1 };
4497
- }
4498
-
4499
- const start = Number(startRaw);
4500
- if (!Number.isSafeInteger(start) || start < 0 || start >= total) return null;
4501
- const end = endRaw === "" ? total - 1 : Math.min(Number(endRaw), total - 1);
4502
- if (!Number.isSafeInteger(end) || end < start) return null;
4503
- return { start, end };
4504
- }
4443
+ // Storage upload policy: DENY-LIST (vault#517). A knowledge vault stores
4444
+ // arbitrary files ebooks, office docs, datasets, archives, binaries — so we
4445
+ // accept ANY upload EXCEPT the handful of types a browser can execute as
4446
+ // active content in our origin when served back from /storage/. (The prior
4447
+ // allowlist rejected the long tail: .epub/.csv/.zip/… all came back "File type
4448
+ // not allowed".)
4449
+ //
4450
+ // BLOCKED same-origin-XSS / active-content set:
4451
+ // .html/.htm/.xhtml/.shtml/.xht HTML — embeds <script>
4452
+ // .svg XML image — embeds <script>
4453
+ // .xml can carry XSLT / be parsed as XHTML
4454
+ // .js/.mjs/.cjs JavaScript
4455
+ // .css style-injection / UI-redress vector
4456
+ //
4457
+ // Two independent guards keep every STORED file inert when served:
4458
+ // 1. Only the curated MIME_TYPES below map to a real (always passive) type;
4459
+ // every other extension serves as application/octet-stream a download,
4460
+ // never rendered.
4461
+ // 2. The GET byte-serve response pins `X-Content-Type-Options: nosniff`, so
4462
+ // a browser can't sniff an octet-stream body into an executable type.
4463
+ // The blocklist is belt-and-suspenders on top of those: even if a future MIME
4464
+ // entry or an upstream proxy weakened (1) or (2), these extensions still never
4465
+ // land on disk. If a future use case needs SVG, sanitize on read (strip
4466
+ // <script>/<foreignObject>) and revisit.
4467
+ const BLOCKED_EXTENSIONS = new Set([
4468
+ ".html", ".htm", ".xhtml", ".shtml", ".xht",
4469
+ ".svg",
4470
+ ".xml",
4471
+ ".js", ".mjs", ".cjs",
4472
+ ".css",
4473
+ ]);
4474
+
4475
+ // Explicit MIME types for the commonly-previewed formats. Anything accepted
4476
+ // but absent here serves as application/octet-stream a download, never
4477
+ // rendered (e.g. .pages/.key/.numbers/.azw3/.exe/arbitrary binaries). None of
4478
+ // these map to an active type (text/html, image/svg+xml), so a served asset
4479
+ // can't execute script; `nosniff` on the GET response makes that ironclad.
4480
+ //
4481
+ // INVARIANT: never add an entry that maps to a browser-active type
4482
+ // text/html, image/svg+xml, application/xhtml+xml, text/javascript,
4483
+ // application/wasm, text/css. Doing so re-enables same-origin execution for
4484
+ // that extension (and would mean it must also join BLOCKED_EXTENSIONS).
4485
+ const MIME_TYPES: Record<string, string> = {
4486
+ // Audio
4487
+ ".wav": "audio/wav",
4488
+ ".mp3": "audio/mpeg",
4489
+ ".m4a": "audio/mp4",
4490
+ ".ogg": "audio/ogg",
4491
+ ".oga": "audio/ogg",
4492
+ ".opus": "audio/opus",
4493
+ ".aac": "audio/aac",
4494
+ ".flac": "audio/flac",
4495
+ ".webm": "audio/webm",
4496
+ // Image
4497
+ ".png": "image/png",
4498
+ ".jpg": "image/jpeg",
4499
+ ".jpeg": "image/jpeg",
4500
+ ".gif": "image/gif",
4501
+ ".webp": "image/webp",
4502
+ ".bmp": "image/bmp",
4503
+ ".tiff": "image/tiff",
4504
+ ".tif": "image/tiff",
4505
+ ".heic": "image/heic",
4506
+ ".heif": "image/heif",
4507
+ ".avif": "image/avif",
4508
+ // Video
4509
+ ".mp4": "video/mp4",
4510
+ ".m4v": "video/x-m4v",
4511
+ ".mov": "video/quicktime",
4512
+ // Documents / ebooks / data
4513
+ ".pdf": "application/pdf",
4514
+ ".epub": "application/epub+zip",
4515
+ ".mobi": "application/x-mobipocket-ebook",
4516
+ ".txt": "text/plain; charset=utf-8",
4517
+ ".md": "text/markdown; charset=utf-8",
4518
+ ".markdown": "text/markdown; charset=utf-8",
4519
+ ".rtf": "application/rtf",
4520
+ ".csv": "text/csv; charset=utf-8",
4521
+ ".tsv": "text/tab-separated-values; charset=utf-8",
4522
+ ".json": "application/json; charset=utf-8",
4523
+ ".doc": "application/msword",
4524
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
4525
+ ".ppt": "application/vnd.ms-powerpoint",
4526
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
4527
+ ".xls": "application/vnd.ms-excel",
4528
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
4529
+ ".odt": "application/vnd.oasis.opendocument.text",
4530
+ ".ods": "application/vnd.oasis.opendocument.spreadsheet",
4531
+ ".odp": "application/vnd.oasis.opendocument.presentation",
4532
+ ".zip": "application/zip",
4533
+ };
4505
4534
 
4506
4535
  export async function handleStorage(
4507
4536
  req: Request,
@@ -4665,38 +4694,12 @@ export async function handleStorage(
4665
4694
  const stat = statSync(filePath);
4666
4695
  const ext = extname(filePath).toLowerCase();
4667
4696
  const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
4668
- const total = stat.size;
4669
-
4670
- // vault attachments-for-agents design (D9) — the REST twin of MCP's
4671
- // `content_offset`. `Bun.file(filePath)` resolves lazily: `.slice()`
4672
- // creates a bounded view and only the requested bytes are actually read
4673
- // on `.arrayBuffer()`/streaming, replacing the prior whole-file
4674
- // `readFileSync` (a standing memory smell on a large attachment, e.g. a
4675
- // 90 MB video) on BOTH the ranged and full-file paths below.
4676
- const bunFile = Bun.file(filePath);
4677
- const range = parseByteRangeHeader(req.headers.get("range"), total);
4678
-
4679
- if (range) {
4680
- const { start, end } = range; // inclusive
4681
- return new Response(bunFile.slice(start, end + 1), {
4682
- status: 206,
4683
- headers: {
4684
- "Content-Type": contentType,
4685
- "Content-Length": String(end - start + 1),
4686
- "Content-Range": `bytes ${start}-${end}/${total}`,
4687
- "Accept-Ranges": "bytes",
4688
- // Defense-in-depth: never let a browser MIME-sniff a stored asset
4689
- // into an active type — see the full-response branch below.
4690
- "X-Content-Type-Options": "nosniff",
4691
- },
4692
- });
4693
- }
4697
+ const fileBuffer = readFileSync(filePath);
4694
4698
 
4695
- return new Response(bunFile, {
4699
+ return new Response(fileBuffer, {
4696
4700
  headers: {
4697
4701
  "Content-Type": contentType,
4698
- "Content-Length": String(total),
4699
- "Accept-Ranges": "bytes",
4702
+ "Content-Length": String(stat.size),
4700
4703
  // Defense-in-depth: never let a browser MIME-sniff a stored asset into
4701
4704
  // an active type (e.g. an octet-stream body sniffed as text/html).
4702
4705
  // Combined with the upload blocklist (no .svg/.html) this closes the
@@ -10,10 +10,8 @@
10
10
  * 3. `/vault/<name>/...` — per-vault routing (OAuth, MCP, view, API).
11
11
  * 4. The RFC 9728 WWW-Authenticate challenge that decorates MCP 401s.
12
12
  *
13
- * No unscoped `/api/*` or `/oauth/*` routes exist — those per-vault resources
14
- * must name the vault in the URL. The canonical root `/mcp` (U1) is the one
15
- * vault-agnostic exception: it derives the target vault from the token, then
16
- * re-dispatches through the same per-vault machinery.
13
+ * No unscoped `/mcp`, `/api/*`, `/oauth/*` routes exist — every per-vault
14
+ * resource must name the vault it targets.
17
15
  *
18
16
  * Uses PARACHUTE_HOME override so each test's vaults live in a tmp dir and
19
17
  * never touch ~/.parachute.
@@ -708,27 +706,14 @@ describe("per-vault routing under /vault/<name>/", () => {
708
706
  }
709
707
  });
710
708
 
711
- test("no /api, /oauth unscoped routes — all 404", async () => {
709
+ test("no /mcp, /api, /oauth unscoped routes — all 404", async () => {
712
710
  createVault("journal");
713
- for (const path of ["/api/notes", "/oauth/register", "/oauth/authorize"]) {
711
+ for (const path of ["/mcp", "/api/notes", "/oauth/register", "/oauth/authorize"]) {
714
712
  const res = await route(new Request(`http://localhost:1940${path}`), path);
715
713
  expect(res.status).toBe(404);
716
714
  }
717
715
  });
718
716
 
719
- test("root /mcp is NOT a 404 — it's the token-derived MCP endpoint (401 unauthenticated)", async () => {
720
- // The old unscoped `/mcp` compat prefix returned 404. The canonical root
721
- // `/mcp` (U1) replaces it: unauthenticated it 401s + carries the root PRM
722
- // challenge, exactly like the per-vault endpoint but pointed at the root
723
- // metadata document.
724
- createVault("journal");
725
- const res = await route(new Request("http://localhost:1940/mcp", { method: "POST" }), "/mcp");
726
- expect(res.status).toBe(401);
727
- expect(res.headers.get("WWW-Authenticate")).toBe(
728
- 'Bearer resource_metadata="http://localhost:1940/.well-known/oauth-protected-resource/mcp"',
729
- );
730
- });
731
-
732
717
  test("bare /vault/<name> returns metadata for authenticated callers", async () => {
733
718
  createVault("journal", "My journal vault");
734
719
  const path = "/vault/journal";
@@ -825,216 +810,6 @@ describe("MCP 401 WWW-Authenticate challenge (RFC 9728)", () => {
825
810
  });
826
811
  });
827
812
 
828
- // ---------------------------------------------------------------------------
829
- // Canonical root MCP endpoint — token-derived vault dispatch (U1).
830
- //
831
- // `/mcp` at the origin root derives the target vault from the TOKEN, not the
832
- // URL, then re-dispatches through the identical per-vault machinery. These
833
- // tests pin the two load-bearing properties: (1) a token for vault A behaves
834
- // byte-identically at root `/mcp` and at `/vault/A/mcp` (equivalence), and
835
- // (2) the vault is ALWAYS the one the token names — a token can't be steered
836
- // to another vault, and an unnameable/ambiguous token is refused with the root
837
- // discovery challenge rather than a guess (derivation honesty).
838
- // ---------------------------------------------------------------------------
839
-
840
- describe("canonical root /mcp — token-derived vault dispatch (U1)", () => {
841
- const INIT = {
842
- jsonrpc: "2.0",
843
- id: 1,
844
- method: "initialize",
845
- params: {
846
- protocolVersion: "2025-06-18",
847
- capabilities: {},
848
- clientInfo: { name: "u1-test", version: "0" },
849
- },
850
- };
851
- const LIST = { jsonrpc: "2.0", id: 2, method: "tools/list" };
852
- const QUERY = {
853
- jsonrpc: "2.0",
854
- id: 3,
855
- method: "tools/call",
856
- params: { name: "query-notes", arguments: {} },
857
- };
858
-
859
- async function mcpPost(path: string, token: string | null, rpc: object): Promise<Response> {
860
- const headers: Record<string, string> = {
861
- "content-type": "application/json",
862
- accept: "application/json, text/event-stream",
863
- };
864
- if (token) headers.authorization = `Bearer ${token}`;
865
- return route(
866
- new Request(`http://localhost:1940${path}`, {
867
- method: "POST",
868
- headers,
869
- body: JSON.stringify(rpc),
870
- }),
871
- path,
872
- );
873
- }
874
-
875
- /** Parse a JSON-RPC frame off an MCP response, tolerating either the JSON
876
- * body (enableJsonResponse) or an SSE `data:` framing. */
877
- async function rpcFrame(res: Response): Promise<unknown> {
878
- const text = await res.text();
879
- const trimmed = text.trimStart();
880
- if (trimmed.startsWith("{") || trimmed.startsWith("[")) return JSON.parse(trimmed);
881
- const dataLine = text.split("\n").find((l) => l.startsWith("data:"));
882
- return JSON.parse(dataLine!.slice("data:".length).trim());
883
- }
884
-
885
- /** Sign a token directly (bypassing `mintJwt`'s `aud=vault.<name>` coupling)
886
- * so a test can craft a token whose sources name no vault, or disagree. */
887
- async function signRaw(payload: Record<string, unknown>, aud: string): Promise<string> {
888
- const iat = Math.floor(Date.now() / 1000);
889
- return new SignJWT({ client_id: "u1-test", ...payload })
890
- .setProtectedHeader({ alg: "RS256", kid: KID })
891
- .setIssuer(`http://127.0.0.1:${hubServer.port}`)
892
- .setSubject("u1-test-user")
893
- .setAudience(aud)
894
- .setIssuedAt(iat)
895
- .setExpirationTime(iat + 60)
896
- .setJti(`jti-${Math.random().toString(36).slice(2)}`)
897
- .sign(signingKey);
898
- }
899
-
900
- test("root PRM mirrors the per-vault PRM shape (root resource, un-narrowed scopes)", async () => {
901
- createVault("journal");
902
- const path = "/.well-known/oauth-protected-resource/mcp";
903
- const res = await route(new Request(`http://localhost:1940${path}`), path);
904
- expect(res.status).toBe(200);
905
- const prm = (await res.json()) as {
906
- resource: string;
907
- authorization_servers: string[];
908
- scopes_supported: string[];
909
- bearer_methods_supported: string[];
910
- };
911
- expect(prm.resource).toBe("http://localhost:1940/mcp");
912
- // Un-narrowed forms — the hub's consent picker narrows them to a vault.
913
- expect(prm.scopes_supported).toEqual(["vault:read", "vault:write"]);
914
- expect(prm.bearer_methods_supported).toEqual(["header"]);
915
- expect(Array.isArray(prm.authorization_servers)).toBe(true);
916
- expect(prm.authorization_servers.length).toBe(1);
917
- });
918
-
919
- test("unauthenticated → 401 whose challenge names the root PRM (self-consistent pointer)", async () => {
920
- createVault("journal");
921
- const res = await mcpPost("/mcp", null, INIT);
922
- expect(res.status).toBe(401);
923
- const header = res.headers.get("WWW-Authenticate")!;
924
- const prmUrl = header.match(/resource_metadata="([^"]+)"/)![1]!;
925
- const prmPath = new URL(prmUrl).pathname;
926
- expect(prmPath).toBe("/.well-known/oauth-protected-resource/mcp");
927
- // The pointer resolves to a real PRM that names the root resource.
928
- const prmRes = await route(new Request(prmUrl), prmPath);
929
- expect(prmRes.status).toBe(200);
930
- expect(((await prmRes.json()) as { resource: string }).resource).toBe(
931
- "http://localhost:1940/mcp",
932
- );
933
- });
934
-
935
- test("x-forwarded-* shape the root challenge URL", async () => {
936
- createVault("journal");
937
- const req = new Request("http://127.0.0.1:1940/mcp", {
938
- method: "POST",
939
- headers: {
940
- "x-forwarded-host": "vault.example.com",
941
- "x-forwarded-proto": "https",
942
- },
943
- });
944
- const res = await route(req, "/mcp");
945
- expect(res.status).toBe(401);
946
- expect(res.headers.get("WWW-Authenticate")).toBe(
947
- 'Bearer resource_metadata="https://vault.example.com/.well-known/oauth-protected-resource/mcp"',
948
- );
949
- });
950
-
951
- test("token for A at root /mcp === same token at /vault/A/mcp (initialize, tools/list, tool call)", async () => {
952
- createVault("journal");
953
- const store = getVaultStore("journal");
954
- await store.createNote("root-mcp equivalence fixture", { path: "fixture", tags: ["probe"] });
955
- const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:admin"] });
956
-
957
- for (const rpc of [INIT, LIST, QUERY]) {
958
- const rootRes = await mcpPost("/mcp", token, rpc);
959
- const perVaultRes = await mcpPost("/vault/journal/mcp", token, rpc);
960
- expect(rootRes.status).toBe(200);
961
- expect(perVaultRes.status).toBe(200);
962
- // Byte-identical JSON-RPC frame — the root path derived `journal`, so the
963
- // server name, tool list, and tool result all match the URL-addressed
964
- // endpoint exactly.
965
- expect(await rpcFrame(rootRes)).toEqual(await rpcFrame(perVaultRes));
966
- }
967
- });
968
-
969
- test("a read-only token narrows the root tool list identically to the per-vault endpoint", async () => {
970
- createVault("journal");
971
- getVaultStore("journal");
972
- const readToken = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
973
- const rootList = await rpcFrame(await mcpPost("/mcp", readToken, LIST));
974
- const perVaultList = await rpcFrame(await mcpPost("/vault/journal/mcp", readToken, LIST));
975
- expect(rootList).toEqual(perVaultList);
976
- // Sanity: a read token sees the read tools but not write/admin ones.
977
- const names = (rootList as { result: { tools: { name: string }[] } }).result.tools.map(
978
- (t) => t.name,
979
- );
980
- expect(names).toContain("query-notes");
981
- expect(names).not.toContain("create-note");
982
- expect(names).not.toContain("manage-token");
983
- });
984
-
985
- test("a vault-A token ALWAYS lands in A through root /mcp — never another vault's data", async () => {
986
- createVault("alpha");
987
- createVault("beta");
988
- // Seed a secret in BETA. A token minted for ALPHA must never surface it.
989
- await getVaultStore("beta").createNote("BETA-ONLY-SECRET", { path: "secret" });
990
- getVaultStore("alpha");
991
- const alphaToken = await mintJwt({ vaultName: "alpha", scopes: ["vault:alpha:admin"] });
992
-
993
- const res = await mcpPost("/mcp", alphaToken, QUERY);
994
- expect(res.status).toBe(200);
995
- // The vault is derived from the token (alpha); beta's note is unreachable.
996
- expect(JSON.stringify(await rpcFrame(res))).not.toContain("BETA-ONLY-SECRET");
997
- });
998
-
999
- test("scope and aud naming DIFFERENT vaults → 401 (disagreement, never guess)", async () => {
1000
- createVault("alpha");
1001
- createVault("beta");
1002
- // aud=vault.alpha (from vaultName) but the scope names beta — two sources
1003
- // disagree, so derivation refuses rather than picking a winner.
1004
- const token = await mintJwt({ vaultName: "alpha", scopes: ["vault:beta:admin"] });
1005
- const res = await mcpPost("/mcp", token, INIT);
1006
- expect(res.status).toBe(401);
1007
- expect(res.headers.get("WWW-Authenticate")).toContain("/.well-known/oauth-protected-resource/mcp");
1008
- });
1009
-
1010
- test("a token that names NO vault (broad scope, non-vault aud, no vault_scope) → 401", async () => {
1011
- createVault("journal");
1012
- // Broad `vault:read` names no vault; aud is not `vault.<name>`; no
1013
- // vault_scope claim. Nothing derives a target → the discovery challenge.
1014
- const token = await signRaw({ scope: "vault:read" }, "some-other-resource");
1015
- const res = await mcpPost("/mcp", token, INIT);
1016
- expect(res.status).toBe(401);
1017
- expect(res.headers.get("WWW-Authenticate")).toContain("/.well-known/oauth-protected-resource/mcp");
1018
- });
1019
-
1020
- test("an invalid / unverifiable token → 401 + root challenge (no vault leaked)", async () => {
1021
- createVault("journal");
1022
- const res = await mcpPost("/mcp", "not-a-real-jwt", INIT);
1023
- expect(res.status).toBe(401);
1024
- expect(res.headers.get("WWW-Authenticate")).toContain("/.well-known/oauth-protected-resource/mcp");
1025
- });
1026
-
1027
- test("a validly-signed token naming a vault absent on THIS server → 404 (mirrors per-vault)", async () => {
1028
- createVault("journal");
1029
- // Token is real (signed by the fixture hub) but names a vault that isn't
1030
- // installed here. The per-vault URL 404s for a missing vault; root matches.
1031
- const token = await mintJwt({ vaultName: "ghost", scopes: ["vault:ghost:admin"] });
1032
- const res = await mcpPost("/mcp", token, INIT);
1033
- expect(res.status).toBe(404);
1034
- expect(((await res.json()) as { error: string }).error).toBe("Vault not found");
1035
- });
1036
- });
1037
-
1038
813
  // ---------------------------------------------------------------------------
1039
814
  // Per-vault OAuth discovery (RFC 8414 / RFC 9728, path-append form).
1040
815
  //