@openparachute/vault 0.7.3-rc.8 → 0.7.3

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 (44) hide show
  1. package/README.md +5 -3
  2. package/core/src/attachment/bytes-provider.ts +65 -0
  3. package/core/src/content-range-constants.ts +19 -0
  4. package/core/src/content-range.test.ts +127 -0
  5. package/core/src/content-range.ts +105 -8
  6. package/core/src/core.test.ts +66 -4
  7. package/core/src/expand.ts +11 -3
  8. package/core/src/lede.test.ts +96 -0
  9. package/core/src/mcp-manifest.test.ts +200 -0
  10. package/core/src/mcp-manifest.ts +736 -0
  11. package/core/src/mcp.ts +357 -607
  12. package/core/src/notes.ts +69 -10
  13. package/core/src/vault-projection.ts +17 -10
  14. package/package.json +1 -1
  15. package/src/attachment-bytes.ts +68 -0
  16. package/src/attachment-tickets.test.ts +126 -1
  17. package/src/attachment-tickets.ts +77 -1
  18. package/src/auth-hub-jwt.test.ts +118 -1
  19. package/src/auth.ts +64 -0
  20. package/src/config.test.ts +16 -0
  21. package/src/config.ts +17 -0
  22. package/src/embedding/select.test.ts +58 -30
  23. package/src/embedding/select.ts +62 -21
  24. package/src/live-frame-parity.test.ts +21 -0
  25. package/src/mcp-http.ts +20 -3
  26. package/src/mcp-tools.ts +15 -3
  27. package/src/oauth-discovery.ts +31 -0
  28. package/src/read-attachment.test.ts +436 -0
  29. package/src/routes.ts +80 -4
  30. package/src/routing.test.ts +229 -4
  31. package/src/routing.ts +135 -23
  32. package/src/scopes.ts +22 -0
  33. package/src/server.ts +17 -8
  34. package/src/storage.test.ts +200 -1
  35. package/src/subscriptions.ts +13 -1
  36. package/src/transcription-worker.test.ts +151 -0
  37. package/src/transcription-worker.ts +113 -52
  38. package/src/vault-embeddings-capability.test.ts +28 -6
  39. package/src/vault-store-embedding-wiring.test.ts +25 -16
  40. package/src/vault-store.ts +32 -16
  41. package/src/vault.test.ts +26 -13
  42. package/src/ws-server.ts +9 -1
  43. package/src/ws-subscribe.test.ts +87 -0
  44. package/src/ws-subscribe.ts +25 -6
@@ -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 `/mcp`, `/api/*`, `/oauth/*` routes exist — every per-vault
14
- * resource must name the vault it targets.
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 /mcp, /api, /oauth unscoped routes — all 404", async () => {
711
+ test("no /api, /oauth unscoped routes — all 404", async () => {
710
712
  createVault("journal");
711
- for (const path of ["/mcp", "/api/notes", "/oauth/register", "/oauth/authorize"]) {
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
  //
package/src/routing.ts CHANGED
@@ -1,15 +1,23 @@
1
1
  /**
2
2
  * HTTP request router for the multi-vault server.
3
3
  *
4
- * All per-vault resources live under `/vault/<name>/...`. There is no
5
- * unscoped fallback a request must name the vault it targets. A fresh
6
- * install creates a vault named `default`, so `/vault/default/...` is the
7
- * baseline URL for single-vault deployments.
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
- * There is deliberately no compat for the old `/api/*`, `/mcp`, `/oauth/*`,
31
- * `/view/*`, or `/vaults/<name>/*` prefixes. Clients must re-authenticate
32
- * after the upgrade and point at the new URLs.
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";
@@ -135,6 +150,58 @@ async function withMcpChallenge(
135
150
  return new Response(body, { status: 401, headers });
136
151
  }
137
152
 
153
+ /**
154
+ * 401 + RFC 9728 challenge for the canonical ROOT `/mcp` endpoint (U1), when
155
+ * the token is absent / invalid / names no single vault. Mirrors
156
+ * `withMcpChallenge` but points at the ROOT protected-resource metadata
157
+ * (`/.well-known/oauth-protected-resource/mcp`) rather than a vault-scoped one,
158
+ * since the root resource is vault-agnostic. A spec-following MCP client
159
+ * follows this pointer to the root PRM, discovers the hub, and requests the
160
+ * un-narrowed scopes the hub's consent picker narrows to a chosen vault.
161
+ */
162
+ function rootMcpChallenge(req: Request): Response {
163
+ const base = getBaseUrl(req);
164
+ return Response.json(
165
+ { error: "Unauthorized", message: "API key required" },
166
+ {
167
+ status: 401,
168
+ headers: {
169
+ "WWW-Authenticate": `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource/mcp"`,
170
+ },
171
+ },
172
+ );
173
+ }
174
+
175
+ /**
176
+ * Shared scoped-MCP dispatch tail used by BOTH the URL-addressed per-vault
177
+ * endpoint (`/vault/<name>/mcp`) and the token-derived root endpoint (`/mcp`,
178
+ * U1). Runs the FULL per-vault auth machinery via `authenticateVaultRequest`
179
+ * (audience strict-check, `vault_scope` pin, broad-scope rejection, tag-scope
180
+ * parse) and, on success, hands off to `handleScopedMcp`. A 401 is decorated
181
+ * with the per-vault RFC 9728 challenge. Because both entry points funnel
182
+ * through this one function, a root request behaves byte-identically to the
183
+ * same token at the per-vault URL — and, for the root, the re-validation here
184
+ * is the authoritative gate: a derivation that named the wrong vault fails the
185
+ * audience strict-check, it never bypasses it.
186
+ */
187
+ async function dispatchScopedMcp(
188
+ req: Request,
189
+ vaultName: string,
190
+ vaultConfig: VaultConfig,
191
+ ): Promise<Response> {
192
+ const auth = await authenticateVaultRequest(req, vaultConfig);
193
+ if ("error" in auth) return withMcpChallenge(auth.error, req, vaultName);
194
+ // Thread the RAW caller bearer (the exact credential the session presented)
195
+ // into the MCP layer so the manage-token tool can forward it to hub's
196
+ // mint-token attenuation proxy (vault#403, MGT). Only the raw validated
197
+ // bearer — never a fabricated one. extractApiKey returns the same value
198
+ // `authenticateVaultRequest` validated above; non-forwardable credentials
199
+ // (env-var secret, legacy pvt_*) are handled by manage-token itself (it only
200
+ // forwards JWT-shaped bearers).
201
+ const callerBearer = extractApiKey(req);
202
+ return handleScopedMcp(req, vaultName, auth, callerBearer);
203
+ }
204
+
138
205
  /**
139
206
  * Check if a /view request has a valid API key (header or ?key= query param).
140
207
  * Returns true if authenticated, false if not. Never rejects — unauthenticated
@@ -260,6 +327,18 @@ export async function route(
260
327
  return handleAuthorizationServer(req, vaultName);
261
328
  }
262
329
 
330
+ // Root-endpoint protected-resource metadata (RFC 9728 §3.1 path-insertion)
331
+ // for the canonical `/mcp` endpoint (U1). The root resource is vault-
332
+ // agnostic — the target vault is derived from the token, not the URL — so
333
+ // this document carries no vault name and advertises the un-narrowed
334
+ // `vault:read` / `vault:write` scopes; the hub's consent picker narrows them
335
+ // to a chosen vault at authorization time. Distinct from the per-vault
336
+ // insertion shapes above (which require a `/vault/<name>` segment), so there
337
+ // is no overlap. The `rootMcpChallenge` 401 points clients here.
338
+ if (path === "/.well-known/oauth-protected-resource/mcp") {
339
+ return handleRootProtectedResource(req);
340
+ }
341
+
263
342
  // ---------------------------------------------------------------------
264
343
  // Cross-vault / origin-root endpoints
265
344
  // ---------------------------------------------------------------------
@@ -368,6 +447,43 @@ export async function route(
368
447
  return Response.json({ vaults });
369
448
  }
370
449
 
450
+ // ---------------------------------------------------------------------
451
+ // Canonical root MCP endpoint — token-derived vault dispatch (U1).
452
+ //
453
+ // `POST /mcp` (plus the GET/DELETE the streamable-HTTP transport uses, and
454
+ // the `/mcp/*` subpaths) is the vault-AGNOSTIC entry point: the target vault
455
+ // is derived from the TOKEN, not the URL. We validate the bearer with the
456
+ // same scope-guard trust kernel the per-vault path uses, read the vault name
457
+ // from the token's claims, then RE-DISPATCH through the identical per-vault
458
+ // machinery (`dispatchScopedMcp` → `authenticateVaultRequest` →
459
+ // `handleScopedMcp`). The re-dispatch re-validates WITH the audience pin, so
460
+ // a bad derivation fails the inner check rather than bypassing it (defense in
461
+ // depth). The legacy URL-addressed `/vault/<name>/mcp` is untouched and lives
462
+ // forever.
463
+ //
464
+ // No / invalid / unnameable token → 401 whose `WWW-Authenticate` points at
465
+ // the ROOT PRM (`/.well-known/oauth-protected-resource/mcp`), so a spec-
466
+ // following MCP client discovers the hub and requests the un-narrowed scopes
467
+ // its consent picker narrows to a chosen vault.
468
+ if (path === "/mcp" || path.startsWith("/mcp/")) {
469
+ const derived = await deriveVaultFromToken(req);
470
+ if ("error" in derived) {
471
+ return rootMcpChallenge(req);
472
+ }
473
+ const vaultConfig = readVaultConfig(derived.vaultName);
474
+ if (!vaultConfig) {
475
+ // A validly-signed hub token naming a vault that isn't on THIS server.
476
+ // Mirror the per-vault 404 (`/vault/<name>/mcp` on a missing vault) — the
477
+ // holder knows which vault their token targets; nothing is leaked to an
478
+ // anonymous caller (they never validate a token to reach here).
479
+ return Response.json(
480
+ { error: "Vault not found", vault: derived.vaultName },
481
+ { status: 404 },
482
+ );
483
+ }
484
+ return dispatchScopedMcp(req, derived.vaultName, vaultConfig);
485
+ }
486
+
371
487
  // ---------------------------------------------------------------------
372
488
  // Per-vault routing: /vault/<name>/...
373
489
  // ---------------------------------------------------------------------
@@ -507,28 +623,24 @@ export async function route(
507
623
  return handleTicketSpend(req, ticketId, vaultName, store);
508
624
  }
509
625
 
626
+ // MCP (per-vault, single-vault session) — the URL names the vault. Shares
627
+ // the exact dispatch tail with the token-derived root `/mcp` (U1), so both
628
+ // behave identically. Handled BEFORE the shared authenticated-surface auth
629
+ // below because `dispatchScopedMcp` owns its own auth + 401 challenge; a
630
+ // non-MCP 401 further down never carries the RFC 9728 challenge (it's
631
+ // MCP-only).
632
+ if (subpath === "/mcp" || subpath.startsWith("/mcp/")) {
633
+ return dispatchScopedMcp(req, vaultName, vaultConfig);
634
+ }
635
+
510
636
  // ---------------------------------------------------------------------
511
637
  // Authenticated surface
512
638
  // ---------------------------------------------------------------------
513
639
 
514
640
  const store = getVaultStore(vaultName);
515
641
  const auth = await authenticateVaultRequest(req, vaultConfig);
516
- const isScopedMcp = subpath === "/mcp" || subpath.startsWith("/mcp/");
517
642
  if ("error" in auth) {
518
- return isScopedMcp ? withMcpChallenge(auth.error, req, vaultName) : auth.error;
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);
643
+ return auth.error;
532
644
  }
533
645
 
534
646
  // Bare `/vault/<name>` — single-vault root. Returns name, description,
package/src/scopes.ts CHANGED
@@ -263,6 +263,28 @@ export function validateMintedScopes(
263
263
  return { ok: true };
264
264
  }
265
265
 
266
+ /**
267
+ * Distinct vault names NAMED by narrowed `vault:<name>:<verb>` scopes in the
268
+ * granted list. Broad `vault:<verb>` scopes name no vault (a legacy/operator
269
+ * shape) and are skipped; the `migrate` axis isn't a read/write/admin verb so
270
+ * `decomposeVaultScope` returns null for it and it too is skipped.
271
+ *
272
+ * Used by the canonical root `/mcp` endpoint (U1) to read the target vault
273
+ * from a token's scope claim — one of three agreeing sources (scope / `aud` /
274
+ * single-element `vault_scope`) that `deriveVaultFromToken` cross-checks. A
275
+ * hub-minted token carries scopes for exactly one vault, so this returns a
276
+ * single-element list in practice; a return of length ≠ 1 signals a
277
+ * malformed/multi-vault scope set the derivation treats as a disagreement.
278
+ */
279
+ export function narrowedVaultNames(granted: string[]): string[] {
280
+ const names = new Set<string>();
281
+ for (const s of granted) {
282
+ const d = decomposeVaultScope(s);
283
+ if (d && d.vault !== null) names.add(d.vault);
284
+ }
285
+ return [...names];
286
+ }
287
+
266
288
  /**
267
289
  * Detect a broad `vault:<verb>` scope in a granted list. Hub-issued JWTs
268
290
  * must NOT carry broad vault scopes — the hub mints `vault:<name>:<verb>` so
package/src/server.ts CHANGED
@@ -21,6 +21,7 @@ import { migrateVaultKeys } from "./token-store.ts";
21
21
  import { resolveFirstBootVaultName, reservedNameSquatWarnings } from "./vault-name.ts";
22
22
  import { getVaultStore, getVaultNameForStore, getSharedEmbeddingProvider } from "./vault-store.ts";
23
23
  import { EmbeddingWorker, registerEmbeddingHook } from "./embedding-worker.ts";
24
+ import { startAttachmentTicketSweep, stopAttachmentTicketSweep } from "./attachment-tickets.ts";
24
25
  import { seedOnboardingNotesBestEffort } from "./onboarding-seed.ts";
25
26
  import { defaultHookRegistry } from "../core/src/hooks.ts";
26
27
  import { registerTriggers } from "./triggers.ts";
@@ -221,14 +222,16 @@ if (providerName === "transcribe-cpp") {
221
222
  }
222
223
  }
223
224
 
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.
225
+ // Embedding worker (semantic search MVP — EXPERIMENTAL). Semantic search
226
+ // is OPT-IN as of 0.7.3 (Aaron-ratified): `getSharedEmbeddingProvider()`
227
+ // returns `undefined` unless the operator enabled it (env override
228
+ // `EMBEDDINGS_ENABLED=true`, or the persisted `embeddings_enabled` setting
229
+ // see `vault-store.ts`). With no provider the worker is constructed +
230
+ // started but every path no-ops immediately (no embed-on-write hook work,
231
+ // no backfill sweep, no ~270MB model download). Once enabled, the event
232
+ // hook embeds on every note create/update (a no-op edit makes zero
233
+ // provider calls — see `embedding-worker.ts`) and the sweep drains the
234
+ // backfill for pre-existing notes.
232
235
  const embeddingWorker = new EmbeddingWorker({
233
236
  provider: getSharedEmbeddingProvider(),
234
237
  vaultList: () => listVaults(),
@@ -237,6 +240,11 @@ const embeddingWorker = new EmbeddingWorker({
237
240
  registerEmbeddingHook(defaultHookRegistry, embeddingWorker, (store) => getVaultNameForStore(store as never));
238
241
  embeddingWorker.start();
239
242
 
243
+ // Attachment-ticket sweep (vault#612) — drops expired-unspent tickets from
244
+ // the in-process store so an abandoned mint (an agent that never curls)
245
+ // doesn't sit in memory forever. See src/attachment-tickets.ts.
246
+ startAttachmentTicketSweep();
247
+
240
248
  if (process.env.VAULT_AUTH_TOKEN?.trim()) {
241
249
  console.log("[auth] VAULT_AUTH_TOKEN set — server-wide operator bearer active");
242
250
  }
@@ -619,6 +627,7 @@ async function shutdown(signal: string): Promise<void> {
619
627
  // Then drain hooks + stop the transcription/embedding workers in
620
628
  // parallel.
621
629
  embeddingWorker.stop();
630
+ stopAttachmentTicketSweep();
622
631
  await Promise.all([
623
632
  defaultHookRegistry.drain(),
624
633
  transcriptionWorker?.stop() ?? Promise.resolve(),