@gaganref/convex-api-keys 0.1.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 (102) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +419 -0
  3. package/dist/client/_generated/_ignore.d.ts +1 -0
  4. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  5. package/dist/client/_generated/_ignore.js +3 -0
  6. package/dist/client/_generated/_ignore.js.map +1 -0
  7. package/dist/client/crypto.d.ts +4 -0
  8. package/dist/client/crypto.d.ts.map +1 -0
  9. package/dist/client/crypto.js +48 -0
  10. package/dist/client/crypto.js.map +1 -0
  11. package/dist/client/errors.d.ts +32 -0
  12. package/dist/client/errors.d.ts.map +1 -0
  13. package/dist/client/errors.js +43 -0
  14. package/dist/client/errors.js.map +1 -0
  15. package/dist/client/index.d.ts +7 -0
  16. package/dist/client/index.d.ts.map +1 -0
  17. package/dist/client/index.js +4 -0
  18. package/dist/client/index.js.map +1 -0
  19. package/dist/client/operations.d.ts +240 -0
  20. package/dist/client/operations.d.ts.map +1 -0
  21. package/dist/client/operations.js +700 -0
  22. package/dist/client/operations.js.map +1 -0
  23. package/dist/client/options.d.ts +79 -0
  24. package/dist/client/options.d.ts.map +1 -0
  25. package/dist/client/options.js +51 -0
  26. package/dist/client/options.js.map +1 -0
  27. package/dist/client/types.d.ts +269 -0
  28. package/dist/client/types.d.ts.map +1 -0
  29. package/dist/client/types.js +24 -0
  30. package/dist/client/types.js.map +1 -0
  31. package/dist/component/_generated/api.d.ts +40 -0
  32. package/dist/component/_generated/api.d.ts.map +1 -0
  33. package/dist/component/_generated/api.js +31 -0
  34. package/dist/component/_generated/api.js.map +1 -0
  35. package/dist/component/_generated/component.d.ts +253 -0
  36. package/dist/component/_generated/component.d.ts.map +1 -0
  37. package/dist/component/_generated/component.js +11 -0
  38. package/dist/component/_generated/component.js.map +1 -0
  39. package/dist/component/_generated/dataModel.d.ts +46 -0
  40. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  41. package/dist/component/_generated/dataModel.js +11 -0
  42. package/dist/component/_generated/dataModel.js.map +1 -0
  43. package/dist/component/_generated/server.d.ts +121 -0
  44. package/dist/component/_generated/server.d.ts.map +1 -0
  45. package/dist/component/_generated/server.js +78 -0
  46. package/dist/component/_generated/server.js.map +1 -0
  47. package/dist/component/cleanup.d.ts +29 -0
  48. package/dist/component/cleanup.d.ts.map +1 -0
  49. package/dist/component/cleanup.js +70 -0
  50. package/dist/component/cleanup.js.map +1 -0
  51. package/dist/component/convex.config.d.ts +3 -0
  52. package/dist/component/convex.config.d.ts.map +1 -0
  53. package/dist/component/convex.config.js +3 -0
  54. package/dist/component/convex.config.js.map +1 -0
  55. package/dist/component/crons.d.ts +3 -0
  56. package/dist/component/crons.d.ts.map +1 -0
  57. package/dist/component/crons.js +7 -0
  58. package/dist/component/crons.js.map +1 -0
  59. package/dist/component/lib.d.ts +323 -0
  60. package/dist/component/lib.d.ts.map +1 -0
  61. package/dist/component/lib.js +659 -0
  62. package/dist/component/lib.js.map +1 -0
  63. package/dist/component/schema.d.ts +82 -0
  64. package/dist/component/schema.d.ts.map +1 -0
  65. package/dist/component/schema.js +38 -0
  66. package/dist/component/schema.js.map +1 -0
  67. package/dist/component/sweep.d.ts +27 -0
  68. package/dist/component/sweep.d.ts.map +1 -0
  69. package/dist/component/sweep.js +94 -0
  70. package/dist/component/sweep.js.map +1 -0
  71. package/dist/shared.d.ts +11 -0
  72. package/dist/shared.d.ts.map +1 -0
  73. package/dist/shared.js +11 -0
  74. package/dist/shared.js.map +1 -0
  75. package/package.json +116 -0
  76. package/src/client/__tests__/contracts.test.ts +109 -0
  77. package/src/client/__tests__/errors.test.ts +133 -0
  78. package/src/client/__tests__/hooks.test.ts +154 -0
  79. package/src/client/__tests__/operations.test.ts +742 -0
  80. package/src/client/__tests__/setup.test.ts +31 -0
  81. package/src/client/_generated/_ignore.ts +1 -0
  82. package/src/client/crypto.ts +64 -0
  83. package/src/client/errors.ts +67 -0
  84. package/src/client/index.ts +44 -0
  85. package/src/client/operations.ts +881 -0
  86. package/src/client/options.ts +146 -0
  87. package/src/client/types.ts +313 -0
  88. package/src/component/__tests__/cleanup.test.ts +472 -0
  89. package/src/component/__tests__/lib.test.ts +676 -0
  90. package/src/component/__tests__/setup.test.ts +11 -0
  91. package/src/component/_generated/api.ts +56 -0
  92. package/src/component/_generated/component.ts +300 -0
  93. package/src/component/_generated/dataModel.ts +60 -0
  94. package/src/component/_generated/server.ts +156 -0
  95. package/src/component/cleanup.ts +85 -0
  96. package/src/component/convex.config.ts +3 -0
  97. package/src/component/crons.ts +20 -0
  98. package/src/component/lib.ts +843 -0
  99. package/src/component/schema.ts +49 -0
  100. package/src/component/sweep.ts +117 -0
  101. package/src/shared.ts +18 -0
  102. package/src/test.ts +18 -0
@@ -0,0 +1,742 @@
1
+ import { afterEach, describe, expect, test, vi } from "vitest";
2
+ import { ApiKeys } from "../index.js";
3
+ import { components, initConvexTest } from "./setup.test.js";
4
+ import type { RunMutationCtx, RunQueryCtx } from "../types.js";
5
+
6
+ function ctxFrom(t: ReturnType<typeof initConvexTest>) {
7
+ const mutationCtx: RunMutationCtx = {
8
+ runMutation: (mutation, args) => t.mutation(mutation, args),
9
+ };
10
+ const queryCtx: RunQueryCtx = {
11
+ runQuery: (query, args) => t.query(query, args),
12
+ };
13
+ return { mutationCtx, queryCtx };
14
+ }
15
+
16
+ afterEach(() => {
17
+ vi.useRealTimers();
18
+ });
19
+
20
+ // ---------------------------------------------------------------------------
21
+ // create
22
+ // ---------------------------------------------------------------------------
23
+
24
+ describe("create", () => {
25
+ test("generates token and stores only hash", async () => {
26
+ const t = initConvexTest();
27
+ const client = new ApiKeys<{ namespace: string }>(components.apiKeys, {});
28
+ const { mutationCtx } = ctxFrom(t);
29
+
30
+ const result = await client.create(mutationCtx, {
31
+ namespace: "team_alpha",
32
+ name: "test key",
33
+ });
34
+
35
+ expect(result.keyId).toBeDefined();
36
+ expect(result.token).toMatch(/^ak_/);
37
+ expect(result.tokenLast4).toBe(result.token.slice(-4));
38
+ expect(result.token.length).toBeGreaterThan(20);
39
+ });
40
+
41
+ test("applies permissionDefaults when create args omit permissions", async () => {
42
+ const t = initConvexTest();
43
+ const client = new ApiKeys<{
44
+ namespace: string;
45
+ permissions: { beacon: string[] };
46
+ }>(components.apiKeys, {
47
+ permissionDefaults: {
48
+ beacon: ["events:write", "reports:read"],
49
+ },
50
+ });
51
+ const { mutationCtx, queryCtx } = ctxFrom(t);
52
+
53
+ const created = await client.create(mutationCtx, {
54
+ namespace: "team_alpha",
55
+ name: "default permissions",
56
+ });
57
+
58
+ const result = await client.validate(queryCtx, { token: created.token });
59
+ expect(result.ok).toBe(true);
60
+ if (result.ok) {
61
+ expect(result.permissions?.beacon).toEqual([
62
+ "events:write",
63
+ "reports:read",
64
+ ]);
65
+ }
66
+ });
67
+ });
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // validate
71
+ // ---------------------------------------------------------------------------
72
+
73
+ describe("validate", () => {
74
+ test("returns ok for valid token", async () => {
75
+ const t = initConvexTest();
76
+ const client = new ApiKeys<{
77
+ namespace: string;
78
+ permissions: { beacon: string[] };
79
+ }>(components.apiKeys, {});
80
+ const { mutationCtx, queryCtx } = ctxFrom(t);
81
+
82
+ const created = await client.create(mutationCtx, {
83
+ namespace: "team_alpha",
84
+ name: "validate me",
85
+ permissions: { beacon: ["events:write"] },
86
+ });
87
+
88
+ const result = await client.validate(queryCtx, { token: created.token });
89
+ expect(result.ok).toBe(true);
90
+ if (result.ok) {
91
+ expect(result.keyId).toBe(created.keyId);
92
+ expect(result.namespace).toBe("team_alpha");
93
+ expect(result.permissions?.beacon).toEqual(["events:write"]);
94
+ }
95
+ });
96
+
97
+ test("returns not_found for unknown token", async () => {
98
+ const t = initConvexTest();
99
+ const client = new ApiKeys(components.apiKeys, {});
100
+ const { queryCtx } = ctxFrom(t);
101
+
102
+ const result = await client.validate(queryCtx, {
103
+ token: "ak_missing_token",
104
+ });
105
+ expect(result).toEqual({ ok: false, reason: "not_found" });
106
+ });
107
+
108
+ test("returns expired for key past absolute ttl", async () => {
109
+ vi.useFakeTimers();
110
+ const t = initConvexTest();
111
+ const client = new ApiKeys(components.apiKeys, {});
112
+ const { mutationCtx, queryCtx } = ctxFrom(t);
113
+
114
+ const created = await client.create(mutationCtx, { ttlMs: 1_000 });
115
+ vi.advanceTimersByTime(2_000);
116
+
117
+ const result = await client.validate(queryCtx, { token: created.token });
118
+ expect(result).toEqual({ ok: false, reason: "expired" });
119
+ });
120
+
121
+ test("returns idle_timeout for key past idle window", async () => {
122
+ vi.useFakeTimers();
123
+ const t = initConvexTest();
124
+ const client = new ApiKeys(components.apiKeys, {});
125
+ const { mutationCtx, queryCtx } = ctxFrom(t);
126
+
127
+ const created = await client.create(mutationCtx, { idleTimeoutMs: 1_000 });
128
+ vi.advanceTimersByTime(2_000);
129
+
130
+ const result = await client.validate(queryCtx, { token: created.token });
131
+ expect(result).toEqual({ ok: false, reason: "idle_timeout" });
132
+ });
133
+
134
+ test("throws typed input errors for invalid token input", async () => {
135
+ const client = new ApiKeys(components.apiKeys, { logLevel: "none" });
136
+ const queryCtx: RunQueryCtx = {
137
+ runQuery: async () => {
138
+ throw new Error("should not be called");
139
+ },
140
+ };
141
+
142
+ await expect(
143
+ client.validate(queryCtx, { token: " " }),
144
+ ).rejects.toMatchObject({
145
+ name: "ApiKeysClientError",
146
+ code: "TOKEN_REQUIRED",
147
+ });
148
+ });
149
+ });
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // touch
153
+ // ---------------------------------------------------------------------------
154
+
155
+ describe("touch", () => {
156
+ test("succeeds for active key with idle timeout", async () => {
157
+ const t = initConvexTest();
158
+ const client = new ApiKeys<{ namespace: string }>(components.apiKeys, {});
159
+ const { mutationCtx } = ctxFrom(t);
160
+
161
+ const created = await client.create(mutationCtx, {
162
+ namespace: "team_alpha",
163
+ name: "touch me",
164
+ idleTimeoutMs: 60_000,
165
+ });
166
+
167
+ const touched = await client.touch(mutationCtx, {
168
+ keyId: created.keyId,
169
+ });
170
+
171
+ expect(touched.ok).toBe(true);
172
+ if (touched.ok) {
173
+ expect(touched.keyId).toBe(created.keyId);
174
+ expect(touched.touchedAt).toBeGreaterThan(0);
175
+ }
176
+ });
177
+ });
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // getKey
181
+ // ---------------------------------------------------------------------------
182
+
183
+ describe("getKey", () => {
184
+ test("returns key details for existing key", async () => {
185
+ const t = initConvexTest();
186
+ const client = new ApiKeys<{ namespace: string }>(components.apiKeys, {});
187
+ const { mutationCtx, queryCtx } = ctxFrom(t);
188
+
189
+ const created = await client.create(mutationCtx, {
190
+ namespace: "team_alpha",
191
+ name: "my key",
192
+ permissions: { api: ["read"] },
193
+ });
194
+
195
+ const result = await client.getKey(queryCtx, { keyId: created.keyId });
196
+ expect(result.ok).toBe(true);
197
+ if (!result.ok) return;
198
+
199
+ expect(result.keyId).toBe(created.keyId);
200
+ expect(result.namespace).toBe("team_alpha");
201
+ expect(result.name).toBe("my key");
202
+ expect(result.tokenLast4).toBe(created.tokenLast4);
203
+ expect(result.tokenPrefix).toBe(created.tokenPrefix);
204
+ expect(result.status).toBe("active");
205
+ expect(result.effectiveStatus).toBe("active");
206
+ expect(result.createdAt).toBe(created.createdAt);
207
+ expect(result.permissions?.api).toEqual(["read"]);
208
+ });
209
+
210
+ test("returns ok for revoked key (not not_found)", async () => {
211
+ const t = initConvexTest();
212
+ const client = new ApiKeys(components.apiKeys, {});
213
+ const { mutationCtx, queryCtx } = ctxFrom(t);
214
+
215
+ const created = await client.create(mutationCtx, { name: "temp" });
216
+ await client.invalidate(mutationCtx, { keyId: created.keyId });
217
+
218
+ const result = await client.getKey(queryCtx, { keyId: created.keyId });
219
+ expect(result.ok).toBe(true);
220
+ });
221
+
222
+ test("reflects revoked status after invalidate", async () => {
223
+ const t = initConvexTest();
224
+ const client = new ApiKeys<{ namespace: string }>(components.apiKeys, {});
225
+ const { mutationCtx, queryCtx } = ctxFrom(t);
226
+
227
+ const created = await client.create(mutationCtx, {
228
+ namespace: "team_alpha",
229
+ name: "revoke me",
230
+ });
231
+ await client.invalidate(mutationCtx, {
232
+ keyId: created.keyId,
233
+ reason: "test revocation",
234
+ });
235
+
236
+ const result = await client.getKey(queryCtx, { keyId: created.keyId });
237
+ expect(result.ok).toBe(true);
238
+ if (!result.ok) return;
239
+
240
+ expect(result.status).toBe("revoked");
241
+ expect(result.effectiveStatus).toBe("revoked");
242
+ expect(result.revokedAt).toBeDefined();
243
+ expect(result.revocationReason).toBe("test revocation");
244
+ });
245
+
246
+ test("reflects expired effectiveStatus past ttl", async () => {
247
+ vi.useFakeTimers();
248
+ const t = initConvexTest();
249
+ const client = new ApiKeys(components.apiKeys, {});
250
+ const { mutationCtx, queryCtx } = ctxFrom(t);
251
+
252
+ const created = await client.create(mutationCtx, { ttlMs: 1_000 });
253
+ vi.advanceTimersByTime(2_000);
254
+
255
+ const result = await client.getKey(queryCtx, { keyId: created.keyId });
256
+ expect(result.ok).toBe(true);
257
+ if (!result.ok) return;
258
+
259
+ expect(result.status).toBe("active");
260
+ expect(result.effectiveStatus).toBe("expired");
261
+ });
262
+ });
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // invalidate
266
+ // ---------------------------------------------------------------------------
267
+
268
+ describe("invalidate", () => {
269
+ test("marks key as revoked", async () => {
270
+ const t = initConvexTest();
271
+ const client = new ApiKeys<{ namespace: string }>(components.apiKeys, {});
272
+ const { mutationCtx, queryCtx } = ctxFrom(t);
273
+
274
+ const created = await client.create(mutationCtx, {
275
+ namespace: "team_alpha",
276
+ name: "revoke me",
277
+ });
278
+
279
+ const invalidated = await client.invalidate(mutationCtx, {
280
+ keyId: created.keyId,
281
+ reason: "compromised",
282
+ });
283
+ expect(invalidated.ok).toBe(true);
284
+
285
+ const validated = await client.validate(queryCtx, {
286
+ token: created.token,
287
+ });
288
+ expect(validated).toEqual({ ok: false, reason: "revoked" });
289
+ });
290
+
291
+ test("invalidateAll revokes all active keys in namespace", async () => {
292
+ const t = initConvexTest();
293
+ const client = new ApiKeys<{ namespace: string }>(components.apiKeys, {});
294
+ const { mutationCtx, queryCtx } = ctxFrom(t);
295
+
296
+ const k1 = await client.create(mutationCtx, {
297
+ namespace: "team_alpha",
298
+ name: "bulk key 1",
299
+ });
300
+ const k2 = await client.create(mutationCtx, {
301
+ namespace: "team_alpha",
302
+ name: "bulk key 2",
303
+ });
304
+
305
+ const result = await client.invalidateAll(mutationCtx, {
306
+ namespace: "team_alpha",
307
+ pageSize: 1,
308
+ reason: "bulk test",
309
+ });
310
+
311
+ expect(result.revoked).toBe(2);
312
+ expect(result.pages).toBeGreaterThanOrEqual(2);
313
+
314
+ const v1 = await client.validate(queryCtx, { token: k1.token });
315
+ const v2 = await client.validate(queryCtx, { token: k2.token });
316
+ expect(v1).toEqual({ ok: false, reason: "revoked" });
317
+ expect(v2).toEqual({ ok: false, reason: "revoked" });
318
+ });
319
+
320
+ test("invalidateAll respects before/after creation time filters", async () => {
321
+ vi.useFakeTimers();
322
+ const t = initConvexTest();
323
+ const client = new ApiKeys<{ namespace: string }>(components.apiKeys, {});
324
+ const { mutationCtx, queryCtx } = ctxFrom(t);
325
+
326
+ const early = await client.create(mutationCtx, {
327
+ namespace: "team_filter",
328
+ name: "early key",
329
+ });
330
+
331
+ vi.advanceTimersByTime(2_000);
332
+ const cutoff = Date.now();
333
+
334
+ vi.advanceTimersByTime(2_000);
335
+ const late = await client.create(mutationCtx, {
336
+ namespace: "team_filter",
337
+ name: "late key",
338
+ });
339
+
340
+ const result = await client.invalidateAll(mutationCtx, {
341
+ namespace: "team_filter",
342
+ before: cutoff,
343
+ });
344
+ expect(result.revoked).toBe(1);
345
+
346
+ const earlyValidation = await client.validate(queryCtx, {
347
+ token: early.token,
348
+ });
349
+ const lateValidation = await client.validate(queryCtx, {
350
+ token: late.token,
351
+ });
352
+ expect(earlyValidation).toEqual({ ok: false, reason: "revoked" });
353
+ expect(lateValidation.ok).toBe(true);
354
+ });
355
+ });
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // refresh
359
+ // ---------------------------------------------------------------------------
360
+
361
+ describe("refresh", () => {
362
+ test("rotates key and returns new token", async () => {
363
+ const t = initConvexTest();
364
+ const client = new ApiKeys<{ namespace: string }>(components.apiKeys, {});
365
+ const { mutationCtx, queryCtx } = ctxFrom(t);
366
+
367
+ const created = await client.create(mutationCtx, {
368
+ namespace: "team_alpha",
369
+ name: "rotate me",
370
+ });
371
+
372
+ const refreshed = await client.refresh(mutationCtx, {
373
+ keyId: created.keyId,
374
+ });
375
+ expect(refreshed.ok).toBe(true);
376
+ if (!refreshed.ok) return;
377
+
378
+ const oldValidation = await client.validate(queryCtx, {
379
+ token: created.token,
380
+ });
381
+ expect(oldValidation).toEqual({ ok: false, reason: "revoked" });
382
+
383
+ const newValidation = await client.validate(queryCtx, {
384
+ token: refreshed.token,
385
+ });
386
+ expect(newValidation.ok).toBe(true);
387
+ });
388
+
389
+ test("inherits namespace, name, permissions, and expiresAt", async () => {
390
+ const t = initConvexTest();
391
+ const client = new ApiKeys<{ namespace: string }>(components.apiKeys, {});
392
+ const { mutationCtx, queryCtx } = ctxFrom(t);
393
+
394
+ const created = await client.create(mutationCtx, {
395
+ namespace: "team_alpha",
396
+ name: "inherit me",
397
+ permissions: { api: ["read", "write"] },
398
+ ttlMs: 60_000,
399
+ });
400
+
401
+ const refreshed = await client.refresh(mutationCtx, {
402
+ keyId: created.keyId,
403
+ reason: "rotation",
404
+ });
405
+ expect(refreshed.ok).toBe(true);
406
+ if (!refreshed.ok) return;
407
+
408
+ expect(refreshed.expiresAt).toBe(created.expiresAt);
409
+
410
+ const validated = await client.validate(queryCtx, {
411
+ token: refreshed.token,
412
+ });
413
+ expect(validated.ok).toBe(true);
414
+ if (!validated.ok) return;
415
+
416
+ expect(validated.namespace).toBe("team_alpha");
417
+ expect(validated.name).toBe("inherit me");
418
+ expect(validated.permissions?.api).toEqual(["read", "write"]);
419
+ });
420
+ });
421
+
422
+ // ---------------------------------------------------------------------------
423
+ // update
424
+ // ---------------------------------------------------------------------------
425
+
426
+ describe("update", () => {
427
+ test("updates key name", async () => {
428
+ const t = initConvexTest();
429
+ const client = new ApiKeys<{ namespace: string }>(components.apiKeys, {});
430
+ const { mutationCtx, queryCtx } = ctxFrom(t);
431
+
432
+ const created = await client.create(mutationCtx, {
433
+ namespace: "team_alpha",
434
+ name: "original name",
435
+ });
436
+
437
+ const updated = await client.update(mutationCtx, {
438
+ keyId: created.keyId,
439
+ name: "new name",
440
+ });
441
+ expect(updated.ok).toBe(true);
442
+
443
+ const key = await client.getKey(queryCtx, { keyId: created.keyId });
444
+ expect(key.ok).toBe(true);
445
+ if (!key.ok) return;
446
+ expect(key.name).toBe("new name");
447
+ });
448
+
449
+ test("updates key metadata", async () => {
450
+ const t = initConvexTest();
451
+ const client = new ApiKeys<{
452
+ namespace: string;
453
+ metadata: { source: string; owner?: string };
454
+ }>(components.apiKeys, {});
455
+ const { mutationCtx, queryCtx } = ctxFrom(t);
456
+
457
+ const created = await client.create(mutationCtx, {
458
+ namespace: "team_alpha",
459
+ name: "meta key",
460
+ metadata: { source: "test" },
461
+ });
462
+
463
+ await client.update(mutationCtx, {
464
+ keyId: created.keyId,
465
+ metadata: { source: "updated", owner: "alice" },
466
+ });
467
+
468
+ const key = await client.getKey(queryCtx, { keyId: created.keyId });
469
+ expect(key.ok).toBe(true);
470
+ if (!key.ok) return;
471
+ expect(key.metadata).toEqual({ source: "updated", owner: "alice" });
472
+ });
473
+
474
+ test("updates expiresAt to a future timestamp", async () => {
475
+ const t = initConvexTest();
476
+ const client = new ApiKeys(components.apiKeys, {});
477
+ const { mutationCtx, queryCtx } = ctxFrom(t);
478
+
479
+ const created = await client.create(mutationCtx, { name: "expiry key" });
480
+ expect(created.expiresAt).toBeUndefined();
481
+
482
+ const newExpiry = Date.now() + 60_000;
483
+ await client.update(mutationCtx, {
484
+ keyId: created.keyId,
485
+ expiresAt: newExpiry,
486
+ });
487
+
488
+ const key = await client.getKey(queryCtx, { keyId: created.keyId });
489
+ expect(key.ok).toBe(true);
490
+ if (!key.ok) return;
491
+ expect(key.expiresAt).toBe(newExpiry);
492
+ });
493
+
494
+ test("removes expiresAt when passed null", async () => {
495
+ const t = initConvexTest();
496
+ const client = new ApiKeys(components.apiKeys, {});
497
+ const { mutationCtx, queryCtx } = ctxFrom(t);
498
+
499
+ const created = await client.create(mutationCtx, {
500
+ name: "remove expiry",
501
+ ttlMs: 60_000,
502
+ });
503
+ expect(created.expiresAt).toBeDefined();
504
+
505
+ await client.update(mutationCtx, {
506
+ keyId: created.keyId,
507
+ expiresAt: null,
508
+ });
509
+
510
+ const key = await client.getKey(queryCtx, { keyId: created.keyId });
511
+ expect(key.ok).toBe(true);
512
+ if (!key.ok) return;
513
+ expect(key.expiresAt).toBeUndefined();
514
+ });
515
+
516
+ test("returns not_found for unknown keyId", async () => {
517
+ const t = initConvexTest();
518
+ const client = new ApiKeys(components.apiKeys, {});
519
+ const { mutationCtx } = ctxFrom(t);
520
+
521
+ const created = await client.create(mutationCtx, { name: "temp" });
522
+ const t2 = initConvexTest();
523
+ const mutationCtx2: RunMutationCtx = {
524
+ runMutation: (mutation, args) => t2.mutation(mutation, args),
525
+ };
526
+
527
+ const result = await client.update(mutationCtx2, {
528
+ keyId: created.keyId,
529
+ name: "ghost",
530
+ });
531
+ expect(result).toEqual({ ok: false, reason: "not_found" });
532
+ });
533
+
534
+ test("updated name is visible via validate", async () => {
535
+ const t = initConvexTest();
536
+ const client = new ApiKeys(components.apiKeys, {});
537
+ const { mutationCtx, queryCtx } = ctxFrom(t);
538
+
539
+ const created = await client.create(mutationCtx, { name: "old name" });
540
+
541
+ await client.update(mutationCtx, {
542
+ keyId: created.keyId,
543
+ name: "renamed",
544
+ });
545
+
546
+ const result = await client.validate(queryCtx, { token: created.token });
547
+ expect(result.ok).toBe(true);
548
+ if (!result.ok) return;
549
+ expect(result.name).toBe("renamed");
550
+ });
551
+ });
552
+
553
+ // ---------------------------------------------------------------------------
554
+ // listKeys / listEvents / listKeyEvents
555
+ // ---------------------------------------------------------------------------
556
+
557
+ describe("list operations", () => {
558
+ test("listKeys returns paginated keys filtered by namespace", async () => {
559
+ const t = initConvexTest();
560
+ const client = new ApiKeys<{ namespace: string }>(components.apiKeys, {});
561
+ const { mutationCtx, queryCtx } = ctxFrom(t);
562
+
563
+ await client.create(mutationCtx, {
564
+ namespace: "team_alpha",
565
+ name: "list key 1",
566
+ });
567
+ await client.create(mutationCtx, {
568
+ namespace: "team_alpha",
569
+ name: "list key 2",
570
+ });
571
+ await client.create(mutationCtx, {
572
+ namespace: "team_beta",
573
+ name: "list key 3",
574
+ });
575
+
576
+ const page = await client.listKeys(queryCtx, {
577
+ namespace: "team_alpha",
578
+ paginationOpts: { numItems: 2, cursor: null },
579
+ });
580
+ expect(page.page).toHaveLength(2);
581
+ expect(
582
+ page.page.every(
583
+ (row: { namespace?: string }) => row.namespace === "team_alpha",
584
+ ),
585
+ ).toBe(true);
586
+ });
587
+
588
+ test("listKeys defaults to desc (newest first)", async () => {
589
+ vi.useFakeTimers();
590
+ const t = initConvexTest();
591
+ const client = new ApiKeys<{ namespace: string }>(components.apiKeys, {});
592
+ const { mutationCtx, queryCtx } = ctxFrom(t);
593
+
594
+ const k1 = await client.create(mutationCtx, {
595
+ namespace: "ns",
596
+ name: "first",
597
+ });
598
+ vi.advanceTimersByTime(10);
599
+ const k2 = await client.create(mutationCtx, {
600
+ namespace: "ns",
601
+ name: "second",
602
+ });
603
+
604
+ const page = await client.listKeys(queryCtx, {
605
+ namespace: "ns",
606
+ paginationOpts: { numItems: 10, cursor: null },
607
+ });
608
+
609
+ expect(page.page[0].keyId).toBe(k2.keyId);
610
+ expect(page.page[1].keyId).toBe(k1.keyId);
611
+ });
612
+
613
+ test("listKeys with order asc returns oldest first", async () => {
614
+ vi.useFakeTimers();
615
+ const t = initConvexTest();
616
+ const client = new ApiKeys<{ namespace: string }>(components.apiKeys, {});
617
+ const { mutationCtx, queryCtx } = ctxFrom(t);
618
+
619
+ const k1 = await client.create(mutationCtx, {
620
+ namespace: "ns",
621
+ name: "first",
622
+ });
623
+ vi.advanceTimersByTime(10);
624
+ const k2 = await client.create(mutationCtx, {
625
+ namespace: "ns",
626
+ name: "second",
627
+ });
628
+
629
+ const page = await client.listKeys(queryCtx, {
630
+ namespace: "ns",
631
+ order: "asc",
632
+ paginationOpts: { numItems: 10, cursor: null },
633
+ });
634
+
635
+ expect(page.page[0].keyId).toBe(k1.keyId);
636
+ expect(page.page[1].keyId).toBe(k2.keyId);
637
+ });
638
+
639
+ test("listEvents with order asc returns oldest event first", async () => {
640
+ vi.useFakeTimers();
641
+ const t = initConvexTest();
642
+ const client = new ApiKeys<{ namespace: string }>(components.apiKeys, {});
643
+ const { mutationCtx, queryCtx } = ctxFrom(t);
644
+
645
+ await client.create(mutationCtx, { namespace: "ns", name: "key 1" });
646
+ vi.advanceTimersByTime(10);
647
+ await client.create(mutationCtx, { namespace: "ns", name: "key 2" });
648
+
649
+ const asc = await client.listEvents(queryCtx, {
650
+ namespace: "ns",
651
+ order: "asc",
652
+ paginationOpts: { numItems: 10, cursor: null },
653
+ });
654
+ const desc = await client.listEvents(queryCtx, {
655
+ namespace: "ns",
656
+ order: "desc",
657
+ paginationOpts: { numItems: 10, cursor: null },
658
+ });
659
+
660
+ expect(asc.page.length).toBe(2);
661
+ expect(asc.page[0].createdAt).toBeLessThanOrEqual(asc.page[1].createdAt);
662
+ expect(desc.page[0].createdAt).toBeGreaterThanOrEqual(
663
+ desc.page[1].createdAt,
664
+ );
665
+ });
666
+
667
+ test("listEvents filterable by namespace", async () => {
668
+ const t = initConvexTest();
669
+ const client = new ApiKeys<{ namespace: string }>(components.apiKeys, {});
670
+ const { mutationCtx, queryCtx } = ctxFrom(t);
671
+
672
+ await client.create(mutationCtx, { namespace: "ns_a", name: "key a" });
673
+ await client.create(mutationCtx, { namespace: "ns_b", name: "key b" });
674
+
675
+ const all = await client.listEvents(queryCtx, {
676
+ paginationOpts: { numItems: 20, cursor: null },
677
+ });
678
+ expect(all.page.length).toBeGreaterThanOrEqual(2);
679
+
680
+ const filtered = await client.listEvents(queryCtx, {
681
+ namespace: "ns_a",
682
+ paginationOpts: { numItems: 20, cursor: null },
683
+ });
684
+ expect(
685
+ filtered.page.every(
686
+ (e: { namespace?: string }) => e.namespace === "ns_a",
687
+ ),
688
+ ).toBe(true);
689
+ });
690
+
691
+ test("listKeyEvents returns events for key", async () => {
692
+ const t = initConvexTest();
693
+ const client = new ApiKeys<{ namespace: string }>(components.apiKeys, {});
694
+ const { mutationCtx, queryCtx } = ctxFrom(t);
695
+
696
+ const created = await client.create(mutationCtx, {
697
+ namespace: "team_alpha",
698
+ name: "events me",
699
+ });
700
+ await client.invalidate(mutationCtx, { keyId: created.keyId });
701
+
702
+ const events = await client.listKeyEvents(queryCtx, {
703
+ keyId: created.keyId,
704
+ paginationOpts: { numItems: 10, cursor: null },
705
+ });
706
+
707
+ expect(
708
+ events.page.some((event: { type: string }) => event.type === "created"),
709
+ ).toBe(true);
710
+ expect(
711
+ events.page.some((event: { type: string }) => event.type === "revoked"),
712
+ ).toBe(true);
713
+ });
714
+
715
+ test("listKeyEvents with order asc returns created before revoked", async () => {
716
+ const t = initConvexTest();
717
+ const client = new ApiKeys<{ namespace: string }>(components.apiKeys, {});
718
+ const { mutationCtx, queryCtx } = ctxFrom(t);
719
+
720
+ const created = await client.create(mutationCtx, {
721
+ namespace: "ns",
722
+ name: "event key",
723
+ });
724
+ await client.invalidate(mutationCtx, { keyId: created.keyId });
725
+
726
+ const asc = await client.listKeyEvents(queryCtx, {
727
+ keyId: created.keyId,
728
+ order: "asc",
729
+ paginationOpts: { numItems: 10, cursor: null },
730
+ });
731
+ const desc = await client.listKeyEvents(queryCtx, {
732
+ keyId: created.keyId,
733
+ order: "desc",
734
+ paginationOpts: { numItems: 10, cursor: null },
735
+ });
736
+
737
+ expect(asc.page[0].type).toBe("created");
738
+ expect(asc.page[1].type).toBe("revoked");
739
+ expect(desc.page[0].type).toBe("revoked");
740
+ expect(desc.page[1].type).toBe("created");
741
+ });
742
+ });