@openparachute/vault 0.2.4 → 0.3.0-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.
Files changed (98) hide show
  1. package/.claude/settings.local.json +2 -25
  2. package/CHANGELOG.md +64 -0
  3. package/CLAUDE.md +17 -7
  4. package/README.md +169 -136
  5. package/core/src/core.test.ts +591 -19
  6. package/core/src/indexed-fields.test.ts +285 -0
  7. package/core/src/indexed-fields.ts +238 -0
  8. package/core/src/mcp.ts +127 -6
  9. package/core/src/notes.ts +153 -11
  10. package/core/src/query-operators.ts +174 -0
  11. package/core/src/schema.ts +69 -2
  12. package/core/src/store.ts +92 -0
  13. package/core/src/tag-schemas.ts +5 -0
  14. package/core/src/types.ts +28 -1
  15. package/docs/HTTP_API.md +105 -1
  16. package/package/package.json +32 -0
  17. package/package.json +2 -2
  18. package/src/auth.test.ts +83 -114
  19. package/src/auth.ts +68 -6
  20. package/src/backup-launchd.ts +1 -1
  21. package/src/backup.test.ts +1 -1
  22. package/src/backup.ts +18 -17
  23. package/src/cli.ts +179 -121
  24. package/src/config-triggers.test.ts +49 -0
  25. package/src/config.test.ts +317 -2
  26. package/src/config.ts +420 -40
  27. package/src/context.test.ts +136 -0
  28. package/src/context.ts +115 -0
  29. package/src/daemon.ts +17 -16
  30. package/src/doctor.test.ts +9 -7
  31. package/src/launchd.test.ts +1 -1
  32. package/src/launchd.ts +6 -6
  33. package/src/mcp-http.ts +75 -21
  34. package/src/mcp-install.test.ts +125 -0
  35. package/src/mcp-install.ts +60 -0
  36. package/src/mcp-tools.ts +34 -96
  37. package/src/module-config.ts +109 -0
  38. package/src/oauth.test.ts +345 -57
  39. package/src/oauth.ts +155 -35
  40. package/src/published.test.ts +2 -2
  41. package/src/routes.ts +209 -33
  42. package/src/routing.test.ts +817 -300
  43. package/src/routing.ts +204 -202
  44. package/src/scopes.test.ts +136 -0
  45. package/src/scopes.ts +105 -0
  46. package/src/scribe-env.test.ts +49 -0
  47. package/src/scribe-env.ts +33 -0
  48. package/src/server.ts +57 -5
  49. package/src/services-manifest.test.ts +140 -0
  50. package/src/services-manifest.ts +99 -0
  51. package/src/systemd.ts +3 -3
  52. package/src/token-store.ts +42 -9
  53. package/src/transcription-worker.test.ts +583 -0
  54. package/src/transcription-worker.ts +346 -0
  55. package/src/triggers.test.ts +191 -1
  56. package/src/triggers.ts +17 -2
  57. package/src/vault.test.ts +693 -77
  58. package/src/version.test.ts +1 -1
  59. package/.playwright-mcp/console-2026-04-14T04-17-25-395Z.log +0 -2
  60. package/.playwright-mcp/console-2026-04-14T04-18-11-767Z.log +0 -1
  61. package/.playwright-mcp/console-2026-04-14T04-19-07-733Z.log +0 -2
  62. package/.playwright-mcp/console-2026-04-14T04-20-45-440Z.log +0 -2
  63. package/.playwright-mcp/page-2026-04-14T04-17-25-536Z.yml +0 -1
  64. package/.playwright-mcp/page-2026-04-14T04-18-11-816Z.yml +0 -1
  65. package/.playwright-mcp/page-2026-04-14T04-18-31-674Z.yml +0 -211
  66. package/.playwright-mcp/page-2026-04-14T04-19-07-795Z.yml +0 -59
  67. package/.playwright-mcp/page-2026-04-14T04-19-36-239Z.yml +0 -232
  68. package/.playwright-mcp/page-2026-04-14T04-19-58-327Z.yml +0 -182
  69. package/.playwright-mcp/page-2026-04-14T04-20-10-517Z.yml +0 -91
  70. package/.playwright-mcp/page-2026-04-14T04-20-14-796Z.yml +0 -70
  71. package/.playwright-mcp/page-2026-04-14T04-20-45-509Z.yml +0 -59
  72. package/religions-abrahamic-filter.png +0 -0
  73. package/religions-buddhism-v2.png +0 -0
  74. package/religions-buddhism.png +0 -0
  75. package/religions-final.png +0 -0
  76. package/religions-v1.png +0 -0
  77. package/religions-v2.png +0 -0
  78. package/religions-zen.png +0 -0
  79. package/web/README.md +0 -73
  80. package/web/bun.lock +0 -827
  81. package/web/eslint.config.js +0 -23
  82. package/web/index.html +0 -15
  83. package/web/package.json +0 -36
  84. package/web/public/favicon.svg +0 -1
  85. package/web/public/icons.svg +0 -24
  86. package/web/src/App.tsx +0 -149
  87. package/web/src/Graph.tsx +0 -200
  88. package/web/src/NoteView.tsx +0 -155
  89. package/web/src/Sidebar.tsx +0 -186
  90. package/web/src/api.ts +0 -21
  91. package/web/src/index.css +0 -50
  92. package/web/src/main.tsx +0 -10
  93. package/web/src/types.ts +0 -37
  94. package/web/src/utils.ts +0 -107
  95. package/web/tsconfig.app.json +0 -25
  96. package/web/tsconfig.json +0 -7
  97. package/web/tsconfig.node.json +0 -24
  98. package/web/vite.config.ts +0 -16
package/src/oauth.test.ts CHANGED
@@ -5,6 +5,9 @@
5
5
  import { describe, test, expect, beforeEach, afterEach } from "bun:test";
6
6
  import { Database } from "bun:sqlite";
7
7
  import crypto from "node:crypto";
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
8
11
  import { initSchema } from "../core/src/schema.ts";
9
12
  import { generateToken, createToken, resolveToken } from "./token-store.ts";
10
13
  import {
@@ -113,44 +116,45 @@ async function fullOAuthFlow(opts?: { scope?: string }): Promise<string> {
113
116
 
114
117
  describe("OAuth discovery", () => {
115
118
  test("protected resource metadata", async () => {
116
- const req = makeRequest("https://vault.test/.well-known/oauth-protected-resource");
117
- const res = handleProtectedResource(req);
119
+ const req = makeRequest("https://vault.test/vault/default/.well-known/oauth-protected-resource");
120
+ const res = handleProtectedResource(req, "default");
118
121
  expect(res.status).toBe(200);
119
122
  const body = await res.json();
120
- expect(body.resource).toBe("https://vault.test/mcp");
123
+ expect(body.resource).toBe("https://vault.test/vault/default/mcp");
124
+ expect(body.authorization_servers).toEqual(["https://vault.test/vault/default"]);
121
125
  expect(body.scopes_supported).toContain("full");
122
126
  expect(body.scopes_supported).toContain("read");
123
127
  });
124
128
 
125
129
  test("authorization server metadata", async () => {
126
- const req = makeRequest("https://vault.test/.well-known/oauth-authorization-server");
127
- const res = handleAuthorizationServer(req);
130
+ const req = makeRequest("https://vault.test/vault/default/.well-known/oauth-authorization-server");
131
+ const res = handleAuthorizationServer(req, "default");
128
132
  expect(res.status).toBe(200);
129
133
  const body = await res.json();
130
- expect(body.issuer).toBe("https://vault.test");
131
- expect(body.authorization_endpoint).toBe("https://vault.test/oauth/authorize");
132
- expect(body.token_endpoint).toBe("https://vault.test/oauth/token");
133
- expect(body.registration_endpoint).toBe("https://vault.test/oauth/register");
134
+ expect(body.issuer).toBe("https://vault.test/vault/default");
135
+ expect(body.authorization_endpoint).toBe("https://vault.test/vault/default/oauth/authorize");
136
+ expect(body.token_endpoint).toBe("https://vault.test/vault/default/oauth/token");
137
+ expect(body.registration_endpoint).toBe("https://vault.test/vault/default/oauth/register");
134
138
  expect(body.code_challenge_methods_supported).toEqual(["S256"]);
135
139
  });
136
140
 
137
- test("resource URL includes custom mcpPath for per-vault", async () => {
138
- const req = makeRequest("https://vault.test/.well-known/oauth-protected-resource");
139
- const res = handleProtectedResource(req, "/vaults/work/mcp");
141
+ test("resource URL reflects the vault name", async () => {
142
+ const req = makeRequest("https://vault.test/vault/work/.well-known/oauth-protected-resource");
143
+ const res = handleProtectedResource(req, "work");
140
144
  const body = await res.json();
141
- expect(body.resource).toBe("https://vault.test/vaults/work/mcp");
145
+ expect(body.resource).toBe("https://vault.test/vault/work/mcp");
142
146
  });
143
147
 
144
148
  test("uses x-forwarded-proto and x-forwarded-host", async () => {
145
- const req = makeRequest("http://localhost:1940/.well-known/oauth-protected-resource", {
149
+ const req = makeRequest("http://localhost:1940/vault/default/.well-known/oauth-protected-resource", {
146
150
  headers: {
147
151
  "x-forwarded-proto": "https",
148
152
  "x-forwarded-host": "vault.example.com",
149
153
  },
150
154
  });
151
- const res = handleProtectedResource(req);
155
+ const res = handleProtectedResource(req, "default");
152
156
  const body = await res.json();
153
- expect(body.resource).toBe("https://vault.example.com/mcp");
157
+ expect(body.resource).toBe("https://vault.example.com/vault/default/mcp");
154
158
  });
155
159
  });
156
160
 
@@ -989,7 +993,7 @@ describe("OAuth consent — scope selection", () => {
989
993
  "default",
990
994
  );
991
995
  const body = await tokenRes.json();
992
- expect(body.scope).toBe("read");
996
+ expect(body.scope).toBe("vault:read");
993
997
  });
994
998
 
995
999
  test("defaults selected_scope to requested scope when not provided", async () => {
@@ -1032,7 +1036,7 @@ describe("OAuth consent — scope selection", () => {
1032
1036
  "default",
1033
1037
  );
1034
1038
  const body = await tokenRes.json();
1035
- expect(body.scope).toBe("read");
1039
+ expect(body.scope).toBe("vault:read");
1036
1040
  });
1037
1041
 
1038
1042
  test("consent HTML includes both scope radio buttons", async () => {
@@ -1303,7 +1307,7 @@ describe("OAuth token response — vault name", () => {
1303
1307
  expect(body.access_token).toMatch(/^pvt_/);
1304
1308
  expect(body.vault).toBe("default");
1305
1309
  expect(body.token_type).toBe("bearer");
1306
- expect(body.scope).toBe("full");
1310
+ expect(body.scope).toBe("vault:read vault:write vault:admin");
1307
1311
  });
1308
1312
 
1309
1313
  test("includes vault name for a scoped (named-vault) flow", async () => {
@@ -1316,7 +1320,7 @@ describe("OAuth token response — vault name", () => {
1316
1320
  const redirectUri = "https://example.com/callback";
1317
1321
 
1318
1322
  const authRes = await handleAuthorizePost(
1319
- makeRequest("https://vault.test/vaults/work/oauth/authorize", {
1323
+ makeRequest("https://vault.test/vault/work/oauth/authorize", {
1320
1324
  method: "POST",
1321
1325
  body: new URLSearchParams({
1322
1326
  action: "authorize",
@@ -1334,7 +1338,7 @@ describe("OAuth token response — vault name", () => {
1334
1338
  const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1335
1339
 
1336
1340
  const tokenRes = await handleToken(
1337
- makeRequest("https://vault.test/vaults/work/oauth/token", {
1341
+ makeRequest("https://vault.test/vault/work/oauth/token", {
1338
1342
  method: "POST",
1339
1343
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
1340
1344
  body: new URLSearchParams({
@@ -1359,49 +1363,31 @@ describe("OAuth token response — vault name", () => {
1359
1363
 
1360
1364
  describe("OAuth discovery — vault-scoped", () => {
1361
1365
  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");
1366
+ const req = makeRequest("https://vault.test/vault/work/.well-known/oauth-authorization-server");
1363
1367
  const res = handleAuthorizationServer(req, "work");
1364
1368
  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,
1369
+ // Issuer and endpoints all live under /vault/work. A client following the
1370
+ // scoped discovery gets redirected to the scoped authorize/token endpoints,
1368
1371
  // 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");
1372
+ expect(body.issuer).toBe("https://vault.test/vault/work");
1373
+ expect(body.authorization_endpoint).toBe("https://vault.test/vault/work/oauth/authorize");
1374
+ expect(body.token_endpoint).toBe("https://vault.test/vault/work/oauth/token");
1375
+ expect(body.registration_endpoint).toBe("https://vault.test/vault/work/oauth/register");
1373
1376
  expect(body.code_challenge_methods_supported).toEqual(["S256"]);
1374
1377
  });
1375
1378
 
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
1379
  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");
1380
+ const req = makeRequest("https://vault.test/vault/work/.well-known/oauth-protected-resource");
1381
+ const res = handleProtectedResource(req, "work");
1389
1382
  const body = await res.json();
1390
- expect(body.resource).toBe("https://vault.test/vaults/work/mcp");
1383
+ expect(body.resource).toBe("https://vault.test/vault/work/mcp");
1391
1384
  // The authorization server the client should fetch next is the scoped one,
1392
1385
  // 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"]);
1386
+ expect(body.authorization_servers).toEqual(["https://vault.test/vault/work"]);
1401
1387
  });
1402
1388
 
1403
1389
  test("scoped discovery honors x-forwarded-host", async () => {
1404
- const req = makeRequest("http://localhost:1940/vaults/work/.well-known/oauth-authorization-server", {
1390
+ const req = makeRequest("http://localhost:1940/vault/work/.well-known/oauth-authorization-server", {
1405
1391
  headers: {
1406
1392
  "x-forwarded-proto": "https",
1407
1393
  "x-forwarded-host": "vault.example.com",
@@ -1409,8 +1395,8 @@ describe("OAuth discovery — vault-scoped", () => {
1409
1395
  });
1410
1396
  const res = handleAuthorizationServer(req, "work");
1411
1397
  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");
1398
+ expect(body.issuer).toBe("https://vault.example.com/vault/work");
1399
+ expect(body.authorization_endpoint).toBe("https://vault.example.com/vault/work/oauth/authorize");
1414
1400
  });
1415
1401
  });
1416
1402
 
@@ -1432,7 +1418,7 @@ describe("OAuth token — cross-vault code replay", () => {
1432
1418
 
1433
1419
  // Issue a code under vault A's authorize endpoint
1434
1420
  const authRes = await handleAuthorizePost(
1435
- makeRequest("https://vault.test/vaults/vault-a/oauth/authorize", {
1421
+ makeRequest("https://vault.test/vault/vault-a/oauth/authorize", {
1436
1422
  method: "POST",
1437
1423
  body: new URLSearchParams({
1438
1424
  action: "authorize",
@@ -1455,7 +1441,7 @@ describe("OAuth token — cross-vault code replay", () => {
1455
1441
  // barrier: without the vault_name pinning, the code would mint a token
1456
1442
  // into whichever vault's DB this handleToken was called against.
1457
1443
  const tokenRes = await handleToken(
1458
- makeRequest("https://vault.test/vaults/vault-b/oauth/token", {
1444
+ makeRequest("https://vault.test/vault/vault-b/oauth/token", {
1459
1445
  method: "POST",
1460
1446
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
1461
1447
  body: new URLSearchParams({
@@ -1483,7 +1469,7 @@ describe("OAuth token — cross-vault code replay", () => {
1483
1469
  const redirectUri = "https://example.com/callback";
1484
1470
 
1485
1471
  const authRes = await handleAuthorizePost(
1486
- makeRequest("https://vault.test/vaults/vault-a/oauth/authorize", {
1472
+ makeRequest("https://vault.test/vault/vault-a/oauth/authorize", {
1487
1473
  method: "POST",
1488
1474
  body: new URLSearchParams({
1489
1475
  action: "authorize",
@@ -1501,7 +1487,7 @@ describe("OAuth token — cross-vault code replay", () => {
1501
1487
  const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1502
1488
 
1503
1489
  const tokenRes = await handleToken(
1504
- makeRequest("https://vault.test/vaults/vault-a/oauth/token", {
1490
+ makeRequest("https://vault.test/vault/vault-a/oauth/token", {
1505
1491
  method: "POST",
1506
1492
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
1507
1493
  body: new URLSearchParams({
@@ -1521,3 +1507,305 @@ describe("OAuth token — cross-vault code replay", () => {
1521
1507
  expect(body.access_token).toMatch(/^pvt_/);
1522
1508
  });
1523
1509
  });
1510
+
1511
+ // ---------------------------------------------------------------------------
1512
+ // Phase 0+1: PARACHUTE_HUB_ORIGIN + service catalog in token response
1513
+ // ---------------------------------------------------------------------------
1514
+
1515
+ describe("OAuth Phase 0: PARACHUTE_HUB_ORIGIN", () => {
1516
+ const HUB = "https://hub.example";
1517
+ let origHub: string | undefined;
1518
+ let origHome: string | undefined;
1519
+ let tmpHome: string;
1520
+
1521
+ beforeEach(() => {
1522
+ origHub = process.env.PARACHUTE_HUB_ORIGIN;
1523
+ origHome = process.env.PARACHUTE_HOME;
1524
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), "vault-oauth-phase0-"));
1525
+ process.env.PARACHUTE_HOME = tmpHome;
1526
+ });
1527
+
1528
+ afterEach(() => {
1529
+ if (origHub === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
1530
+ else process.env.PARACHUTE_HUB_ORIGIN = origHub;
1531
+ if (origHome === undefined) delete process.env.PARACHUTE_HOME;
1532
+ else process.env.PARACHUTE_HOME = origHome;
1533
+ fs.rmSync(tmpHome, { recursive: true, force: true });
1534
+ });
1535
+
1536
+ test("discovery: returns hub issuer when request arrives via hub origin", () => {
1537
+ process.env.PARACHUTE_HUB_ORIGIN = HUB;
1538
+ const req = makeRequest(`${HUB}/vault/default/.well-known/oauth-authorization-server`);
1539
+ const res = handleAuthorizationServer(req, "default");
1540
+ expect(res.status).toBe(200);
1541
+ return res.json().then((body: any) => {
1542
+ expect(body.issuer).toBe(HUB);
1543
+ expect(body.authorization_endpoint).toBe(`${HUB}/oauth/authorize`);
1544
+ expect(body.token_endpoint).toBe(`${HUB}/oauth/token`);
1545
+ expect(body.registration_endpoint).toBe(`${HUB}/oauth/register`);
1546
+ expect(body.scopes_supported).toContain("vault:read");
1547
+ expect(body.scopes_supported).toContain("vault:write");
1548
+ });
1549
+ });
1550
+
1551
+ test("discovery: trailing slash on hub origin is stripped", async () => {
1552
+ process.env.PARACHUTE_HUB_ORIGIN = `${HUB}/`;
1553
+ const req = makeRequest(`${HUB}/vault/default/.well-known/oauth-authorization-server`);
1554
+ const res = handleAuthorizationServer(req, "default");
1555
+ const body = await res.json();
1556
+ expect(body.issuer).toBe(HUB);
1557
+ expect(body.token_endpoint).toBe(`${HUB}/oauth/token`);
1558
+ });
1559
+
1560
+ test("discovery: protected-resource metadata uses hub as authorization_server when request arrives via hub", async () => {
1561
+ process.env.PARACHUTE_HUB_ORIGIN = HUB;
1562
+ const req = makeRequest(`${HUB}/vault/default/.well-known/oauth-protected-resource`);
1563
+ const res = handleProtectedResource(req, "default");
1564
+ const body = await res.json();
1565
+ expect(body.authorization_servers).toEqual([HUB]);
1566
+ expect(body.resource).toBe(`${HUB}/vault/default/mcp`);
1567
+ expect(body.scopes_supported).toContain("vault:read");
1568
+ });
1569
+
1570
+ test("discovery: RFC 8414 — hub env set, request via loopback returns loopback issuer, not hub", async () => {
1571
+ // Aaron's bug: mcp-install wrote a loopback URL while PARACHUTE_HUB_ORIGIN
1572
+ // was set, so the client fetched discovery via http://127.0.0.1 but got
1573
+ // back `issuer: https://hub.example` — origin mismatch, strict OAuth
1574
+ // clients (Claude Code) reject. Each origin must advertise its own issuer.
1575
+ process.env.PARACHUTE_HUB_ORIGIN = HUB;
1576
+ const req = makeRequest("http://127.0.0.1:1940/vault/default/.well-known/oauth-authorization-server");
1577
+ const res = handleAuthorizationServer(req, "default");
1578
+ const body = await res.json();
1579
+ expect(body.issuer).toBe("http://127.0.0.1:1940/vault/default");
1580
+ expect(body.token_endpoint).toBe("http://127.0.0.1:1940/vault/default/oauth/token");
1581
+ expect(body.registration_endpoint).toBe("http://127.0.0.1:1940/vault/default/oauth/register");
1582
+ });
1583
+
1584
+ test("discovery: protected-resource on loopback returns loopback AS even with hub env set", async () => {
1585
+ process.env.PARACHUTE_HUB_ORIGIN = HUB;
1586
+ const req = makeRequest("http://127.0.0.1:1940/vault/default/.well-known/oauth-protected-resource");
1587
+ const res = handleProtectedResource(req, "default");
1588
+ const body = await res.json();
1589
+ expect(body.authorization_servers).toEqual(["http://127.0.0.1:1940/vault/default"]);
1590
+ expect(body.resource).toBe("http://127.0.0.1:1940/vault/default/mcp");
1591
+ });
1592
+
1593
+ test("discovery: falls back to vault origin when env is unset", async () => {
1594
+ delete process.env.PARACHUTE_HUB_ORIGIN;
1595
+ const req = makeRequest("https://vault.test/vault/default/.well-known/oauth-authorization-server");
1596
+ const res = handleAuthorizationServer(req, "default");
1597
+ const body = await res.json();
1598
+ expect(body.issuer).toBe("https://vault.test/vault/default");
1599
+ expect(body.token_endpoint).toBe("https://vault.test/vault/default/oauth/token");
1600
+ });
1601
+
1602
+ test("scopes_supported publishes new shape alongside legacy names", async () => {
1603
+ const req = makeRequest("https://vault.test/vault/default/.well-known/oauth-authorization-server");
1604
+ const res = handleAuthorizationServer(req, "default");
1605
+ const body = await res.json();
1606
+ expect(body.scopes_supported).toEqual(expect.arrayContaining(["vault:read", "vault:write", "full", "read"]));
1607
+ });
1608
+
1609
+ test("token response includes iss = hub when issued on hub origin", async () => {
1610
+ process.env.PARACHUTE_HUB_ORIGIN = HUB;
1611
+ const token = await fullOAuthFlow();
1612
+ // Re-issue a token to inspect the body (fullOAuthFlow returns only the string)
1613
+ const ownerToken = createOwnerToken();
1614
+ const clientId = await registerClient();
1615
+ const { codeVerifier, codeChallenge } = generatePkce();
1616
+ const redirectUri = "https://example.com/callback";
1617
+ const authRes = await handleAuthorizePost(
1618
+ makeRequest(`${HUB}/vault/default/oauth/authorize`, {
1619
+ method: "POST",
1620
+ body: new URLSearchParams({
1621
+ action: "authorize",
1622
+ client_id: clientId,
1623
+ redirect_uri: redirectUri,
1624
+ code_challenge: codeChallenge,
1625
+ code_challenge_method: "S256",
1626
+ scope: "full",
1627
+ owner_token: ownerToken,
1628
+ }),
1629
+ }),
1630
+ db,
1631
+ { vaultName: "default" },
1632
+ );
1633
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1634
+ const tokenRes = await handleToken(
1635
+ makeRequest(`${HUB}/vault/default/oauth/token`, {
1636
+ method: "POST",
1637
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1638
+ body: new URLSearchParams({
1639
+ grant_type: "authorization_code",
1640
+ code,
1641
+ code_verifier: codeVerifier,
1642
+ client_id: clientId,
1643
+ redirect_uri: redirectUri,
1644
+ }).toString(),
1645
+ }),
1646
+ db,
1647
+ "default",
1648
+ );
1649
+ expect(tokenRes.status).toBe(200);
1650
+ const body = await tokenRes.json();
1651
+ expect(body.iss).toBe(HUB);
1652
+ expect(body.services).toEqual({});
1653
+ // Back-compat: access_token still present and unchanged shape-wise.
1654
+ expect(body.access_token).toMatch(/^pvt_/);
1655
+ expect(token).toMatch(/^pvt_/);
1656
+ });
1657
+
1658
+ test("token iss matches request origin when client came via loopback even with hub env set", async () => {
1659
+ // Same-vault twin of the discovery-on-loopback test: a token minted over
1660
+ // the loopback flow carries `iss` = the loopback issuer, not the hub.
1661
+ // Tokens introspected against loopback discovery's issuer must validate.
1662
+ process.env.PARACHUTE_HUB_ORIGIN = HUB;
1663
+ const ownerToken = createOwnerToken();
1664
+ const clientId = await registerClient();
1665
+ const { codeVerifier, codeChallenge } = generatePkce();
1666
+ const redirectUri = "https://example.com/callback";
1667
+ const authRes = await handleAuthorizePost(
1668
+ makeRequest("http://127.0.0.1:1940/vault/default/oauth/authorize", {
1669
+ method: "POST",
1670
+ body: new URLSearchParams({
1671
+ action: "authorize",
1672
+ client_id: clientId,
1673
+ redirect_uri: redirectUri,
1674
+ code_challenge: codeChallenge,
1675
+ code_challenge_method: "S256",
1676
+ scope: "full",
1677
+ owner_token: ownerToken,
1678
+ }),
1679
+ }),
1680
+ db,
1681
+ { vaultName: "default" },
1682
+ );
1683
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1684
+ const tokenRes = await handleToken(
1685
+ makeRequest("http://127.0.0.1:1940/vault/default/oauth/token", {
1686
+ method: "POST",
1687
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1688
+ body: new URLSearchParams({
1689
+ grant_type: "authorization_code",
1690
+ code,
1691
+ code_verifier: codeVerifier,
1692
+ client_id: clientId,
1693
+ redirect_uri: redirectUri,
1694
+ }).toString(),
1695
+ }),
1696
+ db,
1697
+ "default",
1698
+ );
1699
+ const body = await tokenRes.json();
1700
+ expect(body.iss).toBe("http://127.0.0.1:1940/vault/default");
1701
+ });
1702
+
1703
+ test("token response services catalog reflects services.json using hub origin when issued via hub", async () => {
1704
+ process.env.PARACHUTE_HUB_ORIGIN = HUB;
1705
+ fs.writeFileSync(
1706
+ path.join(tmpHome, "services.json"),
1707
+ JSON.stringify({
1708
+ services: [
1709
+ { name: "vault", port: 1940, paths: ["/vault/default"], health: "/health", version: "0.3.0" },
1710
+ { name: "notes", port: 1941, paths: ["/notes"], health: "/health", version: "0.1.0" },
1711
+ ],
1712
+ }),
1713
+ );
1714
+
1715
+ const ownerToken = createOwnerToken();
1716
+ const clientId = await registerClient();
1717
+ const { codeVerifier, codeChallenge } = generatePkce();
1718
+ const redirectUri = "https://example.com/callback";
1719
+ const authRes = await handleAuthorizePost(
1720
+ makeRequest(`${HUB}/vault/default/oauth/authorize`, {
1721
+ method: "POST",
1722
+ body: new URLSearchParams({
1723
+ action: "authorize",
1724
+ client_id: clientId,
1725
+ redirect_uri: redirectUri,
1726
+ code_challenge: codeChallenge,
1727
+ code_challenge_method: "S256",
1728
+ scope: "full",
1729
+ owner_token: ownerToken,
1730
+ }),
1731
+ }),
1732
+ db,
1733
+ { vaultName: "default" },
1734
+ );
1735
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1736
+ const tokenRes = await handleToken(
1737
+ makeRequest(`${HUB}/vault/default/oauth/token`, {
1738
+ method: "POST",
1739
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1740
+ body: new URLSearchParams({
1741
+ grant_type: "authorization_code",
1742
+ code,
1743
+ code_verifier: codeVerifier,
1744
+ client_id: clientId,
1745
+ redirect_uri: redirectUri,
1746
+ }).toString(),
1747
+ }),
1748
+ db,
1749
+ "default",
1750
+ );
1751
+ const body = await tokenRes.json();
1752
+ expect(body.services).toEqual({
1753
+ vault: { url: `${HUB}/vault/default`, version: "0.3.0" },
1754
+ notes: { url: `${HUB}/notes`, version: "0.1.0" },
1755
+ });
1756
+ });
1757
+
1758
+ test("token response services catalog falls back to vault origin when hub env unset", async () => {
1759
+ delete process.env.PARACHUTE_HUB_ORIGIN;
1760
+ fs.writeFileSync(
1761
+ path.join(tmpHome, "services.json"),
1762
+ JSON.stringify({
1763
+ services: [
1764
+ { name: "vault", port: 1940, paths: ["/vault/default"], health: "/health", version: "0.3.0" },
1765
+ ],
1766
+ }),
1767
+ );
1768
+
1769
+ const ownerToken = createOwnerToken();
1770
+ const clientId = await registerClient();
1771
+ const { codeVerifier, codeChallenge } = generatePkce();
1772
+ const redirectUri = "https://example.com/callback";
1773
+ const authRes = await handleAuthorizePost(
1774
+ makeRequest("https://vault.test/vault/default/oauth/authorize", {
1775
+ method: "POST",
1776
+ body: new URLSearchParams({
1777
+ action: "authorize",
1778
+ client_id: clientId,
1779
+ redirect_uri: redirectUri,
1780
+ code_challenge: codeChallenge,
1781
+ code_challenge_method: "S256",
1782
+ scope: "full",
1783
+ owner_token: ownerToken,
1784
+ }),
1785
+ }),
1786
+ db,
1787
+ { vaultName: "default" },
1788
+ );
1789
+ const code = new URL(authRes.headers.get("location")!).searchParams.get("code")!;
1790
+ const tokenRes = await handleToken(
1791
+ makeRequest("https://vault.test/vault/default/oauth/token", {
1792
+ method: "POST",
1793
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1794
+ body: new URLSearchParams({
1795
+ grant_type: "authorization_code",
1796
+ code,
1797
+ code_verifier: codeVerifier,
1798
+ client_id: clientId,
1799
+ redirect_uri: redirectUri,
1800
+ }).toString(),
1801
+ }),
1802
+ db,
1803
+ "default",
1804
+ );
1805
+ const body = await tokenRes.json();
1806
+ expect(body.iss).toBe("https://vault.test/vault/default");
1807
+ expect(body.services).toEqual({
1808
+ vault: { url: "https://vault.test/vault/default", version: "0.3.0" },
1809
+ });
1810
+ });
1811
+ });