@openparachute/hub 0.5.2 → 0.5.9-rc.6

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 (76) hide show
  1. package/package.json +1 -1
  2. package/src/__tests__/admin-clients.test.ts +275 -0
  3. package/src/__tests__/admin-handlers.test.ts +159 -320
  4. package/src/__tests__/admin-host-admin-token.test.ts +52 -4
  5. package/src/__tests__/api-me.test.ts +149 -0
  6. package/src/__tests__/api-mint-token.test.ts +381 -0
  7. package/src/__tests__/api-revocation-list.test.ts +198 -0
  8. package/src/__tests__/api-revoke-token.test.ts +320 -0
  9. package/src/__tests__/api-tokens.test.ts +629 -0
  10. package/src/__tests__/auth.test.ts +680 -16
  11. package/src/__tests__/expose-2fa-warning.test.ts +123 -0
  12. package/src/__tests__/expose-cloudflare.test.ts +101 -0
  13. package/src/__tests__/expose.test.ts +199 -340
  14. package/src/__tests__/hub-server.test.ts +986 -66
  15. package/src/__tests__/hub.test.ts +108 -55
  16. package/src/__tests__/install-source.test.ts +249 -0
  17. package/src/__tests__/install.test.ts +50 -31
  18. package/src/__tests__/jwt-sign.test.ts +205 -0
  19. package/src/__tests__/lifecycle.test.ts +97 -2
  20. package/src/__tests__/module-manifest.test.ts +48 -0
  21. package/src/__tests__/notes-serve.test.ts +154 -2
  22. package/src/__tests__/oauth-handlers.test.ts +1000 -3
  23. package/src/__tests__/operator-token.test.ts +379 -3
  24. package/src/__tests__/origin-check.test.ts +220 -0
  25. package/src/__tests__/port-assign.test.ts +41 -52
  26. package/src/__tests__/rate-limit.test.ts +190 -0
  27. package/src/__tests__/services-manifest.test.ts +341 -0
  28. package/src/__tests__/setup.test.ts +12 -9
  29. package/src/__tests__/status.test.ts +372 -0
  30. package/src/__tests__/well-known.test.ts +69 -0
  31. package/src/admin-clients.ts +139 -0
  32. package/src/admin-handlers.ts +63 -260
  33. package/src/admin-host-admin-token.ts +25 -10
  34. package/src/admin-login-ui.ts +256 -0
  35. package/src/admin-vault-admin-token.ts +1 -1
  36. package/src/api-me.ts +124 -0
  37. package/src/api-mint-token.ts +239 -0
  38. package/src/api-revocation-list.ts +59 -0
  39. package/src/api-revoke-token.ts +153 -0
  40. package/src/api-tokens.ts +224 -0
  41. package/src/commands/auth.ts +408 -51
  42. package/src/commands/expose-2fa-warning.ts +82 -0
  43. package/src/commands/expose-cloudflare.ts +27 -0
  44. package/src/commands/expose-public-auto.ts +3 -7
  45. package/src/commands/expose.ts +88 -173
  46. package/src/commands/install.ts +11 -13
  47. package/src/commands/lifecycle.ts +53 -4
  48. package/src/commands/status.ts +99 -8
  49. package/src/csrf.ts +6 -3
  50. package/src/help.ts +13 -7
  51. package/src/hub-db.ts +63 -0
  52. package/src/hub-server.ts +572 -106
  53. package/src/hub.ts +272 -149
  54. package/src/install-source.ts +291 -0
  55. package/src/jwt-sign.ts +265 -5
  56. package/src/module-manifest.ts +48 -10
  57. package/src/notes-serve.ts +70 -9
  58. package/src/oauth-handlers.ts +395 -29
  59. package/src/oauth-ui.ts +188 -0
  60. package/src/operator-token.ts +272 -18
  61. package/src/origin-check.ts +127 -0
  62. package/src/port-assign.ts +28 -35
  63. package/src/rate-limit.ts +166 -0
  64. package/src/scope-explanations.ts +33 -2
  65. package/src/service-spec.ts +58 -13
  66. package/src/services-manifest.ts +62 -3
  67. package/src/sessions.ts +19 -0
  68. package/src/well-known.ts +54 -1
  69. package/web/ui/dist/assets/index-Bv6Bq_wx.js +60 -0
  70. package/web/ui/dist/assets/index-D54otIhv.css +1 -0
  71. package/web/ui/dist/index.html +2 -2
  72. package/src/__tests__/admin-config.test.ts +0 -281
  73. package/src/admin-config-ui.ts +0 -534
  74. package/src/admin-config.ts +0 -226
  75. package/web/ui/dist/assets/index-BKzPDdB0.js +0 -60
  76. package/web/ui/dist/assets/index-Dyk6g7vT.css +0 -1
@@ -0,0 +1,198 @@
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 { handleRevocationList } from "../api-revocation-list.ts";
6
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
7
+ import { recordTokenMint, revokeTokenByJti, signRefreshToken } from "../jwt-sign.ts";
8
+ import { rotateSigningKey } from "../signing-keys.ts";
9
+ import { createUser } from "../users.ts";
10
+
11
+ interface Harness {
12
+ dir: string;
13
+ cleanup: () => void;
14
+ }
15
+
16
+ function makeHarness(): Harness {
17
+ const dir = mkdtempSync(join(tmpdir(), "phub-revocation-"));
18
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
19
+ }
20
+
21
+ describe("GET /.well-known/parachute-revocation.json (hub#212 Phase 1)", () => {
22
+ test("empty list when nothing revoked", async () => {
23
+ const h = makeHarness();
24
+ try {
25
+ const db = openHubDb(hubDbPath(h.dir));
26
+ try {
27
+ rotateSigningKey(db);
28
+ const req = new Request("http://localhost/.well-known/parachute-revocation.json");
29
+ const resp = handleRevocationList(req, { db });
30
+ expect(resp.status).toBe(200);
31
+ expect(resp.headers.get("content-type")).toBe("application/json");
32
+ expect(resp.headers.get("cache-control")).toBe("public, max-age=60");
33
+ const body = (await resp.json()) as { generated_at: string; jtis: string[] };
34
+ expect(body.jtis).toEqual([]);
35
+ expect(typeof body.generated_at).toBe("string");
36
+ } finally {
37
+ db.close();
38
+ }
39
+ } finally {
40
+ h.cleanup();
41
+ }
42
+ });
43
+
44
+ test("returns revoked jti after revokeTokenByJti", async () => {
45
+ const h = makeHarness();
46
+ try {
47
+ const db = openHubDb(hubDbPath(h.dir));
48
+ try {
49
+ rotateSigningKey(db);
50
+ const futureExpiry = new Date(Date.now() + 86400_000).toISOString();
51
+ recordTokenMint(db, {
52
+ jti: "jti-revoked-1",
53
+ createdVia: "cli_mint",
54
+ subject: "operator",
55
+ clientId: "parachute-hub",
56
+ scopes: ["scribe:transcribe"],
57
+ expiresAt: futureExpiry,
58
+ });
59
+ recordTokenMint(db, {
60
+ jti: "jti-active-1",
61
+ createdVia: "cli_mint",
62
+ subject: "operator",
63
+ clientId: "parachute-hub",
64
+ scopes: ["vault:read"],
65
+ expiresAt: futureExpiry,
66
+ });
67
+ revokeTokenByJti(db, "jti-revoked-1", new Date());
68
+
69
+ const req = new Request("http://localhost/.well-known/parachute-revocation.json");
70
+ const resp = handleRevocationList(req, { db });
71
+ expect(resp.status).toBe(200);
72
+ const body = (await resp.json()) as { jtis: string[] };
73
+ expect(body.jtis).toEqual(["jti-revoked-1"]);
74
+ } finally {
75
+ db.close();
76
+ }
77
+ } finally {
78
+ h.cleanup();
79
+ }
80
+ });
81
+
82
+ test("filters out already-expired revoked jtis", async () => {
83
+ const h = makeHarness();
84
+ try {
85
+ const db = openHubDb(hubDbPath(h.dir));
86
+ try {
87
+ rotateSigningKey(db);
88
+ const past = new Date(Date.now() - 86400_000).toISOString();
89
+ const future = new Date(Date.now() + 86400_000).toISOString();
90
+ // Revoked but expired — should NOT appear in the list (consumers'
91
+ // own exp check would reject it anyway; listing it is noise).
92
+ recordTokenMint(db, {
93
+ jti: "jti-expired-revoked",
94
+ createdVia: "cli_mint",
95
+ subject: "operator",
96
+ clientId: "parachute-hub",
97
+ scopes: ["vault:read"],
98
+ expiresAt: past,
99
+ });
100
+ recordTokenMint(db, {
101
+ jti: "jti-active-revoked",
102
+ createdVia: "cli_mint",
103
+ subject: "operator",
104
+ clientId: "parachute-hub",
105
+ scopes: ["vault:read"],
106
+ expiresAt: future,
107
+ });
108
+ revokeTokenByJti(db, "jti-expired-revoked", new Date());
109
+ revokeTokenByJti(db, "jti-active-revoked", new Date());
110
+
111
+ const req = new Request("http://localhost/.well-known/parachute-revocation.json");
112
+ const resp = handleRevocationList(req, { db });
113
+ const body = (await resp.json()) as { jtis: string[] };
114
+ expect(body.jtis).toEqual(["jti-active-revoked"]);
115
+ } finally {
116
+ db.close();
117
+ }
118
+ } finally {
119
+ h.cleanup();
120
+ }
121
+ });
122
+
123
+ test("OAuth-refresh rows participate in the same revocation surface", async () => {
124
+ const h = makeHarness();
125
+ try {
126
+ const db = openHubDb(hubDbPath(h.dir));
127
+ try {
128
+ rotateSigningKey(db);
129
+ const u = await createUser(db, "owner", "pw");
130
+ // signRefreshToken writes a row with created_via='oauth_refresh';
131
+ // revoking by jti must surface it the same way as cli_mint rows.
132
+ const refresh = signRefreshToken(db, {
133
+ jti: "jti-oauth-refresh-1",
134
+ userId: u.id,
135
+ clientId: "parachute-hub",
136
+ scopes: ["vault:read"],
137
+ });
138
+ expect(refresh.familyId).toBeDefined();
139
+ revokeTokenByJti(db, "jti-oauth-refresh-1", new Date());
140
+
141
+ const req = new Request("http://localhost/.well-known/parachute-revocation.json");
142
+ const resp = handleRevocationList(req, { db });
143
+ const body = (await resp.json()) as { jtis: string[] };
144
+ expect(body.jtis).toContain("jti-oauth-refresh-1");
145
+ } finally {
146
+ db.close();
147
+ }
148
+ } finally {
149
+ h.cleanup();
150
+ }
151
+ });
152
+
153
+ test("rejects non-GET methods with 405", async () => {
154
+ const h = makeHarness();
155
+ try {
156
+ const db = openHubDb(hubDbPath(h.dir));
157
+ try {
158
+ rotateSigningKey(db);
159
+ const req = new Request("http://localhost/.well-known/parachute-revocation.json", {
160
+ method: "POST",
161
+ });
162
+ const resp = handleRevocationList(req, { db });
163
+ expect(resp.status).toBe(405);
164
+ } finally {
165
+ db.close();
166
+ }
167
+ } finally {
168
+ h.cleanup();
169
+ }
170
+ });
171
+
172
+ test("revokeTokenByJti is idempotent — second call returns false", () => {
173
+ const h = makeHarness();
174
+ try {
175
+ const db = openHubDb(hubDbPath(h.dir));
176
+ try {
177
+ rotateSigningKey(db);
178
+ const future = new Date(Date.now() + 86400_000).toISOString();
179
+ recordTokenMint(db, {
180
+ jti: "jti-once",
181
+ createdVia: "cli_mint",
182
+ subject: "operator",
183
+ clientId: "parachute-hub",
184
+ scopes: ["vault:read"],
185
+ expiresAt: future,
186
+ });
187
+ const now = new Date();
188
+ expect(revokeTokenByJti(db, "jti-once", now)).toBe(true);
189
+ expect(revokeTokenByJti(db, "jti-once", now)).toBe(false);
190
+ expect(revokeTokenByJti(db, "jti-does-not-exist", now)).toBe(false);
191
+ } finally {
192
+ db.close();
193
+ }
194
+ } finally {
195
+ h.cleanup();
196
+ }
197
+ });
198
+ });
@@ -0,0 +1,320 @@
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 { handleApiRevokeToken } from "../api-revoke-token.ts";
6
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
7
+ import { findTokenRowByJti, recordTokenMint, signAccessToken } from "../jwt-sign.ts";
8
+ import { mintOperatorToken } from "../operator-token.ts";
9
+ import { rotateSigningKey } from "../signing-keys.ts";
10
+ import { createUser } from "../users.ts";
11
+
12
+ interface Harness {
13
+ dir: string;
14
+ cleanup: () => void;
15
+ }
16
+
17
+ function makeHarness(): Harness {
18
+ const dir = mkdtempSync(join(tmpdir(), "phub-api-revoke-"));
19
+ return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
20
+ }
21
+
22
+ const ISSUER = "http://127.0.0.1:1939";
23
+
24
+ async function bootstrap(
25
+ dir: string,
26
+ ): Promise<{ db: ReturnType<typeof openHubDb>; userId: string }> {
27
+ const db = openHubDb(hubDbPath(dir));
28
+ rotateSigningKey(db);
29
+ const u = await createUser(db, "owner", "pw");
30
+ return { db, userId: u.id };
31
+ }
32
+
33
+ function jsonRequest(body: unknown, headers: Record<string, string> = {}): Request {
34
+ return new Request("http://localhost/api/auth/revoke-token", {
35
+ method: "POST",
36
+ body: JSON.stringify(body),
37
+ headers: { "content-type": "application/json", ...headers },
38
+ });
39
+ }
40
+
41
+ /** Seed a tokens row by minting + recording, mirroring the CLI mint path. */
42
+ async function seedToken(
43
+ db: ReturnType<typeof openHubDb>,
44
+ userId: string,
45
+ scopes = ["scribe:transcribe"],
46
+ ): Promise<string> {
47
+ const signed = await signAccessToken(db, {
48
+ sub: userId,
49
+ scopes,
50
+ audience: "scribe",
51
+ clientId: "parachute-hub",
52
+ issuer: ISSUER,
53
+ ttlSeconds: 3600,
54
+ });
55
+ recordTokenMint(db, {
56
+ jti: signed.jti,
57
+ createdVia: "cli_mint",
58
+ subject: userId,
59
+ clientId: "parachute-hub",
60
+ scopes,
61
+ expiresAt: signed.expiresAt,
62
+ });
63
+ return signed.jti;
64
+ }
65
+
66
+ describe("POST /api/auth/revoke-token (closes hub#220)", () => {
67
+ test("405 on non-POST", async () => {
68
+ const h = makeHarness();
69
+ try {
70
+ const { db } = await bootstrap(h.dir);
71
+ try {
72
+ const req = new Request("http://localhost/api/auth/revoke-token", { method: "GET" });
73
+ const resp = await handleApiRevokeToken(req, { db, issuer: ISSUER });
74
+ expect(resp.status).toBe(405);
75
+ expect(((await resp.json()) as { error: string }).error).toBe("method_not_allowed");
76
+ } finally {
77
+ db.close();
78
+ }
79
+ } finally {
80
+ h.cleanup();
81
+ }
82
+ });
83
+
84
+ test("401 when no Authorization header", async () => {
85
+ const h = makeHarness();
86
+ try {
87
+ const { db } = await bootstrap(h.dir);
88
+ try {
89
+ const resp = await handleApiRevokeToken(jsonRequest({ jti: "x" }), { db, issuer: ISSUER });
90
+ expect(resp.status).toBe(401);
91
+ expect(((await resp.json()) as { error: string }).error).toBe("unauthenticated");
92
+ } finally {
93
+ db.close();
94
+ }
95
+ } finally {
96
+ h.cleanup();
97
+ }
98
+ });
99
+
100
+ test("401 when bearer fails signature/issuer validation", async () => {
101
+ const h = makeHarness();
102
+ try {
103
+ const { db } = await bootstrap(h.dir);
104
+ try {
105
+ const resp = await handleApiRevokeToken(
106
+ jsonRequest({ jti: "x" }, { authorization: "Bearer not-a-real-jwt" }),
107
+ { db, issuer: ISSUER },
108
+ );
109
+ expect(resp.status).toBe(401);
110
+ } finally {
111
+ db.close();
112
+ }
113
+ } finally {
114
+ h.cleanup();
115
+ }
116
+ });
117
+
118
+ test("403 when bearer scope lacks parachute:host:auth", async () => {
119
+ const h = makeHarness();
120
+ try {
121
+ const { db, userId } = await bootstrap(h.dir);
122
+ try {
123
+ const narrow = await signAccessToken(db, {
124
+ sub: userId,
125
+ scopes: ["hub:admin"],
126
+ audience: "hub",
127
+ clientId: "parachute-hub",
128
+ issuer: ISSUER,
129
+ ttlSeconds: 3600,
130
+ });
131
+ const resp = await handleApiRevokeToken(
132
+ jsonRequest({ jti: "x" }, { authorization: `Bearer ${narrow.token}` }),
133
+ { db, issuer: ISSUER },
134
+ );
135
+ expect(resp.status).toBe(403);
136
+ expect(((await resp.json()) as { error: string }).error).toBe("insufficient_scope");
137
+ } finally {
138
+ db.close();
139
+ }
140
+ } finally {
141
+ h.cleanup();
142
+ }
143
+ });
144
+
145
+ test("400 when body missing jti", async () => {
146
+ const h = makeHarness();
147
+ try {
148
+ const { db, userId } = await bootstrap(h.dir);
149
+ try {
150
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
151
+ const resp = await handleApiRevokeToken(
152
+ jsonRequest({}, { authorization: `Bearer ${op.token}` }),
153
+ { db, issuer: ISSUER },
154
+ );
155
+ expect(resp.status).toBe(400);
156
+ const body = (await resp.json()) as { error: string; error_description: string };
157
+ expect(body.error).toBe("invalid_request");
158
+ expect(body.error_description).toContain("jti");
159
+ } finally {
160
+ db.close();
161
+ }
162
+ } finally {
163
+ h.cleanup();
164
+ }
165
+ });
166
+
167
+ test("400 when jti is not a non-empty string", async () => {
168
+ const h = makeHarness();
169
+ try {
170
+ const { db, userId } = await bootstrap(h.dir);
171
+ try {
172
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
173
+ for (const badJti of [null, 42, "", true, []]) {
174
+ const resp = await handleApiRevokeToken(
175
+ jsonRequest({ jti: badJti }, { authorization: `Bearer ${op.token}` }),
176
+ { db, issuer: ISSUER },
177
+ );
178
+ expect(resp.status).toBe(400);
179
+ }
180
+ } finally {
181
+ db.close();
182
+ }
183
+ } finally {
184
+ h.cleanup();
185
+ }
186
+ });
187
+
188
+ test("400 when body is not valid JSON", async () => {
189
+ const h = makeHarness();
190
+ try {
191
+ const { db, userId } = await bootstrap(h.dir);
192
+ try {
193
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
194
+ const req = new Request("http://localhost/api/auth/revoke-token", {
195
+ method: "POST",
196
+ body: "not json {[",
197
+ headers: {
198
+ "content-type": "application/json",
199
+ authorization: `Bearer ${op.token}`,
200
+ },
201
+ });
202
+ const resp = await handleApiRevokeToken(req, { db, issuer: ISSUER });
203
+ expect(resp.status).toBe(400);
204
+ const body = (await resp.json()) as { error: string };
205
+ expect(body.error).toBe("invalid_request");
206
+ } finally {
207
+ db.close();
208
+ }
209
+ } finally {
210
+ h.cleanup();
211
+ }
212
+ });
213
+
214
+ test("404 when jti has no registry row", async () => {
215
+ const h = makeHarness();
216
+ try {
217
+ const { db, userId } = await bootstrap(h.dir);
218
+ try {
219
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
220
+ const resp = await handleApiRevokeToken(
221
+ jsonRequest({ jti: "this-jti-does-not-exist" }, { authorization: `Bearer ${op.token}` }),
222
+ { db, issuer: ISSUER },
223
+ );
224
+ expect(resp.status).toBe(404);
225
+ const body = (await resp.json()) as { error: string; error_description: string };
226
+ expect(body.error).toBe("not_found");
227
+ expect(body.error_description).toContain("this-jti-does-not-exist");
228
+ } finally {
229
+ db.close();
230
+ }
231
+ } finally {
232
+ h.cleanup();
233
+ }
234
+ });
235
+
236
+ test("happy path: revokes a fresh token; row's revoked_at is set", async () => {
237
+ const h = makeHarness();
238
+ try {
239
+ const { db, userId } = await bootstrap(h.dir);
240
+ try {
241
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
242
+ const jti = await seedToken(db, userId);
243
+ const before = findTokenRowByJti(db, jti);
244
+ expect(before?.revokedAt).toBeNull();
245
+
246
+ const resp = await handleApiRevokeToken(
247
+ jsonRequest({ jti }, { authorization: `Bearer ${op.token}` }),
248
+ { db, issuer: ISSUER },
249
+ );
250
+ expect(resp.status).toBe(200);
251
+ const body = (await resp.json()) as { jti: string; revoked_at: string };
252
+ expect(body.jti).toBe(jti);
253
+ expect(typeof body.revoked_at).toBe("string");
254
+ expect(body.revoked_at.length).toBeGreaterThan(0);
255
+
256
+ const after = findTokenRowByJti(db, jti);
257
+ expect(after?.revokedAt).toBe(body.revoked_at);
258
+ } finally {
259
+ db.close();
260
+ }
261
+ } finally {
262
+ h.cleanup();
263
+ }
264
+ });
265
+
266
+ test("idempotent: re-revoking returns 200 with the original revoked_at", async () => {
267
+ const h = makeHarness();
268
+ try {
269
+ const { db, userId } = await bootstrap(h.dir);
270
+ try {
271
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
272
+ const jti = await seedToken(db, userId);
273
+
274
+ const first = await handleApiRevokeToken(
275
+ jsonRequest({ jti }, { authorization: `Bearer ${op.token}` }),
276
+ { db, issuer: ISSUER },
277
+ );
278
+ expect(first.status).toBe(200);
279
+ const firstBody = (await first.json()) as { revoked_at: string };
280
+ const firstAt = firstBody.revoked_at;
281
+
282
+ // Sleep 1ms so a clock-skew bug would make `now` != `first.revoked_at`.
283
+ await Bun.sleep(2);
284
+
285
+ const second = await handleApiRevokeToken(
286
+ jsonRequest({ jti }, { authorization: `Bearer ${op.token}` }),
287
+ { db, issuer: ISSUER },
288
+ );
289
+ expect(second.status).toBe(200);
290
+ const secondBody = (await second.json()) as { revoked_at: string };
291
+ // Idempotent: returns the original timestamp, not a fresh one.
292
+ expect(secondBody.revoked_at).toBe(firstAt);
293
+ } finally {
294
+ db.close();
295
+ }
296
+ } finally {
297
+ h.cleanup();
298
+ }
299
+ });
300
+
301
+ test("happy path: --scope-set=auth narrow operator token passes the gate", async () => {
302
+ const h = makeHarness();
303
+ try {
304
+ const { db, userId } = await bootstrap(h.dir);
305
+ try {
306
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER, scopeSet: "auth" });
307
+ const jti = await seedToken(db, userId);
308
+ const resp = await handleApiRevokeToken(
309
+ jsonRequest({ jti }, { authorization: `Bearer ${op.token}` }),
310
+ { db, issuer: ISSUER },
311
+ );
312
+ expect(resp.status).toBe(200);
313
+ } finally {
314
+ db.close();
315
+ }
316
+ } finally {
317
+ h.cleanup();
318
+ }
319
+ });
320
+ });