@openparachute/hub 0.7.0 → 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 (93) hide show
  1. package/README.md +13 -14
  2. package/package.json +1 -1
  3. package/src/__tests__/account-setup.test.ts +276 -6
  4. package/src/__tests__/admin-agent-grants.test.ts +1547 -0
  5. package/src/__tests__/{admin-channel-token.test.ts → admin-agent-token.test.ts} +32 -32
  6. package/src/__tests__/admin-connections-credentials.test.ts +1324 -0
  7. package/src/__tests__/admin-connections.test.ts +211 -57
  8. package/src/__tests__/admin-csrf-belt.test.ts +7 -7
  9. package/src/__tests__/admin-lock.test.ts +600 -0
  10. package/src/__tests__/admin-module-token.test.ts +36 -8
  11. package/src/__tests__/admin-vaults.test.ts +8 -8
  12. package/src/__tests__/api-invites.test.ts +166 -6
  13. package/src/__tests__/api-modules-ops.test.ts +17 -16
  14. package/src/__tests__/api-modules.test.ts +35 -36
  15. package/src/__tests__/api-ready.test.ts +2 -2
  16. package/src/__tests__/audience-gate.test.ts +752 -0
  17. package/src/__tests__/clients.test.ts +91 -0
  18. package/src/__tests__/grants-store.test.ts +219 -0
  19. package/src/__tests__/hub-db.test.ts +36 -0
  20. package/src/__tests__/hub-server.test.ts +275 -5
  21. package/src/__tests__/invites.test.ts +64 -1
  22. package/src/__tests__/lifecycle.test.ts +238 -3
  23. package/src/__tests__/migrate.test.ts +1 -1
  24. package/src/__tests__/module-manifest.test.ts +11 -11
  25. package/src/__tests__/oauth-client.test.ts +446 -0
  26. package/src/__tests__/oauth-flows-store.test.ts +141 -0
  27. package/src/__tests__/oauth-handlers.test.ts +124 -26
  28. package/src/__tests__/operator-token.test.ts +2 -2
  29. package/src/__tests__/scope-explanations.test.ts +3 -3
  30. package/src/__tests__/serve-boot.test.ts +14 -14
  31. package/src/__tests__/serve.test.ts +26 -0
  32. package/src/__tests__/service-spec-discovery.test.ts +26 -18
  33. package/src/__tests__/services-manifest.test.ts +60 -48
  34. package/src/__tests__/setup-gate.test.ts +52 -3
  35. package/src/__tests__/setup-wizard.test.ts +86 -280
  36. package/src/__tests__/setup.test.ts +1 -1
  37. package/src/__tests__/upgrade.test.ts +276 -0
  38. package/src/__tests__/vault-remove.test.ts +393 -0
  39. package/src/__tests__/ws-bridge.test.ts +573 -0
  40. package/src/__tests__/ws-connection-caps.test.ts +456 -0
  41. package/src/account-setup.ts +94 -23
  42. package/src/admin-agent-grants.ts +1365 -0
  43. package/src/admin-agent-token.ts +147 -0
  44. package/src/admin-connections.ts +980 -61
  45. package/src/admin-host-admin-token.ts +14 -1
  46. package/src/admin-lock.ts +281 -0
  47. package/src/admin-login-ui.ts +64 -15
  48. package/src/admin-module-token.ts +15 -7
  49. package/src/admin-vault-admin-token.ts +8 -1
  50. package/src/admin-vaults.ts +21 -12
  51. package/src/api-admin-lock.ts +335 -0
  52. package/src/api-invites.ts +92 -12
  53. package/src/api-modules-ops.ts +3 -2
  54. package/src/api-modules.ts +9 -9
  55. package/src/audience-gate.ts +268 -0
  56. package/src/chrome-strip.ts +8 -1
  57. package/src/cli.ts +13 -1
  58. package/src/clients.ts +88 -0
  59. package/src/commands/install.ts +7 -0
  60. package/src/commands/lifecycle.ts +187 -47
  61. package/src/commands/serve-boot.ts +5 -4
  62. package/src/commands/serve.ts +45 -19
  63. package/src/commands/setup.ts +4 -3
  64. package/src/commands/upgrade.ts +118 -2
  65. package/src/commands/vault-remove.ts +361 -0
  66. package/src/commands/wizard.ts +4 -4
  67. package/src/connections-store.ts +35 -5
  68. package/src/grants-store.ts +272 -0
  69. package/src/help.ts +17 -7
  70. package/src/host-admin-token-validation.ts +6 -2
  71. package/src/hub-db.ts +26 -1
  72. package/src/hub-server.ts +710 -39
  73. package/src/hub-settings.ts +23 -8
  74. package/src/invites.ts +69 -2
  75. package/src/jwt-sign.ts +11 -1
  76. package/src/module-manifest.ts +109 -2
  77. package/src/oauth-client.ts +497 -0
  78. package/src/oauth-flows-store.ts +163 -0
  79. package/src/oauth-handlers.ts +40 -13
  80. package/src/operator-token.ts +1 -1
  81. package/src/origin-check.ts +13 -5
  82. package/src/resource-binding.ts +4 -4
  83. package/src/scope-explanations.ts +3 -3
  84. package/src/service-spec.ts +56 -43
  85. package/src/services-manifest.ts +97 -0
  86. package/src/setup-wizard.ts +56 -240
  87. package/src/ws-bridge.ts +256 -0
  88. package/src/ws-connection-caps.ts +170 -0
  89. package/web/ui/dist/assets/index-B5AUE359.js +61 -0
  90. package/web/ui/dist/assets/{index-E_9wqjEm.css → index-DR6R8EFf.css} +1 -1
  91. package/web/ui/dist/index.html +2 -2
  92. package/src/admin-channel-token.ts +0 -135
  93. package/web/ui/dist/assets/index-C-XzMVqN.js +0 -61
@@ -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
+ });
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Tests for the in-flight OAuth consent-flow store (`oauth-flows-store.ts`, 4b-2).
3
+ *
4
+ * put / get-by-state / delete-on-use / round-trip / TTL prune / 0600 perms / the
5
+ * lenient-read posture. The file holds PKCE verifiers (secrets) → 0600.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
8
+ import { mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import {
12
+ FLOW_TTL_MS,
13
+ type PendingFlow,
14
+ deleteFlow,
15
+ getFlowByState,
16
+ pruneExpiredFlows,
17
+ putFlow,
18
+ } from "../oauth-flows-store.ts";
19
+
20
+ let dir: string;
21
+ let storePath: string;
22
+ beforeEach(() => {
23
+ dir = mkdtempSync(join(tmpdir(), "phub-oauth-flows-"));
24
+ storePath = join(dir, "agent-oauth-flows.json");
25
+ });
26
+ afterEach(() => {
27
+ rmSync(dir, { recursive: true, force: true });
28
+ });
29
+
30
+ function flow(over: Partial<PendingFlow> = {}): PendingFlow {
31
+ return {
32
+ state: over.state ?? "state-1",
33
+ grantId: over.grantId ?? "grant-1",
34
+ issuer: over.issuer ?? "https://issuer.test",
35
+ clientId: over.clientId ?? "cid-1",
36
+ tokenEndpoint: over.tokenEndpoint ?? "https://issuer.test/oauth/token",
37
+ verifier: over.verifier ?? "pkce-verifier-secret",
38
+ mcpUrl: over.mcpUrl ?? "https://remote.test/mcp",
39
+ redirectUri: over.redirectUri ?? "https://hub.test/oauth/agent-grant/callback",
40
+ createdAt: over.createdAt ?? new Date().toISOString(),
41
+ ...(over.revocationEndpoint ? { revocationEndpoint: over.revocationEndpoint } : {}),
42
+ ...(over.scope ? { scope: over.scope } : {}),
43
+ };
44
+ }
45
+
46
+ describe("round-trip", () => {
47
+ test("a missing file returns null for any state", () => {
48
+ expect(getFlowByState(storePath, "anything")).toBeNull();
49
+ });
50
+
51
+ test("put → get by state", () => {
52
+ const f = flow();
53
+ putFlow(storePath, f);
54
+ const back = getFlowByState(storePath, "state-1");
55
+ expect(back).toEqual(f);
56
+ });
57
+
58
+ test("put upserts by state (no duplicate)", () => {
59
+ putFlow(storePath, flow({ clientId: "old" }));
60
+ putFlow(storePath, flow({ clientId: "new" }));
61
+ expect(getFlowByState(storePath, "state-1")?.clientId).toBe("new");
62
+ });
63
+
64
+ test("delete-on-use removes the flow (single-use)", () => {
65
+ putFlow(storePath, flow());
66
+ const removed = deleteFlow(storePath, "state-1");
67
+ expect(removed?.state).toBe("state-1");
68
+ expect(getFlowByState(storePath, "state-1")).toBeNull();
69
+ // second delete is a no-op
70
+ expect(deleteFlow(storePath, "state-1")).toBeNull();
71
+ });
72
+
73
+ test("distinct states coexist", () => {
74
+ putFlow(storePath, flow({ state: "a" }));
75
+ putFlow(storePath, flow({ state: "b", grantId: "grant-2" }));
76
+ expect(getFlowByState(storePath, "a")?.grantId).toBe("grant-1");
77
+ expect(getFlowByState(storePath, "b")?.grantId).toBe("grant-2");
78
+ });
79
+ });
80
+
81
+ describe("TTL prune", () => {
82
+ test("an expired flow is pruned on read (returns null)", () => {
83
+ const old = new Date(Date.now() - (FLOW_TTL_MS + 60_000)).toISOString();
84
+ putFlow(storePath, flow({ createdAt: old }));
85
+ // The default-now read prunes it.
86
+ expect(getFlowByState(storePath, "state-1")).toBeNull();
87
+ });
88
+
89
+ test("pruneExpiredFlows drops the expired and keeps the live", () => {
90
+ const now = Date.now();
91
+ putFlow(storePath, flow({ state: "fresh", createdAt: new Date(now).toISOString() }), now);
92
+ putFlow(
93
+ storePath,
94
+ flow({ state: "stale", createdAt: new Date(now - FLOW_TTL_MS - 1).toISOString() }),
95
+ now,
96
+ );
97
+ const live = pruneExpiredFlows(storePath, now);
98
+ expect(live.map((f) => f.state).sort()).toEqual(["fresh"]);
99
+ });
100
+
101
+ test("put prunes expired flows before persisting", () => {
102
+ const now = Date.now();
103
+ // Seed an expired flow directly.
104
+ writeFileSync(
105
+ storePath,
106
+ JSON.stringify({
107
+ flows: [flow({ state: "stale", createdAt: new Date(now - FLOW_TTL_MS - 1).toISOString() })],
108
+ }),
109
+ );
110
+ putFlow(storePath, flow({ state: "fresh", createdAt: new Date(now).toISOString() }), now);
111
+ expect(getFlowByState(storePath, "stale", now)).toBeNull();
112
+ expect(getFlowByState(storePath, "fresh", now)).not.toBeNull();
113
+ });
114
+ });
115
+
116
+ describe("0600 perms (holds PKCE verifiers)", () => {
117
+ test("the store file is created mode 0600", () => {
118
+ putFlow(storePath, flow());
119
+ const mode = statSync(storePath).mode & 0o777;
120
+ expect(mode).toBe(0o600);
121
+ });
122
+ });
123
+
124
+ describe("lenient read", () => {
125
+ test("garbage JSON reads as empty", () => {
126
+ writeFileSync(storePath, "not json {{{");
127
+ expect(getFlowByState(storePath, "x")).toBeNull();
128
+ });
129
+
130
+ test("a malformed row is dropped, valid rows survive", () => {
131
+ const valid = flow({ state: "ok" });
132
+ writeFileSync(
133
+ storePath,
134
+ JSON.stringify({
135
+ flows: [valid, { state: "bad" /* missing required fields */ }],
136
+ }),
137
+ );
138
+ expect(getFlowByState(storePath, "ok")).not.toBeNull();
139
+ expect(getFlowByState(storePath, "bad")).toBeNull();
140
+ });
141
+ });