@openparachute/hub 0.7.1 → 0.7.2-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 (73) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  4. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  5. package/src/__tests__/admin-connections-credentials.test.ts +8 -4
  6. package/src/__tests__/admin-connections.test.ts +211 -57
  7. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  8. package/src/__tests__/admin-lock.test.ts +600 -0
  9. package/src/__tests__/admin-module-token.test.ts +36 -8
  10. package/src/__tests__/admin-vaults.test.ts +8 -8
  11. package/src/__tests__/api-modules-ops.test.ts +17 -16
  12. package/src/__tests__/api-modules.test.ts +35 -36
  13. package/src/__tests__/api-ready.test.ts +2 -2
  14. package/src/__tests__/clients.test.ts +91 -0
  15. package/src/__tests__/grants-store.test.ts +219 -0
  16. package/src/__tests__/hub-server.test.ts +9 -5
  17. package/src/__tests__/migrate.test.ts +1 -1
  18. package/src/__tests__/module-manifest.test.ts +11 -11
  19. package/src/__tests__/oauth-client.test.ts +446 -0
  20. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  21. package/src/__tests__/oauth-handlers.test.ts +124 -26
  22. package/src/__tests__/operator-token.test.ts +2 -2
  23. package/src/__tests__/scope-explanations.test.ts +3 -3
  24. package/src/__tests__/serve-boot.test.ts +14 -14
  25. package/src/__tests__/serve.test.ts +26 -0
  26. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  27. package/src/__tests__/services-manifest.test.ts +60 -48
  28. package/src/__tests__/setup-gate.test.ts +52 -3
  29. package/src/__tests__/setup-wizard.test.ts +86 -280
  30. package/src/__tests__/setup.test.ts +1 -1
  31. package/src/__tests__/upgrade.test.ts +276 -0
  32. package/src/__tests__/vault-remove.test.ts +393 -0
  33. package/src/admin-agent-grants.ts +1365 -0
  34. package/src/admin-agent-token.ts +147 -0
  35. package/src/admin-connections.ts +67 -50
  36. package/src/admin-host-admin-token.ts +14 -1
  37. package/src/admin-lock.ts +281 -0
  38. package/src/admin-module-token.ts +15 -7
  39. package/src/admin-vault-admin-token.ts +8 -1
  40. package/src/admin-vaults.ts +12 -12
  41. package/src/api-admin-lock.ts +335 -0
  42. package/src/api-modules-ops.ts +3 -2
  43. package/src/api-modules.ts +9 -9
  44. package/src/cli.ts +13 -1
  45. package/src/clients.ts +88 -0
  46. package/src/commands/install.ts +7 -0
  47. package/src/commands/serve-boot.ts +5 -4
  48. package/src/commands/serve.ts +45 -19
  49. package/src/commands/setup.ts +4 -3
  50. package/src/commands/upgrade.ts +118 -2
  51. package/src/commands/vault-remove.ts +361 -0
  52. package/src/commands/wizard.ts +4 -4
  53. package/src/connections-store.ts +3 -3
  54. package/src/grants-store.ts +272 -0
  55. package/src/help.ts +4 -1
  56. package/src/hub-server.ts +209 -27
  57. package/src/hub-settings.ts +23 -8
  58. package/src/jwt-sign.ts +5 -1
  59. package/src/module-manifest.ts +2 -2
  60. package/src/oauth-client.ts +497 -0
  61. package/src/oauth-flows-store.ts +163 -0
  62. package/src/oauth-handlers.ts +40 -13
  63. package/src/operator-token.ts +1 -1
  64. package/src/origin-check.ts +7 -2
  65. package/src/resource-binding.ts +4 -4
  66. package/src/scope-explanations.ts +3 -3
  67. package/src/service-spec.ts +56 -43
  68. package/src/setup-wizard.ts +56 -240
  69. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  70. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/admin-channel-token.ts +0 -135
  73. package/web/ui/dist/assets/index-Cxtod68O.js +0 -61
@@ -1576,8 +1576,10 @@ describe("hubFetch routing", () => {
1576
1576
  });
1577
1577
 
1578
1578
  // Pre-admin lockout (hub#258). When no admin row exists, operator-
1579
- // facing surfaces (admin/api/login) 503 with a JSON body pointing at
1580
- // /admin/setup. Public surfaces (health, well-known, /, oauth, vault,
1579
+ // facing API surfaces (admin/api) 503 with a JSON body pointing at
1580
+ // /admin/setup. The browser-facing `/login` GET 302s to the wizard
1581
+ // instead (hub#644) — a JSON 503 there would render as raw text in the
1582
+ // visitor's tab. Public surfaces (health, well-known, /, oauth, vault,
1581
1583
  // /admin/setup itself) stay open so the container is reachable and
1582
1584
  // OAuth third parties aren't held hostage by admin onboarding.
1583
1585
  test("pre-admin lockout: /admin/vaults returns 503 setup_required", async () => {
@@ -1615,7 +1617,7 @@ describe("hubFetch routing", () => {
1615
1617
  }
1616
1618
  });
1617
1619
 
1618
- test("pre-admin lockout: /login is gated, /admin/setup + /health + well-known stay open, / funnels to /admin/setup", async () => {
1620
+ test("pre-admin lockout: /login GET funnels to /admin/setup, /admin/setup + /health + well-known stay open, / funnels to /admin/setup", async () => {
1619
1621
  const h = makeHarness();
1620
1622
  try {
1621
1623
  writeFileSync(join(h.dir, "hub.html"), "<html>discovery</html>");
@@ -1623,9 +1625,11 @@ describe("hubFetch routing", () => {
1623
1625
  const db = openHubDb(hubDbPath(h.dir));
1624
1626
  try {
1625
1627
  const handler = hubFetch(h.dir, { getDb: () => db, manifestPath: h.manifestPath });
1626
- // /login gated
1628
+ // /login GET funnels to the wizard (hub#644) — the browser-facing
1629
+ // sign-in surface 302s to /admin/setup instead of a raw JSON 503.
1627
1630
  const loginRes = await handler(req("/login"));
1628
- expect(loginRes.status).toBe(503);
1631
+ expect(loginRes.status).toBe(302);
1632
+ expect(loginRes.headers.get("location")).toBe("/admin/setup");
1629
1633
  // /admin/setup open
1630
1634
  const setupRes = await handler(req("/admin/setup"));
1631
1635
  expect(setupRes.status).toBe(200);
@@ -56,7 +56,7 @@ describe("safelistEntries", () => {
56
56
  expect(s.has("vault")).toBe(true);
57
57
  expect(s.has("notes")).toBe(true);
58
58
  expect(s.has("scribe")).toBe(true);
59
- expect(s.has("channel")).toBe(true);
59
+ expect(s.has("agent")).toBe(true);
60
60
  // Internal
61
61
  expect(s.has("hub")).toBe(true);
62
62
  // CLI state
@@ -40,13 +40,13 @@ describe("validateModuleManifest", () => {
40
40
  key: "link-to-vault",
41
41
  title: "Link a channel to a vault",
42
42
  description: "Back a channel with a vault.",
43
- requestedBy: "channel",
43
+ requestedBy: "agent",
44
44
  source: {
45
45
  module: "vault",
46
46
  event: "note.created",
47
- filter: { tags: ["#channel-message/inbound"] },
47
+ filter: { tags: ["#agent-message/inbound"] },
48
48
  },
49
- sink: { module: "channel", action: "message.deliver" },
49
+ sink: { module: "agent", action: "message.deliver" },
50
50
  parameters: [
51
51
  { key: "vault", target: "source.vault", title: "Vault" },
52
52
  { key: "channel", target: "sink.params.channel", example: "eng" },
@@ -61,9 +61,9 @@ describe("validateModuleManifest", () => {
61
61
  expect(t?.source).toEqual({
62
62
  module: "vault",
63
63
  event: "note.created",
64
- filter: { tags: ["#channel-message/inbound"] },
64
+ filter: { tags: ["#agent-message/inbound"] },
65
65
  });
66
- expect(t?.sink).toEqual({ module: "channel", action: "message.deliver" });
66
+ expect(t?.sink).toEqual({ module: "agent", action: "message.deliver" });
67
67
  expect(t?.parameters?.[1]).toEqual({
68
68
  key: "channel",
69
69
  target: "sink.params.channel",
@@ -119,7 +119,7 @@ describe("validateModuleManifest", () => {
119
119
  key: "k",
120
120
  title: "T",
121
121
  source: { module: "vault", event: "note.created" },
122
- sink: { module: "channel", action: "message.deliver" },
122
+ sink: { module: "agent", action: "message.deliver" },
123
123
  parameters: [{ key: "p" }],
124
124
  },
125
125
  ],
@@ -417,24 +417,24 @@ describe("validateModuleManifest", () => {
417
417
  "x",
418
418
  ),
419
419
  ).toThrow(/must be namespaced as "<name>:<verb>"/);
420
- // A matching-namespace scope still validates — the real channel case
421
- // (channel.message.deliver → channel:send).
420
+ // A matching-namespace scope still validates — the real agent case
421
+ // (message.deliver → agent:send).
422
422
  const okm = validateModuleManifest(
423
423
  {
424
424
  ...VALID,
425
- name: "channel",
425
+ name: "agent",
426
426
  actions: [
427
427
  {
428
428
  key: "message.deliver",
429
429
  title: "Deliver",
430
430
  endpoint: "/api/vault/inbound",
431
- scope: "channel:send",
431
+ scope: "agent:send",
432
432
  },
433
433
  ],
434
434
  },
435
435
  "x",
436
436
  );
437
- expect(okm.actions?.[0]?.scope).toBe("channel:send");
437
+ expect(okm.actions?.[0]?.scope).toBe("agent:send");
438
438
  });
439
439
 
440
440
  test("uiUrl accepts a leading-slash path (Phase D)", () => {
@@ -0,0 +1,446 @@
1
+ /**
2
+ * Tests for the hub-as-OAuth-CLIENT engine (`oauth-client.ts`, 4b-2).
3
+ *
4
+ * Fully offline — every network call goes through an injected `fetchFn`. No real
5
+ * fetch is ever made. Covers: discovery (9728→8414 + 8414-only fallback), DCR,
6
+ * authorize-URL building (S256 + state + challenge), code exchange + refresh
7
+ * (form body + expiresAt computation), best-effort revoke, and the
8
+ * fetchWithTimeout abort.
9
+ */
10
+ import { describe, expect, test } from "bun:test";
11
+ import { createHash } from "node:crypto";
12
+ import {
13
+ OAuthClientError,
14
+ buildAuthorizeUrl,
15
+ deriveVaultScopeFromMcpUrl,
16
+ discover,
17
+ exchangeCode,
18
+ fetchWithTimeout,
19
+ generateCodeChallenge,
20
+ generateCodeVerifier,
21
+ refreshToken,
22
+ registerClient,
23
+ revokeRemote,
24
+ } from "../oauth-client.ts";
25
+
26
+ type FetchFn = typeof fetch;
27
+
28
+ /** A tiny router-style fake fetch. Maps URL → a handler returning a Response. */
29
+ function fakeFetch(routes: Record<string, (req: { url: string; init?: RequestInit }) => Response>) {
30
+ const calls: { url: string; init?: RequestInit }[] = [];
31
+ const fn = (async (input: string | URL | Request, init?: RequestInit) => {
32
+ const url = typeof input === "string" ? input : input.toString();
33
+ calls.push({ url, init });
34
+ // Match by pathname (ignore query) OR full url.
35
+ const path = new URL(url).pathname;
36
+ const handler = routes[url] ?? routes[path];
37
+ if (!handler) return new Response("not found", { status: 404 });
38
+ return handler({ url, init });
39
+ }) as unknown as FetchFn;
40
+ return { fn, calls };
41
+ }
42
+
43
+ function json(body: unknown, status = 200): Response {
44
+ return new Response(JSON.stringify(body), {
45
+ status,
46
+ headers: { "content-type": "application/json" },
47
+ });
48
+ }
49
+
50
+ // === PKCE ===================================================================
51
+
52
+ describe("PKCE", () => {
53
+ test("verifier is base64url, challenge is its S256", () => {
54
+ const v = generateCodeVerifier();
55
+ expect(v).toMatch(/^[A-Za-z0-9_-]+$/);
56
+ const c = generateCodeChallenge(v);
57
+ expect(c).toBe(createHash("sha256").update(v).digest("base64url"));
58
+ });
59
+
60
+ test("verifiers are distinct (random)", () => {
61
+ expect(generateCodeVerifier()).not.toBe(generateCodeVerifier());
62
+ });
63
+ });
64
+
65
+ // === Discovery ==============================================================
66
+
67
+ describe("discover", () => {
68
+ test("RFC 9728 → 8414 (resource advertises authorization_servers)", async () => {
69
+ const { fn } = fakeFetch({
70
+ "https://remote.test/.well-known/oauth-protected-resource": () =>
71
+ json({
72
+ resource: "https://remote.test",
73
+ authorization_servers: ["https://issuer.test"],
74
+ scopes_supported: ["vault:eng:read", "vault:eng:write"],
75
+ }),
76
+ "https://issuer.test/.well-known/oauth-authorization-server": () =>
77
+ json({
78
+ issuer: "https://issuer.test",
79
+ authorization_endpoint: "https://issuer.test/oauth/authorize",
80
+ token_endpoint: "https://issuer.test/oauth/token",
81
+ registration_endpoint: "https://issuer.test/oauth/register",
82
+ revocation_endpoint: "https://issuer.test/oauth/revoke",
83
+ }),
84
+ });
85
+ const d = await discover("https://remote.test/vault/eng/mcp", fn);
86
+ expect(d.issuer).toBe("https://issuer.test");
87
+ expect(d.authorizationEndpoint).toBe("https://issuer.test/oauth/authorize");
88
+ expect(d.tokenEndpoint).toBe("https://issuer.test/oauth/token");
89
+ expect(d.registrationEndpoint).toBe("https://issuer.test/oauth/register");
90
+ expect(d.revocationEndpoint).toBe("https://issuer.test/oauth/revoke");
91
+ // 9728 scopes win
92
+ expect(d.scopesSupported).toEqual(["vault:eng:read", "vault:eng:write"]);
93
+ });
94
+
95
+ test("8414-only fallback (no 9728 doc → MCP origin is the issuer)", async () => {
96
+ const { fn, calls } = fakeFetch({
97
+ "https://solo.test/.well-known/oauth-protected-resource": () =>
98
+ new Response("nope", { status: 404 }),
99
+ "https://solo.test/.well-known/oauth-authorization-server": () =>
100
+ json({
101
+ issuer: "https://solo.test",
102
+ authorization_endpoint: "https://solo.test/authorize",
103
+ token_endpoint: "https://solo.test/token",
104
+ scopes_supported: ["mcp:read"],
105
+ }),
106
+ });
107
+ const d = await discover("https://solo.test/mcp", fn);
108
+ expect(d.issuer).toBe("https://solo.test");
109
+ expect(d.authorizationEndpoint).toBe("https://solo.test/authorize");
110
+ // scopes from 8414 (no 9728)
111
+ expect(d.scopesSupported).toEqual(["mcp:read"]);
112
+ // it DID try the 9728 doc first
113
+ expect(calls.some((c) => c.url.includes("oauth-protected-resource"))).toBe(true);
114
+ });
115
+
116
+ test("throws OAuthClientError when the 8414 doc lacks endpoints", async () => {
117
+ const { fn } = fakeFetch({
118
+ "https://bad.test/.well-known/oauth-protected-resource": () =>
119
+ json({ authorization_servers: ["https://bad.test"] }),
120
+ "https://bad.test/.well-known/oauth-authorization-server": () => json({ issuer: "x" }),
121
+ });
122
+ await expect(discover("https://bad.test/mcp", fn)).rejects.toBeInstanceOf(OAuthClientError);
123
+ });
124
+
125
+ test("throws on an invalid mcp url", async () => {
126
+ const { fn } = fakeFetch({});
127
+ await expect(discover("not-a-url", fn)).rejects.toBeInstanceOf(OAuthClientError);
128
+ });
129
+ });
130
+
131
+ // === DCR ====================================================================
132
+
133
+ describe("registerClient", () => {
134
+ test("posts RFC 7591 body, returns client_id", async () => {
135
+ const { fn, calls } = fakeFetch({
136
+ "https://issuer.test/oauth/register": () => json({ client_id: "dcr-abc123" }, 201),
137
+ });
138
+ const r = await registerClient(
139
+ "https://issuer.test/oauth/register",
140
+ "https://hub.test/oauth/agent-grant/callback",
141
+ fn,
142
+ );
143
+ expect(r.clientId).toBe("dcr-abc123");
144
+ const body = JSON.parse((calls[0]?.init?.body as string) ?? "{}");
145
+ expect(body.redirect_uris).toEqual(["https://hub.test/oauth/agent-grant/callback"]);
146
+ expect(body.token_endpoint_auth_method).toBe("none");
147
+ expect(body.grant_types).toEqual(["authorization_code", "refresh_token"]);
148
+ expect(body.response_types).toEqual(["code"]);
149
+ });
150
+
151
+ test("throws when the response lacks client_id", async () => {
152
+ const { fn } = fakeFetch({
153
+ "https://issuer.test/oauth/register": () => json({ nope: true }),
154
+ });
155
+ await expect(
156
+ registerClient("https://issuer.test/oauth/register", "https://hub.test/cb", fn),
157
+ ).rejects.toBeInstanceOf(OAuthClientError);
158
+ });
159
+
160
+ test("throws on a non-2xx", async () => {
161
+ const { fn } = fakeFetch({
162
+ "https://issuer.test/oauth/register": () => new Response("forbidden", { status: 403 }),
163
+ });
164
+ await expect(
165
+ registerClient("https://issuer.test/oauth/register", "https://hub.test/cb", fn),
166
+ ).rejects.toBeInstanceOf(OAuthClientError);
167
+ });
168
+ });
169
+
170
+ // === buildAuthorizeUrl ======================================================
171
+
172
+ describe("buildAuthorizeUrl", () => {
173
+ test("includes S256, state, challenge, and scope", () => {
174
+ const url = new URL(
175
+ buildAuthorizeUrl({
176
+ authorizationEndpoint: "https://issuer.test/oauth/authorize",
177
+ clientId: "cid",
178
+ redirectUri: "https://hub.test/oauth/agent-grant/callback",
179
+ scope: "vault:eng:read vault:eng:write",
180
+ state: "st-123",
181
+ codeChallenge: "chal-xyz",
182
+ }),
183
+ );
184
+ expect(url.origin + url.pathname).toBe("https://issuer.test/oauth/authorize");
185
+ expect(url.searchParams.get("response_type")).toBe("code");
186
+ expect(url.searchParams.get("client_id")).toBe("cid");
187
+ expect(url.searchParams.get("redirect_uri")).toBe(
188
+ "https://hub.test/oauth/agent-grant/callback",
189
+ );
190
+ expect(url.searchParams.get("scope")).toBe("vault:eng:read vault:eng:write");
191
+ expect(url.searchParams.get("state")).toBe("st-123");
192
+ expect(url.searchParams.get("code_challenge")).toBe("chal-xyz");
193
+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
194
+ });
195
+
196
+ test("omits scope when not provided", () => {
197
+ const url = new URL(
198
+ buildAuthorizeUrl({
199
+ authorizationEndpoint: "https://issuer.test/oauth/authorize",
200
+ clientId: "cid",
201
+ redirectUri: "https://hub.test/cb",
202
+ state: "st",
203
+ codeChallenge: "ch",
204
+ }),
205
+ );
206
+ expect(url.searchParams.has("scope")).toBe(false);
207
+ });
208
+ });
209
+
210
+ // === exchangeCode + refreshToken ============================================
211
+
212
+ describe("exchangeCode", () => {
213
+ test("posts the form, computes expiresAt from expires_in", async () => {
214
+ const fixedNow = new Date("2026-06-18T12:00:00.000Z");
215
+ const { fn, calls } = fakeFetch({
216
+ "https://issuer.test/oauth/token": () =>
217
+ json({
218
+ access_token: "at-1",
219
+ refresh_token: "rt-1",
220
+ expires_in: 3600,
221
+ token_type: "Bearer",
222
+ scope: "vault:eng:read",
223
+ }),
224
+ });
225
+ const res = await exchangeCode(
226
+ {
227
+ tokenEndpoint: "https://issuer.test/oauth/token",
228
+ code: "code-1",
229
+ redirectUri: "https://hub.test/cb",
230
+ codeVerifier: "verifier-1",
231
+ clientId: "cid",
232
+ now: () => fixedNow,
233
+ },
234
+ fn,
235
+ );
236
+ expect(res.access_token).toBe("at-1");
237
+ expect(res.refresh_token).toBe("rt-1");
238
+ expect(res.expiresAt).toBe(new Date(fixedNow.getTime() + 3600 * 1000).toISOString());
239
+ expect(res.scope).toBe("vault:eng:read");
240
+
241
+ // form body assertions
242
+ const body = new URLSearchParams((calls[0]?.init?.body as string) ?? "");
243
+ expect(body.get("grant_type")).toBe("authorization_code");
244
+ expect(body.get("code")).toBe("code-1");
245
+ expect(body.get("code_verifier")).toBe("verifier-1");
246
+ expect(body.get("redirect_uri")).toBe("https://hub.test/cb");
247
+ expect(body.get("client_id")).toBe("cid");
248
+ expect((calls[0]?.init?.headers as Record<string, string>)["content-type"]).toBe(
249
+ "application/x-www-form-urlencoded",
250
+ );
251
+ });
252
+
253
+ test("throws OAuthClientError on an error response", async () => {
254
+ const { fn } = fakeFetch({
255
+ "https://issuer.test/oauth/token": () =>
256
+ json({ error: "invalid_grant", error_description: "code expired" }, 400),
257
+ });
258
+ await expect(
259
+ exchangeCode(
260
+ {
261
+ tokenEndpoint: "https://issuer.test/oauth/token",
262
+ code: "x",
263
+ redirectUri: "https://hub.test/cb",
264
+ codeVerifier: "v",
265
+ clientId: "cid",
266
+ },
267
+ fn,
268
+ ),
269
+ ).rejects.toBeInstanceOf(OAuthClientError);
270
+ });
271
+
272
+ test("throws when access_token is missing", async () => {
273
+ const { fn } = fakeFetch({
274
+ "https://issuer.test/oauth/token": () => json({ token_type: "Bearer" }),
275
+ });
276
+ await expect(
277
+ exchangeCode(
278
+ {
279
+ tokenEndpoint: "https://issuer.test/oauth/token",
280
+ code: "x",
281
+ redirectUri: "https://hub.test/cb",
282
+ codeVerifier: "v",
283
+ clientId: "cid",
284
+ },
285
+ fn,
286
+ ),
287
+ ).rejects.toBeInstanceOf(OAuthClientError);
288
+ });
289
+ });
290
+
291
+ describe("refreshToken", () => {
292
+ test("posts grant_type=refresh_token, returns new tokens", async () => {
293
+ const fixedNow = new Date("2026-06-18T12:00:00.000Z");
294
+ const { fn, calls } = fakeFetch({
295
+ "https://issuer.test/oauth/token": () =>
296
+ json({ access_token: "at-2", refresh_token: "rt-2", expires_in: 1800 }),
297
+ });
298
+ const res = await refreshToken(
299
+ {
300
+ tokenEndpoint: "https://issuer.test/oauth/token",
301
+ refreshToken: "rt-1",
302
+ clientId: "cid",
303
+ now: () => fixedNow,
304
+ },
305
+ fn,
306
+ );
307
+ expect(res.access_token).toBe("at-2");
308
+ expect(res.refresh_token).toBe("rt-2");
309
+ expect(res.expiresAt).toBe(new Date(fixedNow.getTime() + 1800 * 1000).toISOString());
310
+ const body = new URLSearchParams((calls[0]?.init?.body as string) ?? "");
311
+ expect(body.get("grant_type")).toBe("refresh_token");
312
+ expect(body.get("refresh_token")).toBe("rt-1");
313
+ expect(body.get("client_id")).toBe("cid");
314
+ });
315
+
316
+ test("rejects on a revoked/expired refresh token", async () => {
317
+ const { fn } = fakeFetch({
318
+ "https://issuer.test/oauth/token": () => json({ error: "invalid_grant" }, 400),
319
+ });
320
+ await expect(
321
+ refreshToken(
322
+ { tokenEndpoint: "https://issuer.test/oauth/token", refreshToken: "dead", clientId: "c" },
323
+ fn,
324
+ ),
325
+ ).rejects.toBeInstanceOf(OAuthClientError);
326
+ });
327
+ });
328
+
329
+ // === revokeRemote (best-effort) =============================================
330
+
331
+ describe("revokeRemote", () => {
332
+ test("posts the revocation form", async () => {
333
+ const { fn, calls } = fakeFetch({
334
+ "https://issuer.test/oauth/revoke": () => new Response(null, { status: 200 }),
335
+ });
336
+ await revokeRemote(
337
+ {
338
+ revocationEndpoint: "https://issuer.test/oauth/revoke",
339
+ refreshToken: "rt-1",
340
+ clientId: "cid",
341
+ },
342
+ fn,
343
+ );
344
+ const body = new URLSearchParams((calls[0]?.init?.body as string) ?? "");
345
+ expect(body.get("token")).toBe("rt-1");
346
+ expect(body.get("token_type_hint")).toBe("refresh_token");
347
+ });
348
+
349
+ test("swallows errors (network failure must not throw)", async () => {
350
+ const fn = (async () => {
351
+ throw new Error("network down");
352
+ }) as unknown as FetchFn;
353
+ // Must resolve, not reject.
354
+ await expect(
355
+ revokeRemote(
356
+ {
357
+ revocationEndpoint: "https://issuer.test/oauth/revoke",
358
+ refreshToken: "x",
359
+ clientId: "c",
360
+ },
361
+ fn,
362
+ ),
363
+ ).resolves.toBeUndefined();
364
+ });
365
+
366
+ test("swallows a non-2xx response", async () => {
367
+ const { fn } = fakeFetch({
368
+ "https://issuer.test/oauth/revoke": () => new Response("nope", { status: 500 }),
369
+ });
370
+ await expect(
371
+ revokeRemote(
372
+ {
373
+ revocationEndpoint: "https://issuer.test/oauth/revoke",
374
+ refreshToken: "x",
375
+ clientId: "c",
376
+ },
377
+ fn,
378
+ ),
379
+ ).resolves.toBeUndefined();
380
+ });
381
+ });
382
+
383
+ // === fetchWithTimeout =======================================================
384
+
385
+ describe("fetchWithTimeout", () => {
386
+ test("aborts a hung request after the timeout", async () => {
387
+ // A fetch that never resolves until aborted.
388
+ const fn = ((_url: string, init?: RequestInit) =>
389
+ new Promise((_resolve, reject) => {
390
+ const signal = init?.signal;
391
+ signal?.addEventListener("abort", () => reject(new Error("aborted")));
392
+ })) as unknown as FetchFn;
393
+ await expect(fetchWithTimeout("https://slow.test", { timeout: 10 }, fn)).rejects.toBeTruthy();
394
+ });
395
+
396
+ test("passes through a fast response", async () => {
397
+ const fn = (async () => new Response("ok", { status: 200 })) as unknown as FetchFn;
398
+ const res = await fetchWithTimeout("https://fast.test", { timeout: 1000 }, fn);
399
+ expect(res.status).toBe(200);
400
+ });
401
+ });
402
+
403
+ // === deriveVaultScopeFromMcpUrl (#671) ======================================
404
+
405
+ describe("deriveVaultScopeFromMcpUrl", () => {
406
+ test("a Parachute vault MCP URL → least-privilege vault:<name>:read", () => {
407
+ expect(deriveVaultScopeFromMcpUrl("https://hub.test/vault/research/mcp")).toBe(
408
+ "vault:research:read",
409
+ );
410
+ });
411
+
412
+ test("a trailing slash after /mcp still matches", () => {
413
+ expect(deriveVaultScopeFromMcpUrl("https://hub.test/vault/research/mcp/")).toBe(
414
+ "vault:research:read",
415
+ );
416
+ });
417
+
418
+ test("a vault name with dot/dash/underscore is captured", () => {
419
+ expect(deriveVaultScopeFromMcpUrl("https://hub.test/vault/my-team_v2.0/mcp")).toBe(
420
+ "vault:my-team_v2.0:read",
421
+ );
422
+ });
423
+
424
+ test("a query string / fragment does not break the match", () => {
425
+ expect(deriveVaultScopeFromMcpUrl("https://hub.test/vault/research/mcp?x=1#frag")).toBe(
426
+ "vault:research:read",
427
+ );
428
+ });
429
+
430
+ test("a non-vault MCP URL → null (caller falls back to scopes_supported)", () => {
431
+ expect(deriveVaultScopeFromMcpUrl("https://remote.test/mcp")).toBeNull();
432
+ expect(deriveVaultScopeFromMcpUrl("https://remote.test/some/other/mcp")).toBeNull();
433
+ });
434
+
435
+ test("a deeper path after /mcp is NOT a vault MCP (anchored) → null", () => {
436
+ expect(deriveVaultScopeFromMcpUrl("https://hub.test/vault/research/mcp/extra")).toBeNull();
437
+ });
438
+
439
+ test("a /vault/ path with no name segment → null", () => {
440
+ expect(deriveVaultScopeFromMcpUrl("https://hub.test/vault//mcp")).toBeNull();
441
+ });
442
+
443
+ test("an unparseable URL → null", () => {
444
+ expect(deriveVaultScopeFromMcpUrl("not a url")).toBeNull();
445
+ });
446
+ });