@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,881 @@
1
+ import type { ComponentApi } from "../component/_generated/component.js";
2
+ import { generateToken, sha256Base64Url, tokenLast4 } from "./crypto.js";
3
+ import {
4
+ type ApiKeysOptions,
5
+ assertNullableNonNegativeInteger,
6
+ normalizeApiKeysOptions,
7
+ type NormalizedApiKeysOptions,
8
+ } from "./options.js";
9
+ import {
10
+ ApiKeysClientError,
11
+ optionsError,
12
+ tokenRequiredError,
13
+ } from "./errors.js";
14
+ import type { FunctionReference, FunctionVisibility } from "convex/server";
15
+ import type {
16
+ ApiKeysTypeOptions,
17
+ CleanupExpiredArgs,
18
+ CleanupExpiredResult,
19
+ CreateArgs,
20
+ CreateResult,
21
+ GetKeyArgs,
22
+ GetKeyResult,
23
+ InvalidateArgs,
24
+ InvalidateAllArgs,
25
+ InvalidateAllPageResult,
26
+ InvalidateAllResult,
27
+ InvalidateResult,
28
+ ListKeysArgs,
29
+ ListEventsArgs,
30
+ ListEventsResult,
31
+ ListKeyEventsArgs,
32
+ ListKeyEventsResult,
33
+ ListKeysResult,
34
+ OnInvalidateHookPayload,
35
+ RefreshArgs,
36
+ RefreshResult,
37
+ RunMutationCtx,
38
+ RunQueryCtx,
39
+ TouchArgs,
40
+ TouchResult,
41
+ UpdateArgs,
42
+ UpdateResult,
43
+ ValidateArgs,
44
+ ValidateResult,
45
+ } from "./types.js";
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Inlined helpers (validate, create config, logging)
49
+ // ---------------------------------------------------------------------------
50
+
51
+ function normalizeValidateToken(token: string) {
52
+ const normalized = token.trim();
53
+ if (normalized.length === 0) {
54
+ throw tokenRequiredError();
55
+ }
56
+ return normalized;
57
+ }
58
+
59
+ function validatePrefix(prefix: string) {
60
+ if (prefix.length === 0) {
61
+ throw optionsError("prefix must not be empty");
62
+ }
63
+ if (prefix.length > 32) {
64
+ throw optionsError("prefix exceeds max allowed length (32)");
65
+ }
66
+ }
67
+
68
+ function resolveCreateConfig(
69
+ args: {
70
+ prefix?: string;
71
+ ttlMs?: number | null;
72
+ idleTimeoutMs?: number | null;
73
+ },
74
+ options: NormalizedApiKeysOptions,
75
+ ) {
76
+ const prefix = args.prefix ?? options.keyDefaults.prefix;
77
+ const ttlMs = args.ttlMs ?? options.keyDefaults.ttlMs;
78
+ const idleTimeoutMs = args.idleTimeoutMs ?? options.keyDefaults.idleTimeoutMs;
79
+
80
+ if (args.prefix !== undefined) validatePrefix(prefix);
81
+ if (args.ttlMs !== undefined)
82
+ assertNullableNonNegativeInteger(ttlMs, "ttlMs");
83
+ if (args.idleTimeoutMs !== undefined)
84
+ assertNullableNonNegativeInteger(idleTimeoutMs, "idleTimeoutMs");
85
+
86
+ return { prefix, ttlMs, idleTimeoutMs };
87
+ }
88
+
89
+ function buildCreateLifecycle(
90
+ now: number,
91
+ ttlMs: number | null,
92
+ idleTimeoutMs: number | null,
93
+ ) {
94
+ return {
95
+ expiresAt: ttlMs === null ? undefined : now + ttlMs,
96
+ maxIdleMs: idleTimeoutMs === null ? undefined : idleTimeoutMs,
97
+ };
98
+ }
99
+
100
+ function resolveCreatePermissions(
101
+ inputPermissions: Record<string, readonly string[]> | undefined,
102
+ defaultPermissions: Record<string, string[]> | undefined,
103
+ ): Record<string, Array<string>> | undefined {
104
+ if (inputPermissions) {
105
+ return canonicalizePermissions(inputPermissions);
106
+ }
107
+ if (defaultPermissions) {
108
+ return canonicalizePermissions(defaultPermissions);
109
+ }
110
+ return undefined;
111
+ }
112
+
113
+ function canonicalizePermissions(
114
+ permissions: Record<string, readonly string[] | undefined>,
115
+ ): Record<string, Array<string>> {
116
+ const canonical: Record<string, Array<string>> = {};
117
+ for (const [scope, values] of Object.entries(permissions)) {
118
+ if (values === undefined) {
119
+ continue;
120
+ }
121
+ canonical[scope] = Array.from(new Set(values)).sort();
122
+ }
123
+ return canonical;
124
+ }
125
+
126
+ function readNamespace(args: object): string | undefined {
127
+ if (!("namespace" in args)) {
128
+ return undefined;
129
+ }
130
+ return typeof (args as { namespace?: unknown }).namespace === "string"
131
+ ? (args as { namespace: string }).namespace
132
+ : undefined;
133
+ }
134
+
135
+ function shouldLog(
136
+ configured: "debug" | "warn" | "error" | "none",
137
+ level: "debug" | "warn" | "error",
138
+ ): boolean {
139
+ if (configured === "none") return false;
140
+ if (configured === "debug") return true;
141
+ if (configured === "warn") return level === "warn" || level === "error";
142
+ return level === "error"; // configured === "error"
143
+ }
144
+
145
+ function logWithLevel(
146
+ configured: "debug" | "warn" | "error" | "none",
147
+ level: "debug" | "warn" | "error",
148
+ tag: string,
149
+ data: Record<string, unknown>,
150
+ ): void {
151
+ if (!shouldLog(configured, level)) return;
152
+ const method =
153
+ level === "error" ? "error" : level === "warn" ? "warn" : "log";
154
+ console[method](`[api-keys:${tag}]`, data);
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // ApiKeys class
159
+ // ---------------------------------------------------------------------------
160
+
161
+ /**
162
+ * Client for managing API keys in a Convex application.
163
+ *
164
+ * Handles the full API key lifecycle: creation, validation, rotation,
165
+ * revocation, and cleanup. Tokens are hashed with SHA-256 before storage —
166
+ * plaintext tokens are only returned at creation and rotation time.
167
+ *
168
+ * Type-level configuration is passed via the generic parameter to narrow
169
+ * namespace literals, permission shapes, metadata types, and whether `name`
170
+ * is required on creation.
171
+ *
172
+ * ```ts
173
+ * import { ApiKeys } from "@gaganref/convex-api-keys";
174
+ * import { components } from "./_generated/api.js";
175
+ *
176
+ * export const apiKeys = new ApiKeys(components.apiKeys, {
177
+ * keyDefaults: { prefix: "myapp_", ttlMs: 90 * 24 * 60 * 60 * 1000 },
178
+ * logLevel: "debug",
179
+ * });
180
+ * ```
181
+ *
182
+ * @param component The API keys component. Like `components.apiKeys` from
183
+ * `./_generated/api.js`.
184
+ * @param options Configuration for key defaults, permissions, and logging.
185
+ * See {@link ApiKeysOptions} for details.
186
+ */
187
+ export class ApiKeys<
188
+ TOptions extends ApiKeysTypeOptions = Record<never, never>,
189
+ > {
190
+ public readonly component: ComponentApi;
191
+ public readonly options: NormalizedApiKeysOptions;
192
+ private _onInvalidate:
193
+ | FunctionReference<
194
+ "mutation",
195
+ FunctionVisibility,
196
+ { event: OnInvalidateHookPayload },
197
+ null
198
+ >
199
+ | undefined;
200
+
201
+ constructor(component: ComponentApi, options?: ApiKeysOptions<TOptions>) {
202
+ this.component = component;
203
+ this.options = normalizeApiKeysOptions(options ?? {});
204
+ }
205
+
206
+ /**
207
+ * Attach lifecycle hooks.
208
+ *
209
+ * Call this after construction to avoid circular module type inference
210
+ * that occurs when `internal.*` references appear in the constructor call.
211
+ *
212
+ * @example
213
+ * export const apiKeys = new ApiKeys<TOptions>(components.apiKeys, { ... })
214
+ * .withHooks({ onInvalidate: internal.hooks.onInvalidate });
215
+ */
216
+ withHooks(hooks: {
217
+ onInvalidate?: FunctionReference<
218
+ "mutation",
219
+ FunctionVisibility,
220
+ { event: OnInvalidateHookPayload },
221
+ null
222
+ >;
223
+ }): this {
224
+ this._onInvalidate = hooks.onInvalidate;
225
+ return this;
226
+ }
227
+
228
+ /**
229
+ * Create a new API key.
230
+ *
231
+ * Generates a cryptographically random token, hashes it with SHA-256, and
232
+ * stores only the hash. The plaintext token is returned exactly once — it
233
+ * cannot be retrieved later.
234
+ *
235
+ * Per-key overrides for `prefix`, `ttlMs`, and `idleTimeoutMs` fall back to
236
+ * the defaults configured in {@link ApiKeysOptions.keyDefaults}. Permissions
237
+ * fall back to {@link ApiKeysOptions.permissionDefaults} when omitted.
238
+ *
239
+ * @param ctx Any context that can run a mutation.
240
+ * @param args Key configuration including namespace, name, permissions,
241
+ * metadata, and optional lifecycle overrides. See {@link CreateArgs}.
242
+ * @returns The plaintext token, key ID, and computed expiry timestamps.
243
+ * **Store or display the token immediately** — it will not be available again.
244
+ * @throws {ApiKeysClientError} With code `OPERATION_FAILED` if the mutation fails.
245
+ */
246
+ async create(
247
+ ctx: RunMutationCtx,
248
+ args: CreateArgs<TOptions>,
249
+ ): Promise<CreateResult> {
250
+ const now = Date.now();
251
+
252
+ try {
253
+ const config = resolveCreateConfig(args, this.options);
254
+
255
+ const token = generateToken(
256
+ config.prefix,
257
+ this.options.keyDefaults.keyLengthBytes,
258
+ );
259
+ const tokenHash = await sha256Base64Url(token);
260
+ const lifecycle = buildCreateLifecycle(
261
+ now,
262
+ config.ttlMs,
263
+ config.idleTimeoutMs,
264
+ );
265
+ const permissions = resolveCreatePermissions(
266
+ (args as { permissions?: Record<string, readonly string[]> })
267
+ .permissions,
268
+ this.options.permissionDefaults as Record<string, string[]> | undefined,
269
+ );
270
+ const last4 = tokenLast4(token);
271
+
272
+ const result = await ctx.runMutation(this.component.lib.create, {
273
+ tokenHash,
274
+ tokenPrefix: config.prefix,
275
+ tokenLast4: last4,
276
+ namespace: (args as { namespace?: string }).namespace,
277
+ name: (args as { name?: string }).name,
278
+ permissions,
279
+ metadata: (args as { metadata?: Record<string, unknown> }).metadata,
280
+ expiresAt: lifecycle.expiresAt,
281
+ maxIdleMs: lifecycle.maxIdleMs,
282
+ logLevel: this.options.logLevel,
283
+ });
284
+
285
+ logWithLevel(this.options.logLevel, "debug", "create", {
286
+ keyId: String(result.keyId),
287
+ namespace: (args as { namespace?: string }).namespace,
288
+ });
289
+
290
+ return {
291
+ keyId: result.keyId,
292
+ token,
293
+ tokenPrefix: config.prefix,
294
+ tokenLast4: last4,
295
+ createdAt: result.createdAt,
296
+ expiresAt: lifecycle.expiresAt,
297
+ };
298
+ } catch (error) {
299
+ logWithLevel(this.options.logLevel, "error", "create", {
300
+ code: "OPERATION_FAILED",
301
+ message: "failed to create api key",
302
+ cause: error,
303
+ });
304
+ throw this.toThrownError(error, "failed to create api key");
305
+ }
306
+ }
307
+
308
+ /**
309
+ * Validate a plaintext API key token.
310
+ *
311
+ * Hashes the token with SHA-256 and looks up the matching key. Returns the
312
+ * key's metadata and permissions on success (`ok: true`), or a failure
313
+ * reason on failure (`ok: false`).
314
+ *
315
+ * This method does **not** update `lastUsedAt` — call {@link touch} separately
316
+ * if you need idle-timeout tracking.
317
+ *
318
+ * @param ctx Any context that can run a query.
319
+ * @param args The plaintext token to validate. See {@link ValidateArgs}.
320
+ * @returns `{ ok: true, keyId, namespace, name, permissions, metadata }` on
321
+ * success, or `{ ok: false, reason }` with one of `"not_found"`,
322
+ * `"revoked"`, `"expired"`, or `"idle_timeout"`.
323
+ * @throws {ApiKeysClientError} With code `TOKEN_REQUIRED` if the token is empty.
324
+ * @throws {ApiKeysClientError} With code `OPERATION_FAILED` if the query fails.
325
+ */
326
+ async validate(
327
+ ctx: RunQueryCtx,
328
+ args: ValidateArgs,
329
+ ): Promise<ValidateResult<TOptions>> {
330
+ const now = Date.now();
331
+
332
+ // Input validation outside try-catch — throws TOKEN_REQUIRED
333
+ const token = normalizeValidateToken(args.token);
334
+
335
+ try {
336
+ const tokenHash = await sha256Base64Url(token);
337
+
338
+ const result = await ctx.runQuery(this.component.lib.validate, {
339
+ tokenHash,
340
+ now,
341
+ logLevel: this.options.logLevel,
342
+ });
343
+
344
+ if (!result.ok) {
345
+ logWithLevel(this.options.logLevel, "warn", "validate", {
346
+ reason: result.reason,
347
+ tokenLast4: tokenLast4(token),
348
+ });
349
+ return result as ValidateResult<TOptions>;
350
+ }
351
+
352
+ logWithLevel(this.options.logLevel, "debug", "validate", {
353
+ keyId: String(result.keyId),
354
+ namespace: result.namespace,
355
+ tokenLast4: tokenLast4(token),
356
+ });
357
+
358
+ return result as ValidateResult<TOptions>;
359
+ } catch (error) {
360
+ logWithLevel(this.options.logLevel, "error", "validate", {
361
+ code: "OPERATION_FAILED",
362
+ message: "failed to validate api key",
363
+ cause: error,
364
+ });
365
+ throw this.toThrownError(error, "failed to validate api key");
366
+ }
367
+ }
368
+
369
+ /**
370
+ * Get a single API key by ID.
371
+ *
372
+ * Returns the key's full metadata including its computed
373
+ * `effectiveStatus` (which accounts for expiry and idle timeout).
374
+ *
375
+ * @param ctx Any context that can run a query.
376
+ * @param args The key ID to look up. See {@link GetKeyArgs}.
377
+ * @returns `{ ok: true, ...keyData }` or `{ ok: false, reason: "not_found" }`.
378
+ */
379
+ async getKey(ctx: RunQueryCtx, args: GetKeyArgs): Promise<GetKeyResult> {
380
+ const now = Date.now();
381
+
382
+ try {
383
+ return await ctx.runQuery(this.component.lib.getKey, {
384
+ keyId: args.keyId,
385
+ now,
386
+ });
387
+ } catch (error) {
388
+ logWithLevel(this.options.logLevel, "error", "getKey", {
389
+ code: "OPERATION_FAILED",
390
+ message: "failed to get api key",
391
+ cause: error,
392
+ });
393
+ throw this.toThrownError(error, "failed to get api key");
394
+ }
395
+ }
396
+
397
+ /**
398
+ * List API keys with cursor-based pagination.
399
+ *
400
+ * Results include the computed `effectiveStatus` for each key. Supports
401
+ * optional filtering by namespace and/or stored status (`"active"` or
402
+ * `"revoked"`), and ordering by creation time.
403
+ *
404
+ * @param ctx Any context that can run a query.
405
+ * @param args Pagination options and optional filters. See {@link ListKeysArgs}.
406
+ * @returns `{ page, isDone, continueCursor }`.
407
+ */
408
+ async listKeys(
409
+ ctx: RunQueryCtx,
410
+ args: ListKeysArgs<TOptions>,
411
+ ): Promise<ListKeysResult> {
412
+ const now = Date.now();
413
+ const namespace = readNamespace(args);
414
+ try {
415
+ return await ctx.runQuery(this.component.lib.listKeys, {
416
+ paginationOpts: args.paginationOpts,
417
+ namespace,
418
+ status: args.status,
419
+ now,
420
+ order: args.order,
421
+ });
422
+ } catch (error) {
423
+ logWithLevel(this.options.logLevel, "error", "listKeys", {
424
+ code: "OPERATION_FAILED",
425
+ message: "failed to list api keys",
426
+ cause: error,
427
+ });
428
+ throw this.toThrownError(error, "failed to list api keys");
429
+ }
430
+ }
431
+
432
+ /**
433
+ * List audit events across all keys with cursor-based pagination.
434
+ *
435
+ * Events are immutable records of key lifecycle changes (`"created"`,
436
+ * `"revoked"`, `"rotated"`). Optionally filter by namespace.
437
+ *
438
+ * @param ctx Any context that can run a query.
439
+ * @param args Pagination options and optional namespace filter.
440
+ * See {@link ListEventsArgs}.
441
+ * @returns `{ page, isDone, continueCursor }`.
442
+ */
443
+ async listEvents(
444
+ ctx: RunQueryCtx,
445
+ args: ListEventsArgs<TOptions>,
446
+ ): Promise<ListEventsResult> {
447
+ const namespace = readNamespace(args);
448
+
449
+ try {
450
+ return await ctx.runQuery(this.component.lib.listEvents, {
451
+ paginationOpts: args.paginationOpts,
452
+ namespace,
453
+ order: args.order,
454
+ });
455
+ } catch (error) {
456
+ logWithLevel(this.options.logLevel, "error", "listEvents", {
457
+ code: "OPERATION_FAILED",
458
+ message: "failed to list api key events",
459
+ cause: error,
460
+ });
461
+ throw this.toThrownError(error, "failed to list api key events");
462
+ }
463
+ }
464
+
465
+ /**
466
+ * List audit events for a specific key with cursor-based pagination.
467
+ *
468
+ * @param ctx Any context that can run a query.
469
+ * @param args The key ID, pagination options, and optional order.
470
+ * See {@link ListKeyEventsArgs}.
471
+ * @returns `{ page, isDone, continueCursor }`.
472
+ */
473
+ async listKeyEvents(
474
+ ctx: RunQueryCtx,
475
+ args: ListKeyEventsArgs,
476
+ ): Promise<ListKeyEventsResult> {
477
+ try {
478
+ return await ctx.runQuery(this.component.lib.listKeyEvents, {
479
+ keyId: args.keyId,
480
+ paginationOpts: args.paginationOpts,
481
+ order: args.order,
482
+ });
483
+ } catch (error) {
484
+ logWithLevel(this.options.logLevel, "error", "listKeyEvents", {
485
+ code: "OPERATION_FAILED",
486
+ message: "failed to list api key events for key",
487
+ cause: error,
488
+ });
489
+ throw this.toThrownError(error, "failed to list api key events for key");
490
+ }
491
+ }
492
+
493
+ /**
494
+ * Touch an API key, updating `lastUsedAt` and resetting the idle timeout.
495
+ *
496
+ * If the key has a `maxIdleMs` configured, the idle expiry is derived from
497
+ * `lastUsedAt + maxIdleMs`. Call this during request handling to keep
498
+ * idle-timeout keys alive.
499
+ *
500
+ * @param ctx Any context that can run a mutation.
501
+ * @param args The key ID to touch. See {@link TouchArgs}.
502
+ * @returns `{ ok: true, keyId, touchedAt }` on success,
503
+ * or `{ ok: false, reason }` if the key is not found or inactive.
504
+ */
505
+ async touch(ctx: RunMutationCtx, args: TouchArgs): Promise<TouchResult> {
506
+ const now = Date.now();
507
+
508
+ try {
509
+ const result = await ctx.runMutation(this.component.lib.touch, {
510
+ keyId: args.keyId,
511
+ now,
512
+ });
513
+
514
+ if (!result.ok) {
515
+ logWithLevel(this.options.logLevel, "warn", "touch", {
516
+ reason: result.reason,
517
+ });
518
+ return result;
519
+ }
520
+
521
+ logWithLevel(this.options.logLevel, "debug", "touch", {
522
+ keyId: String(result.keyId),
523
+ });
524
+
525
+ return result;
526
+ } catch (error) {
527
+ logWithLevel(this.options.logLevel, "error", "touch", {
528
+ code: "OPERATION_FAILED",
529
+ message: "failed to touch api key",
530
+ cause: error,
531
+ });
532
+ throw this.toThrownError(error, "failed to touch api key");
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Invalidate (revoke) a single API key.
538
+ *
539
+ * Sets the key's status to `"revoked"` and records a revocation audit event.
540
+ * If an `onInvalidate` hook is configured, it fires after the revocation
541
+ * succeeds (hook failures are swallowed and logged).
542
+ *
543
+ * @param ctx Any context that can run a mutation.
544
+ * @param args The key ID, optional reason, and optional event metadata.
545
+ * See {@link InvalidateArgs}.
546
+ * @returns `{ ok: true, keyId, revokedAt }` on success,
547
+ * or `{ ok: false, reason }` if the key is not found or already revoked.
548
+ */
549
+ async invalidate(
550
+ ctx: RunMutationCtx,
551
+ args: InvalidateArgs,
552
+ ): Promise<InvalidateResult> {
553
+ const now = Date.now();
554
+
555
+ try {
556
+ const result = await ctx.runMutation(this.component.lib.invalidate, {
557
+ keyId: args.keyId,
558
+ now,
559
+ reason: args.reason,
560
+ metadata: args.metadata,
561
+ logLevel: this.options.logLevel,
562
+ });
563
+
564
+ if (!result.ok) {
565
+ logWithLevel(this.options.logLevel, "warn", "invalidate", {
566
+ reason: result.reason,
567
+ });
568
+ return result;
569
+ }
570
+
571
+ await this.runOnInvalidateHook(ctx, {
572
+ trigger: "invalidate",
573
+ at: now,
574
+ keyId: String(result.keyId),
575
+ reason: args.reason,
576
+ });
577
+
578
+ return result;
579
+ } catch (error) {
580
+ logWithLevel(this.options.logLevel, "error", "invalidate", {
581
+ code: "OPERATION_FAILED",
582
+ message: "failed to invalidate api key",
583
+ cause: error,
584
+ });
585
+ throw this.toThrownError(error, "failed to invalidate api key");
586
+ }
587
+ }
588
+
589
+ /**
590
+ * Bulk-invalidate API keys matching the given filters.
591
+ *
592
+ * Iterates through all matching active keys in paginated batches,
593
+ * revoking each one and recording audit events. Supports filtering by
594
+ * namespace and creation-time range (`before` / `after`).
595
+ *
596
+ * The `onInvalidate` hook fires once after all pages are processed with
597
+ * aggregated stats.
598
+ *
599
+ * @param ctx Any context that can run a mutation.
600
+ * @param args Filters, optional reason/metadata, and page size.
601
+ * See {@link InvalidateAllArgs}.
602
+ * @returns `{ processed, revoked, pages }` — total keys examined,
603
+ * total keys revoked, and number of pages processed.
604
+ */
605
+ async invalidateAll(
606
+ ctx: RunMutationCtx,
607
+ args: InvalidateAllArgs<TOptions>,
608
+ ): Promise<InvalidateAllResult> {
609
+ const now = Date.now();
610
+ const namespace = readNamespace(args);
611
+ const pageSize = args.pageSize ?? 100;
612
+ let cursor: string | null = null;
613
+ let pages = 0;
614
+ let processed = 0;
615
+ let revoked = 0;
616
+
617
+ try {
618
+ while (true) {
619
+ const result: InvalidateAllPageResult = await ctx.runMutation(
620
+ this.component.lib.invalidateAll,
621
+ {
622
+ namespace,
623
+ before: args.before,
624
+ after: args.after,
625
+ paginationOpts: {
626
+ numItems: pageSize,
627
+ cursor,
628
+ },
629
+ now,
630
+ reason: args.reason,
631
+ metadata: args.metadata,
632
+ logLevel: this.options.logLevel,
633
+ },
634
+ );
635
+
636
+ pages += 1;
637
+ processed += result.processed;
638
+ revoked += result.revoked;
639
+
640
+ if (result.isDone) {
641
+ break;
642
+ }
643
+ cursor = result.continueCursor;
644
+ }
645
+
646
+ logWithLevel(this.options.logLevel, "debug", "invalidateAll", {
647
+ processed,
648
+ revoked,
649
+ });
650
+
651
+ await this.runOnInvalidateHook(ctx, {
652
+ trigger: "invalidateAll",
653
+ at: now,
654
+ namespace,
655
+ before: args.before,
656
+ after: args.after,
657
+ reason: args.reason,
658
+ processed,
659
+ revoked,
660
+ pages,
661
+ });
662
+
663
+ return { processed, revoked, pages };
664
+ } catch (error) {
665
+ logWithLevel(this.options.logLevel, "error", "invalidateAll", {
666
+ code: "OPERATION_FAILED",
667
+ message: "failed to invalidate api keys in bulk",
668
+ cause: error,
669
+ });
670
+ throw this.toThrownError(error, "failed to invalidate api keys in bulk");
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Rotate an API key: revoke the current key and issue a replacement.
676
+ *
677
+ * The new key inherits the old key's namespace, name, permissions, metadata,
678
+ * expiry configuration, and idle timeout. A `"rotated"` event is recorded on
679
+ * the old key and a `"created"` event on the new key.
680
+ *
681
+ * The `onInvalidate` hook fires with `trigger: "refresh"` after success.
682
+ *
683
+ * @param ctx Any context that can run a mutation.
684
+ * @param args The key ID to rotate, optional reason, and optional event
685
+ * metadata. See {@link RefreshArgs}.
686
+ * @returns On success: `{ ok: true, keyId, token, ... }` with the new
687
+ * plaintext token. On failure: `{ ok: false, reason }` if the key is
688
+ * not found or inactive.
689
+ */
690
+ async refresh(
691
+ ctx: RunMutationCtx,
692
+ args: RefreshArgs,
693
+ ): Promise<RefreshResult> {
694
+ const now = Date.now();
695
+
696
+ try {
697
+ const tokenPrefix = args.prefix ?? this.options.keyDefaults.prefix;
698
+ if (args.prefix !== undefined) validatePrefix(tokenPrefix);
699
+ const token = generateToken(
700
+ tokenPrefix,
701
+ this.options.keyDefaults.keyLengthBytes,
702
+ );
703
+ const tokenHash = await sha256Base64Url(token);
704
+ const tokenLast4Value = tokenLast4(token);
705
+
706
+ const result = await ctx.runMutation(this.component.lib.refresh, {
707
+ keyId: args.keyId,
708
+ tokenHash,
709
+ tokenPrefix,
710
+ tokenLast4: tokenLast4Value,
711
+ now,
712
+ reason: args.reason,
713
+ metadata: args.metadata,
714
+ logLevel: this.options.logLevel,
715
+ });
716
+
717
+ if (!result.ok) {
718
+ logWithLevel(this.options.logLevel, "warn", "refresh", {
719
+ reason: result.reason,
720
+ });
721
+ return result;
722
+ }
723
+
724
+ logWithLevel(this.options.logLevel, "debug", "refresh", {
725
+ keyId: String(result.keyId),
726
+ });
727
+
728
+ await this.runOnInvalidateHook(ctx, {
729
+ trigger: "refresh",
730
+ at: now,
731
+ keyId: String(result.replacedKeyId),
732
+ replacementKeyId: String(result.keyId),
733
+ reason: args.reason,
734
+ });
735
+
736
+ return {
737
+ ...result,
738
+ token,
739
+ tokenPrefix,
740
+ tokenLast4: tokenLast4Value,
741
+ };
742
+ } catch (error) {
743
+ logWithLevel(this.options.logLevel, "error", "refresh", {
744
+ code: "OPERATION_FAILED",
745
+ message: "failed to refresh api key",
746
+ cause: error,
747
+ });
748
+ throw this.toThrownError(error, "failed to refresh api key");
749
+ }
750
+ }
751
+
752
+ /**
753
+ * Update a key's mutable properties: name, metadata, and/or expiry.
754
+ *
755
+ * Pass `expiresAt: null` to remove the absolute expiry entirely.
756
+ * Omitted fields are left unchanged.
757
+ *
758
+ * @param ctx Any context that can run a mutation.
759
+ * @param args The key ID and fields to update. See {@link UpdateArgs}.
760
+ * @returns `{ ok: true, keyId }` on success,
761
+ * or `{ ok: false, reason }` where reason is `"not_found"` or `"already_revoked"`.
762
+ */
763
+ async update(ctx: RunMutationCtx, args: UpdateArgs): Promise<UpdateResult> {
764
+ if (args.expiresAt !== undefined)
765
+ assertNullableNonNegativeInteger(args.expiresAt, "expiresAt");
766
+ if (args.maxIdleMs !== undefined)
767
+ assertNullableNonNegativeInteger(args.maxIdleMs, "maxIdleMs");
768
+
769
+ try {
770
+ const result = await ctx.runMutation(this.component.lib.update, {
771
+ keyId: args.keyId,
772
+ name: args.name,
773
+ metadata: args.metadata,
774
+ expiresAt: args.expiresAt,
775
+ maxIdleMs: args.maxIdleMs,
776
+ logLevel: this.options.logLevel,
777
+ });
778
+
779
+ if (!result.ok) {
780
+ logWithLevel(this.options.logLevel, "warn", "update", {
781
+ reason: result.reason,
782
+ });
783
+ return result;
784
+ }
785
+
786
+ logWithLevel(this.options.logLevel, "debug", "update", {
787
+ keyId: String(result.keyId),
788
+ });
789
+
790
+ return result;
791
+ } catch (error) {
792
+ logWithLevel(this.options.logLevel, "error", "update", {
793
+ code: "OPERATION_FAILED",
794
+ message: "failed to update api key",
795
+ cause: error,
796
+ });
797
+ throw this.toThrownError(error, "failed to update api key");
798
+ }
799
+ }
800
+
801
+ /**
802
+ * Hard-delete revoked keys older than the retention period.
803
+ *
804
+ * Expired and idle keys are automatically swept to revoked status by the
805
+ * component's internal cron — this method only deletes revoked keys past
806
+ * the retention window. Processes up to 100 keys per run and automatically
807
+ * reschedules itself when a full batch is found.
808
+ *
809
+ * Call this from your app's cron job to control the schedule:
810
+ *
811
+ * ```ts
812
+ * // convex/crons.ts
813
+ * crons.interval("cleanup api keys", { hours: 24 }, internal.tasks.cleanupApiKeys);
814
+ *
815
+ * // convex/tasks.ts
816
+ * export const cleanupApiKeys = internalMutation({
817
+ * handler: (ctx) => apiKeys.cleanupExpired(ctx),
818
+ * });
819
+ * ```
820
+ *
821
+ * @param ctx Any context that can run a mutation.
822
+ * @param args Optional retention period override. Defaults to 30 days.
823
+ * See {@link CleanupExpiredArgs}.
824
+ * @returns `{ deleted, isDone }` — keys hard-deleted and whether all
825
+ * work was completed in this run.
826
+ */
827
+ async cleanupExpired(
828
+ ctx: RunMutationCtx,
829
+ args?: CleanupExpiredArgs,
830
+ ): Promise<CleanupExpiredResult> {
831
+ const retentionMs = args?.retentionMs ?? 30 * 24 * 60 * 60 * 1000;
832
+ try {
833
+ return await ctx.runMutation(this.component.cleanup.cleanupExpired, {
834
+ retentionMs,
835
+ });
836
+ } catch (error) {
837
+ logWithLevel(this.options.logLevel, "error", "cleanupExpired", {
838
+ code: "OPERATION_FAILED",
839
+ message: "failed to cleanup expired api keys",
840
+ cause: error,
841
+ });
842
+ throw this.toThrownError(error, "failed to cleanup expired api keys");
843
+ }
844
+ }
845
+
846
+ /**
847
+ * Executes the optional invalidation hook and reports hook failures without
848
+ * affecting the main API-key operation.
849
+ */
850
+ private async runOnInvalidateHook(
851
+ ctx: RunMutationCtx,
852
+ payload: OnInvalidateHookPayload,
853
+ ): Promise<void> {
854
+ const hook = this._onInvalidate;
855
+ if (hook == null) {
856
+ return;
857
+ }
858
+
859
+ try {
860
+ await ctx.runMutation(hook, { event: payload });
861
+ } catch (error) {
862
+ logWithLevel(this.options.logLevel, "warn", "system", {
863
+ message: "onInvalidate hook failed",
864
+ error: error instanceof Error ? error.message : String(error),
865
+ });
866
+ // Swallowed — hook failure must not affect the operation.
867
+ }
868
+ }
869
+
870
+ private toThrownError(error: unknown, message: string): ApiKeysClientError {
871
+ if (error instanceof ApiKeysClientError) {
872
+ return error;
873
+ }
874
+ const cause = error instanceof Error ? error : new Error(String(error));
875
+ return new ApiKeysClientError(
876
+ "OPERATION_FAILED",
877
+ `api-keys: ${message}`,
878
+ cause,
879
+ );
880
+ }
881
+ }