@openparachute/hub 0.3.0-rc.1 → 0.5.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 (90) hide show
  1. package/README.md +19 -17
  2. package/package.json +15 -4
  3. package/src/__tests__/admin-auth.test.ts +197 -0
  4. package/src/__tests__/admin-config.test.ts +281 -0
  5. package/src/__tests__/admin-grants.test.ts +271 -0
  6. package/src/__tests__/admin-handlers.test.ts +530 -0
  7. package/src/__tests__/admin-host-admin-token.test.ts +115 -0
  8. package/src/__tests__/admin-vault-admin-token.test.ts +190 -0
  9. package/src/__tests__/admin-vaults.test.ts +615 -0
  10. package/src/__tests__/auth-codes.test.ts +253 -0
  11. package/src/__tests__/auth.test.ts +712 -17
  12. package/src/__tests__/cli.test.ts +50 -0
  13. package/src/__tests__/clients.test.ts +264 -0
  14. package/src/__tests__/cloudflare-state.test.ts +167 -7
  15. package/src/__tests__/csrf.test.ts +117 -0
  16. package/src/__tests__/expose-cloudflare.test.ts +232 -37
  17. package/src/__tests__/expose-off-auto.test.ts +15 -9
  18. package/src/__tests__/expose-public-auto.test.ts +153 -0
  19. package/src/__tests__/expose.test.ts +216 -24
  20. package/src/__tests__/grants.test.ts +164 -0
  21. package/src/__tests__/hub-db.test.ts +153 -0
  22. package/src/__tests__/hub-server.test.ts +984 -26
  23. package/src/__tests__/hub.test.ts +56 -49
  24. package/src/__tests__/install.test.ts +327 -3
  25. package/src/__tests__/jwks.test.ts +37 -0
  26. package/src/__tests__/jwt-sign.test.ts +361 -0
  27. package/src/__tests__/lifecycle.test.ts +519 -5
  28. package/src/__tests__/module-manifest.test.ts +183 -0
  29. package/src/__tests__/oauth-handlers.test.ts +3112 -0
  30. package/src/__tests__/oauth-ui.test.ts +253 -0
  31. package/src/__tests__/operator-token.test.ts +140 -0
  32. package/src/__tests__/providers-detect.test.ts +158 -0
  33. package/src/__tests__/scope-explanations.test.ts +108 -0
  34. package/src/__tests__/scope-registry.test.ts +220 -0
  35. package/src/__tests__/services-manifest.test.ts +137 -1
  36. package/src/__tests__/sessions.test.ts +116 -0
  37. package/src/__tests__/setup.test.ts +361 -0
  38. package/src/__tests__/signing-keys.test.ts +153 -0
  39. package/src/__tests__/upgrade.test.ts +541 -0
  40. package/src/__tests__/users.test.ts +154 -0
  41. package/src/__tests__/well-known.test.ts +127 -10
  42. package/src/admin-auth.ts +126 -0
  43. package/src/admin-config-ui.ts +534 -0
  44. package/src/admin-config.ts +226 -0
  45. package/src/admin-grants.ts +160 -0
  46. package/src/admin-handlers.ts +365 -0
  47. package/src/admin-host-admin-token.ts +83 -0
  48. package/src/admin-vault-admin-token.ts +98 -0
  49. package/src/admin-vaults.ts +359 -0
  50. package/src/auth-codes.ts +189 -0
  51. package/src/cli.ts +202 -25
  52. package/src/clients.ts +210 -0
  53. package/src/cloudflare/config.ts +25 -6
  54. package/src/cloudflare/state.ts +108 -28
  55. package/src/commands/auth.ts +652 -19
  56. package/src/commands/expose-cloudflare.ts +85 -45
  57. package/src/commands/expose-interactive.ts +20 -44
  58. package/src/commands/expose-off-auto.ts +27 -11
  59. package/src/commands/expose-public-auto.ts +179 -0
  60. package/src/commands/expose.ts +63 -32
  61. package/src/commands/install.ts +337 -48
  62. package/src/commands/lifecycle.ts +242 -37
  63. package/src/commands/setup.ts +366 -0
  64. package/src/commands/status.ts +4 -1
  65. package/src/commands/upgrade.ts +429 -0
  66. package/src/csrf.ts +101 -0
  67. package/src/grants.ts +142 -0
  68. package/src/help.ts +133 -19
  69. package/src/hub-control.ts +12 -0
  70. package/src/hub-db.ts +164 -0
  71. package/src/hub-server.ts +643 -22
  72. package/src/hub.ts +97 -390
  73. package/src/jwks.ts +41 -0
  74. package/src/jwt-sign.ts +275 -0
  75. package/src/module-manifest.ts +435 -0
  76. package/src/oauth-handlers.ts +1206 -0
  77. package/src/oauth-ui.ts +582 -0
  78. package/src/operator-token.ts +129 -0
  79. package/src/providers/detect.ts +97 -0
  80. package/src/scope-explanations.ts +137 -0
  81. package/src/scope-registry.ts +158 -0
  82. package/src/service-spec.ts +270 -97
  83. package/src/services-manifest.ts +57 -1
  84. package/src/sessions.ts +115 -0
  85. package/src/signing-keys.ts +120 -0
  86. package/src/users.ts +144 -0
  87. package/src/well-known.ts +62 -26
  88. package/web/ui/dist/assets/index-BKzPDdB0.js +60 -0
  89. package/web/ui/dist/assets/index-Dyk6g7vT.css +1 -0
  90. package/web/ui/dist/index.html +14 -0
@@ -0,0 +1,361 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { InstallOpts } from "../commands/install.ts";
6
+ import { parseServicePicks, setup } from "../commands/setup.ts";
7
+ import { upsertService } from "../services-manifest.ts";
8
+
9
+ interface InstallCall {
10
+ short: string;
11
+ opts: InstallOpts;
12
+ }
13
+
14
+ interface Harness {
15
+ manifestPath: string;
16
+ configDir: string;
17
+ logs: string[];
18
+ calls: InstallCall[];
19
+ cleanup: () => void;
20
+ }
21
+
22
+ function makeHarness(): Harness {
23
+ const dir = mkdtempSync(join(tmpdir(), "pcli-setup-"));
24
+ const logs: string[] = [];
25
+ const calls: InstallCall[] = [];
26
+ return {
27
+ manifestPath: join(dir, "services.json"),
28
+ configDir: dir,
29
+ logs,
30
+ calls,
31
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
32
+ };
33
+ }
34
+
35
+ function scriptedAvailability(answers: string[]) {
36
+ const queue = [...answers];
37
+ return {
38
+ kind: "available" as const,
39
+ prompt: async (_q: string) => {
40
+ const next = queue.shift();
41
+ if (next === undefined) throw new Error("scripted prompt exhausted");
42
+ return next;
43
+ },
44
+ remaining: () => queue.length,
45
+ };
46
+ }
47
+
48
+ const offered = [
49
+ // The exact ServiceChoice shape used internally; we only need the surface
50
+ // parseServicePicks reads — the export accepts the live structure.
51
+ {
52
+ short: "vault",
53
+ installed: false,
54
+ manifestName: "parachute-vault",
55
+ spec: { manifestName: "parachute-vault" } as never,
56
+ },
57
+ {
58
+ short: "notes",
59
+ installed: false,
60
+ manifestName: "parachute-notes",
61
+ spec: { manifestName: "parachute-notes" } as never,
62
+ },
63
+ {
64
+ short: "scribe",
65
+ installed: false,
66
+ manifestName: "parachute-scribe",
67
+ spec: { manifestName: "parachute-scribe" } as never,
68
+ },
69
+ ];
70
+
71
+ describe("parseServicePicks", () => {
72
+ test("empty input picks every offered service", () => {
73
+ const result = parseServicePicks("", offered);
74
+ if ("error" in result) throw new Error(result.error);
75
+ expect(result.picks.map((p) => p.short)).toEqual(["vault", "notes", "scribe"]);
76
+ });
77
+
78
+ test("'all' picks every offered service", () => {
79
+ const result = parseServicePicks("all", offered);
80
+ if ("error" in result) throw new Error(result.error);
81
+ expect(result.picks.map((p) => p.short)).toEqual(["vault", "notes", "scribe"]);
82
+ });
83
+
84
+ test("numeric indices", () => {
85
+ const result = parseServicePicks("1, 3", offered);
86
+ if ("error" in result) throw new Error(result.error);
87
+ expect(result.picks.map((p) => p.short)).toEqual(["vault", "scribe"]);
88
+ });
89
+
90
+ test("shortnames", () => {
91
+ const result = parseServicePicks("vault scribe", offered);
92
+ if ("error" in result) throw new Error(result.error);
93
+ expect(result.picks.map((p) => p.short)).toEqual(["vault", "scribe"]);
94
+ });
95
+
96
+ test("dedupes repeated picks", () => {
97
+ const result = parseServicePicks("1, vault, 1", offered);
98
+ if ("error" in result) throw new Error(result.error);
99
+ expect(result.picks.map((p) => p.short)).toEqual(["vault"]);
100
+ });
101
+
102
+ test("out-of-range index errors", () => {
103
+ const result = parseServicePicks("9", offered);
104
+ expect("error" in result && result.error).toMatch(/out-of-range/);
105
+ });
106
+
107
+ test("unknown name errors", () => {
108
+ const result = parseServicePicks("nope", offered);
109
+ expect("error" in result && result.error).toMatch(/unknown service/);
110
+ });
111
+ });
112
+
113
+ describe("setup", () => {
114
+ test("exits 0 with friendly note when every known service is installed", async () => {
115
+ const h = makeHarness();
116
+ try {
117
+ // Pre-seed every first-party shortname so survey returns all-installed.
118
+ for (const m of [
119
+ "parachute-vault",
120
+ "parachute-notes",
121
+ "parachute-scribe",
122
+ "parachute-channel",
123
+ ]) {
124
+ upsertService(
125
+ {
126
+ name: m,
127
+ version: "0.0.0",
128
+ port: 1940,
129
+ paths: [`/${m.replace(/^parachute-/, "")}`],
130
+ health: "/health",
131
+ },
132
+ h.manifestPath,
133
+ );
134
+ }
135
+ const code = await setup({
136
+ manifestPath: h.manifestPath,
137
+ configDir: h.configDir,
138
+ log: (l) => h.logs.push(l),
139
+ availability: { kind: "not-tty" },
140
+ installFn: async (short, opts) => {
141
+ h.calls.push({ short, opts });
142
+ return 0;
143
+ },
144
+ });
145
+ expect(code).toBe(0);
146
+ expect(h.calls).toHaveLength(0);
147
+ expect(h.logs.join("\n")).toMatch(/All known services are already installed/);
148
+ } finally {
149
+ h.cleanup();
150
+ }
151
+ });
152
+
153
+ test("rejects non-TTY when there's work to offer", async () => {
154
+ const h = makeHarness();
155
+ try {
156
+ const code = await setup({
157
+ manifestPath: h.manifestPath,
158
+ configDir: h.configDir,
159
+ log: (l) => h.logs.push(l),
160
+ availability: { kind: "not-tty" },
161
+ installFn: async () => 0,
162
+ });
163
+ expect(code).toBe(1);
164
+ expect(h.logs.join("\n")).toMatch(/needs a TTY/);
165
+ } finally {
166
+ h.cleanup();
167
+ }
168
+ });
169
+
170
+ test("happy path: pick vault + scribe; threads vaultName + scribe answers to install()", async () => {
171
+ const h = makeHarness();
172
+ try {
173
+ const availability = scriptedAvailability([
174
+ "vault, scribe", // multi-select
175
+ "myvault", // vault name
176
+ "1", // scribe provider (parakeet-mlx)
177
+ ]);
178
+ const code = await setup({
179
+ manifestPath: h.manifestPath,
180
+ configDir: h.configDir,
181
+ log: (l) => h.logs.push(l),
182
+ availability,
183
+ installFn: async (short, opts) => {
184
+ h.calls.push({ short, opts });
185
+ // Simulate install registering the service so the summary banner finds it.
186
+ const manifestName =
187
+ short === "vault"
188
+ ? "parachute-vault"
189
+ : short === "scribe"
190
+ ? "parachute-scribe"
191
+ : `parachute-${short}`;
192
+ const port = short === "vault" ? 1940 : 1941;
193
+ upsertService(
194
+ { name: manifestName, version: "0.1.0", port, paths: [`/${short}`], health: "/health" },
195
+ opts.manifestPath ?? h.manifestPath,
196
+ );
197
+ return 0;
198
+ },
199
+ });
200
+ expect(code).toBe(0);
201
+ expect(h.calls.map((c) => c.short)).toEqual(["vault", "scribe"]);
202
+ const vaultCall = h.calls.find((c) => c.short === "vault");
203
+ const scribeCall = h.calls.find((c) => c.short === "scribe");
204
+ expect(vaultCall?.opts.vaultName).toBe("myvault");
205
+ expect(scribeCall?.opts.scribeProvider).toBe("parakeet-mlx");
206
+ expect(scribeCall?.opts.scribeKey).toBeUndefined();
207
+ expect(availability.remaining()).toBe(0);
208
+ expect(h.logs.join("\n")).toMatch(/Setup complete/);
209
+ } finally {
210
+ h.cleanup();
211
+ }
212
+ });
213
+
214
+ test("threads --tag and --no-start to every install()", async () => {
215
+ const h = makeHarness();
216
+ try {
217
+ const availability = scriptedAvailability([
218
+ "notes", // single pick — no follow-up prompts
219
+ ]);
220
+ const code = await setup({
221
+ manifestPath: h.manifestPath,
222
+ configDir: h.configDir,
223
+ log: (l) => h.logs.push(l),
224
+ availability,
225
+ installFn: async (short, opts) => {
226
+ h.calls.push({ short, opts });
227
+ upsertService(
228
+ {
229
+ name: "parachute-notes",
230
+ version: "0.1.0",
231
+ port: 1942,
232
+ paths: ["/notes"],
233
+ health: "/health",
234
+ },
235
+ opts.manifestPath ?? h.manifestPath,
236
+ );
237
+ return 0;
238
+ },
239
+ tag: "rc",
240
+ noStart: true,
241
+ });
242
+ expect(code).toBe(0);
243
+ expect(h.calls).toHaveLength(1);
244
+ expect(h.calls[0]?.opts.tag).toBe("rc");
245
+ expect(h.calls[0]?.opts.noStart).toBe(true);
246
+ } finally {
247
+ h.cleanup();
248
+ }
249
+ });
250
+
251
+ test("partial failure: later picks still run; exit code reflects first failure", async () => {
252
+ const h = makeHarness();
253
+ try {
254
+ const availability = scriptedAvailability([
255
+ "vault, notes", // multi-select
256
+ "default", // vault name
257
+ ]);
258
+ const code = await setup({
259
+ manifestPath: h.manifestPath,
260
+ configDir: h.configDir,
261
+ log: (l) => h.logs.push(l),
262
+ availability,
263
+ installFn: async (short, opts) => {
264
+ h.calls.push({ short, opts });
265
+ if (short === "vault") return 7; // fail vault
266
+ upsertService(
267
+ {
268
+ name: "parachute-notes",
269
+ version: "0.1.0",
270
+ port: 1942,
271
+ paths: ["/notes"],
272
+ health: "/health",
273
+ },
274
+ opts.manifestPath ?? h.manifestPath,
275
+ );
276
+ return 0;
277
+ },
278
+ });
279
+ expect(code).toBe(7);
280
+ expect(h.calls.map((c) => c.short)).toEqual(["vault", "notes"]);
281
+ expect(h.logs.join("\n")).toMatch(/non-zero exit code/);
282
+ } finally {
283
+ h.cleanup();
284
+ }
285
+ });
286
+
287
+ test("retries pick prompt on invalid token then accepts a valid one (#111)", async () => {
288
+ const h = makeHarness();
289
+ try {
290
+ const availability = scriptedAvailability([
291
+ "9", // out-of-range index — loop re-prompts
292
+ "nope", // unknown name — loop re-prompts again
293
+ "notes", // single pick, no follow-up prompts
294
+ ]);
295
+ const code = await setup({
296
+ manifestPath: h.manifestPath,
297
+ configDir: h.configDir,
298
+ log: (l) => h.logs.push(l),
299
+ availability,
300
+ installFn: async (short, opts) => {
301
+ h.calls.push({ short, opts });
302
+ upsertService(
303
+ {
304
+ name: "parachute-notes",
305
+ version: "0.1.0",
306
+ port: 1942,
307
+ paths: ["/notes"],
308
+ health: "/health",
309
+ },
310
+ opts.manifestPath ?? h.manifestPath,
311
+ );
312
+ return 0;
313
+ },
314
+ });
315
+ expect(code).toBe(0);
316
+ expect(h.calls.map((c) => c.short)).toEqual(["notes"]);
317
+ expect(availability.remaining()).toBe(0);
318
+ const joined = h.logs.join("\n");
319
+ expect(joined).toMatch(/out-of-range/);
320
+ expect(joined).toMatch(/unknown service/);
321
+ } finally {
322
+ h.cleanup();
323
+ }
324
+ });
325
+
326
+ test("retries on invalid vault name then accepts a valid one", async () => {
327
+ const h = makeHarness();
328
+ try {
329
+ const availability = scriptedAvailability([
330
+ "vault",
331
+ "Bad Name!", // rejected
332
+ "good-vault", // accepted
333
+ ]);
334
+ const code = await setup({
335
+ manifestPath: h.manifestPath,
336
+ configDir: h.configDir,
337
+ log: (l) => h.logs.push(l),
338
+ availability,
339
+ installFn: async (short, opts) => {
340
+ h.calls.push({ short, opts });
341
+ upsertService(
342
+ {
343
+ name: "parachute-vault",
344
+ version: "0.1.0",
345
+ port: 1940,
346
+ paths: ["/vault"],
347
+ health: "/health",
348
+ },
349
+ opts.manifestPath ?? h.manifestPath,
350
+ );
351
+ return 0;
352
+ },
353
+ });
354
+ expect(code).toBe(0);
355
+ expect(h.calls[0]?.opts.vaultName).toBe("good-vault");
356
+ expect(h.logs.join("\n")).toMatch(/invalid name "Bad Name!"/);
357
+ } finally {
358
+ h.cleanup();
359
+ }
360
+ });
361
+ });
@@ -0,0 +1,153 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
6
+ import {
7
+ JWKS_RETENTION_MS,
8
+ computeKid,
9
+ getActiveSigningKey,
10
+ getAllPublicKeys,
11
+ rotateSigningKey,
12
+ } from "../signing-keys.ts";
13
+
14
+ function makeDb() {
15
+ const configDir = mkdtempSync(join(tmpdir(), "phub-sk-"));
16
+ const db = openHubDb(hubDbPath(configDir));
17
+ return { db, cleanup: () => rmSync(configDir, { recursive: true, force: true }) };
18
+ }
19
+
20
+ describe("computeKid", () => {
21
+ test("is deterministic and base64url-shaped", () => {
22
+ const a = computeKid("-----BEGIN PUBLIC KEY-----\nABC\n-----END PUBLIC KEY-----\n");
23
+ const b = computeKid("-----BEGIN PUBLIC KEY-----\nABC\n-----END PUBLIC KEY-----\n");
24
+ expect(a).toBe(b);
25
+ // base64url has no `+`, `/`, or `=` padding.
26
+ expect(a).not.toMatch(/[+/=]/);
27
+ // SHA-256 → 32 bytes → 43 base64url chars.
28
+ expect(a.length).toBe(43);
29
+ });
30
+
31
+ test("differs for different PEMs", () => {
32
+ const a = computeKid("PEM-A");
33
+ const b = computeKid("PEM-B");
34
+ expect(a).not.toBe(b);
35
+ });
36
+ });
37
+
38
+ describe("getActiveSigningKey", () => {
39
+ test("auto-seeds an active key on a fresh db", () => {
40
+ const { db, cleanup } = makeDb();
41
+ try {
42
+ const k = getActiveSigningKey(db);
43
+ expect(k.kid.length).toBe(43);
44
+ expect(k.algorithm).toBe("RS256");
45
+ expect(k.retiredAt).toBeNull();
46
+ expect(k.publicKeyPem).toContain("BEGIN PUBLIC KEY");
47
+ expect(k.privateKeyPem).toContain("BEGIN PRIVATE KEY");
48
+ // kid is content-addressed against the public PEM.
49
+ expect(k.kid).toBe(computeKid(k.publicKeyPem));
50
+ } finally {
51
+ cleanup();
52
+ }
53
+ });
54
+
55
+ test("is idempotent — repeat calls return the same active key", () => {
56
+ const { db, cleanup } = makeDb();
57
+ try {
58
+ const a = getActiveSigningKey(db);
59
+ const b = getActiveSigningKey(db);
60
+ expect(b.kid).toBe(a.kid);
61
+ expect(b.privateKeyPem).toBe(a.privateKeyPem);
62
+ } finally {
63
+ cleanup();
64
+ }
65
+ });
66
+ });
67
+
68
+ describe("rotateSigningKey", () => {
69
+ test("retires the prior active key and creates a fresh one", () => {
70
+ const { db, cleanup } = makeDb();
71
+ try {
72
+ const old = getActiveSigningKey(db);
73
+ const fresh = rotateSigningKey(db);
74
+ expect(fresh.kid).not.toBe(old.kid);
75
+ expect(fresh.retiredAt).toBeNull();
76
+ const next = getActiveSigningKey(db);
77
+ expect(next.kid).toBe(fresh.kid);
78
+
79
+ const oldRow = db
80
+ .query<{ retired_at: string | null }, [string]>(
81
+ "SELECT retired_at FROM signing_keys WHERE kid = ?",
82
+ )
83
+ .get(old.kid);
84
+ expect(oldRow?.retired_at).not.toBeNull();
85
+ } finally {
86
+ cleanup();
87
+ }
88
+ });
89
+
90
+ test("transactional — if the insert succeeds, exactly one active key remains", () => {
91
+ const { db, cleanup } = makeDb();
92
+ try {
93
+ getActiveSigningKey(db);
94
+ rotateSigningKey(db);
95
+ rotateSigningKey(db);
96
+ const activeCount = (
97
+ db
98
+ .query<{ n: number }, []>(
99
+ "SELECT COUNT(*) AS n FROM signing_keys WHERE retired_at IS NULL",
100
+ )
101
+ .get() ?? { n: -1 }
102
+ ).n;
103
+ expect(activeCount).toBe(1);
104
+ } finally {
105
+ cleanup();
106
+ }
107
+ });
108
+ });
109
+
110
+ describe("getAllPublicKeys", () => {
111
+ test("includes active + recently-retired (within 24h)", () => {
112
+ const { db, cleanup } = makeDb();
113
+ try {
114
+ const old = getActiveSigningKey(db);
115
+ const fresh = rotateSigningKey(db);
116
+ const keys = getAllPublicKeys(db);
117
+ const kids = keys.map((k) => k.kid).sort();
118
+ expect(kids).toEqual([old.kid, fresh.kid].sort());
119
+ } finally {
120
+ cleanup();
121
+ }
122
+ });
123
+
124
+ test("excludes retired keys past 24h retention", () => {
125
+ const { db, cleanup } = makeDb();
126
+ try {
127
+ const old = getActiveSigningKey(db);
128
+ const fresh = rotateSigningKey(db);
129
+ // Force the retired_at far enough in the past to be filtered out.
130
+ const oldRetired = new Date(Date.now() - JWKS_RETENTION_MS - 60_000).toISOString();
131
+ db.prepare("UPDATE signing_keys SET retired_at = ? WHERE kid = ?").run(oldRetired, old.kid);
132
+
133
+ const kids = getAllPublicKeys(db).map((k) => k.kid);
134
+ expect(kids).toContain(fresh.kid);
135
+ expect(kids).not.toContain(old.kid);
136
+ } finally {
137
+ cleanup();
138
+ }
139
+ });
140
+
141
+ test("on a fresh db with no keys, returns []", () => {
142
+ const { db, cleanup } = makeDb();
143
+ try {
144
+ expect(getAllPublicKeys(db)).toEqual([]);
145
+ } finally {
146
+ cleanup();
147
+ }
148
+ });
149
+
150
+ test("retention boundary is 24h to the millisecond", () => {
151
+ expect(JWKS_RETENTION_MS).toBe(24 * 60 * 60 * 1000);
152
+ });
153
+ });