@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.
- package/README.md +3 -5
- package/core/src/conformance.ts +1 -2
- package/core/src/content-range.test.ts +0 -127
- package/core/src/content-range.ts +0 -100
- package/core/src/contract-typed-index.test.ts +3 -4
- package/core/src/core.test.ts +4 -521
- package/core/src/expand.ts +3 -11
- package/core/src/indexed-fields.test.ts +3 -9
- package/core/src/indexed-fields.ts +1 -9
- package/core/src/mcp.ts +10 -601
- package/core/src/notes.ts +4 -201
- package/core/src/schema-defaults.ts +1 -85
- package/core/src/search-fts-v25.test.ts +1 -9
- package/core/src/search-query.test.ts +0 -42
- package/core/src/search-query.ts +0 -27
- package/core/src/seed-packs.test.ts +1 -117
- package/core/src/seed-packs.ts +1 -217
- package/core/src/store.ts +3 -59
- package/core/src/tag-schemas.ts +12 -27
- package/core/src/types.ts +1 -7
- package/core/src/vault-projection.ts +0 -55
- package/package.json +1 -1
- package/src/add-pack.test.ts +0 -32
- package/src/auth-hub-jwt.test.ts +1 -118
- package/src/auth.ts +0 -64
- package/src/cli.ts +1 -5
- package/src/contract-errors.test.ts +1 -2
- package/src/mcp-http.ts +4 -33
- package/src/mcp-tools.ts +14 -61
- package/src/oauth-discovery.ts +0 -31
- package/src/onboarding-seed.test.ts +0 -64
- package/src/routes.ts +95 -92
- package/src/routing.test.ts +4 -229
- package/src/routing.ts +23 -152
- package/src/scopes.ts +0 -22
- package/src/server.ts +0 -7
- package/src/storage.test.ts +1 -200
- package/src/transcription-worker.test.ts +0 -151
- package/src/transcription-worker.ts +52 -113
- package/src/vault.test.ts +11 -48
- package/core/src/attachment/bytes-provider.ts +0 -65
- package/core/src/attachment/policy.test.ts +0 -66
- package/core/src/attachment/policy.ts +0 -131
- package/core/src/attachment/tickets.test.ts +0 -45
- package/core/src/attachment/tickets.ts +0 -117
- package/core/src/attachment-tickets-tool.test.ts +0 -286
- package/core/src/display-title.test.ts +0 -190
- package/core/src/lede.test.ts +0 -96
- package/core/src/search-title-boost.test.ts +0 -125
- package/src/attachment-bytes.ts +0 -68
- package/src/attachment-tickets.test.ts +0 -475
- package/src/attachment-tickets.ts +0 -340
- 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 (
|
|
4448
|
-
//
|
|
4449
|
-
//
|
|
4450
|
-
//
|
|
4451
|
-
//
|
|
4452
|
-
//
|
|
4453
|
-
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
4461
|
-
|
|
4462
|
-
|
|
4463
|
-
|
|
4464
|
-
|
|
4465
|
-
|
|
4466
|
-
|
|
4467
|
-
|
|
4468
|
-
|
|
4469
|
-
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
|
|
4480
|
-
|
|
4481
|
-
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
|
|
4498
|
-
|
|
4499
|
-
|
|
4500
|
-
|
|
4501
|
-
|
|
4502
|
-
|
|
4503
|
-
|
|
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
|
|
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(
|
|
4699
|
+
return new Response(fileBuffer, {
|
|
4696
4700
|
headers: {
|
|
4697
4701
|
"Content-Type": contentType,
|
|
4698
|
-
"Content-Length": String(
|
|
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
|
package/src/routing.test.ts
CHANGED
|
@@ -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
|
|
14
|
-
* must name the vault
|
|
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
|
//
|