@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,629 @@
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 { handleApiTokens } from "../api-tokens.ts";
6
+ import { hubDbPath, openHubDb } from "../hub-db.ts";
7
+ import { recordTokenMint, revokeTokenByJti, 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-tokens-"));
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 getRequest(query = "", headers: Record<string, string> = {}): Request {
34
+ return new Request(`http://localhost/api/auth/tokens${query}`, {
35
+ method: "GET",
36
+ headers,
37
+ });
38
+ }
39
+
40
+ interface SeedOpts {
41
+ scopes?: string[];
42
+ subject?: string;
43
+ /** Set to a non-null Date to mark the row revoked at that time. */
44
+ revokedAt?: Date | null;
45
+ /** Override created_at — drives ORDER BY. Tests use ascending real timestamps. */
46
+ createdAt?: Date;
47
+ /** Mint provenance for the registry row. Defaults to `cli_mint`. */
48
+ createdVia?: "cli_mint" | "operator_mint";
49
+ }
50
+
51
+ async function seed(
52
+ db: ReturnType<typeof openHubDb>,
53
+ userId: string,
54
+ opts: SeedOpts = {},
55
+ ): Promise<string> {
56
+ const scopes = opts.scopes ?? ["scribe:transcribe"];
57
+ const subject = opts.subject ?? userId;
58
+ const createdAt = opts.createdAt ?? new Date();
59
+ const createdVia = opts.createdVia ?? "cli_mint";
60
+ const signed = await signAccessToken(db, {
61
+ sub: subject,
62
+ scopes,
63
+ audience: "scribe",
64
+ clientId: "parachute-hub",
65
+ issuer: ISSUER,
66
+ ttlSeconds: 3600,
67
+ now: () => createdAt,
68
+ });
69
+ recordTokenMint(db, {
70
+ jti: signed.jti,
71
+ createdVia,
72
+ subject,
73
+ clientId: "parachute-hub",
74
+ scopes,
75
+ expiresAt: signed.expiresAt,
76
+ now: () => createdAt,
77
+ });
78
+ if (opts.revokedAt) {
79
+ revokeTokenByJti(db, signed.jti, opts.revokedAt);
80
+ }
81
+ return signed.jti;
82
+ }
83
+
84
+ describe("GET /api/auth/tokens (admin token list — Phase 2 backend)", () => {
85
+ test("405 on non-GET", async () => {
86
+ const h = makeHarness();
87
+ try {
88
+ const { db } = await bootstrap(h.dir);
89
+ try {
90
+ const req = new Request("http://localhost/api/auth/tokens", { method: "POST" });
91
+ const resp = await handleApiTokens(req, { db, issuer: ISSUER });
92
+ expect(resp.status).toBe(405);
93
+ } finally {
94
+ db.close();
95
+ }
96
+ } finally {
97
+ h.cleanup();
98
+ }
99
+ });
100
+
101
+ test("401 when no Authorization header", async () => {
102
+ const h = makeHarness();
103
+ try {
104
+ const { db } = await bootstrap(h.dir);
105
+ try {
106
+ const resp = await handleApiTokens(getRequest(), { db, issuer: ISSUER });
107
+ expect(resp.status).toBe(401);
108
+ } finally {
109
+ db.close();
110
+ }
111
+ } finally {
112
+ h.cleanup();
113
+ }
114
+ });
115
+
116
+ test("403 when bearer scope lacks parachute:host:auth", async () => {
117
+ const h = makeHarness();
118
+ try {
119
+ const { db, userId } = await bootstrap(h.dir);
120
+ try {
121
+ const narrow = await signAccessToken(db, {
122
+ sub: userId,
123
+ scopes: ["hub:admin"],
124
+ audience: "hub",
125
+ clientId: "parachute-hub",
126
+ issuer: ISSUER,
127
+ ttlSeconds: 3600,
128
+ });
129
+ const resp = await handleApiTokens(
130
+ getRequest("", { authorization: `Bearer ${narrow.token}` }),
131
+ { db, issuer: ISSUER },
132
+ );
133
+ expect(resp.status).toBe(403);
134
+ } finally {
135
+ db.close();
136
+ }
137
+ } finally {
138
+ h.cleanup();
139
+ }
140
+ });
141
+
142
+ test("happy path: empty registry returns empty array", async () => {
143
+ const h = makeHarness();
144
+ try {
145
+ const { db, userId } = await bootstrap(h.dir);
146
+ try {
147
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
148
+ const resp = await handleApiTokens(
149
+ getRequest("", { authorization: `Bearer ${op.token}` }),
150
+ { db, issuer: ISSUER },
151
+ );
152
+ expect(resp.status).toBe(200);
153
+ const body = (await resp.json()) as {
154
+ tokens: unknown[];
155
+ next_cursor: string | null;
156
+ };
157
+ // mintOperatorToken seeds one row; no other seeds.
158
+ expect(body.tokens).toHaveLength(1);
159
+ expect(body.next_cursor).toBeNull();
160
+ } finally {
161
+ db.close();
162
+ }
163
+ } finally {
164
+ h.cleanup();
165
+ }
166
+ });
167
+
168
+ test("returns rows newest-first with full surface of fields", async () => {
169
+ const h = makeHarness();
170
+ try {
171
+ const { db, userId } = await bootstrap(h.dir);
172
+ try {
173
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
174
+ // Seed rows AFTER the operator-token row so they sort ahead under
175
+ // newest-first. mintOperatorToken stamps real `Date.now()`, so we
176
+ // need our seed timestamps to be strictly later than that.
177
+ const baseTime = Date.now() + 60_000; // 1 min in the future
178
+ const a = await seed(db, userId, {
179
+ scopes: ["a:read"],
180
+ createdAt: new Date(baseTime + 1000),
181
+ });
182
+ const b = await seed(db, userId, {
183
+ scopes: ["b:write"],
184
+ createdAt: new Date(baseTime + 2000),
185
+ });
186
+ const c = await seed(db, userId, {
187
+ scopes: ["c:admin"],
188
+ createdAt: new Date(baseTime + 3000),
189
+ });
190
+
191
+ const resp = await handleApiTokens(
192
+ getRequest("", { authorization: `Bearer ${op.token}` }),
193
+ { db, issuer: ISSUER },
194
+ );
195
+ expect(resp.status).toBe(200);
196
+ const body = (await resp.json()) as {
197
+ tokens: Array<{
198
+ jti: string;
199
+ user_id: string | null;
200
+ subject: string | null;
201
+ client_id: string;
202
+ scopes: string[];
203
+ expires_at: string;
204
+ revoked_at: string | null;
205
+ created_at: string;
206
+ created_via: string;
207
+ permissions: Record<string, unknown> | null;
208
+ }>;
209
+ };
210
+ // Newest-first: c, b, a, then op (which was minted before via mintOperatorToken).
211
+ const jtis = body.tokens.map((t) => t.jti);
212
+ expect(jtis.slice(0, 3)).toEqual([c, b, a]);
213
+
214
+ // Surface check on the most recent row.
215
+ const newest = body.tokens[0]!;
216
+ expect(newest.scopes).toEqual(["c:admin"]);
217
+ expect(newest.created_via).toBe("cli_mint");
218
+ expect(newest.subject).toBe(userId);
219
+ expect(newest.revoked_at).toBeNull();
220
+ } finally {
221
+ db.close();
222
+ }
223
+ } finally {
224
+ h.cleanup();
225
+ }
226
+ });
227
+
228
+ test("?revoked=true filters to revoked rows only", async () => {
229
+ const h = makeHarness();
230
+ try {
231
+ const { db, userId } = await bootstrap(h.dir);
232
+ try {
233
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
234
+ const live = await seed(db, userId, { scopes: ["live:r"] });
235
+ const dead = await seed(db, userId, {
236
+ scopes: ["dead:r"],
237
+ revokedAt: new Date(),
238
+ });
239
+
240
+ const resp = await handleApiTokens(
241
+ getRequest("?revoked=true", { authorization: `Bearer ${op.token}` }),
242
+ { db, issuer: ISSUER },
243
+ );
244
+ expect(resp.status).toBe(200);
245
+ const body = (await resp.json()) as { tokens: Array<{ jti: string }> };
246
+ const jtis = body.tokens.map((t) => t.jti);
247
+ expect(jtis).toContain(dead);
248
+ expect(jtis).not.toContain(live);
249
+ } finally {
250
+ db.close();
251
+ }
252
+ } finally {
253
+ h.cleanup();
254
+ }
255
+ });
256
+
257
+ test("?revoked=false filters to un-revoked rows only", async () => {
258
+ const h = makeHarness();
259
+ try {
260
+ const { db, userId } = await bootstrap(h.dir);
261
+ try {
262
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
263
+ const live = await seed(db, userId, { scopes: ["live:r"] });
264
+ const dead = await seed(db, userId, {
265
+ scopes: ["dead:r"],
266
+ revokedAt: new Date(),
267
+ });
268
+
269
+ const resp = await handleApiTokens(
270
+ getRequest("?revoked=false", { authorization: `Bearer ${op.token}` }),
271
+ { db, issuer: ISSUER },
272
+ );
273
+ expect(resp.status).toBe(200);
274
+ const body = (await resp.json()) as { tokens: Array<{ jti: string }> };
275
+ const jtis = body.tokens.map((t) => t.jti);
276
+ expect(jtis).toContain(live);
277
+ expect(jtis).not.toContain(dead);
278
+ } finally {
279
+ db.close();
280
+ }
281
+ } finally {
282
+ h.cleanup();
283
+ }
284
+ });
285
+
286
+ test("?revoked=all returns both revoked and un-revoked rows (explicit, mirrors omit-default)", async () => {
287
+ const h = makeHarness();
288
+ try {
289
+ const { db, userId } = await bootstrap(h.dir);
290
+ try {
291
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
292
+ const live = await seed(db, userId, { scopes: ["live:r"] });
293
+ const dead = await seed(db, userId, {
294
+ scopes: ["dead:r"],
295
+ revokedAt: new Date(),
296
+ });
297
+
298
+ const resp = await handleApiTokens(
299
+ getRequest("?revoked=all", { authorization: `Bearer ${op.token}` }),
300
+ { db, issuer: ISSUER },
301
+ );
302
+ expect(resp.status).toBe(200);
303
+ const body = (await resp.json()) as { tokens: Array<{ jti: string }> };
304
+ const jtis = body.tokens.map((t) => t.jti);
305
+ expect(jtis).toContain(live);
306
+ expect(jtis).toContain(dead);
307
+ } finally {
308
+ db.close();
309
+ }
310
+ } finally {
311
+ h.cleanup();
312
+ }
313
+ });
314
+
315
+ test("permissions field is parsed to native object (not raw JSON string)", async () => {
316
+ const h = makeHarness();
317
+ try {
318
+ const { db, userId } = await bootstrap(h.dir);
319
+ try {
320
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
321
+ // Mint a row WITH a permissions claim. The CLI / api-mint-token
322
+ // path stores it as JSON-string in the registry; the wire shape
323
+ // surfaces it parsed so the UI doesn't need a JSON.parse step.
324
+ const permissions = { vault: { default: { write_tags: ["health"] } } };
325
+ const signed = await signAccessToken(db, {
326
+ sub: userId,
327
+ scopes: ["vault:default:write"],
328
+ audience: "vault.default",
329
+ clientId: "parachute-hub",
330
+ issuer: ISSUER,
331
+ ttlSeconds: 3600,
332
+ extraClaims: { permissions },
333
+ });
334
+ recordTokenMint(db, {
335
+ jti: signed.jti,
336
+ createdVia: "cli_mint",
337
+ subject: userId,
338
+ clientId: "parachute-hub",
339
+ scopes: ["vault:default:write"],
340
+ expiresAt: signed.expiresAt,
341
+ permissions: JSON.stringify(permissions),
342
+ });
343
+
344
+ const resp = await handleApiTokens(
345
+ getRequest(`?subject=${encodeURIComponent(userId)}`, {
346
+ authorization: `Bearer ${op.token}`,
347
+ }),
348
+ { db, issuer: ISSUER },
349
+ );
350
+ expect(resp.status).toBe(200);
351
+ const body = (await resp.json()) as {
352
+ tokens: Array<{ jti: string; permissions: Record<string, unknown> | null }>;
353
+ };
354
+ const row = body.tokens.find((t) => t.jti === signed.jti);
355
+ expect(row).toBeDefined();
356
+ // Wire shape returns native object (deep equality), NOT a string.
357
+ expect(row?.permissions).toEqual(permissions);
358
+ expect(typeof row?.permissions).toBe("object");
359
+ } finally {
360
+ db.close();
361
+ }
362
+ } finally {
363
+ h.cleanup();
364
+ }
365
+ });
366
+
367
+ test("?revoked=invalid → 400", async () => {
368
+ const h = makeHarness();
369
+ try {
370
+ const { db, userId } = await bootstrap(h.dir);
371
+ try {
372
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
373
+ const resp = await handleApiTokens(
374
+ getRequest("?revoked=maybe", { authorization: `Bearer ${op.token}` }),
375
+ { db, issuer: ISSUER },
376
+ );
377
+ expect(resp.status).toBe(400);
378
+ } finally {
379
+ db.close();
380
+ }
381
+ } finally {
382
+ h.cleanup();
383
+ }
384
+ });
385
+
386
+ test("?subject=<value> matches user_id OR subject column", async () => {
387
+ const h = makeHarness();
388
+ try {
389
+ const { db, userId } = await bootstrap(h.dir);
390
+ try {
391
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
392
+ // Seed two rows under two different "subject" values.
393
+ const mine = await seed(db, userId, { subject: userId });
394
+ const theirs = await seed(db, userId, { subject: "service-a" });
395
+
396
+ const respMine = await handleApiTokens(
397
+ getRequest(`?subject=${encodeURIComponent(userId)}`, {
398
+ authorization: `Bearer ${op.token}`,
399
+ }),
400
+ { db, issuer: ISSUER },
401
+ );
402
+ const bodyMine = (await respMine.json()) as { tokens: Array<{ jti: string }> };
403
+ const jtisMine = bodyMine.tokens.map((t) => t.jti);
404
+ expect(jtisMine).toContain(mine);
405
+ expect(jtisMine).not.toContain(theirs);
406
+
407
+ const respTheirs = await handleApiTokens(
408
+ getRequest("?subject=service-a", { authorization: `Bearer ${op.token}` }),
409
+ { db, issuer: ISSUER },
410
+ );
411
+ const bodyTheirs = (await respTheirs.json()) as { tokens: Array<{ jti: string }> };
412
+ const jtisTheirs = bodyTheirs.tokens.map((t) => t.jti);
413
+ expect(jtisTheirs).toContain(theirs);
414
+ expect(jtisTheirs).not.toContain(mine);
415
+ } finally {
416
+ db.close();
417
+ }
418
+ } finally {
419
+ h.cleanup();
420
+ }
421
+ });
422
+
423
+ // No dedicated `?created_via=oauth_refresh` filter test — the WHERE clause
424
+ // is identical for all three created_via values (parameterized SQL), and
425
+ // seeding an `oauth_refresh` row requires calling `signRefreshToken` (the
426
+ // OAuth grant path) rather than the test helper's `cli_mint`/`operator_mint`
427
+ // arms. The two value-specific tests below pin the filter logic;
428
+ // `oauth_refresh` would add no new coverage.
429
+ test("?created_via=cli_mint narrows to CLI-minted rows", async () => {
430
+ const h = makeHarness();
431
+ try {
432
+ const { db, userId } = await bootstrap(h.dir);
433
+ try {
434
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
435
+ // mintOperatorToken seeds an operator_mint row already.
436
+ const cliJti = await seed(db, userId, { createdVia: "cli_mint" });
437
+ const opJti = await seed(db, userId, { createdVia: "operator_mint" });
438
+
439
+ const resp = await handleApiTokens(
440
+ getRequest("?created_via=cli_mint", { authorization: `Bearer ${op.token}` }),
441
+ { db, issuer: ISSUER },
442
+ );
443
+ expect(resp.status).toBe(200);
444
+ const body = (await resp.json()) as {
445
+ tokens: Array<{ jti: string; created_via: string }>;
446
+ };
447
+ const jtis = body.tokens.map((t) => t.jti);
448
+ expect(jtis).toContain(cliJti);
449
+ expect(jtis).not.toContain(opJti);
450
+ // Every returned row reports created_via=cli_mint (sanity).
451
+ expect(body.tokens.every((t) => t.created_via === "cli_mint")).toBe(true);
452
+ } finally {
453
+ db.close();
454
+ }
455
+ } finally {
456
+ h.cleanup();
457
+ }
458
+ });
459
+
460
+ test("?created_via=operator_mint narrows to operator-token rows", async () => {
461
+ const h = makeHarness();
462
+ try {
463
+ const { db, userId } = await bootstrap(h.dir);
464
+ try {
465
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
466
+ const cliJti = await seed(db, userId, { createdVia: "cli_mint" });
467
+ const opJti = await seed(db, userId, { createdVia: "operator_mint" });
468
+
469
+ const resp = await handleApiTokens(
470
+ getRequest("?created_via=operator_mint", { authorization: `Bearer ${op.token}` }),
471
+ { db, issuer: ISSUER },
472
+ );
473
+ expect(resp.status).toBe(200);
474
+ const body = (await resp.json()) as {
475
+ tokens: Array<{ jti: string; created_via: string }>;
476
+ };
477
+ const jtis = body.tokens.map((t) => t.jti);
478
+ expect(jtis).toContain(opJti);
479
+ expect(jtis).not.toContain(cliJti);
480
+ expect(body.tokens.every((t) => t.created_via === "operator_mint")).toBe(true);
481
+ } finally {
482
+ db.close();
483
+ }
484
+ } finally {
485
+ h.cleanup();
486
+ }
487
+ });
488
+
489
+ test("?created_via composes with ?revoked", async () => {
490
+ const h = makeHarness();
491
+ try {
492
+ const { db, userId } = await bootstrap(h.dir);
493
+ try {
494
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
495
+ const liveCli = await seed(db, userId, { createdVia: "cli_mint" });
496
+ const deadCli = await seed(db, userId, {
497
+ createdVia: "cli_mint",
498
+ revokedAt: new Date(),
499
+ });
500
+ const liveOp = await seed(db, userId, { createdVia: "operator_mint" });
501
+
502
+ const resp = await handleApiTokens(
503
+ getRequest("?revoked=false&created_via=cli_mint", {
504
+ authorization: `Bearer ${op.token}`,
505
+ }),
506
+ { db, issuer: ISSUER },
507
+ );
508
+ expect(resp.status).toBe(200);
509
+ const body = (await resp.json()) as { tokens: Array<{ jti: string }> };
510
+ const jtis = body.tokens.map((t) => t.jti);
511
+ expect(jtis).toContain(liveCli);
512
+ expect(jtis).not.toContain(deadCli); // wrong revoked status
513
+ expect(jtis).not.toContain(liveOp); // wrong created_via
514
+ } finally {
515
+ db.close();
516
+ }
517
+ } finally {
518
+ h.cleanup();
519
+ }
520
+ });
521
+
522
+ test("?created_via=invalid → 400", async () => {
523
+ const h = makeHarness();
524
+ try {
525
+ const { db, userId } = await bootstrap(h.dir);
526
+ try {
527
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
528
+ const resp = await handleApiTokens(
529
+ getRequest("?created_via=bogus", { authorization: `Bearer ${op.token}` }),
530
+ { db, issuer: ISSUER },
531
+ );
532
+ expect(resp.status).toBe(400);
533
+ } finally {
534
+ db.close();
535
+ }
536
+ } finally {
537
+ h.cleanup();
538
+ }
539
+ });
540
+
541
+ test("cursor pagination: round-trip walks all rows newest-first without dupes or gaps", async () => {
542
+ const h = makeHarness();
543
+ try {
544
+ const { db, userId } = await bootstrap(h.dir);
545
+ try {
546
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
547
+ // Seed 7 rows with monotonically-increasing created_at so order is
548
+ // deterministic. The default page size is 50, so we hand-construct a
549
+ // smaller page via direct listTokens — but for the API endpoint here
550
+ // we exercise the cursor flow with the default size by creating
551
+ // enough rows AND temporarily setting a small limit via a new query
552
+ // string. The endpoint itself doesn't expose a `limit` param; it
553
+ // uses the default. So we exercise pagination by seeding 51 rows
554
+ // and walking the cursor.
555
+ // Same future-relative trick as the prior test — operator-token
556
+ // row stamps real Date.now() and would otherwise interleave.
557
+ const baseTime = Date.now() + 60_000;
558
+ const seededJtis: string[] = [];
559
+ // 51 rows in addition to the operator token = 52 total. Page 1 = 50,
560
+ // page 2 = 2.
561
+ for (let i = 0; i < 51; i++) {
562
+ const j = await seed(db, userId, {
563
+ scopes: [`scope-${i}:r`],
564
+ createdAt: new Date(baseTime + i * 1000),
565
+ });
566
+ seededJtis.push(j);
567
+ }
568
+ // Newest-first means seededJtis[50], [49], ..., [0], then op.
569
+ const expectedOrder = [...seededJtis].reverse();
570
+
571
+ const collected: string[] = [];
572
+ let cursor: string | null = null;
573
+ for (let page = 0; page < 5; page++) {
574
+ const q = cursor ? `?cursor=${encodeURIComponent(cursor)}` : "";
575
+ const resp = await handleApiTokens(
576
+ getRequest(q, { authorization: `Bearer ${op.token}` }),
577
+ { db, issuer: ISSUER },
578
+ );
579
+ expect(resp.status).toBe(200);
580
+ const body = (await resp.json()) as {
581
+ tokens: Array<{ jti: string }>;
582
+ next_cursor: string | null;
583
+ };
584
+ collected.push(...body.tokens.map((t) => t.jti));
585
+ cursor = body.next_cursor;
586
+ if (!cursor) break;
587
+ }
588
+
589
+ // First 51 = our seeded rows in newest-first order.
590
+ expect(collected.slice(0, 51)).toEqual(expectedOrder);
591
+ // 52nd row = the operator-mint row (it predates the seeded ones).
592
+ expect(collected).toHaveLength(52);
593
+ // No dupes.
594
+ expect(new Set(collected).size).toBe(52);
595
+ // Final cursor is null (we walked to the end).
596
+ expect(cursor).toBeNull();
597
+ } finally {
598
+ db.close();
599
+ }
600
+ } finally {
601
+ h.cleanup();
602
+ }
603
+ });
604
+
605
+ test("malformed cursor silently resets to page 1", async () => {
606
+ const h = makeHarness();
607
+ try {
608
+ const { db, userId } = await bootstrap(h.dir);
609
+ try {
610
+ const op = await mintOperatorToken(db, userId, { issuer: ISSUER });
611
+ await seed(db, userId);
612
+ const resp = await handleApiTokens(
613
+ getRequest("?cursor=this-is-not-base64-json", {
614
+ authorization: `Bearer ${op.token}`,
615
+ }),
616
+ { db, issuer: ISSUER },
617
+ );
618
+ expect(resp.status).toBe(200);
619
+ // Returned the full set (no implicit filter from a corrupt cursor).
620
+ const body = (await resp.json()) as { tokens: unknown[] };
621
+ expect(body.tokens.length).toBeGreaterThan(0);
622
+ } finally {
623
+ db.close();
624
+ }
625
+ } finally {
626
+ h.cleanup();
627
+ }
628
+ });
629
+ });