@openparachute/vault 0.7.3-rc.9 → 0.7.4-rc.1
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/core/src/attachment/bytes-provider.ts +65 -0
- package/core/src/content-range-constants.ts +19 -0
- package/core/src/content-range.test.ts +127 -0
- package/core/src/content-range.ts +105 -8
- package/core/src/core.test.ts +66 -4
- package/core/src/expand.ts +11 -3
- package/core/src/lede.test.ts +96 -0
- package/core/src/mcp-manifest.test.ts +200 -0
- package/core/src/mcp-manifest.ts +736 -0
- package/core/src/mcp.ts +357 -607
- package/core/src/notes.ts +69 -10
- package/core/src/vault-projection.ts +17 -10
- package/package.json +1 -1
- package/src/attachment-bytes.ts +68 -0
- package/src/attachment-tickets.test.ts +126 -1
- package/src/attachment-tickets.ts +77 -1
- package/src/auth-hub-jwt.test.ts +118 -1
- package/src/auth.ts +64 -0
- package/src/config.test.ts +16 -0
- package/src/config.ts +17 -0
- package/src/embedding/select.test.ts +58 -30
- package/src/embedding/select.ts +62 -21
- package/src/embeddings-routes.test.ts +188 -0
- package/src/embeddings-routes.ts +178 -0
- package/src/live-frame-parity.test.ts +21 -0
- package/src/mcp-http.ts +20 -3
- package/src/mcp-tools.ts +15 -3
- package/src/oauth-discovery.ts +31 -0
- package/src/read-attachment.test.ts +436 -0
- package/src/routes.ts +80 -4
- package/src/routing.test.ts +358 -4
- package/src/routing.ts +163 -23
- package/src/scopes.ts +22 -0
- package/src/server.ts +17 -8
- package/src/storage.test.ts +200 -1
- package/src/subscriptions.ts +13 -1
- package/src/transcription-worker.test.ts +151 -0
- package/src/transcription-worker.ts +113 -52
- package/src/vault-embeddings-capability.test.ts +28 -6
- package/src/vault-store-embedding-wiring.test.ts +25 -16
- package/src/vault-store.ts +47 -16
- package/src/vault.test.ts +26 -13
- package/src/ws-server.ts +9 -1
- package/src/ws-subscribe.test.ts +87 -0
- package/src/ws-subscribe.ts +25 -6
- package/web/ui/dist/assets/index-NvwxfZcu.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-CJ6NtIwh.js +0 -61
package/src/routing.test.ts
CHANGED
|
@@ -10,8 +10,10 @@
|
|
|
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 `/
|
|
14
|
-
*
|
|
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.
|
|
15
17
|
*
|
|
16
18
|
* Uses PARACHUTE_HOME override so each test's vaults live in a tmp dir and
|
|
17
19
|
* never touch ~/.parachute.
|
|
@@ -706,14 +708,27 @@ describe("per-vault routing under /vault/<name>/", () => {
|
|
|
706
708
|
}
|
|
707
709
|
});
|
|
708
710
|
|
|
709
|
-
test("no /
|
|
711
|
+
test("no /api, /oauth unscoped routes — all 404", async () => {
|
|
710
712
|
createVault("journal");
|
|
711
|
-
for (const path of ["/
|
|
713
|
+
for (const path of ["/api/notes", "/oauth/register", "/oauth/authorize"]) {
|
|
712
714
|
const res = await route(new Request(`http://localhost:1940${path}`), path);
|
|
713
715
|
expect(res.status).toBe(404);
|
|
714
716
|
}
|
|
715
717
|
});
|
|
716
718
|
|
|
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
|
+
|
|
717
732
|
test("bare /vault/<name> returns metadata for authenticated callers", async () => {
|
|
718
733
|
createVault("journal", "My journal vault");
|
|
719
734
|
const path = "/vault/journal";
|
|
@@ -810,6 +825,216 @@ describe("MCP 401 WWW-Authenticate challenge (RFC 9728)", () => {
|
|
|
810
825
|
});
|
|
811
826
|
});
|
|
812
827
|
|
|
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
|
+
|
|
813
1038
|
// ---------------------------------------------------------------------------
|
|
814
1039
|
// Per-vault OAuth discovery (RFC 8414 / RFC 9728, path-append form).
|
|
815
1040
|
//
|
|
@@ -2491,6 +2716,135 @@ describe("/vault/<name>/.parachute/mirror — auth + dispatch", () => {
|
|
|
2491
2716
|
});
|
|
2492
2717
|
});
|
|
2493
2718
|
|
|
2719
|
+
// ---------------------------------------------------------------------------
|
|
2720
|
+
// /vault/<name>/.parachute/embeddings — semantic-search (embeddings) opt-in
|
|
2721
|
+
// toggle admin surface (0.7.3 fast-follow, vault#624).
|
|
2722
|
+
//
|
|
2723
|
+
// Pins the auth gate + GET/PUT dispatch at the routing layer: the endpoint is
|
|
2724
|
+
// `vault:admin`-only (a host-global setting reached through the same per-vault
|
|
2725
|
+
// admin surface as the sibling `.parachute/mirror` / `.parachute/config`
|
|
2726
|
+
// pages). Snapshot shape + the env-override / restart-required semantics are
|
|
2727
|
+
// covered in embeddings-routes.test.ts; here we pin the *security* property —
|
|
2728
|
+
// unauth → 401, read/write → 403, admin → 200 + the flip persists — so it
|
|
2729
|
+
// can't silently regress below the rest of the admin surface. Mirrors the
|
|
2730
|
+
// `.parachute/mirror` auth+dispatch describe above.
|
|
2731
|
+
// ---------------------------------------------------------------------------
|
|
2732
|
+
|
|
2733
|
+
describe("/vault/<name>/.parachute/embeddings — auth + dispatch", () => {
|
|
2734
|
+
const P = "/vault/journal/.parachute/embeddings";
|
|
2735
|
+
|
|
2736
|
+
test("unauthenticated GET → 401", async () => {
|
|
2737
|
+
createVault("journal");
|
|
2738
|
+
const res = await route(new Request(`http://localhost:1940${P}`), P);
|
|
2739
|
+
expect(res.status).toBe(401);
|
|
2740
|
+
});
|
|
2741
|
+
|
|
2742
|
+
test("unauthenticated PUT → 401", async () => {
|
|
2743
|
+
createVault("journal");
|
|
2744
|
+
const res = await route(
|
|
2745
|
+
new Request(`http://localhost:1940${P}`, {
|
|
2746
|
+
method: "PUT",
|
|
2747
|
+
headers: { "content-type": "application/json" },
|
|
2748
|
+
body: JSON.stringify({ enabled: true }),
|
|
2749
|
+
}),
|
|
2750
|
+
P,
|
|
2751
|
+
);
|
|
2752
|
+
expect(res.status).toBe(401);
|
|
2753
|
+
});
|
|
2754
|
+
|
|
2755
|
+
test("vault:read token PUT → 403 insufficient_scope", async () => {
|
|
2756
|
+
createVault("journal");
|
|
2757
|
+
const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:read"] });
|
|
2758
|
+
const res = await route(
|
|
2759
|
+
new Request(`http://localhost:1940${P}`, {
|
|
2760
|
+
method: "PUT",
|
|
2761
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
2762
|
+
body: JSON.stringify({ enabled: true }),
|
|
2763
|
+
}),
|
|
2764
|
+
P,
|
|
2765
|
+
);
|
|
2766
|
+
expect(res.status).toBe(403);
|
|
2767
|
+
const body = (await res.json()) as { error_type?: string; required_scope?: string };
|
|
2768
|
+
expect(body.error_type).toBe("insufficient_scope");
|
|
2769
|
+
expect(body.required_scope).toBe("vault:admin");
|
|
2770
|
+
});
|
|
2771
|
+
|
|
2772
|
+
test("vault:write token PUT → 403 insufficient_scope (write ranks below admin)", async () => {
|
|
2773
|
+
createVault("journal");
|
|
2774
|
+
const token = await mintJwt({ vaultName: "journal", scopes: ["vault:journal:write"] });
|
|
2775
|
+
const res = await route(
|
|
2776
|
+
new Request(`http://localhost:1940${P}`, {
|
|
2777
|
+
method: "PUT",
|
|
2778
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
2779
|
+
body: JSON.stringify({ enabled: true }),
|
|
2780
|
+
}),
|
|
2781
|
+
P,
|
|
2782
|
+
);
|
|
2783
|
+
expect(res.status).toBe(403);
|
|
2784
|
+
const body = (await res.json()) as { error_type?: string; required_scope?: string };
|
|
2785
|
+
expect(body.error_type).toBe("insufficient_scope");
|
|
2786
|
+
expect(body.required_scope).toBe("vault:admin");
|
|
2787
|
+
});
|
|
2788
|
+
|
|
2789
|
+
test("cross-vault admin token → 403 (scope names a different vault)", async () => {
|
|
2790
|
+
createVault("journal");
|
|
2791
|
+
createVault("other");
|
|
2792
|
+
// Audience is `journal` (auth passes) but the admin scope names `other`,
|
|
2793
|
+
// so hasScopeForVault denies it against `journal`.
|
|
2794
|
+
const token = await mintJwt({ vaultName: "journal", scopes: ["vault:other:admin"] });
|
|
2795
|
+
const res = await route(
|
|
2796
|
+
new Request(`http://localhost:1940${P}`, {
|
|
2797
|
+
headers: { authorization: `Bearer ${token}` },
|
|
2798
|
+
}),
|
|
2799
|
+
P,
|
|
2800
|
+
);
|
|
2801
|
+
expect(res.status).toBe(403);
|
|
2802
|
+
const body = (await res.json()) as { error_type?: string };
|
|
2803
|
+
expect(body.error_type).toBe("insufficient_scope");
|
|
2804
|
+
});
|
|
2805
|
+
|
|
2806
|
+
test("vault:admin token GET → 200 (default off); admin PUT persists the flip", async () => {
|
|
2807
|
+
createVault("journal");
|
|
2808
|
+
const token = await createAdminToken("journal");
|
|
2809
|
+
|
|
2810
|
+
// GET reaches the handler (auth + admin scope satisfied) → 200 snapshot,
|
|
2811
|
+
// default-off (config seeded with `port` only).
|
|
2812
|
+
const getRes = await route(
|
|
2813
|
+
new Request(`http://localhost:1940${P}`, {
|
|
2814
|
+
headers: { authorization: `Bearer ${token}` },
|
|
2815
|
+
}),
|
|
2816
|
+
P,
|
|
2817
|
+
);
|
|
2818
|
+
expect(getRes.status).toBe(200);
|
|
2819
|
+
const getBody = (await getRes.json()) as { enabled?: boolean };
|
|
2820
|
+
expect(getBody.enabled).toBe(false);
|
|
2821
|
+
|
|
2822
|
+
// PUT flips the persisted setting → 200, echoing the new value.
|
|
2823
|
+
const putRes = await route(
|
|
2824
|
+
new Request(`http://localhost:1940${P}`, {
|
|
2825
|
+
method: "PUT",
|
|
2826
|
+
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
|
2827
|
+
body: JSON.stringify({ enabled: true }),
|
|
2828
|
+
}),
|
|
2829
|
+
P,
|
|
2830
|
+
);
|
|
2831
|
+
expect(putRes.status).toBe(200);
|
|
2832
|
+
const putBody = (await putRes.json()) as { enabled?: boolean };
|
|
2833
|
+
expect(putBody.enabled).toBe(true);
|
|
2834
|
+
|
|
2835
|
+
// A follow-up GET reads the persisted value back → the write stuck.
|
|
2836
|
+
const reGet = await route(
|
|
2837
|
+
new Request(`http://localhost:1940${P}`, {
|
|
2838
|
+
headers: { authorization: `Bearer ${token}` },
|
|
2839
|
+
}),
|
|
2840
|
+
P,
|
|
2841
|
+
);
|
|
2842
|
+
expect(reGet.status).toBe(200);
|
|
2843
|
+
const reBody = (await reGet.json()) as { enabled?: boolean };
|
|
2844
|
+
expect(reBody.enabled).toBe(true);
|
|
2845
|
+
});
|
|
2846
|
+
});
|
|
2847
|
+
|
|
2494
2848
|
// ---------------------------------------------------------------------------
|
|
2495
2849
|
// /vault/<name>/.parachute/mirror/run-now — manual-trigger endpoint added
|
|
2496
2850
|
// alongside the SPA UI. Tests pin the auth gate matches the parent
|
package/src/routing.ts
CHANGED
|
@@ -1,15 +1,23 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* HTTP request router for the multi-vault server.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Per-vault resources live under `/vault/<name>/...` — a request names the
|
|
5
|
+
* vault it targets in the URL. The one exception is the canonical root `/mcp`
|
|
6
|
+
* endpoint (U1), which is vault-AGNOSTIC: the vault is derived from the token,
|
|
7
|
+
* not the URL. A fresh install creates a vault named `default`, so
|
|
8
|
+
* `/vault/default/...` is the baseline URL for single-vault deployments.
|
|
8
9
|
*
|
|
9
10
|
* Dispatch shape:
|
|
10
11
|
*
|
|
11
12
|
* /.well-known/parachute.json — NOT served here (CLI owns it at
|
|
12
13
|
* origin root; vault never handles it)
|
|
14
|
+
* /.well-known/oauth-protected-resource/mcp
|
|
15
|
+
* — root-endpoint PRM (RFC 9728) for the
|
|
16
|
+
* canonical `/mcp` below (U1)
|
|
17
|
+
* /mcp[/*] — canonical root MCP endpoint; the vault
|
|
18
|
+
* is DERIVED FROM THE TOKEN, then the
|
|
19
|
+
* request re-dispatches through the same
|
|
20
|
+
* per-vault machinery (U1)
|
|
13
21
|
* /health — liveness ping, vault names leaked
|
|
14
22
|
* only to authenticated callers
|
|
15
23
|
* /vaults/list — public vault-name discovery (can be
|
|
@@ -21,15 +29,20 @@
|
|
|
21
29
|
* /vault/<name>/.well-known/oauth-* — discovery forwarder; metadata names
|
|
22
30
|
* the hub as the authorization server
|
|
23
31
|
* (vault is resource-server only)
|
|
24
|
-
* /vault/<name>/mcp[/*] — MCP endpoint (Bearer auth)
|
|
32
|
+
* /vault/<name>/mcp[/*] — MCP endpoint (Bearer auth); the
|
|
33
|
+
* URL-addressed twin of root `/mcp`,
|
|
34
|
+
* untouched and permanent
|
|
25
35
|
* /vault/<name>/view/<idOrPath> — auth-aware HTML view
|
|
26
36
|
* /vault/<name>/public/<noteId> — legacy alias → /view redirect
|
|
27
37
|
* /vault/<name> — vault metadata + stats (auth)
|
|
28
38
|
* /vault/<name>/api/... — REST surface (auth)
|
|
29
39
|
*
|
|
30
|
-
*
|
|
31
|
-
* `/
|
|
32
|
-
*
|
|
40
|
+
* The root `/mcp` here is NOT the old unscoped compat prefix (that pre-0.5.0
|
|
41
|
+
* `/mcp` — an alias for a single default vault — is gone). It's the token-
|
|
42
|
+
* derived door: same auth machinery as the per-vault endpoint, reached without
|
|
43
|
+
* the client having to know the vault's URL name. There is still deliberately
|
|
44
|
+
* no compat for the old `/api/*`, `/oauth/*`, `/view/*`, or `/vaults/<name>/*`
|
|
45
|
+
* prefixes; clients using those must re-authenticate and point at the new URLs.
|
|
33
46
|
*
|
|
34
47
|
* **No standalone OAuth issuer.** vault does not mint OAuth tokens or
|
|
35
48
|
* render a consent UI. Hub is the issuer; vault validates hub-signed
|
|
@@ -50,6 +63,7 @@ import {
|
|
|
50
63
|
import {
|
|
51
64
|
authenticateVaultRequest,
|
|
52
65
|
authenticateGlobalRequest,
|
|
66
|
+
deriveVaultFromToken,
|
|
53
67
|
extractApiKey,
|
|
54
68
|
} from "./auth.ts";
|
|
55
69
|
import { hasScopeForVault, hasMigrateScopeForVault, SCOPE_ADMIN, SCOPE_READ, scopeForMethod, verbForMethod } from "./scopes.ts";
|
|
@@ -78,6 +92,7 @@ import { handleTriggers } from "./triggers-api.ts";
|
|
|
78
92
|
import { expandTokenTagScope } from "./tag-scope.ts";
|
|
79
93
|
import {
|
|
80
94
|
handleProtectedResource,
|
|
95
|
+
handleRootProtectedResource,
|
|
81
96
|
handleAuthorizationServer,
|
|
82
97
|
getBaseUrl,
|
|
83
98
|
} from "./oauth-discovery.ts";
|
|
@@ -101,6 +116,7 @@ import {
|
|
|
101
116
|
handleMirrorPut,
|
|
102
117
|
handleMirrorRunNow,
|
|
103
118
|
} from "./mirror-routes.ts";
|
|
119
|
+
import { handleEmbeddingsGet, handleEmbeddingsPut } from "./embeddings-routes.ts";
|
|
104
120
|
import { getMirrorManager } from "./mirror-registry.ts";
|
|
105
121
|
import { buildUsageReport } from "./usage.ts";
|
|
106
122
|
import { handleTicketSpend } from "./attachment-tickets.ts";
|
|
@@ -135,6 +151,58 @@ async function withMcpChallenge(
|
|
|
135
151
|
return new Response(body, { status: 401, headers });
|
|
136
152
|
}
|
|
137
153
|
|
|
154
|
+
/**
|
|
155
|
+
* 401 + RFC 9728 challenge for the canonical ROOT `/mcp` endpoint (U1), when
|
|
156
|
+
* the token is absent / invalid / names no single vault. Mirrors
|
|
157
|
+
* `withMcpChallenge` but points at the ROOT protected-resource metadata
|
|
158
|
+
* (`/.well-known/oauth-protected-resource/mcp`) rather than a vault-scoped one,
|
|
159
|
+
* since the root resource is vault-agnostic. A spec-following MCP client
|
|
160
|
+
* follows this pointer to the root PRM, discovers the hub, and requests the
|
|
161
|
+
* un-narrowed scopes the hub's consent picker narrows to a chosen vault.
|
|
162
|
+
*/
|
|
163
|
+
function rootMcpChallenge(req: Request): Response {
|
|
164
|
+
const base = getBaseUrl(req);
|
|
165
|
+
return Response.json(
|
|
166
|
+
{ error: "Unauthorized", message: "API key required" },
|
|
167
|
+
{
|
|
168
|
+
status: 401,
|
|
169
|
+
headers: {
|
|
170
|
+
"WWW-Authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"`,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Shared scoped-MCP dispatch tail used by BOTH the URL-addressed per-vault
|
|
178
|
+
* endpoint (`/vault/<name>/mcp`) and the token-derived root endpoint (`/mcp`,
|
|
179
|
+
* U1). Runs the FULL per-vault auth machinery via `authenticateVaultRequest`
|
|
180
|
+
* (audience strict-check, `vault_scope` pin, broad-scope rejection, tag-scope
|
|
181
|
+
* parse) and, on success, hands off to `handleScopedMcp`. A 401 is decorated
|
|
182
|
+
* with the per-vault RFC 9728 challenge. Because both entry points funnel
|
|
183
|
+
* through this one function, a root request behaves byte-identically to the
|
|
184
|
+
* same token at the per-vault URL — and, for the root, the re-validation here
|
|
185
|
+
* is the authoritative gate: a derivation that named the wrong vault fails the
|
|
186
|
+
* audience strict-check, it never bypasses it.
|
|
187
|
+
*/
|
|
188
|
+
async function dispatchScopedMcp(
|
|
189
|
+
req: Request,
|
|
190
|
+
vaultName: string,
|
|
191
|
+
vaultConfig: VaultConfig,
|
|
192
|
+
): Promise<Response> {
|
|
193
|
+
const auth = await authenticateVaultRequest(req, vaultConfig);
|
|
194
|
+
if ("error" in auth) return withMcpChallenge(auth.error, req, vaultName);
|
|
195
|
+
// Thread the RAW caller bearer (the exact credential the session presented)
|
|
196
|
+
// into the MCP layer so the manage-token tool can forward it to hub's
|
|
197
|
+
// mint-token attenuation proxy (vault#403, MGT). Only the raw validated
|
|
198
|
+
// bearer — never a fabricated one. extractApiKey returns the same value
|
|
199
|
+
// `authenticateVaultRequest` validated above; non-forwardable credentials
|
|
200
|
+
// (env-var secret, legacy pvt_*) are handled by manage-token itself (it only
|
|
201
|
+
// forwards JWT-shaped bearers).
|
|
202
|
+
const callerBearer = extractApiKey(req);
|
|
203
|
+
return handleScopedMcp(req, vaultName, auth, callerBearer);
|
|
204
|
+
}
|
|
205
|
+
|
|
138
206
|
/**
|
|
139
207
|
* Check if a /view request has a valid API key (header or ?key= query param).
|
|
140
208
|
* Returns true if authenticated, false if not. Never rejects — unauthenticated
|
|
@@ -260,6 +328,18 @@ export async function route(
|
|
|
260
328
|
return handleAuthorizationServer(req, vaultName);
|
|
261
329
|
}
|
|
262
330
|
|
|
331
|
+
// Root-endpoint protected-resource metadata (RFC 9728 §3.1 path-insertion)
|
|
332
|
+
// for the canonical `/mcp` endpoint (U1). The root resource is vault-
|
|
333
|
+
// agnostic — the target vault is derived from the token, not the URL — so
|
|
334
|
+
// this document carries no vault name and advertises the un-narrowed
|
|
335
|
+
// `vault:read` / `vault:write` scopes; the hub's consent picker narrows them
|
|
336
|
+
// to a chosen vault at authorization time. Distinct from the per-vault
|
|
337
|
+
// insertion shapes above (which require a `/vault/<name>` segment), so there
|
|
338
|
+
// is no overlap. The `rootMcpChallenge` 401 points clients here.
|
|
339
|
+
if (path === "/.well-known/oauth-protected-resource/mcp") {
|
|
340
|
+
return handleRootProtectedResource(req);
|
|
341
|
+
}
|
|
342
|
+
|
|
263
343
|
// ---------------------------------------------------------------------
|
|
264
344
|
// Cross-vault / origin-root endpoints
|
|
265
345
|
// ---------------------------------------------------------------------
|
|
@@ -368,6 +448,43 @@ export async function route(
|
|
|
368
448
|
return Response.json({ vaults });
|
|
369
449
|
}
|
|
370
450
|
|
|
451
|
+
// ---------------------------------------------------------------------
|
|
452
|
+
// Canonical root MCP endpoint — token-derived vault dispatch (U1).
|
|
453
|
+
//
|
|
454
|
+
// `POST /mcp` (plus the GET/DELETE the streamable-HTTP transport uses, and
|
|
455
|
+
// the `/mcp/*` subpaths) is the vault-AGNOSTIC entry point: the target vault
|
|
456
|
+
// is derived from the TOKEN, not the URL. We validate the bearer with the
|
|
457
|
+
// same scope-guard trust kernel the per-vault path uses, read the vault name
|
|
458
|
+
// from the token's claims, then RE-DISPATCH through the identical per-vault
|
|
459
|
+
// machinery (`dispatchScopedMcp` → `authenticateVaultRequest` →
|
|
460
|
+
// `handleScopedMcp`). The re-dispatch re-validates WITH the audience pin, so
|
|
461
|
+
// a bad derivation fails the inner check rather than bypassing it (defense in
|
|
462
|
+
// depth). The legacy URL-addressed `/vault/<name>/mcp` is untouched and lives
|
|
463
|
+
// forever.
|
|
464
|
+
//
|
|
465
|
+
// No / invalid / unnameable token → 401 whose `WWW-Authenticate` points at
|
|
466
|
+
// the ROOT PRM (`/.well-known/oauth-protected-resource/mcp`), so a spec-
|
|
467
|
+
// following MCP client discovers the hub and requests the un-narrowed scopes
|
|
468
|
+
// its consent picker narrows to a chosen vault.
|
|
469
|
+
if (path === "/mcp" || path.startsWith("/mcp/")) {
|
|
470
|
+
const derived = await deriveVaultFromToken(req);
|
|
471
|
+
if ("error" in derived) {
|
|
472
|
+
return rootMcpChallenge(req);
|
|
473
|
+
}
|
|
474
|
+
const vaultConfig = readVaultConfig(derived.vaultName);
|
|
475
|
+
if (!vaultConfig) {
|
|
476
|
+
// A validly-signed hub token naming a vault that isn't on THIS server.
|
|
477
|
+
// Mirror the per-vault 404 (`/vault/<name>/mcp` on a missing vault) — the
|
|
478
|
+
// holder knows which vault their token targets; nothing is leaked to an
|
|
479
|
+
// anonymous caller (they never validate a token to reach here).
|
|
480
|
+
return Response.json(
|
|
481
|
+
{ error: "Vault not found", vault: derived.vaultName },
|
|
482
|
+
{ status: 404 },
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
return dispatchScopedMcp(req, derived.vaultName, vaultConfig);
|
|
486
|
+
}
|
|
487
|
+
|
|
371
488
|
// ---------------------------------------------------------------------
|
|
372
489
|
// Per-vault routing: /vault/<name>/...
|
|
373
490
|
// ---------------------------------------------------------------------
|
|
@@ -507,28 +624,24 @@ export async function route(
|
|
|
507
624
|
return handleTicketSpend(req, ticketId, vaultName, store);
|
|
508
625
|
}
|
|
509
626
|
|
|
627
|
+
// MCP (per-vault, single-vault session) — the URL names the vault. Shares
|
|
628
|
+
// the exact dispatch tail with the token-derived root `/mcp` (U1), so both
|
|
629
|
+
// behave identically. Handled BEFORE the shared authenticated-surface auth
|
|
630
|
+
// below because `dispatchScopedMcp` owns its own auth + 401 challenge; a
|
|
631
|
+
// non-MCP 401 further down never carries the RFC 9728 challenge (it's
|
|
632
|
+
// MCP-only).
|
|
633
|
+
if (subpath === "/mcp" || subpath.startsWith("/mcp/")) {
|
|
634
|
+
return dispatchScopedMcp(req, vaultName, vaultConfig);
|
|
635
|
+
}
|
|
636
|
+
|
|
510
637
|
// ---------------------------------------------------------------------
|
|
511
638
|
// Authenticated surface
|
|
512
639
|
// ---------------------------------------------------------------------
|
|
513
640
|
|
|
514
641
|
const store = getVaultStore(vaultName);
|
|
515
642
|
const auth = await authenticateVaultRequest(req, vaultConfig);
|
|
516
|
-
const isScopedMcp = subpath === "/mcp" || subpath.startsWith("/mcp/");
|
|
517
643
|
if ("error" in auth) {
|
|
518
|
-
return
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
// MCP (per-vault, single-vault session).
|
|
522
|
-
if (isScopedMcp) {
|
|
523
|
-
// Thread the RAW caller bearer (the exact credential the session
|
|
524
|
-
// presented) into the MCP layer so the manage-token tool can forward it
|
|
525
|
-
// to hub's mint-token attenuation proxy (vault#403, MGT). Only the raw
|
|
526
|
-
// validated bearer — never a fabricated one. extractApiKey returns the
|
|
527
|
-
// same value `authenticateVaultRequest` validated above; non-forwardable
|
|
528
|
-
// credentials (env-var secret, legacy pvt_*) are handled by manage-token
|
|
529
|
-
// itself (it only forwards JWT-shaped bearers).
|
|
530
|
-
const callerBearer = extractApiKey(req);
|
|
531
|
-
return handleScopedMcp(req, vaultName, auth, callerBearer);
|
|
644
|
+
return auth.error;
|
|
532
645
|
}
|
|
533
646
|
|
|
534
647
|
// Bare `/vault/<name>` — single-vault root. Returns name, description,
|
|
@@ -575,6 +688,33 @@ export async function route(
|
|
|
575
688
|
return Response.json(buildUsageReport(vaultName, stats, { fresh }));
|
|
576
689
|
}
|
|
577
690
|
|
|
691
|
+
// /.parachute/embeddings — Admin-gated read+write of the semantic-search
|
|
692
|
+
// (embeddings) opt-in toggle. The 0.7.3 fast-follow: gives the admin SPA a
|
|
693
|
+
// real toggle over the persisted `embeddings_enabled` config.yaml setting so
|
|
694
|
+
// an operator flips semantic search on from the UI instead of hand-editing
|
|
695
|
+
// config or setting an env var. Host-global setting (affects every vault),
|
|
696
|
+
// reached through the same per-vault admin surface as the other settings
|
|
697
|
+
// pages. Activation is restart-to-apply — the endpoint persists the setting
|
|
698
|
+
// and reports `restart_required`; see embeddings-routes.ts for why the boot-
|
|
699
|
+
// captured provider isn't hot-reconfigured.
|
|
700
|
+
if (subpath === "/.parachute/embeddings") {
|
|
701
|
+
if (!hasScopeForVault(auth.scopes, vaultName, "admin")) {
|
|
702
|
+
return Response.json(
|
|
703
|
+
{
|
|
704
|
+
error: "Forbidden",
|
|
705
|
+
error_type: "insufficient_scope",
|
|
706
|
+
message: `This endpoint requires the '${SCOPE_ADMIN}' scope (or '${SCOPE_ADMIN.replace("vault:", `vault:${vaultName}:`)}').`,
|
|
707
|
+
required_scope: SCOPE_ADMIN,
|
|
708
|
+
granted_scopes: auth.scopes,
|
|
709
|
+
},
|
|
710
|
+
{ status: 403 },
|
|
711
|
+
);
|
|
712
|
+
}
|
|
713
|
+
if (req.method === "GET") return handleEmbeddingsGet();
|
|
714
|
+
if (req.method === "PUT") return handleEmbeddingsPut(req);
|
|
715
|
+
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
716
|
+
}
|
|
717
|
+
|
|
578
718
|
// The per-vault `/tokens` REST surface (pvt_* mint/list/revoke) was removed
|
|
579
719
|
// at 0.5.0 (vault#282 Stage 2 — vault is a pure hub resource-server). Hub
|
|
580
720
|
// JWTs are minted via hub's registry (`/api/auth/mint-token`); a `/tokens`
|