@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,843 @@
1
+ import { mutation, query } from "./_generated/server.js";
2
+ import type { MutationCtx } from "./_generated/server.js";
3
+ import { v, ConvexError } from "convex/values";
4
+ import { paginationOptsValidator } from "convex/server";
5
+ import { paginator } from "convex-helpers/server/pagination";
6
+ import {
7
+ apiKeyStatusValidator,
8
+ metadataValidator,
9
+ permissionsValidator,
10
+ } from "../shared.js";
11
+ import type { ApiKeyStatus } from "../shared.js";
12
+ import type { Doc, Id } from "./_generated/dataModel.js";
13
+ import schema from "./schema.js";
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Helpers
17
+ // ---------------------------------------------------------------------------
18
+
19
+ type IdleFields = { maxIdleMs?: number; lastUsedAt: number };
20
+
21
+ /**
22
+ * Computes the idle expiry timestamp from `lastUsedAt + maxIdleMs`.
23
+ * Returns `undefined` when idle timeout is not configured.
24
+ */
25
+ function idleExpiresAt(key: IdleFields): number | undefined {
26
+ if (key.maxIdleMs === undefined) return undefined;
27
+ return key.lastUsedAt + key.maxIdleMs;
28
+ }
29
+
30
+ type StatusFields = {
31
+ status: ApiKeyStatus;
32
+ expiresAt?: number;
33
+ } & IdleFields;
34
+
35
+ type EffectiveStatus = "active" | "revoked" | "expired" | "idle_timeout";
36
+
37
+ function effectiveStatus(key: StatusFields, now: number): EffectiveStatus {
38
+ if (key.status === "revoked") return "revoked";
39
+ if (key.expiresAt !== undefined && now >= key.expiresAt) return "expired";
40
+ const idle = idleExpiresAt(key);
41
+ if (idle !== undefined && now >= idle) return "idle_timeout";
42
+ return "active";
43
+ }
44
+
45
+ function mapEventRow(event: Doc<"apiKeyEvents">) {
46
+ return {
47
+ eventId: event._id,
48
+ keyId: event.keyId,
49
+ namespace: event.namespace,
50
+ type: event.type,
51
+ reason: event.reason,
52
+ metadata: event.metadata,
53
+ createdAt: event._creationTime,
54
+ };
55
+ }
56
+
57
+ function throwDuplicateTokenHashError(): never {
58
+ throw new ConvexError({
59
+ code: "invalid_argument",
60
+ message: "token hash already exists",
61
+ });
62
+ }
63
+
64
+ async function recordEvent(
65
+ ctx: MutationCtx,
66
+ keyId: Id<"apiKeys">,
67
+ namespace: string | undefined,
68
+ type: "created" | "revoked" | "rotated",
69
+ reason?: string,
70
+ metadata?: Record<string, any>,
71
+ ): Promise<void> {
72
+ await ctx.db.insert("apiKeyEvents", {
73
+ keyId,
74
+ namespace,
75
+ type,
76
+ reason,
77
+ metadata,
78
+ });
79
+ }
80
+
81
+ // ---------------------------------------------------------------------------
82
+ // Validators
83
+ // ---------------------------------------------------------------------------
84
+
85
+ const effectiveStatusValidator = v.union(
86
+ v.literal("active"),
87
+ v.literal("revoked"),
88
+ v.literal("expired"),
89
+ v.literal("idle_timeout"),
90
+ );
91
+
92
+ const failureReasonValidator = v.union(
93
+ v.literal("not_found"),
94
+ v.literal("revoked"),
95
+ v.literal("expired"),
96
+ v.literal("idle_timeout"),
97
+ );
98
+
99
+ const orderValidator = v.optional(v.union(v.literal("asc"), v.literal("desc")));
100
+
101
+ const logLevelValidator = v.optional(
102
+ v.union(
103
+ v.literal("debug"),
104
+ v.literal("warn"),
105
+ v.literal("error"),
106
+ v.literal("none"),
107
+ ),
108
+ );
109
+
110
+ const createResultValidator = v.object({
111
+ keyId: v.id("apiKeys"),
112
+ createdAt: v.number(),
113
+ });
114
+
115
+ const validateResultValidator = v.union(
116
+ v.object({
117
+ ok: v.literal(true),
118
+ keyId: v.id("apiKeys"),
119
+ namespace: v.optional(v.string()),
120
+ name: v.optional(v.string()),
121
+ permissions: v.optional(permissionsValidator),
122
+ metadata: v.optional(metadataValidator),
123
+ }),
124
+ v.object({
125
+ ok: v.literal(false),
126
+ reason: failureReasonValidator,
127
+ }),
128
+ );
129
+
130
+ const touchResultValidator = v.union(
131
+ v.object({
132
+ ok: v.literal(true),
133
+ keyId: v.id("apiKeys"),
134
+ touchedAt: v.number(),
135
+ }),
136
+ v.object({
137
+ ok: v.literal(false),
138
+ reason: failureReasonValidator,
139
+ }),
140
+ );
141
+
142
+ const invalidateResultValidator = v.union(
143
+ v.object({
144
+ ok: v.literal(true),
145
+ keyId: v.id("apiKeys"),
146
+ revokedAt: v.number(),
147
+ }),
148
+ v.object({
149
+ ok: v.literal(false),
150
+ reason: v.union(v.literal("not_found"), v.literal("revoked")),
151
+ }),
152
+ );
153
+
154
+ const refreshResultValidator = v.union(
155
+ v.object({
156
+ ok: v.literal(true),
157
+ keyId: v.id("apiKeys"),
158
+ replacedKeyId: v.id("apiKeys"),
159
+ createdAt: v.number(),
160
+ expiresAt: v.optional(v.number()),
161
+ }),
162
+ v.object({
163
+ ok: v.literal(false),
164
+ reason: failureReasonValidator,
165
+ }),
166
+ );
167
+
168
+ const invalidateAllResultValidator = v.object({
169
+ processed: v.number(),
170
+ revoked: v.number(),
171
+ isDone: v.boolean(),
172
+ continueCursor: v.string(),
173
+ });
174
+
175
+ const listKeyItemValidator = v.object({
176
+ keyId: v.id("apiKeys"),
177
+ namespace: v.optional(v.string()),
178
+ name: v.optional(v.string()),
179
+ tokenPrefix: v.string(),
180
+ tokenLast4: v.string(),
181
+ permissions: v.optional(permissionsValidator),
182
+ metadata: v.optional(metadataValidator),
183
+ status: apiKeyStatusValidator,
184
+ effectiveStatus: effectiveStatusValidator,
185
+ createdAt: v.number(),
186
+ updatedAt: v.number(),
187
+ lastUsedAt: v.number(),
188
+ expiresAt: v.optional(v.number()),
189
+ maxIdleMs: v.optional(v.number()),
190
+ revokedAt: v.optional(v.number()),
191
+ revocationReason: v.optional(v.string()),
192
+ replaces: v.optional(v.id("apiKeys")),
193
+ });
194
+
195
+ const listKeysResultValidator = v.object({
196
+ page: v.array(listKeyItemValidator),
197
+ isDone: v.boolean(),
198
+ continueCursor: v.string(),
199
+ });
200
+
201
+ const getKeyResultValidator = v.union(
202
+ v.object({
203
+ ok: v.literal(true),
204
+ ...listKeyItemValidator.fields,
205
+ }),
206
+ v.object({
207
+ ok: v.literal(false),
208
+ reason: v.literal("not_found"),
209
+ }),
210
+ );
211
+
212
+ const listEventItemValidator = v.object({
213
+ eventId: v.id("apiKeyEvents"),
214
+ keyId: v.id("apiKeys"),
215
+ namespace: v.optional(v.string()),
216
+ type: v.union(
217
+ v.literal("created"),
218
+ v.literal("revoked"),
219
+ v.literal("rotated"),
220
+ ),
221
+ reason: v.optional(v.string()),
222
+ metadata: v.optional(metadataValidator),
223
+ createdAt: v.number(),
224
+ });
225
+
226
+ const listEventsResultValidator = v.object({
227
+ page: v.array(listEventItemValidator),
228
+ isDone: v.boolean(),
229
+ continueCursor: v.string(),
230
+ });
231
+
232
+ const updateResultValidator = v.union(
233
+ v.object({ ok: v.literal(true), keyId: v.id("apiKeys") }),
234
+ v.object({
235
+ ok: v.literal(false),
236
+ reason: v.union(v.literal("not_found"), v.literal("already_revoked")),
237
+ }),
238
+ );
239
+
240
+ // ---------------------------------------------------------------------------
241
+ // Mutations & Queries
242
+ // ---------------------------------------------------------------------------
243
+
244
+ /**
245
+ * Creates a new key record and emits a creation audit event.
246
+ */
247
+ export const create = mutation({
248
+ args: {
249
+ tokenHash: v.string(),
250
+ tokenPrefix: v.string(),
251
+ tokenLast4: v.string(),
252
+ namespace: v.optional(v.string()),
253
+ name: v.optional(v.string()),
254
+ permissions: v.optional(permissionsValidator),
255
+ metadata: v.optional(metadataValidator),
256
+ expiresAt: v.optional(v.number()),
257
+ maxIdleMs: v.optional(v.number()),
258
+ logLevel: logLevelValidator,
259
+ },
260
+ returns: createResultValidator,
261
+ handler: async (ctx, args) => {
262
+ const existing = await ctx.db
263
+ .query("apiKeys")
264
+ .withIndex("by_token_hash", (q) => q.eq("tokenHash", args.tokenHash))
265
+ .unique();
266
+ if (existing !== null) {
267
+ throwDuplicateTokenHashError();
268
+ }
269
+
270
+ if (
271
+ typeof args.expiresAt === "number" &&
272
+ (!Number.isInteger(args.expiresAt) || args.expiresAt < 0)
273
+ ) {
274
+ throw new ConvexError({
275
+ code: "invalid_argument",
276
+ message: "expiresAt must be a non-negative integer",
277
+ });
278
+ }
279
+ if (
280
+ typeof args.maxIdleMs === "number" &&
281
+ (!Number.isInteger(args.maxIdleMs) || args.maxIdleMs < 0)
282
+ ) {
283
+ throw new ConvexError({
284
+ code: "invalid_argument",
285
+ message: "maxIdleMs must be a non-negative integer",
286
+ });
287
+ }
288
+
289
+ const now = Date.now();
290
+ const keyId = await ctx.db.insert("apiKeys", {
291
+ tokenHash: args.tokenHash,
292
+ tokenPrefix: args.tokenPrefix,
293
+ tokenLast4: args.tokenLast4,
294
+ namespace: args.namespace,
295
+ name: args.name,
296
+ permissions: args.permissions,
297
+ metadata: args.metadata,
298
+ status: "active" as const,
299
+ expiresAt: args.expiresAt,
300
+ maxIdleMs: args.maxIdleMs,
301
+ lastUsedAt: now,
302
+ updatedAt: now,
303
+ });
304
+
305
+ await recordEvent(
306
+ ctx,
307
+ keyId,
308
+ args.namespace,
309
+ "created",
310
+ undefined,
311
+ args.metadata,
312
+ );
313
+
314
+ if (args.logLevel === "debug") {
315
+ console.log("[api-keys:create]", { keyId, namespace: args.namespace });
316
+ }
317
+
318
+ return { keyId, createdAt: now };
319
+ },
320
+ });
321
+
322
+ /**
323
+ * Validates a token hash and returns the matching active key.
324
+ */
325
+ export const validate = query({
326
+ args: {
327
+ tokenHash: v.string(),
328
+ now: v.number(),
329
+ logLevel: logLevelValidator,
330
+ },
331
+ returns: validateResultValidator,
332
+ handler: async (ctx, args) => {
333
+ const key = await ctx.db
334
+ .query("apiKeys")
335
+ .withIndex("by_token_hash", (q) => q.eq("tokenHash", args.tokenHash))
336
+ .unique();
337
+
338
+ if (key === null) {
339
+ if (args.logLevel === "debug") {
340
+ console.log("[api-keys:validate]", { status: "not_found" });
341
+ }
342
+ return { ok: false as const, reason: "not_found" as const };
343
+ }
344
+
345
+ const status = effectiveStatus(key, args.now);
346
+
347
+ if (args.logLevel === "debug") {
348
+ console.log("[api-keys:validate]", { keyId: key._id, status });
349
+ }
350
+
351
+ if (status !== "active") {
352
+ return { ok: false as const, reason: status };
353
+ }
354
+
355
+ return {
356
+ ok: true as const,
357
+ keyId: key._id,
358
+ namespace: key.namespace,
359
+ name: key.name,
360
+ permissions: key.permissions,
361
+ metadata: key.metadata,
362
+ };
363
+ },
364
+ });
365
+
366
+ /**
367
+ * Marks a key as recently used and extends idle expiry when configured.
368
+ */
369
+ export const touch = mutation({
370
+ args: {
371
+ keyId: v.id("apiKeys"),
372
+ now: v.number(),
373
+ },
374
+ returns: touchResultValidator,
375
+ handler: async (ctx, args) => {
376
+ const key = await ctx.db.get(args.keyId);
377
+ if (key === null) {
378
+ return { ok: false as const, reason: "not_found" as const };
379
+ }
380
+
381
+ const status = effectiveStatus(key, args.now);
382
+ if (status !== "active") {
383
+ return { ok: false as const, reason: status };
384
+ }
385
+
386
+ await ctx.db.patch(key._id, {
387
+ lastUsedAt: args.now,
388
+ updatedAt: args.now,
389
+ });
390
+
391
+ return {
392
+ ok: true as const,
393
+ keyId: key._id,
394
+ touchedAt: args.now,
395
+ };
396
+ },
397
+ });
398
+
399
+ /**
400
+ * Lists API keys with derived effective status.
401
+ */
402
+ export const listKeys = query({
403
+ args: {
404
+ paginationOpts: paginationOptsValidator,
405
+ namespace: v.optional(v.string()),
406
+ status: v.optional(apiKeyStatusValidator),
407
+ now: v.number(),
408
+ order: orderValidator,
409
+ },
410
+ returns: listKeysResultValidator,
411
+ handler: async (ctx, args) => {
412
+ const pages = paginator(ctx.db, schema).query("apiKeys");
413
+ const order = args.order ?? "desc";
414
+
415
+ let result;
416
+ if (args.namespace !== undefined) {
417
+ result = await pages
418
+ .withIndex("by_namespace_and_status", (q) => {
419
+ const q1 = q.eq("namespace", args.namespace);
420
+ return args.status ? q1.eq("status", args.status) : q1;
421
+ })
422
+ .order(order)
423
+ .paginate(args.paginationOpts);
424
+ } else if (args.status !== undefined) {
425
+ result = await pages
426
+ .withIndex("by_status", (q) => q.eq("status", args.status!))
427
+ .order(order)
428
+ .paginate(args.paginationOpts);
429
+ } else {
430
+ result = await pages.order(order).paginate(args.paginationOpts);
431
+ }
432
+
433
+ return {
434
+ isDone: result.isDone,
435
+ continueCursor: result.continueCursor,
436
+ page: result.page.map((key) => ({
437
+ keyId: key._id,
438
+ namespace: key.namespace,
439
+ name: key.name,
440
+ tokenPrefix: key.tokenPrefix,
441
+ tokenLast4: key.tokenLast4,
442
+ permissions: key.permissions,
443
+ metadata: key.metadata,
444
+ status: key.status,
445
+ effectiveStatus: effectiveStatus(key, args.now),
446
+ createdAt: key._creationTime,
447
+ updatedAt: key.updatedAt,
448
+ lastUsedAt: key.lastUsedAt,
449
+ expiresAt: key.expiresAt,
450
+ maxIdleMs: key.maxIdleMs,
451
+ revokedAt: key.revokedAt,
452
+ revocationReason: key.revocationReason,
453
+ replaces: key.replaces,
454
+ })),
455
+ };
456
+ },
457
+ });
458
+
459
+ /**
460
+ * Fetches a single API key by ID with its derived effective status.
461
+ */
462
+ export const getKey = query({
463
+ args: {
464
+ keyId: v.id("apiKeys"),
465
+ now: v.number(),
466
+ },
467
+ returns: getKeyResultValidator,
468
+ handler: async (ctx, args) => {
469
+ const key = await ctx.db.get(args.keyId);
470
+ if (key === null) {
471
+ return { ok: false as const, reason: "not_found" as const };
472
+ }
473
+
474
+ return {
475
+ ok: true as const,
476
+ keyId: key._id,
477
+ namespace: key.namespace,
478
+ name: key.name,
479
+ tokenPrefix: key.tokenPrefix,
480
+ tokenLast4: key.tokenLast4,
481
+ permissions: key.permissions,
482
+ metadata: key.metadata,
483
+ status: key.status,
484
+ effectiveStatus: effectiveStatus(key, args.now),
485
+ createdAt: key._creationTime,
486
+ updatedAt: key.updatedAt,
487
+ lastUsedAt: key.lastUsedAt,
488
+ expiresAt: key.expiresAt,
489
+ maxIdleMs: key.maxIdleMs,
490
+ revokedAt: key.revokedAt,
491
+ revocationReason: key.revocationReason,
492
+ replaces: key.replaces,
493
+ };
494
+ },
495
+ });
496
+
497
+ /**
498
+ * Lists audit events for a single key.
499
+ */
500
+ export const listKeyEvents = query({
501
+ args: {
502
+ keyId: v.id("apiKeys"),
503
+ paginationOpts: paginationOptsValidator,
504
+ order: orderValidator,
505
+ },
506
+ returns: listEventsResultValidator,
507
+ handler: async (ctx, args) => {
508
+ const result = await paginator(ctx.db, schema)
509
+ .query("apiKeyEvents")
510
+ .withIndex("by_key_id", (q) => q.eq("keyId", args.keyId))
511
+ .order(args.order ?? "desc")
512
+ .paginate(args.paginationOpts);
513
+
514
+ return {
515
+ isDone: result.isDone,
516
+ continueCursor: result.continueCursor,
517
+ page: result.page.map(mapEventRow),
518
+ };
519
+ },
520
+ });
521
+
522
+ /**
523
+ * Lists audit events across all keys, optionally scoped by namespace.
524
+ */
525
+ export const listEvents = query({
526
+ args: {
527
+ paginationOpts: paginationOptsValidator,
528
+ namespace: v.optional(v.string()),
529
+ order: orderValidator,
530
+ },
531
+ returns: listEventsResultValidator,
532
+ handler: async (ctx, args) => {
533
+ const pages = paginator(ctx.db, schema).query("apiKeyEvents");
534
+ const order = args.order ?? "desc";
535
+ const result =
536
+ args.namespace === undefined
537
+ ? await pages.order(order).paginate(args.paginationOpts)
538
+ : await pages
539
+ .withIndex("by_namespace", (q) => q.eq("namespace", args.namespace))
540
+ .order(order)
541
+ .paginate(args.paginationOpts);
542
+
543
+ return {
544
+ isDone: result.isDone,
545
+ continueCursor: result.continueCursor,
546
+ page: result.page.map(mapEventRow),
547
+ };
548
+ },
549
+ });
550
+
551
+ /**
552
+ * Revokes a single key and records the revocation event.
553
+ */
554
+ export const invalidate = mutation({
555
+ args: {
556
+ keyId: v.id("apiKeys"),
557
+ now: v.number(),
558
+ reason: v.optional(v.string()),
559
+ metadata: v.optional(metadataValidator),
560
+ logLevel: logLevelValidator,
561
+ },
562
+ returns: invalidateResultValidator,
563
+ handler: async (ctx, args) => {
564
+ const key = await ctx.db.get(args.keyId);
565
+ if (key === null) {
566
+ return { ok: false as const, reason: "not_found" as const };
567
+ }
568
+
569
+ if (key.status === "revoked") {
570
+ return { ok: false as const, reason: "revoked" as const };
571
+ }
572
+
573
+ await ctx.db.patch(key._id, {
574
+ status: "revoked" as const,
575
+ revokedAt: args.now,
576
+ revocationReason: args.reason,
577
+ updatedAt: args.now,
578
+ });
579
+
580
+ await recordEvent(
581
+ ctx,
582
+ key._id,
583
+ key.namespace,
584
+ "revoked",
585
+ args.reason,
586
+ args.metadata,
587
+ );
588
+
589
+ if (args.logLevel === "debug") {
590
+ console.log("[api-keys:invalidate]", { keyId: key._id });
591
+ }
592
+
593
+ return { ok: true as const, keyId: key._id, revokedAt: args.now };
594
+ },
595
+ });
596
+
597
+ /**
598
+ * Revokes active keys in pages using optional namespace/time filters.
599
+ */
600
+ export const invalidateAll = mutation({
601
+ args: {
602
+ paginationOpts: paginationOptsValidator,
603
+ namespace: v.optional(v.string()),
604
+ before: v.optional(v.number()),
605
+ after: v.optional(v.number()),
606
+ now: v.number(),
607
+ reason: v.optional(v.string()),
608
+ metadata: v.optional(metadataValidator),
609
+ logLevel: logLevelValidator,
610
+ },
611
+ returns: invalidateAllResultValidator,
612
+ handler: async (ctx, args) => {
613
+ const pages = paginator(ctx.db, schema).query("apiKeys");
614
+ const result =
615
+ args.namespace !== undefined
616
+ ? await pages
617
+ .withIndex("by_namespace_and_status", (q) =>
618
+ q.eq("namespace", args.namespace).eq("status", "active"),
619
+ )
620
+ .order("desc")
621
+ .paginate(args.paginationOpts)
622
+ : await pages
623
+ .withIndex("by_status", (q) => q.eq("status", "active"))
624
+ .order("desc")
625
+ .paginate(args.paginationOpts);
626
+
627
+ const toInvalidate = result.page.filter(
628
+ (key) =>
629
+ (args.before === undefined || key._creationTime < args.before) &&
630
+ (args.after === undefined || key._creationTime > args.after),
631
+ );
632
+
633
+ for (const key of toInvalidate) {
634
+ await ctx.db.patch(key._id, {
635
+ status: "revoked" as const,
636
+ revokedAt: args.now,
637
+ revocationReason: args.reason,
638
+ updatedAt: args.now,
639
+ });
640
+
641
+ await recordEvent(
642
+ ctx,
643
+ key._id,
644
+ key.namespace,
645
+ "revoked",
646
+ args.reason,
647
+ args.metadata,
648
+ );
649
+ }
650
+
651
+ const processed = result.page.length;
652
+ const revoked = toInvalidate.length;
653
+
654
+ if (args.logLevel === "debug") {
655
+ console.log("[api-keys:invalidateAll]", {
656
+ processed,
657
+ revoked,
658
+ isDone: result.isDone,
659
+ namespace: args.namespace,
660
+ });
661
+ }
662
+
663
+ return {
664
+ processed,
665
+ revoked,
666
+ isDone: result.isDone,
667
+ continueCursor: result.continueCursor,
668
+ };
669
+ },
670
+ });
671
+
672
+ /**
673
+ * Updates mutable key properties (name, metadata, expiry).
674
+ * Passing `expiresAt: null` removes the expiry field entirely.
675
+ */
676
+ export const update = mutation({
677
+ args: {
678
+ keyId: v.id("apiKeys"),
679
+ name: v.optional(v.string()),
680
+ metadata: v.optional(metadataValidator),
681
+ expiresAt: v.optional(v.union(v.number(), v.null())),
682
+ maxIdleMs: v.optional(v.union(v.number(), v.null())),
683
+ logLevel: logLevelValidator,
684
+ },
685
+ returns: updateResultValidator,
686
+ handler: async (ctx, args) => {
687
+ const key = await ctx.db.get(args.keyId);
688
+ if (key === null) {
689
+ return { ok: false as const, reason: "not_found" as const };
690
+ }
691
+ if (key.status === "revoked") {
692
+ return { ok: false as const, reason: "already_revoked" as const };
693
+ }
694
+
695
+ if (
696
+ typeof args.expiresAt === "number" &&
697
+ (!Number.isInteger(args.expiresAt) || args.expiresAt < 0)
698
+ ) {
699
+ throw new ConvexError({
700
+ code: "invalid_argument",
701
+ message: "expiresAt must be a non-negative integer or null",
702
+ });
703
+ }
704
+ if (
705
+ typeof args.maxIdleMs === "number" &&
706
+ (!Number.isInteger(args.maxIdleMs) || args.maxIdleMs < 0)
707
+ ) {
708
+ throw new ConvexError({
709
+ code: "invalid_argument",
710
+ message: "maxIdleMs must be a non-negative integer or null",
711
+ });
712
+ }
713
+
714
+ const now = Date.now();
715
+
716
+ const removeExpiresAt = args.expiresAt === null;
717
+ const removeMaxIdleMs = args.maxIdleMs === null;
718
+
719
+ if (removeExpiresAt || removeMaxIdleMs) {
720
+ // Removing optional fields requires replace (patch can't unset).
721
+ const { _id, _creationTime, ...rest } = key;
722
+ const updated = { ...rest, updatedAt: now };
723
+ if (removeExpiresAt)
724
+ delete (updated as Record<string, unknown>).expiresAt;
725
+ if (removeMaxIdleMs)
726
+ delete (updated as Record<string, unknown>).maxIdleMs;
727
+ if (args.name !== undefined) updated.name = args.name;
728
+ if (args.metadata !== undefined) updated.metadata = args.metadata;
729
+ if (typeof args.expiresAt === "number")
730
+ updated.expiresAt = args.expiresAt;
731
+ if (typeof args.maxIdleMs === "number")
732
+ updated.maxIdleMs = args.maxIdleMs;
733
+ await ctx.db.replace(_id, updated);
734
+ } else {
735
+ const patch: {
736
+ updatedAt: number;
737
+ name?: string;
738
+ metadata?: Record<string, any>;
739
+ expiresAt?: number;
740
+ maxIdleMs?: number;
741
+ } = { updatedAt: now };
742
+ if (args.name !== undefined) patch.name = args.name;
743
+ if (args.metadata !== undefined) patch.metadata = args.metadata;
744
+ if (typeof args.expiresAt === "number") patch.expiresAt = args.expiresAt;
745
+ if (typeof args.maxIdleMs === "number") patch.maxIdleMs = args.maxIdleMs;
746
+ await ctx.db.patch(key._id, patch);
747
+ }
748
+
749
+ if (args.logLevel === "debug") {
750
+ console.log("[api-keys:update]", { keyId: key._id });
751
+ }
752
+
753
+ return { ok: true as const, keyId: key._id };
754
+ },
755
+ });
756
+
757
+ /**
758
+ * Rotates a key by revoking the old key and creating a replacement.
759
+ */
760
+ export const refresh = mutation({
761
+ args: {
762
+ keyId: v.id("apiKeys"),
763
+ tokenHash: v.string(),
764
+ tokenPrefix: v.string(),
765
+ tokenLast4: v.string(),
766
+ now: v.number(),
767
+ reason: v.optional(v.string()),
768
+ metadata: v.optional(metadataValidator),
769
+ logLevel: logLevelValidator,
770
+ },
771
+ returns: refreshResultValidator,
772
+ handler: async (ctx, args) => {
773
+ const key = await ctx.db.get(args.keyId);
774
+ if (key === null) {
775
+ return { ok: false as const, reason: "not_found" as const };
776
+ }
777
+
778
+ const status = effectiveStatus(key, args.now);
779
+ if (status !== "active") {
780
+ return { ok: false as const, reason: status };
781
+ }
782
+
783
+ const existing = await ctx.db
784
+ .query("apiKeys")
785
+ .withIndex("by_token_hash", (q) => q.eq("tokenHash", args.tokenHash))
786
+ .unique();
787
+ if (existing !== null) {
788
+ throwDuplicateTokenHashError();
789
+ }
790
+
791
+ const newKeyId = await ctx.db.insert("apiKeys", {
792
+ tokenHash: args.tokenHash,
793
+ tokenPrefix: args.tokenPrefix,
794
+ tokenLast4: args.tokenLast4,
795
+ namespace: key.namespace,
796
+ name: key.name,
797
+ permissions: key.permissions,
798
+ metadata: key.metadata,
799
+ status: "active" as const,
800
+ expiresAt: key.expiresAt,
801
+ maxIdleMs: key.maxIdleMs,
802
+ lastUsedAt: args.now,
803
+ replaces: key._id,
804
+ updatedAt: args.now,
805
+ });
806
+
807
+ await ctx.db.patch(key._id, {
808
+ status: "revoked" as const,
809
+ revokedAt: args.now,
810
+ revocationReason: args.reason,
811
+ updatedAt: args.now,
812
+ });
813
+
814
+ await recordEvent(
815
+ ctx,
816
+ key._id,
817
+ key.namespace,
818
+ "rotated",
819
+ args.reason,
820
+ args.metadata,
821
+ );
822
+ await recordEvent(
823
+ ctx,
824
+ newKeyId,
825
+ key.namespace,
826
+ "created",
827
+ undefined,
828
+ key.metadata,
829
+ );
830
+
831
+ if (args.logLevel === "debug") {
832
+ console.log("[api-keys:refresh]", { oldKeyId: key._id, newKeyId });
833
+ }
834
+
835
+ return {
836
+ ok: true as const,
837
+ keyId: newKeyId,
838
+ replacedKeyId: key._id,
839
+ createdAt: args.now,
840
+ expiresAt: key.expiresAt,
841
+ };
842
+ },
843
+ });