@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,472 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ import { describe, expect, test, vi } from "vitest";
4
+ import { api, internal } from "../_generated/api.js";
5
+ import type { Id } from "../_generated/dataModel.js";
6
+ import { initConvexTest } from "./setup.test.js";
7
+
8
+ // Helper: create a key via the component's create mutation.
9
+ // Default expiresAt is set to FAR_FUTURE so it doesn't interfere with
10
+ // sweep queries. Tests override to control behavior.
11
+ const FAR_FUTURE = Date.now() + 365 * 86_400_000;
12
+
13
+ async function createKey(
14
+ t: ReturnType<typeof initConvexTest>,
15
+ overrides: {
16
+ tokenHash: string;
17
+ expiresAt?: number;
18
+ maxIdleMs?: number;
19
+ lastUsedAt?: number;
20
+ namespace?: string;
21
+ },
22
+ ) {
23
+ const result = await t.mutation(api.lib.create, {
24
+ tokenHash: overrides.tokenHash,
25
+ tokenPrefix: "ak_",
26
+ tokenLast4: "test",
27
+ namespace: overrides.namespace ?? "cleanup-ns",
28
+ expiresAt: overrides.expiresAt ?? FAR_FUTURE,
29
+ maxIdleMs: overrides.maxIdleMs,
30
+ });
31
+
32
+ // If lastUsedAt is provided, patch it directly (simulates a past touch)
33
+ if (overrides.lastUsedAt !== undefined) {
34
+ await t.run(async (ctx) => {
35
+ await ctx.db.patch(result.keyId, { lastUsedAt: overrides.lastUsedAt });
36
+ });
37
+ }
38
+
39
+ return result;
40
+ }
41
+
42
+ async function revokeKey(
43
+ t: ReturnType<typeof initConvexTest>,
44
+ keyId: Id<"apiKeys">,
45
+ now?: number,
46
+ ) {
47
+ return t.mutation(api.lib.invalidate, {
48
+ keyId,
49
+ now: now ?? Date.now(),
50
+ logLevel: "none",
51
+ });
52
+ }
53
+
54
+ async function getKey(
55
+ t: ReturnType<typeof initConvexTest>,
56
+ keyId: Id<"apiKeys">,
57
+ ) {
58
+ return t.query(api.lib.getKey, { keyId, now: Date.now() });
59
+ }
60
+
61
+ async function listKeys(t: ReturnType<typeof initConvexTest>) {
62
+ return t.query(api.lib.listKeys, {
63
+ namespace: "cleanup-ns",
64
+ now: Date.now(),
65
+ paginationOpts: { numItems: 50, cursor: null },
66
+ });
67
+ }
68
+
69
+ const ONE_HOUR = 3_600_000;
70
+ const ONE_DAY = 86_400_000;
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // sweepExpired: marks active keys past absolute TTL as revoked
74
+ // ---------------------------------------------------------------------------
75
+
76
+ describe("sweepExpired", () => {
77
+ test("does not sweep active keys with future expiry", async () => {
78
+ const t = initConvexTest();
79
+ await createKey(t, { tokenHash: "active_key" });
80
+
81
+ const result = await t.mutation(internal.sweep.sweepExpired, {});
82
+
83
+ expect(result.swept).toBe(0);
84
+ expect(result.isDone).toBe(true);
85
+
86
+ const keys = await listKeys(t);
87
+ expect(keys.page).toHaveLength(1);
88
+ expect(keys.page[0].status).toBe("active");
89
+ });
90
+
91
+ test("sweeps time-expired keys to revoked", async () => {
92
+ const t = initConvexTest();
93
+ const past = Date.now() - ONE_DAY * 2;
94
+
95
+ const created = await createKey(t, {
96
+ tokenHash: "expired_key",
97
+ expiresAt: past,
98
+ });
99
+
100
+ const result = await t.mutation(internal.sweep.sweepExpired, {});
101
+
102
+ expect(result.swept).toBe(1);
103
+ expect(result.isDone).toBe(true);
104
+
105
+ const key = await getKey(t, created.keyId);
106
+ expect(key.ok).toBe(true);
107
+ if (key.ok) {
108
+ expect(key.status).toBe("revoked");
109
+ expect(key.revocationReason).toBe("expired");
110
+ }
111
+ });
112
+
113
+ test("records audit event on sweep", async () => {
114
+ const t = initConvexTest();
115
+ const past = Date.now() - ONE_DAY * 2;
116
+
117
+ const created = await createKey(t, {
118
+ tokenHash: "audit_key",
119
+ expiresAt: past,
120
+ });
121
+
122
+ await t.mutation(internal.sweep.sweepExpired, {});
123
+
124
+ const events = await t.query(api.lib.listKeyEvents, {
125
+ keyId: created.keyId,
126
+ paginationOpts: { numItems: 10, cursor: null },
127
+ });
128
+ const types = events.page.map((e: { type: string }) => e.type);
129
+ expect(types).toContain("revoked");
130
+ });
131
+
132
+ test("does not re-sweep already-revoked keys", async () => {
133
+ const t = initConvexTest();
134
+ const past = Date.now() - ONE_DAY * 2;
135
+
136
+ await createKey(t, {
137
+ tokenHash: "no_resweep",
138
+ expiresAt: past,
139
+ });
140
+
141
+ const first = await t.mutation(internal.sweep.sweepExpired, {});
142
+ expect(first.swept).toBe(1);
143
+
144
+ const second = await t.mutation(internal.sweep.sweepExpired, {});
145
+ expect(second.swept).toBe(0);
146
+ });
147
+
148
+ test("does not sweep idle-expired keys", async () => {
149
+ const t = initConvexTest();
150
+ const past = Date.now() - ONE_DAY * 2;
151
+
152
+ await createKey(t, {
153
+ tokenHash: "idle_only",
154
+ maxIdleMs: ONE_HOUR,
155
+ lastUsedAt: past,
156
+ });
157
+
158
+ const result = await t.mutation(internal.sweep.sweepExpired, {});
159
+ expect(result.swept).toBe(0);
160
+ });
161
+
162
+ test("reaches later pages when first page has no expired keys", async () => {
163
+ vi.useFakeTimers();
164
+ const t = initConvexTest();
165
+ const past = Date.now() - ONE_DAY * 2;
166
+
167
+ // First 100 healthy active keys (fill first page)
168
+ for (let i = 0; i < 100; i++) {
169
+ await createKey(t, { tokenHash: `healthy_exp_${i}` });
170
+ }
171
+ // 5 expired keys on later pages
172
+ for (let i = 0; i < 5; i++) {
173
+ await createKey(t, { tokenHash: `expired_late_${i}`, expiresAt: past });
174
+ }
175
+
176
+ const first = await t.mutation(internal.sweep.sweepExpired, {});
177
+ expect(first.swept).toBe(0);
178
+ expect(first.isDone).toBe(false);
179
+
180
+ await t.finishAllScheduledFunctions(() => vi.runAllTimers());
181
+
182
+ const all = await t.run((ctx) => ctx.db.query("apiKeys").collect());
183
+ const revokedExpired = all.filter(
184
+ (k) => k.status === "revoked" && k.revocationReason === "expired",
185
+ );
186
+ expect(revokedExpired).toHaveLength(5);
187
+
188
+ vi.useRealTimers();
189
+ });
190
+ });
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // sweepIdleExpired: marks active keys past idle timeout as revoked
194
+ // ---------------------------------------------------------------------------
195
+
196
+ describe("sweepIdleExpired", () => {
197
+ test("does not sweep active keys with future idle expiry", async () => {
198
+ const t = initConvexTest();
199
+ await createKey(t, { tokenHash: "active_idle", maxIdleMs: ONE_HOUR });
200
+
201
+ const result = await t.mutation(internal.sweep.sweepIdleExpired, {});
202
+
203
+ expect(result.swept).toBe(0);
204
+ expect(result.isDone).toBe(true);
205
+ });
206
+
207
+ test("sweeps idle-expired keys to revoked", async () => {
208
+ const t = initConvexTest();
209
+ const past = Date.now() - ONE_DAY * 2;
210
+
211
+ const created = await createKey(t, {
212
+ tokenHash: "idle_key",
213
+ maxIdleMs: ONE_HOUR,
214
+ lastUsedAt: past,
215
+ });
216
+
217
+ const result = await t.mutation(internal.sweep.sweepIdleExpired, {});
218
+
219
+ expect(result.swept).toBe(1);
220
+ expect(result.isDone).toBe(true);
221
+
222
+ const key = await getKey(t, created.keyId);
223
+ expect(key.ok).toBe(true);
224
+ if (key.ok) {
225
+ expect(key.status).toBe("revoked");
226
+ expect(key.revocationReason).toBe("idle_timeout");
227
+ }
228
+ });
229
+
230
+ test("does not re-sweep already-revoked keys", async () => {
231
+ const t = initConvexTest();
232
+ const past = Date.now() - ONE_DAY * 2;
233
+
234
+ await createKey(t, {
235
+ tokenHash: "idle_no_resweep",
236
+ maxIdleMs: ONE_HOUR,
237
+ lastUsedAt: past,
238
+ });
239
+
240
+ const first = await t.mutation(internal.sweep.sweepIdleExpired, {});
241
+ expect(first.swept).toBe(1);
242
+
243
+ const second = await t.mutation(internal.sweep.sweepIdleExpired, {});
244
+ expect(second.swept).toBe(0);
245
+ });
246
+
247
+ test("does not sweep time-expired keys", async () => {
248
+ const t = initConvexTest();
249
+ const past = Date.now() - ONE_DAY * 2;
250
+
251
+ await createKey(t, {
252
+ tokenHash: "expired_only",
253
+ expiresAt: past,
254
+ });
255
+
256
+ const result = await t.mutation(internal.sweep.sweepIdleExpired, {});
257
+ expect(result.swept).toBe(0);
258
+ });
259
+
260
+ test("reaches later pages when first page has no idle-expired keys", async () => {
261
+ vi.useFakeTimers();
262
+ const t = initConvexTest();
263
+ const past = Date.now() - ONE_DAY * 2;
264
+
265
+ // First 100 healthy active keys (no idle timeout)
266
+ for (let i = 0; i < 100; i++) {
267
+ await createKey(t, { tokenHash: `healthy_idle_${i}` });
268
+ }
269
+ // 5 idle-expired keys on later pages
270
+ for (let i = 0; i < 5; i++) {
271
+ await createKey(t, {
272
+ tokenHash: `idle_late_${i}`,
273
+ maxIdleMs: ONE_HOUR,
274
+ lastUsedAt: past,
275
+ });
276
+ }
277
+
278
+ const first = await t.mutation(internal.sweep.sweepIdleExpired, {});
279
+ expect(first.swept).toBe(0);
280
+ expect(first.isDone).toBe(false);
281
+
282
+ await t.finishAllScheduledFunctions(() => vi.runAllTimers());
283
+
284
+ const all = await t.run((ctx) => ctx.db.query("apiKeys").collect());
285
+ const revokedIdle = all.filter(
286
+ (k) => k.status === "revoked" && k.revocationReason === "idle_timeout",
287
+ );
288
+ expect(revokedIdle).toHaveLength(5);
289
+
290
+ vi.useRealTimers();
291
+ });
292
+ });
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // cleanup (public): hard-deletes revoked keys past retention
296
+ // ---------------------------------------------------------------------------
297
+
298
+ describe("cleanup", () => {
299
+ test("throws for non-positive retentionMs", async () => {
300
+ const t = initConvexTest();
301
+ await expect(
302
+ t.mutation(api.cleanup.cleanupExpired, { retentionMs: 0 }),
303
+ ).rejects.toMatchObject({
304
+ data: expect.stringContaining('"code":"invalid_argument"'),
305
+ });
306
+
307
+ await expect(
308
+ t.mutation(api.cleanup.cleanupExpired, { retentionMs: -1000 }),
309
+ ).rejects.toMatchObject({
310
+ data: expect.stringContaining('"code":"invalid_argument"'),
311
+ });
312
+ });
313
+
314
+ test("returns zero counts when no keys exist", async () => {
315
+ const t = initConvexTest();
316
+ const result = await t.mutation(api.cleanup.cleanupExpired, {
317
+ retentionMs: ONE_DAY,
318
+ });
319
+
320
+ expect(result).toEqual({ deleted: 0, isDone: true });
321
+ });
322
+
323
+ test("does not delete active (non-revoked) keys", async () => {
324
+ const t = initConvexTest();
325
+ await createKey(t, { tokenHash: "active_key" });
326
+
327
+ const result = await t.mutation(api.cleanup.cleanupExpired, {
328
+ retentionMs: ONE_HOUR,
329
+ });
330
+
331
+ expect(result.deleted).toBe(0);
332
+ expect(result.isDone).toBe(true);
333
+
334
+ const keys = await listKeys(t);
335
+ expect(keys.page).toHaveLength(1);
336
+ });
337
+
338
+ test("deletes revoked keys past retention", async () => {
339
+ const t = initConvexTest();
340
+ const past = Date.now() - ONE_DAY * 2;
341
+
342
+ const created = await createKey(t, { tokenHash: "revoked_key" });
343
+ await revokeKey(t, created.keyId, past);
344
+
345
+ const result = await t.mutation(api.cleanup.cleanupExpired, {
346
+ retentionMs: ONE_HOUR,
347
+ });
348
+
349
+ expect(result.deleted).toBe(1);
350
+ expect(result.isDone).toBe(true);
351
+
352
+ const keys = await listKeys(t);
353
+ expect(keys.page).toHaveLength(0);
354
+ });
355
+
356
+ test("does not delete revoked keys still within retention window", async () => {
357
+ const t = initConvexTest();
358
+
359
+ const created = await createKey(t, { tokenHash: "recent_revoked" });
360
+ await revokeKey(t, created.keyId);
361
+
362
+ const result = await t.mutation(api.cleanup.cleanupExpired, {
363
+ retentionMs: ONE_DAY * 30,
364
+ });
365
+
366
+ expect(result.deleted).toBe(0);
367
+ expect(result.isDone).toBe(true);
368
+
369
+ const keys = await listKeys(t);
370
+ expect(keys.page).toHaveLength(1);
371
+ });
372
+
373
+ test("deletes associated audit events alongside key", async () => {
374
+ const t = initConvexTest();
375
+ const past = Date.now() - ONE_DAY * 2;
376
+
377
+ const created = await createKey(t, { tokenHash: "events_key" });
378
+ await revokeKey(t, created.keyId, past);
379
+
380
+ const eventsBefore = await t.query(api.lib.listKeyEvents, {
381
+ keyId: created.keyId,
382
+ paginationOpts: { numItems: 10, cursor: null },
383
+ });
384
+ expect(eventsBefore.page.length).toBeGreaterThan(0);
385
+
386
+ await t.mutation(api.cleanup.cleanupExpired, {
387
+ retentionMs: ONE_HOUR,
388
+ });
389
+
390
+ const eventsAfter = await t.query(api.lib.listKeyEvents, {
391
+ keyId: created.keyId,
392
+ paginationOpts: { numItems: 10, cursor: null },
393
+ });
394
+ expect(eventsAfter.page).toHaveLength(0);
395
+ });
396
+ });
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // Full lifecycle: sweep then cleanup
400
+ // ---------------------------------------------------------------------------
401
+
402
+ describe("sweep + cleanup lifecycle", () => {
403
+ test("expired key is swept then retained until cleanup window passes", async () => {
404
+ const t = initConvexTest();
405
+ const past = Date.now() - ONE_DAY * 2;
406
+
407
+ const created = await createKey(t, {
408
+ tokenHash: "lifecycle_key",
409
+ expiresAt: past,
410
+ });
411
+
412
+ // Sweep marks it as revoked
413
+ const sweep = await t.mutation(internal.sweep.sweepExpired, {});
414
+ expect(sweep.swept).toBe(1);
415
+
416
+ const key = await getKey(t, created.keyId);
417
+ expect(key.ok).toBe(true);
418
+ if (key.ok) {
419
+ expect(key.status).toBe("revoked");
420
+ }
421
+
422
+ // Cleanup with large retention — key was just revoked, not past retention
423
+ const cleanup = await t.mutation(api.cleanup.cleanupExpired, {
424
+ retentionMs: ONE_DAY * 365,
425
+ });
426
+ expect(cleanup.deleted).toBe(0);
427
+
428
+ // Key still exists
429
+ const keys = await listKeys(t);
430
+ expect(keys.page).toHaveLength(1);
431
+ });
432
+
433
+ test("mixed: both sweeps mark their keys, cleanup deletes old revoked", async () => {
434
+ const t = initConvexTest();
435
+ const past = Date.now() - ONE_DAY * 2;
436
+
437
+ // Expired key — will be swept by sweepExpired
438
+ await createKey(t, {
439
+ tokenHash: "mix_expired",
440
+ expiresAt: past,
441
+ });
442
+
443
+ // Idle key — will be swept by sweepIdleExpired
444
+ await createKey(t, {
445
+ tokenHash: "mix_idle",
446
+ maxIdleMs: ONE_HOUR,
447
+ lastUsedAt: past,
448
+ });
449
+
450
+ // Manually revoked key — already revoked, will be deleted by cleanup
451
+ const revokable = await createKey(t, { tokenHash: "mix_revoked" });
452
+ await revokeKey(t, revokable.keyId, past);
453
+
454
+ // Run both sweeps
455
+ const expiredSweep = await t.mutation(internal.sweep.sweepExpired, {});
456
+ expect(expiredSweep.swept).toBe(1);
457
+
458
+ const idleSweep = await t.mutation(internal.sweep.sweepIdleExpired, {});
459
+ expect(idleSweep.swept).toBe(1);
460
+
461
+ // Cleanup — only the manually revoked key (revokedAt=past) is old enough
462
+ const cleanup = await t.mutation(api.cleanup.cleanupExpired, {
463
+ retentionMs: ONE_HOUR,
464
+ });
465
+ expect(cleanup.deleted).toBe(1);
466
+ expect(cleanup.isDone).toBe(true);
467
+
468
+ // Two swept keys remain (revoked but within retention)
469
+ const keys = await listKeys(t);
470
+ expect(keys.page).toHaveLength(2);
471
+ });
472
+ });