@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,676 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
4
+ import { api } from "../_generated/api.js";
5
+ import { initConvexTest } from "./setup.test.js";
6
+
7
+ describe("component lib", () => {
8
+ beforeEach(async () => {
9
+ vi.useFakeTimers();
10
+ });
11
+ afterEach(() => {
12
+ vi.useRealTimers();
13
+ });
14
+
15
+ test("create inserts key and created event", async () => {
16
+ const t = initConvexTest();
17
+ const result = await t.mutation(api.lib.create, {
18
+ tokenHash: "hash_1",
19
+ tokenPrefix: "ak_live",
20
+ tokenLast4: "1234",
21
+ namespace: "test",
22
+ name: "test key",
23
+ metadata: { source: "test" },
24
+ });
25
+ expect(result.keyId).toBeDefined();
26
+ expect(result.createdAt).toBeTypeOf("number");
27
+ });
28
+
29
+ test("create throws for duplicate token hash", async () => {
30
+ const t = initConvexTest();
31
+ const payload = {
32
+ tokenHash: "duplicate_hash",
33
+ tokenPrefix: "ak_live",
34
+ tokenLast4: "5678",
35
+ namespace: "test",
36
+ };
37
+ await t.mutation(api.lib.create, payload);
38
+ await expect(t.mutation(api.lib.create, payload)).rejects.toMatchObject({
39
+ data: expect.stringContaining('"code":"invalid_argument"'),
40
+ });
41
+ });
42
+
43
+ test("validate returns success for active key", async () => {
44
+ const t = initConvexTest();
45
+ const now = Date.now();
46
+ await t.mutation(api.lib.create, {
47
+ tokenHash: "hash_validate_ok",
48
+ tokenPrefix: "ak_",
49
+ tokenLast4: "1111",
50
+ namespace: "test",
51
+ name: "validate key",
52
+ permissions: { beacon: ["events:write"] },
53
+ metadata: { source: "test" },
54
+ });
55
+
56
+ const result = await t.query(api.lib.validate, {
57
+ tokenHash: "hash_validate_ok",
58
+ now,
59
+ });
60
+
61
+ expect(result.ok).toBe(true);
62
+ if (result.ok) {
63
+ expect(result.namespace).toBe("test");
64
+ expect(result.name).toBe("validate key");
65
+ expect(result.permissions?.beacon).toEqual(["events:write"]);
66
+ }
67
+ });
68
+
69
+ test("validate returns not_found for unknown token hash", async () => {
70
+ const t = initConvexTest();
71
+ const result = await t.query(api.lib.validate, {
72
+ tokenHash: "hash_missing",
73
+ now: Date.now(),
74
+ });
75
+ expect(result).toEqual({ ok: false, reason: "not_found" });
76
+ });
77
+
78
+ test("validate uses effective expired status when stored status is active", async () => {
79
+ const t = initConvexTest();
80
+ const now = Date.now();
81
+ await t.mutation(api.lib.create, {
82
+ tokenHash: "hash_expired",
83
+ tokenPrefix: "ak_",
84
+ tokenLast4: "2222",
85
+ expiresAt: now - 1,
86
+ });
87
+
88
+ const result = await t.query(api.lib.validate, {
89
+ tokenHash: "hash_expired",
90
+ now,
91
+ });
92
+ expect(result).toEqual({ ok: false, reason: "expired" });
93
+ });
94
+
95
+ test("validate prioritizes revoked over expiration", async () => {
96
+ const t = initConvexTest();
97
+ const now = Date.now();
98
+ const created = await t.mutation(api.lib.create, {
99
+ tokenHash: "hash_revoked",
100
+ tokenPrefix: "ak_",
101
+ tokenLast4: "3333",
102
+ expiresAt: now - 1,
103
+ });
104
+
105
+ await t.run(async (ctx) => {
106
+ await ctx.db.patch(created.keyId, {
107
+ status: "revoked",
108
+ revokedAt: now,
109
+ });
110
+ });
111
+
112
+ const result = await t.query(api.lib.validate, {
113
+ tokenHash: "hash_revoked",
114
+ now,
115
+ });
116
+ expect(result).toEqual({ ok: false, reason: "revoked" });
117
+ });
118
+
119
+ test("touch updates lastUsedAt", async () => {
120
+ const t = initConvexTest();
121
+ const now = Date.now();
122
+ const created = await t.mutation(api.lib.create, {
123
+ tokenHash: "hash_touch_ok",
124
+ tokenPrefix: "ak_",
125
+ tokenLast4: "4444",
126
+ maxIdleMs: 60_000,
127
+ });
128
+
129
+ const touchedAt = now + 1_000;
130
+ const touchResult = await t.mutation(api.lib.touch, {
131
+ keyId: created.keyId,
132
+ now: touchedAt,
133
+ });
134
+
135
+ expect(touchResult).toEqual({
136
+ ok: true,
137
+ keyId: created.keyId,
138
+ touchedAt,
139
+ });
140
+
141
+ const key = await t.run(async (ctx) => ctx.db.get(created.keyId));
142
+ expect(key?.lastUsedAt).toBe(touchedAt);
143
+ });
144
+
145
+ test("touch returns expired when key is no longer active", async () => {
146
+ const t = initConvexTest();
147
+ const now = Date.now();
148
+ const created = await t.mutation(api.lib.create, {
149
+ tokenHash: "hash_touch_expired",
150
+ tokenPrefix: "ak_",
151
+ tokenLast4: "5555",
152
+ expiresAt: now - 1,
153
+ });
154
+
155
+ const result = await t.mutation(api.lib.touch, {
156
+ keyId: created.keyId,
157
+ now,
158
+ });
159
+ expect(result).toEqual({ ok: false, reason: "expired" });
160
+ });
161
+
162
+ test("getKey returns key details with effective status", async () => {
163
+ const t = initConvexTest();
164
+ const now = Date.now();
165
+ const created = await t.mutation(api.lib.create, {
166
+ tokenHash: "hash_getkey",
167
+ tokenPrefix: "ak_",
168
+ tokenLast4: "5001",
169
+ namespace: "team_alpha",
170
+ name: "getkey test",
171
+ metadata: { env: "test" },
172
+ permissions: { beacon: ["events:write"] },
173
+ maxIdleMs: 60_000,
174
+ });
175
+
176
+ const result = await t.query(api.lib.getKey, {
177
+ keyId: created.keyId,
178
+ now,
179
+ });
180
+
181
+ expect(result.ok).toBe(true);
182
+ if (!result.ok) return;
183
+ expect(result.keyId).toBe(created.keyId);
184
+ expect(result.namespace).toBe("team_alpha");
185
+ expect(result.name).toBe("getkey test");
186
+ expect(result.tokenPrefix).toBe("ak_");
187
+ expect(result.tokenLast4).toBe("5001");
188
+ expect(result.metadata).toEqual({ env: "test" });
189
+ expect(result.permissions).toEqual({ beacon: ["events:write"] });
190
+ expect(result.maxIdleMs).toBe(60_000);
191
+ expect(result.status).toBe("active");
192
+ expect(result.effectiveStatus).toBe("active");
193
+ });
194
+
195
+ test("getKey returns not_found for missing key", async () => {
196
+ const t = initConvexTest();
197
+ // Create and delete a key to get a valid-shaped but missing ID
198
+ const created = await t.mutation(api.lib.create, {
199
+ tokenHash: "hash_getkey_missing",
200
+ tokenPrefix: "ak_",
201
+ tokenLast4: "5002",
202
+ });
203
+ await t.run(async (ctx) => ctx.db.delete(created.keyId));
204
+
205
+ const result = await t.query(api.lib.getKey, {
206
+ keyId: created.keyId,
207
+ now: Date.now(),
208
+ });
209
+
210
+ expect(result).toEqual({ ok: false, reason: "not_found" });
211
+ });
212
+
213
+ test("getKey returns effective expired status for time-expired key", async () => {
214
+ const t = initConvexTest();
215
+ const now = Date.now();
216
+ const created = await t.mutation(api.lib.create, {
217
+ tokenHash: "hash_getkey_expired",
218
+ tokenPrefix: "ak_",
219
+ tokenLast4: "5003",
220
+ expiresAt: now - 1,
221
+ });
222
+
223
+ const result = await t.query(api.lib.getKey, {
224
+ keyId: created.keyId,
225
+ now,
226
+ });
227
+
228
+ expect(result.ok).toBe(true);
229
+ if (!result.ok) return;
230
+ expect(result.status).toBe("active");
231
+ expect(result.effectiveStatus).toBe("expired");
232
+ });
233
+
234
+ test("listKeys paginates and filters by namespace", async () => {
235
+ const t = initConvexTest();
236
+ await t.mutation(api.lib.create, {
237
+ tokenHash: "hash_list_1",
238
+ tokenPrefix: "ak_",
239
+ tokenLast4: "1001",
240
+ namespace: "team_alpha",
241
+ });
242
+ await t.mutation(api.lib.create, {
243
+ tokenHash: "hash_list_2",
244
+ tokenPrefix: "ak_",
245
+ tokenLast4: "1002",
246
+ namespace: "team_alpha",
247
+ });
248
+ await t.mutation(api.lib.create, {
249
+ tokenHash: "hash_list_3",
250
+ tokenPrefix: "ak_",
251
+ tokenLast4: "1003",
252
+ namespace: "team_alpha",
253
+ });
254
+ await t.mutation(api.lib.create, {
255
+ tokenHash: "hash_list_4",
256
+ tokenPrefix: "ak_",
257
+ tokenLast4: "1004",
258
+ namespace: "team_beta",
259
+ });
260
+
261
+ const firstPage = await t.query(api.lib.listKeys, {
262
+ namespace: "team_alpha",
263
+ now: Date.now(),
264
+ paginationOpts: { numItems: 2, cursor: null },
265
+ });
266
+ expect(firstPage.page).toHaveLength(2);
267
+ expect(firstPage.page.every((row) => row.namespace === "team_alpha")).toBe(
268
+ true,
269
+ );
270
+ expect(firstPage.isDone).toBe(false);
271
+
272
+ const secondPage = await t.query(api.lib.listKeys, {
273
+ namespace: "team_alpha",
274
+ now: Date.now(),
275
+ paginationOpts: { numItems: 2, cursor: firstPage.continueCursor },
276
+ });
277
+ expect(secondPage.page).toHaveLength(1);
278
+ expect(secondPage.isDone).toBe(true);
279
+ });
280
+
281
+ test("listKeys returns effective status", async () => {
282
+ const t = initConvexTest();
283
+ const now = Date.now();
284
+ await t.mutation(api.lib.create, {
285
+ tokenHash: "hash_list_expired",
286
+ tokenPrefix: "ak_",
287
+ tokenLast4: "2001",
288
+ namespace: "team_alpha",
289
+ expiresAt: now - 1,
290
+ });
291
+
292
+ const result = await t.query(api.lib.listKeys, {
293
+ namespace: "team_alpha",
294
+ now,
295
+ paginationOpts: { numItems: 10, cursor: null },
296
+ });
297
+
298
+ expect(result.page[0]?.status).toBe("active");
299
+ expect(result.page[0]?.effectiveStatus).toBe("expired");
300
+ });
301
+
302
+ test("listKeys filters by status", async () => {
303
+ const t = initConvexTest();
304
+ const now = Date.now();
305
+
306
+ const active = await t.mutation(api.lib.create, {
307
+ tokenHash: "hash_list_status_active",
308
+ tokenPrefix: "ak_",
309
+ tokenLast4: "2101",
310
+ });
311
+ const toRevoke = await t.mutation(api.lib.create, {
312
+ tokenHash: "hash_list_status_revoked",
313
+ tokenPrefix: "ak_",
314
+ tokenLast4: "2102",
315
+ });
316
+ await t.mutation(api.lib.invalidate, {
317
+ keyId: toRevoke.keyId,
318
+ now,
319
+ });
320
+
321
+ const revokedOnly = await t.query(api.lib.listKeys, {
322
+ status: "revoked",
323
+ now,
324
+ paginationOpts: { numItems: 10, cursor: null },
325
+ });
326
+
327
+ expect(revokedOnly.page).toHaveLength(1);
328
+ expect(revokedOnly.page[0]?.keyId).toBe(toRevoke.keyId);
329
+ expect(revokedOnly.page[0]?.status).toBe("revoked");
330
+
331
+ const activeOnly = await t.query(api.lib.listKeys, {
332
+ status: "active",
333
+ now,
334
+ paginationOpts: { numItems: 10, cursor: null },
335
+ });
336
+
337
+ expect(activeOnly.page).toHaveLength(1);
338
+ expect(activeOnly.page[0]?.keyId).toBe(active.keyId);
339
+ expect(activeOnly.page[0]?.status).toBe("active");
340
+ });
341
+
342
+ test("invalidate revokes key and writes event", async () => {
343
+ const t = initConvexTest();
344
+ const created = await t.mutation(api.lib.create, {
345
+ tokenHash: "hash_invalidate_ok",
346
+ tokenPrefix: "ak_",
347
+ tokenLast4: "3001",
348
+ namespace: "team_alpha",
349
+ });
350
+
351
+ const revokedAt = Date.now();
352
+ const result = await t.mutation(api.lib.invalidate, {
353
+ keyId: created.keyId,
354
+ now: revokedAt,
355
+ reason: "manual revoke",
356
+ metadata: { actor: "tester" },
357
+ });
358
+
359
+ expect(result).toEqual({ ok: true, keyId: created.keyId, revokedAt });
360
+
361
+ const key = await t.run(async (ctx) => ctx.db.get(created.keyId));
362
+ expect(key?.status).toBe("revoked");
363
+ expect(key?.revokedAt).toBe(revokedAt);
364
+ expect(key?.revocationReason).toBe("manual revoke");
365
+
366
+ const events = await t.run(async (ctx) =>
367
+ ctx.db
368
+ .query("apiKeyEvents")
369
+ .withIndex("by_key_id", (q) => q.eq("keyId", created.keyId))
370
+ .collect(),
371
+ );
372
+ expect(events.some((event) => event.type === "revoked")).toBe(true);
373
+ });
374
+
375
+ test("invalidate returns revoked for already revoked key", async () => {
376
+ const t = initConvexTest();
377
+ const created = await t.mutation(api.lib.create, {
378
+ tokenHash: "hash_invalidate_revoked",
379
+ tokenPrefix: "ak_",
380
+ tokenLast4: "3002",
381
+ });
382
+
383
+ await t.mutation(api.lib.invalidate, {
384
+ keyId: created.keyId,
385
+ now: Date.now(),
386
+ });
387
+
388
+ const second = await t.mutation(api.lib.invalidate, {
389
+ keyId: created.keyId,
390
+ now: Date.now(),
391
+ });
392
+
393
+ expect(second).toEqual({ ok: false, reason: "revoked" });
394
+ });
395
+
396
+ test("refresh rotates active key and links records", async () => {
397
+ const t = initConvexTest();
398
+ const now = Date.now();
399
+ const created = await t.mutation(api.lib.create, {
400
+ tokenHash: "hash_refresh_source",
401
+ tokenPrefix: "ak_",
402
+ tokenLast4: "4001",
403
+ namespace: "team_alpha",
404
+ name: "rotatable",
405
+ permissions: { beacon: ["events:write"] },
406
+ maxIdleMs: 60_000,
407
+ });
408
+
409
+ const refreshed = await t.mutation(api.lib.refresh, {
410
+ keyId: created.keyId,
411
+ tokenHash: "hash_refresh_new",
412
+ tokenPrefix: "ak_",
413
+ tokenLast4: "9001",
414
+ now: now + 5_000,
415
+ reason: "rotation",
416
+ });
417
+
418
+ expect(refreshed.ok).toBe(true);
419
+ if (!refreshed.ok) return;
420
+
421
+ const oldKey = await t.run(async (ctx) => ctx.db.get(created.keyId));
422
+ const newKey = await t.run(async (ctx) => ctx.db.get(refreshed.keyId));
423
+
424
+ expect(oldKey?.status).toBe("revoked");
425
+ expect(newKey?.replaces).toBe(created.keyId);
426
+ expect(newKey?.status).toBe("active");
427
+ });
428
+
429
+ test("refresh returns revoked for already revoked key", async () => {
430
+ const t = initConvexTest();
431
+ const created = await t.mutation(api.lib.create, {
432
+ tokenHash: "hash_refresh_revoked",
433
+ tokenPrefix: "ak_",
434
+ tokenLast4: "4002",
435
+ });
436
+
437
+ await t.mutation(api.lib.invalidate, {
438
+ keyId: created.keyId,
439
+ now: Date.now(),
440
+ });
441
+
442
+ const refreshed = await t.mutation(api.lib.refresh, {
443
+ keyId: created.keyId,
444
+ tokenHash: "hash_refresh_should_fail",
445
+ tokenPrefix: "ak_",
446
+ tokenLast4: "9999",
447
+ now: Date.now(),
448
+ });
449
+
450
+ expect(refreshed).toEqual({ ok: false, reason: "revoked" });
451
+ });
452
+
453
+ test("refresh rejects expired key", async () => {
454
+ const t = initConvexTest();
455
+ const now = Date.now();
456
+ const created = await t.mutation(api.lib.create, {
457
+ tokenHash: "hash_refresh_expired",
458
+ tokenPrefix: "ak_",
459
+ tokenLast4: "4003",
460
+ expiresAt: now - 1,
461
+ });
462
+
463
+ const result = await t.mutation(api.lib.refresh, {
464
+ keyId: created.keyId,
465
+ tokenHash: "hash_refresh_expired_new",
466
+ tokenPrefix: "ak_",
467
+ tokenLast4: "4004",
468
+ now,
469
+ });
470
+
471
+ expect(result).toEqual({ ok: false, reason: "expired" });
472
+ });
473
+
474
+ test("refresh preserves metadata and maxIdleMs", async () => {
475
+ const t = initConvexTest();
476
+ const now = Date.now();
477
+ const created = await t.mutation(api.lib.create, {
478
+ tokenHash: "hash_refresh_meta",
479
+ tokenPrefix: "ak_",
480
+ tokenLast4: "4005",
481
+ namespace: "team_alpha",
482
+ metadata: { env: "production", tier: "premium" },
483
+ maxIdleMs: 120_000,
484
+ });
485
+
486
+ const refreshed = await t.mutation(api.lib.refresh, {
487
+ keyId: created.keyId,
488
+ tokenHash: "hash_refresh_meta_new",
489
+ tokenPrefix: "ak_",
490
+ tokenLast4: "4006",
491
+ now: now + 5_000,
492
+ });
493
+
494
+ expect(refreshed.ok).toBe(true);
495
+ if (!refreshed.ok) return;
496
+
497
+ const newKey = await t.run(async (ctx) => ctx.db.get(refreshed.keyId));
498
+ expect(newKey?.metadata).toEqual({ env: "production", tier: "premium" });
499
+ expect(newKey?.maxIdleMs).toBe(120_000);
500
+ expect(newKey?.namespace).toBe("team_alpha");
501
+ });
502
+
503
+ test("listKeyEvents returns lifecycle events for a key", async () => {
504
+ const t = initConvexTest();
505
+ const created = await t.mutation(api.lib.create, {
506
+ tokenHash: "hash_events_key",
507
+ tokenPrefix: "ak_",
508
+ tokenLast4: "7001",
509
+ namespace: "team_alpha",
510
+ });
511
+ await t.mutation(api.lib.invalidate, {
512
+ keyId: created.keyId,
513
+ now: Date.now(),
514
+ reason: "manual",
515
+ });
516
+
517
+ const result = await t.query(api.lib.listKeyEvents, {
518
+ keyId: created.keyId,
519
+ paginationOpts: { numItems: 10, cursor: null },
520
+ });
521
+
522
+ expect(result.page.length).toBeGreaterThanOrEqual(2);
523
+ expect(result.page.some((event) => event.type === "created")).toBe(true);
524
+ expect(result.page.some((event) => event.type === "revoked")).toBe(true);
525
+ });
526
+
527
+ test("listEvents filters by namespace", async () => {
528
+ const t = initConvexTest();
529
+ await t.mutation(api.lib.create, {
530
+ tokenHash: "hash_events_ns_1",
531
+ tokenPrefix: "ak_",
532
+ tokenLast4: "8001",
533
+ namespace: "team_alpha",
534
+ });
535
+ await t.mutation(api.lib.create, {
536
+ tokenHash: "hash_events_ns_2",
537
+ tokenPrefix: "ak_",
538
+ tokenLast4: "8002",
539
+ namespace: "team_beta",
540
+ });
541
+
542
+ const result = await t.query(api.lib.listEvents, {
543
+ namespace: "team_alpha",
544
+ paginationOpts: { numItems: 10, cursor: null },
545
+ });
546
+
547
+ expect(result.page.length).toBeGreaterThan(0);
548
+ expect(result.page.every((event) => event.namespace === "team_alpha")).toBe(
549
+ true,
550
+ );
551
+ });
552
+
553
+ test("update rejects revoked key", async () => {
554
+ const t = initConvexTest();
555
+ const created = await t.mutation(api.lib.create, {
556
+ tokenHash: "hash_update_revoked",
557
+ tokenPrefix: "ak_",
558
+ tokenLast4: "6001",
559
+ });
560
+
561
+ await t.mutation(api.lib.invalidate, {
562
+ keyId: created.keyId,
563
+ now: Date.now(),
564
+ });
565
+
566
+ const result = await t.mutation(api.lib.update, {
567
+ keyId: created.keyId,
568
+ name: "should fail",
569
+ logLevel: "none",
570
+ });
571
+
572
+ expect(result).toEqual({ ok: false, reason: "already_revoked" });
573
+ });
574
+
575
+ test("update removes both expiresAt and maxIdleMs with null", async () => {
576
+ const t = initConvexTest();
577
+ const created = await t.mutation(api.lib.create, {
578
+ tokenHash: "hash_update_remove_both",
579
+ tokenPrefix: "ak_",
580
+ tokenLast4: "6002",
581
+ expiresAt: Date.now() + 86_400_000,
582
+ maxIdleMs: 60_000,
583
+ });
584
+
585
+ const result = await t.mutation(api.lib.update, {
586
+ keyId: created.keyId,
587
+ expiresAt: null,
588
+ maxIdleMs: null,
589
+ logLevel: "none",
590
+ });
591
+
592
+ expect(result.ok).toBe(true);
593
+
594
+ const key = await t.run(async (ctx) => ctx.db.get(created.keyId));
595
+ expect(key?.expiresAt).toBeUndefined();
596
+ expect(key?.maxIdleMs).toBeUndefined();
597
+ });
598
+
599
+ test("update accepts expiresAt in the past", async () => {
600
+ const t = initConvexTest();
601
+ const now = Date.now();
602
+ const created = await t.mutation(api.lib.create, {
603
+ tokenHash: "hash_update_past_expiry",
604
+ tokenPrefix: "ak_",
605
+ tokenLast4: "6003",
606
+ });
607
+
608
+ const result = await t.mutation(api.lib.update, {
609
+ keyId: created.keyId,
610
+ expiresAt: now - 1,
611
+ logLevel: "none",
612
+ });
613
+
614
+ expect(result.ok).toBe(true);
615
+
616
+ const key = await t.run(async (ctx) => ctx.db.get(created.keyId));
617
+ expect(key?.expiresAt).toBe(now - 1);
618
+ });
619
+
620
+ test("update accepts empty string name", async () => {
621
+ const t = initConvexTest();
622
+ const created = await t.mutation(api.lib.create, {
623
+ tokenHash: "hash_update_empty_name",
624
+ tokenPrefix: "ak_",
625
+ tokenLast4: "6004",
626
+ name: "original",
627
+ });
628
+
629
+ const result = await t.mutation(api.lib.update, {
630
+ keyId: created.keyId,
631
+ name: "",
632
+ logLevel: "none",
633
+ });
634
+
635
+ expect(result.ok).toBe(true);
636
+
637
+ const key = await t.run(async (ctx) => ctx.db.get(created.keyId));
638
+ expect(key?.name).toBe("");
639
+ });
640
+
641
+ test("invalidateAll revokes active keys in namespace", async () => {
642
+ const t = initConvexTest();
643
+ const a1 = await t.mutation(api.lib.create, {
644
+ tokenHash: "hash_bulk_a1",
645
+ tokenPrefix: "ak_",
646
+ tokenLast4: "9001",
647
+ namespace: "team_alpha",
648
+ });
649
+ const a2 = await t.mutation(api.lib.create, {
650
+ tokenHash: "hash_bulk_a2",
651
+ tokenPrefix: "ak_",
652
+ tokenLast4: "9002",
653
+ namespace: "team_alpha",
654
+ });
655
+ await t.mutation(api.lib.create, {
656
+ tokenHash: "hash_bulk_b1",
657
+ tokenPrefix: "ak_",
658
+ tokenLast4: "9003",
659
+ namespace: "team_beta",
660
+ });
661
+
662
+ const result = await t.mutation(api.lib.invalidateAll, {
663
+ namespace: "team_alpha",
664
+ paginationOpts: { numItems: 10, cursor: null },
665
+ now: Date.now(),
666
+ reason: "bulk revoke",
667
+ });
668
+
669
+ expect(result.revoked).toBe(2);
670
+
671
+ const keyA1 = await t.run(async (ctx) => ctx.db.get(a1.keyId));
672
+ const keyA2 = await t.run(async (ctx) => ctx.db.get(a2.keyId));
673
+ expect(keyA1?.status).toBe("revoked");
674
+ expect(keyA2?.status).toBe("revoked");
675
+ });
676
+ });
@@ -0,0 +1,11 @@
1
+ /// <reference types="vite/client" />
2
+ import { test } from "vitest";
3
+ import schema from "../schema.js";
4
+ import { convexTest } from "convex-test";
5
+ export const modules = import.meta.glob("../**/*.*s");
6
+
7
+ export function initConvexTest() {
8
+ const t = convexTest(schema, modules);
9
+ return t;
10
+ }
11
+ test("setup", () => {});