@openparachute/vault 0.1.0 → 0.2.0

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 (87) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/CLAUDE.md +2 -2
  3. package/README.md +289 -44
  4. package/core/src/core.test.ts +802 -346
  5. package/core/src/expand.ts +140 -0
  6. package/core/src/hooks.test.ts +27 -27
  7. package/core/src/hooks.ts +1 -1
  8. package/core/src/mcp.ts +102 -39
  9. package/core/src/notes.ts +82 -4
  10. package/core/src/obsidian.test.ts +11 -11
  11. package/core/src/paths.test.ts +46 -46
  12. package/core/src/schema.ts +18 -2
  13. package/core/src/store.ts +51 -51
  14. package/core/src/types.ts +29 -29
  15. package/core/src/wikilinks.test.ts +61 -61
  16. package/docs/HTTP_API.md +4 -2
  17. package/package.json +1 -1
  18. package/src/auth.test.ts +319 -0
  19. package/src/backup-launchd.test.ts +90 -0
  20. package/src/backup-launchd.ts +169 -0
  21. package/src/backup.test.ts +715 -0
  22. package/src/backup.ts +699 -0
  23. package/src/cli.ts +923 -31
  24. package/src/config.test.ts +173 -0
  25. package/src/config.ts +345 -15
  26. package/src/daemon.ts +136 -0
  27. package/src/doctor.test.ts +356 -0
  28. package/src/health.test.ts +201 -0
  29. package/src/health.ts +115 -0
  30. package/src/launchd.test.ts +91 -0
  31. package/src/launchd.ts +37 -40
  32. package/src/mcp-http.ts +1 -1
  33. package/src/mcp-tools.ts +7 -9
  34. package/src/oauth.test.ts +289 -8
  35. package/src/oauth.ts +57 -12
  36. package/src/published.test.ts +21 -21
  37. package/src/routes.ts +152 -70
  38. package/src/routing.test.ts +347 -0
  39. package/src/routing.ts +365 -0
  40. package/src/server.ts +7 -278
  41. package/src/systemd.test.ts +15 -0
  42. package/src/systemd.ts +18 -11
  43. package/src/triggers.test.ts +7 -7
  44. package/src/triggers.ts +6 -6
  45. package/src/vault-store.ts +20 -3
  46. package/src/vault.test.ts +356 -262
  47. package/.claude/settings.local.json +0 -31
  48. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  49. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  50. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  51. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  52. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  53. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  54. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  55. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  56. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  57. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  58. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  59. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  60. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  61. package/religions-abrahamic-filter.png +0 -0
  62. package/religions-buddhism-v2.png +0 -0
  63. package/religions-buddhism.png +0 -0
  64. package/religions-final.png +0 -0
  65. package/religions-v1.png +0 -0
  66. package/religions-v2.png +0 -0
  67. package/religions-zen.png +0 -0
  68. package/web/README.md +0 -73
  69. package/web/bun.lock +0 -827
  70. package/web/eslint.config.js +0 -23
  71. package/web/index.html +0 -15
  72. package/web/package.json +0 -36
  73. package/web/public/favicon.svg +0 -1
  74. package/web/public/icons.svg +0 -24
  75. package/web/src/App.tsx +0 -149
  76. package/web/src/Graph.tsx +0 -200
  77. package/web/src/NoteView.tsx +0 -155
  78. package/web/src/Sidebar.tsx +0 -186
  79. package/web/src/api.ts +0 -21
  80. package/web/src/index.css +0 -50
  81. package/web/src/main.tsx +0 -10
  82. package/web/src/types.ts +0 -37
  83. package/web/src/utils.ts +0 -107
  84. package/web/tsconfig.app.json +0 -25
  85. package/web/tsconfig.json +0 -7
  86. package/web/tsconfig.node.json +0 -24
  87. package/web/vite.config.ts +0 -15
package/src/oauth.test.ts CHANGED
@@ -84,7 +84,7 @@ async function fullOAuthFlow(opts?: { scope?: string }): Promise<string> {
84
84
  owner_token: ownerToken,
85
85
  }),
86
86
  });
87
- const authRes = await handleAuthorizePost(authReq, db);
87
+ const authRes = await handleAuthorizePost(authReq, db, { vaultName: "default" });
88
88
  expect(authRes.status).toBe(302);
89
89
  const location = new URL(authRes.headers.get("location")!);
90
90
  const code = location.searchParams.get("code")!;
@@ -102,7 +102,7 @@ async function fullOAuthFlow(opts?: { scope?: string }): Promise<string> {
102
102
  redirect_uri: redirectUri,
103
103
  }).toString(),
104
104
  });
105
- const tokenRes = await handleToken(tokenReq, db);
105
+ const tokenRes = await handleToken(tokenReq, db, "default");
106
106
  const tokenBody = await tokenRes.json();
107
107
  return tokenBody.access_token;
108
108
  }
@@ -407,7 +407,7 @@ describe("OAuth token exchange", () => {
407
407
  redirect_uri: "https://example.com/callback",
408
408
  }).toString(),
409
409
  });
410
- const res = await handleToken(req, db);
410
+ const res = await handleToken(req, db, "default");
411
411
  expect(res.status).toBe(400);
412
412
  const body = await res.json();
413
413
  expect(body.error).toBe("invalid_grant");
@@ -432,7 +432,7 @@ describe("OAuth token exchange", () => {
432
432
  owner_token: ownerToken,
433
433
  }),
434
434
  });
435
- const authRes = await handleAuthorizePost(authReq, db);
435
+ const authRes = await handleAuthorizePost(authReq, db, { vaultName: "default" });
436
436
  const location = new URL(authRes.headers.get("location")!);
437
437
  const code = location.searchParams.get("code")!;
438
438
 
@@ -448,7 +448,7 @@ describe("OAuth token exchange", () => {
448
448
  redirect_uri: redirectUri,
449
449
  }).toString(),
450
450
  });
451
- const res = await handleToken(tokenReq, db);
451
+ const res = await handleToken(tokenReq, db, "default");
452
452
  expect(res.status).toBe(400);
453
453
  const body = await res.json();
454
454
  expect(body.error_description).toContain("PKCE");
@@ -473,7 +473,7 @@ describe("OAuth token exchange", () => {
473
473
  owner_token: ownerToken,
474
474
  }),
475
475
  });
476
- const authRes = await handleAuthorizePost(authReq, db);
476
+ const authRes = await handleAuthorizePost(authReq, db, { vaultName: "default" });
477
477
  const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
478
478
 
479
479
  const tokenParams = new URLSearchParams({
@@ -492,6 +492,7 @@ describe("OAuth token exchange", () => {
492
492
  body: tokenParams,
493
493
  }),
494
494
  db,
495
+ "default",
495
496
  );
496
497
  expect(res1.status).toBe(200);
497
498
 
@@ -503,6 +504,7 @@ describe("OAuth token exchange", () => {
503
504
  body: tokenParams,
504
505
  }),
505
506
  db,
507
+ "default",
506
508
  );
507
509
  expect(res2.status).toBe(400);
508
510
  const body = await res2.json();
@@ -534,6 +536,7 @@ describe("OAuth token exchange", () => {
534
536
  }).toString(),
535
537
  }),
536
538
  db,
539
+ "default",
537
540
  );
538
541
  expect(res.status).toBe(400);
539
542
  const body = await res.json();
@@ -562,6 +565,7 @@ describe("OAuth token exchange", () => {
562
565
  }),
563
566
  }),
564
567
  db,
568
+ { vaultName: "default" },
565
569
  );
566
570
  const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
567
571
 
@@ -579,6 +583,7 @@ describe("OAuth token exchange", () => {
579
583
  }).toString(),
580
584
  }),
581
585
  db,
586
+ "default",
582
587
  );
583
588
  expect(res.status).toBe(400);
584
589
  const body = await res.json();
@@ -593,6 +598,7 @@ describe("OAuth token exchange", () => {
593
598
  body: "grant_type=client_credentials",
594
599
  }),
595
600
  db,
601
+ "default",
596
602
  );
597
603
  expect(res.status).toBe(400);
598
604
  const body = await res.json();
@@ -619,6 +625,7 @@ describe("OAuth token exchange", () => {
619
625
  }).toString(),
620
626
  }),
621
627
  db,
628
+ "default",
622
629
  );
623
630
  expect(res.status).toBe(400);
624
631
  const body = await res.json();
@@ -629,6 +636,7 @@ describe("OAuth token exchange", () => {
629
636
  const res = await handleToken(
630
637
  makeRequest("https://vault.test/oauth/token"),
631
638
  db,
639
+ "default",
632
640
  );
633
641
  expect(res.status).toBe(405);
634
642
  });
@@ -697,7 +705,7 @@ describe("OAuth consent — password mode", () => {
697
705
  }),
698
706
  }),
699
707
  db,
700
- { ownerPasswordHash: passwordHash },
708
+ { ownerPasswordHash: passwordHash, vaultName: "default" },
701
709
  );
702
710
 
703
711
  expect(authRes.status).toBe(302);
@@ -717,6 +725,7 @@ describe("OAuth consent — password mode", () => {
717
725
  }).toString(),
718
726
  }),
719
727
  db,
728
+ "default",
720
729
  );
721
730
  const body = await tokenRes.json();
722
731
  expect(body.access_token.startsWith("pvt_")).toBe(true);
@@ -959,6 +968,7 @@ describe("OAuth consent — scope selection", () => {
959
968
  }),
960
969
  }),
961
970
  db,
971
+ { vaultName: "default" },
962
972
  );
963
973
  expect(authRes.status).toBe(302);
964
974
  const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
@@ -976,6 +986,7 @@ describe("OAuth consent — scope selection", () => {
976
986
  }).toString(),
977
987
  }),
978
988
  db,
989
+ "default",
979
990
  );
980
991
  const body = await tokenRes.json();
981
992
  expect(body.scope).toBe("read");
@@ -1001,6 +1012,7 @@ describe("OAuth consent — scope selection", () => {
1001
1012
  }),
1002
1013
  }),
1003
1014
  db,
1015
+ { vaultName: "default" },
1004
1016
  );
1005
1017
  const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1006
1018
 
@@ -1017,6 +1029,7 @@ describe("OAuth consent — scope selection", () => {
1017
1029
  }).toString(),
1018
1030
  }),
1019
1031
  db,
1032
+ "default",
1020
1033
  );
1021
1034
  const body = await tokenRes.json();
1022
1035
  expect(body.scope).toBe("read");
@@ -1118,7 +1131,7 @@ describe("OAuth consent — 2FA (TOTP)", () => {
1118
1131
  }),
1119
1132
  }),
1120
1133
  db,
1121
- { ownerPasswordHash: passwordHash, totpSecret: secret },
1134
+ { ownerPasswordHash: passwordHash, totpSecret: secret, vaultName: "default" },
1122
1135
  );
1123
1136
  expect(res.status).toBe(302);
1124
1137
  const authCode = new URL(res.headers.get("location")!).searchParams.get("code")!;
@@ -1138,6 +1151,7 @@ describe("OAuth consent — 2FA (TOTP)", () => {
1138
1151
  }).toString(),
1139
1152
  }),
1140
1153
  db,
1154
+ "default",
1141
1155
  );
1142
1156
  expect(tokenRes.status).toBe(200);
1143
1157
  const body = await tokenRes.json();
@@ -1240,3 +1254,270 @@ describe("OAuth consent — 2FA (TOTP)", () => {
1240
1254
  expect(html).toContain("Invalid credentials");
1241
1255
  });
1242
1256
  });
1257
+
1258
+ // ---------------------------------------------------------------------------
1259
+ // Token response — honest vault name (Fix 1)
1260
+ // ---------------------------------------------------------------------------
1261
+
1262
+ describe("OAuth token response — vault name", () => {
1263
+ test("includes vault name for the default (unscoped) flow", async () => {
1264
+ const ownerToken = createOwnerToken();
1265
+ const clientId = await registerClient();
1266
+ const { codeVerifier, codeChallenge } = generatePkce();
1267
+ const redirectUri = "https://example.com/callback";
1268
+
1269
+ const authRes = await handleAuthorizePost(
1270
+ makeRequest("https://vault.test/oauth/authorize", {
1271
+ method: "POST",
1272
+ body: new URLSearchParams({
1273
+ action: "authorize",
1274
+ client_id: clientId,
1275
+ redirect_uri: redirectUri,
1276
+ code_challenge: codeChallenge,
1277
+ code_challenge_method: "S256",
1278
+ scope: "full",
1279
+ owner_token: ownerToken,
1280
+ }),
1281
+ }),
1282
+ db,
1283
+ { vaultName: "default" },
1284
+ );
1285
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1286
+
1287
+ const tokenRes = await handleToken(
1288
+ makeRequest("https://vault.test/oauth/token", {
1289
+ method: "POST",
1290
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1291
+ body: new URLSearchParams({
1292
+ grant_type: "authorization_code",
1293
+ code,
1294
+ code_verifier: codeVerifier,
1295
+ client_id: clientId,
1296
+ redirect_uri: redirectUri,
1297
+ }).toString(),
1298
+ }),
1299
+ db,
1300
+ "default",
1301
+ );
1302
+ const body = await tokenRes.json();
1303
+ expect(body.access_token).toMatch(/^pvt_/);
1304
+ expect(body.vault).toBe("default");
1305
+ expect(body.token_type).toBe("bearer");
1306
+ expect(body.scope).toBe("full");
1307
+ });
1308
+
1309
+ test("includes vault name for a scoped (named-vault) flow", async () => {
1310
+ // The vaultName is purely a response-shape concern; the DB is the same
1311
+ // in-memory DB here. The point is that handleToken echoes the name it
1312
+ // was called with, so the client can trust which vault it just connected to.
1313
+ const ownerToken = createOwnerToken();
1314
+ const clientId = await registerClient();
1315
+ const { codeVerifier, codeChallenge } = generatePkce();
1316
+ const redirectUri = "https://example.com/callback";
1317
+
1318
+ const authRes = await handleAuthorizePost(
1319
+ makeRequest("https://vault.test/vaults/work/oauth/authorize", {
1320
+ method: "POST",
1321
+ body: new URLSearchParams({
1322
+ action: "authorize",
1323
+ client_id: clientId,
1324
+ redirect_uri: redirectUri,
1325
+ code_challenge: codeChallenge,
1326
+ code_challenge_method: "S256",
1327
+ scope: "full",
1328
+ owner_token: ownerToken,
1329
+ }),
1330
+ }),
1331
+ db,
1332
+ { vaultName: "work" },
1333
+ );
1334
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1335
+
1336
+ const tokenRes = await handleToken(
1337
+ makeRequest("https://vault.test/vaults/work/oauth/token", {
1338
+ method: "POST",
1339
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1340
+ body: new URLSearchParams({
1341
+ grant_type: "authorization_code",
1342
+ code,
1343
+ code_verifier: codeVerifier,
1344
+ client_id: clientId,
1345
+ redirect_uri: redirectUri,
1346
+ }).toString(),
1347
+ }),
1348
+ db,
1349
+ "work",
1350
+ );
1351
+ const body = await tokenRes.json();
1352
+ expect(body.vault).toBe("work");
1353
+ });
1354
+ });
1355
+
1356
+ // ---------------------------------------------------------------------------
1357
+ // Vault-scoped discovery (Fix 3 — routing coherence)
1358
+ // ---------------------------------------------------------------------------
1359
+
1360
+ describe("OAuth discovery — vault-scoped", () => {
1361
+ test("authorization-server metadata scopes all endpoints to the vault", async () => {
1362
+ const req = makeRequest("https://vault.test/vaults/work/.well-known/oauth-authorization-server");
1363
+ const res = handleAuthorizationServer(req, "work");
1364
+ const body = await res.json();
1365
+ // Issuer and endpoints all live under /vaults/work. This is what makes
1366
+ // vault-scoped OAuth work end-to-end: a client following the scoped
1367
+ // discovery gets redirected to the scoped authorize/token endpoints,
1368
+ // which in turn mint the token into the named vault's DB.
1369
+ expect(body.issuer).toBe("https://vault.test/vaults/work");
1370
+ expect(body.authorization_endpoint).toBe("https://vault.test/vaults/work/oauth/authorize");
1371
+ expect(body.token_endpoint).toBe("https://vault.test/vaults/work/oauth/token");
1372
+ expect(body.registration_endpoint).toBe("https://vault.test/vaults/work/oauth/register");
1373
+ expect(body.code_challenge_methods_supported).toEqual(["S256"]);
1374
+ });
1375
+
1376
+ test("authorization-server metadata defaults to unscoped endpoints when no vaultName", async () => {
1377
+ // Regression check — the unscoped form still returns unscoped endpoints.
1378
+ const req = makeRequest("https://vault.test/.well-known/oauth-authorization-server");
1379
+ const res = handleAuthorizationServer(req);
1380
+ const body = await res.json();
1381
+ expect(body.issuer).toBe("https://vault.test");
1382
+ expect(body.authorization_endpoint).toBe("https://vault.test/oauth/authorize");
1383
+ expect(body.token_endpoint).toBe("https://vault.test/oauth/token");
1384
+ });
1385
+
1386
+ test("protected-resource advertises a vault-scoped authorization server", async () => {
1387
+ const req = makeRequest("https://vault.test/vaults/work/.well-known/oauth-protected-resource");
1388
+ const res = handleProtectedResource(req, "/vaults/work/mcp", "/vaults/work");
1389
+ const body = await res.json();
1390
+ expect(body.resource).toBe("https://vault.test/vaults/work/mcp");
1391
+ // The authorization server the client should fetch next is the scoped one,
1392
+ // so the client discovers the scoped authorize/token endpoints.
1393
+ expect(body.authorization_servers).toEqual(["https://vault.test/vaults/work"]);
1394
+ });
1395
+
1396
+ test("protected-resource defaults to root authorization server when no prefix", async () => {
1397
+ const req = makeRequest("https://vault.test/.well-known/oauth-protected-resource");
1398
+ const res = handleProtectedResource(req);
1399
+ const body = await res.json();
1400
+ expect(body.authorization_servers).toEqual(["https://vault.test"]);
1401
+ });
1402
+
1403
+ test("scoped discovery honors x-forwarded-host", async () => {
1404
+ const req = makeRequest("http://localhost:1940/vaults/work/.well-known/oauth-authorization-server", {
1405
+ headers: {
1406
+ "x-forwarded-proto": "https",
1407
+ "x-forwarded-host": "vault.example.com",
1408
+ },
1409
+ });
1410
+ const res = handleAuthorizationServer(req, "work");
1411
+ const body = await res.json();
1412
+ expect(body.issuer).toBe("https://vault.example.com/vaults/work");
1413
+ expect(body.authorization_endpoint).toBe("https://vault.example.com/vaults/work/oauth/authorize");
1414
+ });
1415
+ });
1416
+
1417
+ // ---------------------------------------------------------------------------
1418
+ // Cross-vault code replay defense
1419
+ // ---------------------------------------------------------------------------
1420
+
1421
+ describe("OAuth token — cross-vault code replay", () => {
1422
+ // The in-memory DB in this suite is shared, but handleToken is passed the
1423
+ // vaultName it was invoked under. That's the check: a code issued for
1424
+ // vault A must not mint a token when presented to vault B's token endpoint,
1425
+ // even if both endpoints share storage.
1426
+
1427
+ test("code issued for vault A rejected at vault B's token endpoint", async () => {
1428
+ const ownerToken = createOwnerToken();
1429
+ const clientId = await registerClient();
1430
+ const { codeVerifier, codeChallenge } = generatePkce();
1431
+ const redirectUri = "https://example.com/callback";
1432
+
1433
+ // Issue a code under vault A's authorize endpoint
1434
+ const authRes = await handleAuthorizePost(
1435
+ makeRequest("https://vault.test/vaults/vault-a/oauth/authorize", {
1436
+ method: "POST",
1437
+ body: new URLSearchParams({
1438
+ action: "authorize",
1439
+ client_id: clientId,
1440
+ redirect_uri: redirectUri,
1441
+ code_challenge: codeChallenge,
1442
+ code_challenge_method: "S256",
1443
+ scope: "full",
1444
+ owner_token: ownerToken,
1445
+ }),
1446
+ }),
1447
+ db,
1448
+ { vaultName: "vault-a" },
1449
+ );
1450
+ expect(authRes.status).toBe(302);
1451
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1452
+
1453
+ // Try to redeem it at vault B's token endpoint — must reject with
1454
+ // invalid_grant per RFC 6749 §5.2. This is the privilege-escalation
1455
+ // barrier: without the vault_name pinning, the code would mint a token
1456
+ // into whichever vault's DB this handleToken was called against.
1457
+ const tokenRes = await handleToken(
1458
+ makeRequest("https://vault.test/vaults/vault-b/oauth/token", {
1459
+ method: "POST",
1460
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1461
+ body: new URLSearchParams({
1462
+ grant_type: "authorization_code",
1463
+ code,
1464
+ code_verifier: codeVerifier,
1465
+ client_id: clientId,
1466
+ redirect_uri: redirectUri,
1467
+ }).toString(),
1468
+ }),
1469
+ db,
1470
+ "vault-b",
1471
+ );
1472
+ expect(tokenRes.status).toBe(400);
1473
+ const body = await tokenRes.json();
1474
+ expect(body.error).toBe("invalid_grant");
1475
+ });
1476
+
1477
+ test("code issued for vault A still redeems successfully at vault A's token endpoint", async () => {
1478
+ // Control case — same setup as the rejection test, but the token
1479
+ // endpoint matches the authorize endpoint. Must succeed.
1480
+ const ownerToken = createOwnerToken();
1481
+ const clientId = await registerClient();
1482
+ const { codeVerifier, codeChallenge } = generatePkce();
1483
+ const redirectUri = "https://example.com/callback";
1484
+
1485
+ const authRes = await handleAuthorizePost(
1486
+ makeRequest("https://vault.test/vaults/vault-a/oauth/authorize", {
1487
+ method: "POST",
1488
+ body: new URLSearchParams({
1489
+ action: "authorize",
1490
+ client_id: clientId,
1491
+ redirect_uri: redirectUri,
1492
+ code_challenge: codeChallenge,
1493
+ code_challenge_method: "S256",
1494
+ scope: "full",
1495
+ owner_token: ownerToken,
1496
+ }),
1497
+ }),
1498
+ db,
1499
+ { vaultName: "vault-a" },
1500
+ );
1501
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1502
+
1503
+ const tokenRes = await handleToken(
1504
+ makeRequest("https://vault.test/vaults/vault-a/oauth/token", {
1505
+ method: "POST",
1506
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1507
+ body: new URLSearchParams({
1508
+ grant_type: "authorization_code",
1509
+ code,
1510
+ code_verifier: codeVerifier,
1511
+ client_id: clientId,
1512
+ redirect_uri: redirectUri,
1513
+ }).toString(),
1514
+ }),
1515
+ db,
1516
+ "vault-a",
1517
+ );
1518
+ expect(tokenRes.status).toBe(200);
1519
+ const body = await tokenRes.json();
1520
+ expect(body.vault).toBe("vault-a");
1521
+ expect(body.access_token).toMatch(/^pvt_/);
1522
+ });
1523
+ });
package/src/oauth.ts CHANGED
@@ -63,23 +63,44 @@ function escapeHtml(s: string): string {
63
63
  // Discovery endpoints
64
64
  // ---------------------------------------------------------------------------
65
65
 
66
- export function handleProtectedResource(req: Request, mcpPath = "/mcp"): Response {
66
+ /**
67
+ * OAuth 2.0 Protected Resource Metadata (RFC 9728).
68
+ *
69
+ * @param mcpPath — the resource URL (e.g. `/mcp` or `/vaults/X/mcp`).
70
+ * @param authServerPrefix — path prefix for the authorization server issuer
71
+ * (e.g. `""` for global, `/vaults/X` for scoped).
72
+ * The client discovers the AS metadata at
73
+ * `{base}{prefix}/.well-known/oauth-authorization-server`.
74
+ */
75
+ export function handleProtectedResource(
76
+ req: Request,
77
+ mcpPath = "/mcp",
78
+ authServerPrefix = "",
79
+ ): Response {
67
80
  const base = getBaseUrl(req);
68
81
  return Response.json({
69
82
  resource: `${base}${mcpPath}`,
70
- authorization_servers: [base],
83
+ authorization_servers: [`${base}${authServerPrefix}`],
71
84
  scopes_supported: ["full", "read"],
72
85
  bearer_methods_supported: ["header"],
73
86
  });
74
87
  }
75
88
 
76
- export function handleAuthorizationServer(req: Request): Response {
89
+ /**
90
+ * OAuth 2.0 Authorization Server Metadata (RFC 8414).
91
+ *
92
+ * @param vaultName — when provided, returns vault-scoped endpoints
93
+ * (`/vaults/<name>/oauth/*`) and issuer. Tokens minted
94
+ * via these endpoints are scoped to the named vault's DB.
95
+ */
96
+ export function handleAuthorizationServer(req: Request, vaultName?: string): Response {
77
97
  const base = getBaseUrl(req);
98
+ const prefix = vaultName ? `/vaults/${vaultName}` : "";
78
99
  return Response.json({
79
- issuer: base,
80
- authorization_endpoint: `${base}/oauth/authorize`,
81
- token_endpoint: `${base}/oauth/token`,
82
- registration_endpoint: `${base}/oauth/register`,
100
+ issuer: `${base}${prefix}`,
101
+ authorization_endpoint: `${base}${prefix}/oauth/authorize`,
102
+ token_endpoint: `${base}${prefix}/oauth/token`,
103
+ registration_endpoint: `${base}${prefix}/oauth/register`,
83
104
  response_types_supported: ["code"],
84
105
  code_challenge_methods_supported: ["S256"],
85
106
  grant_types_supported: ["authorization_code"],
@@ -354,10 +375,12 @@ export async function handleAuthorizePost(
354
375
  const code = crypto.randomBytes(32).toString("base64url");
355
376
  const expiresAt = new Date(Date.now() + 10 * 60 * 1000).toISOString(); // 10 minutes
356
377
 
378
+ // vault_name pins the code to the issuing vault. handleToken rejects
379
+ // any code whose vault_name doesn't match the token-endpoint's vault.
357
380
  db.prepare(`
358
- INSERT INTO oauth_codes (code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, created_at)
359
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
360
- `).run(code, clientId, codeChallenge, codeChallengeMethod, selectedScope, redirectUri, expiresAt, new Date().toISOString());
381
+ INSERT INTO oauth_codes (code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, created_at, vault_name)
382
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
383
+ `).run(code, clientId, codeChallenge, codeChallengeMethod, selectedScope, redirectUri, expiresAt, new Date().toISOString(), vaultName ?? null);
361
384
 
362
385
  redirect.searchParams.set("code", code);
363
386
  return Response.redirect(redirect.toString(), 302);
@@ -367,7 +390,19 @@ export async function handleAuthorizePost(
367
390
  // Token endpoint
368
391
  // ---------------------------------------------------------------------------
369
392
 
370
- export async function handleToken(req: Request, db: Database): Promise<Response> {
393
+ /**
394
+ * OAuth 2.1 token endpoint — exchanges an auth code for a vault token.
395
+ *
396
+ * @param vaultName — the name of the vault this token is scoped to. Included
397
+ * in the response as `vault: <name>` so the client knows
398
+ * which vault was just connected. The token itself lives
399
+ * in that vault's tokens table.
400
+ */
401
+ export async function handleToken(
402
+ req: Request,
403
+ db: Database,
404
+ vaultName: string,
405
+ ): Promise<Response> {
371
406
  if (req.method !== "POST") {
372
407
  return Response.json({ error: "method_not_allowed" }, { status: 405 });
373
408
  }
@@ -404,7 +439,7 @@ export async function handleToken(req: Request, db: Database): Promise<Response>
404
439
 
405
440
  // Look up the auth code
406
441
  const authCode = db.prepare(`
407
- SELECT code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, used
442
+ SELECT code, client_id, code_challenge, code_challenge_method, scope, redirect_uri, expires_at, used, vault_name
408
443
  FROM oauth_codes WHERE code = ?
409
444
  `).get(code) as {
410
445
  code: string;
@@ -415,6 +450,7 @@ export async function handleToken(req: Request, db: Database): Promise<Response>
415
450
  redirect_uri: string;
416
451
  expires_at: string;
417
452
  used: number;
453
+ vault_name: string | null;
418
454
  } | null;
419
455
 
420
456
  if (!authCode) {
@@ -441,6 +477,14 @@ export async function handleToken(req: Request, db: Database): Promise<Response>
441
477
  return Response.json({ error: "invalid_grant", error_description: "redirect_uri mismatch" }, { status: 400 });
442
478
  }
443
479
 
480
+ // Validate the code was issued for the same vault this token endpoint
481
+ // serves. Without this, a code issued under /vaults/A/oauth/authorize
482
+ // could be presented to /vaults/B/oauth/token and the token would be
483
+ // minted into B's DB — privilege escalation across vault boundaries.
484
+ if (authCode.vault_name !== vaultName) {
485
+ return Response.json({ error: "invalid_grant", error_description: "vault mismatch" }, { status: 400 });
486
+ }
487
+
444
488
  // PKCE verification: SHA256(code_verifier) must match stored code_challenge
445
489
  const expectedChallenge = crypto
446
490
  .createHash("sha256")
@@ -466,6 +510,7 @@ export async function handleToken(req: Request, db: Database): Promise<Response>
466
510
  access_token: fullToken,
467
511
  token_type: "bearer",
468
512
  scope: permission,
513
+ vault: vaultName,
469
514
  });
470
515
  }
471
516