@robelest/convex-auth 0.0.2-preview.2 → 0.0.3-preview

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 (114) hide show
  1. package/dist/bin.cjs +467 -64
  2. package/dist/client/index.d.ts +127 -0
  3. package/dist/client/index.d.ts.map +1 -1
  4. package/dist/client/index.js +424 -1
  5. package/dist/client/index.js.map +1 -1
  6. package/dist/component/_generated/api.d.ts +56 -1
  7. package/dist/component/_generated/api.d.ts.map +1 -1
  8. package/dist/component/_generated/api.js.map +1 -1
  9. package/dist/component/_generated/component.d.ts +141 -3
  10. package/dist/component/_generated/component.d.ts.map +1 -1
  11. package/dist/component/convex.config.d.ts.map +1 -1
  12. package/dist/component/convex.config.js +2 -0
  13. package/dist/component/convex.config.js.map +1 -1
  14. package/dist/component/index.d.ts +5 -4
  15. package/dist/component/index.d.ts.map +1 -1
  16. package/dist/component/index.js +4 -3
  17. package/dist/component/index.js.map +1 -1
  18. package/dist/component/portalBridge.d.ts +80 -0
  19. package/dist/component/portalBridge.d.ts.map +1 -0
  20. package/dist/component/portalBridge.js +102 -0
  21. package/dist/component/portalBridge.js.map +1 -0
  22. package/dist/component/public.d.ts +353 -9
  23. package/dist/component/public.d.ts.map +1 -1
  24. package/dist/component/public.js +328 -33
  25. package/dist/component/public.js.map +1 -1
  26. package/dist/component/schema.d.ts +168 -9
  27. package/dist/component/schema.d.ts.map +1 -1
  28. package/dist/component/schema.js +113 -7
  29. package/dist/component/schema.js.map +1 -1
  30. package/dist/providers/passkey.d.ts +20 -0
  31. package/dist/providers/passkey.d.ts.map +1 -0
  32. package/dist/providers/passkey.js +32 -0
  33. package/dist/providers/passkey.js.map +1 -0
  34. package/dist/providers/totp.d.ts +14 -0
  35. package/dist/providers/totp.d.ts.map +1 -0
  36. package/dist/providers/totp.js +23 -0
  37. package/dist/providers/totp.js.map +1 -0
  38. package/dist/server/convex-auth.d.ts +296 -0
  39. package/dist/server/convex-auth.d.ts.map +1 -0
  40. package/dist/server/convex-auth.js +480 -0
  41. package/dist/server/convex-auth.js.map +1 -0
  42. package/dist/server/email-templates.d.ts +18 -0
  43. package/dist/server/email-templates.d.ts.map +1 -0
  44. package/dist/server/email-templates.js +74 -0
  45. package/dist/server/email-templates.js.map +1 -0
  46. package/dist/server/implementation/apiKey.d.ts +74 -0
  47. package/dist/server/implementation/apiKey.d.ts.map +1 -0
  48. package/dist/server/implementation/apiKey.js +140 -0
  49. package/dist/server/implementation/apiKey.js.map +1 -0
  50. package/dist/server/implementation/index.d.ts +169 -7
  51. package/dist/server/implementation/index.d.ts.map +1 -1
  52. package/dist/server/implementation/index.js +220 -5
  53. package/dist/server/implementation/index.js.map +1 -1
  54. package/dist/server/implementation/passkey.d.ts +33 -0
  55. package/dist/server/implementation/passkey.d.ts.map +1 -0
  56. package/dist/server/implementation/passkey.js +450 -0
  57. package/dist/server/implementation/passkey.js.map +1 -0
  58. package/dist/server/implementation/redirects.d.ts.map +1 -1
  59. package/dist/server/implementation/redirects.js +4 -9
  60. package/dist/server/implementation/redirects.js.map +1 -1
  61. package/dist/server/implementation/signIn.d.ts +13 -0
  62. package/dist/server/implementation/signIn.d.ts.map +1 -1
  63. package/dist/server/implementation/signIn.js +29 -15
  64. package/dist/server/implementation/signIn.js.map +1 -1
  65. package/dist/server/implementation/totp.d.ts +40 -0
  66. package/dist/server/implementation/totp.d.ts.map +1 -0
  67. package/dist/server/implementation/totp.js +211 -0
  68. package/dist/server/implementation/totp.js.map +1 -0
  69. package/dist/server/index.d.ts +26 -2
  70. package/dist/server/index.d.ts.map +1 -1
  71. package/dist/server/index.js +63 -16
  72. package/dist/server/index.js.map +1 -1
  73. package/dist/server/portal-email.d.ts +19 -0
  74. package/dist/server/portal-email.d.ts.map +1 -0
  75. package/dist/server/portal-email.js +89 -0
  76. package/dist/server/portal-email.js.map +1 -0
  77. package/dist/server/provider_utils.d.ts +3 -1
  78. package/dist/server/provider_utils.d.ts.map +1 -1
  79. package/dist/server/provider_utils.js +39 -1
  80. package/dist/server/provider_utils.js.map +1 -1
  81. package/dist/server/types.d.ts +263 -4
  82. package/dist/server/types.d.ts.map +1 -1
  83. package/dist/server/version.d.ts +2 -0
  84. package/dist/server/version.d.ts.map +1 -0
  85. package/dist/server/version.js +3 -0
  86. package/dist/server/version.js.map +1 -0
  87. package/package.json +7 -3
  88. package/src/cli/index.ts +49 -7
  89. package/src/cli/portal-link.ts +112 -0
  90. package/src/cli/portal-upload.ts +411 -0
  91. package/src/cli/utils.ts +248 -0
  92. package/src/client/index.ts +489 -1
  93. package/src/component/_generated/api.ts +72 -1
  94. package/src/component/_generated/component.ts +241 -4
  95. package/src/component/convex.config.ts +3 -0
  96. package/src/component/index.ts +8 -3
  97. package/src/component/portalBridge.ts +116 -0
  98. package/src/component/public.ts +373 -37
  99. package/src/component/schema.ts +122 -7
  100. package/src/providers/passkey.ts +35 -0
  101. package/src/providers/totp.ts +26 -0
  102. package/src/server/convex-auth.ts +602 -0
  103. package/src/server/email-templates.ts +77 -0
  104. package/src/server/implementation/apiKey.ts +185 -0
  105. package/src/server/implementation/index.ts +301 -8
  106. package/src/server/implementation/passkey.ts +650 -0
  107. package/src/server/implementation/redirects.ts +4 -11
  108. package/src/server/implementation/signIn.ts +41 -13
  109. package/src/server/implementation/totp.ts +366 -0
  110. package/src/server/index.ts +98 -34
  111. package/src/server/portal-email.ts +95 -0
  112. package/src/server/provider_utils.ts +42 -1
  113. package/src/server/types.ts +285 -4
  114. package/src/server/version.ts +2 -0
@@ -0,0 +1,185 @@
1
+ /**
2
+ * API Key crypto utilities.
3
+ *
4
+ * Uses `@oslojs/crypto` primitives for key generation and hashing:
5
+ * - SHA-256 for hashing keys (API keys have high entropy, no need for bcrypt)
6
+ * - Cryptographically secure random generation for key material
7
+ *
8
+ * @module
9
+ */
10
+
11
+ import { sha256, generateRandomString } from "./utils.js";
12
+ import type { KeyScope, ScopeChecker } from "../types.js";
13
+
14
+ // ============================================================================
15
+ // Constants
16
+ // ============================================================================
17
+
18
+ const DEFAULT_KEY_PREFIX = "sk_live_";
19
+ const KEY_RANDOM_LENGTH = 32;
20
+ const KEY_RANDOM_ALPHABET =
21
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
22
+
23
+ /**
24
+ * How many characters of the full key to store as the visible prefix.
25
+ * Includes the prefix string (e.g. "sk_live_") plus a few random chars.
26
+ */
27
+ const VISIBLE_PREFIX_EXTRA_CHARS = 4;
28
+
29
+ // ============================================================================
30
+ // Key generation
31
+ // ============================================================================
32
+
33
+ /**
34
+ * Generate a new API key.
35
+ *
36
+ * Returns the raw key (to be shown once to the user) and metadata for storage.
37
+ * The raw key is `{prefix}{32 random alphanumeric chars}`.
38
+ *
39
+ * @param prefix - Key prefix, defaults to "sk_live_"
40
+ * @returns `{ raw, hashedKey, displayPrefix }`
41
+ */
42
+ export async function generateApiKey(prefix: string = DEFAULT_KEY_PREFIX): Promise<{
43
+ /** The full raw key — show to user once, never store. */
44
+ raw: string;
45
+ /** SHA-256 hex hash of the raw key — store this. */
46
+ hashedKey: string;
47
+ /** Truncated prefix for display (e.g. "sk_live_aBc1..."). */
48
+ displayPrefix: string;
49
+ }> {
50
+ const randomPart = generateRandomString(KEY_RANDOM_LENGTH, KEY_RANDOM_ALPHABET);
51
+ const raw = `${prefix}${randomPart}`;
52
+ const hashedKey = await sha256(raw);
53
+ const displayPrefix = `${raw.substring(0, prefix.length + VISIBLE_PREFIX_EXTRA_CHARS)}...`;
54
+
55
+ return { raw, hashedKey, displayPrefix };
56
+ }
57
+
58
+ /**
59
+ * Hash a raw API key for lookup.
60
+ *
61
+ * Used during Bearer token verification to find the stored key record.
62
+ */
63
+ export async function hashApiKey(rawKey: string): Promise<string> {
64
+ return sha256(rawKey);
65
+ }
66
+
67
+ // ============================================================================
68
+ // Scope checker
69
+ // ============================================================================
70
+
71
+ /**
72
+ * Build a `ScopeChecker` from an array of `KeyScope` entries.
73
+ *
74
+ * The checker provides a `.can(resource, action)` method that returns `true`
75
+ * if any scope entry grants the requested permission.
76
+ *
77
+ * A wildcard action `"*"` grants all actions on that resource.
78
+ * A wildcard resource `"*"` grants the action on all resources.
79
+ */
80
+ export function buildScopeChecker(scopes: KeyScope[]): ScopeChecker {
81
+ return {
82
+ scopes,
83
+ can(resource: string, action: string): boolean {
84
+ return scopes.some(
85
+ (scope) =>
86
+ (scope.resource === resource || scope.resource === "*") &&
87
+ (scope.actions.includes(action) || scope.actions.includes("*")),
88
+ );
89
+ },
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Validate that requested scopes are a subset of the allowed scopes
95
+ * defined in the API key config.
96
+ *
97
+ * @param requested - Scopes the user wants on the new key.
98
+ * @param allowed - The scope definition from `apiKeys.scopes` config.
99
+ * @throws Error if any requested scope is not in the allowed set.
100
+ */
101
+ export function validateScopes(
102
+ requested: KeyScope[],
103
+ allowed: Record<string, string[]> | undefined,
104
+ ): void {
105
+ if (!allowed) {
106
+ // No scope restrictions configured — allow anything.
107
+ return;
108
+ }
109
+
110
+ for (const scope of requested) {
111
+ const allowedActions = allowed[scope.resource];
112
+ if (!allowedActions) {
113
+ throw new Error(
114
+ `Unknown resource "${scope.resource}" in API key scopes. ` +
115
+ `Allowed resources: ${Object.keys(allowed).join(", ")}`,
116
+ );
117
+ }
118
+ for (const action of scope.actions) {
119
+ if (action !== "*" && !allowedActions.includes(action)) {
120
+ throw new Error(
121
+ `Unknown action "${action}" for resource "${scope.resource}". ` +
122
+ `Allowed actions: ${allowedActions.join(", ")}`,
123
+ );
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ // ============================================================================
130
+ // Per-key rate limiting (token-bucket)
131
+ // ============================================================================
132
+
133
+ /**
134
+ * Check whether a key is rate-limited based on its stored state.
135
+ *
136
+ * Uses the same token-bucket algorithm as sign-in rate limiting:
137
+ * tokens refill linearly over the configured window.
138
+ *
139
+ * @returns `{ limited: boolean; newState: { attemptsLeft, lastAttemptTime } }`
140
+ */
141
+ export function checkKeyRateLimit(
142
+ rateLimit: { maxRequests: number; windowMs: number },
143
+ state: { attemptsLeft: number; lastAttemptTime: number } | undefined,
144
+ ): {
145
+ limited: boolean;
146
+ newState: { attemptsLeft: number; lastAttemptTime: number };
147
+ } {
148
+ const now = Date.now();
149
+
150
+ if (!state) {
151
+ // First request — create initial state with one token consumed.
152
+ return {
153
+ limited: false,
154
+ newState: {
155
+ attemptsLeft: rateLimit.maxRequests - 1,
156
+ lastAttemptTime: now,
157
+ },
158
+ };
159
+ }
160
+
161
+ const elapsed = now - state.lastAttemptTime;
162
+ const refillRate = rateLimit.maxRequests / rateLimit.windowMs;
163
+ const refilled = Math.min(
164
+ rateLimit.maxRequests,
165
+ state.attemptsLeft + elapsed * refillRate,
166
+ );
167
+
168
+ if (refilled < 1) {
169
+ return {
170
+ limited: true,
171
+ newState: {
172
+ attemptsLeft: refilled,
173
+ lastAttemptTime: now,
174
+ },
175
+ };
176
+ }
177
+
178
+ return {
179
+ limited: false,
180
+ newState: {
181
+ attemptsLeft: refilled - 1,
182
+ lastAttemptTime: now,
183
+ },
184
+ };
185
+ }
@@ -44,6 +44,13 @@ import {
44
44
  } from "./mutations/index.js";
45
45
  import { signInImpl } from "./signIn.js";
46
46
  import { redirectAbsoluteUrl, setURLSearchParam } from "./redirects.js";
47
+ import {
48
+ generateApiKey,
49
+ hashApiKey,
50
+ buildScopeChecker,
51
+ validateScopes,
52
+ checkKeyRateLimit,
53
+ } from "./apiKey.js";
47
54
  import { getAuthorizationUrl } from "../oauth/authorizationUrl.js";
48
55
  import {
49
56
  defaultCookiesOptions,
@@ -446,12 +453,15 @@ export function Auth(config_: ConvexAuthConfig) {
446
453
  * Create a new invitation.
447
454
  *
448
455
  * @param data.groupId - Optional group to invite the user into.
449
- * @param data.invitedByUserId - The user sending the invitation.
450
- * @param data.email - The email address of the invitee.
456
+ * @param data.invitedByUserId - Optional user sending the invitation
457
+ * (omit for CLI-generated invites).
458
+ * @param data.email - Optional email of the invitee (omit for
459
+ * CLI-generated invite links where the email is unknown upfront).
451
460
  * @param data.tokenHash - Hashed token for secure acceptance.
452
461
  * @param data.role - Optional role to assign on acceptance.
453
462
  * @param data.status - Initial status (typically "pending").
454
- * @param data.expiresTime - Timestamp when the invite expires.
463
+ * @param data.expiresTime - Optional expiration timestamp (omit for
464
+ * single-use, non-expiring invites).
455
465
  * @param data.extend - Optional arbitrary JSON extension data.
456
466
  * @throws ConvexError with code `DUPLICATE_INVITE` if a pending invite
457
467
  * already exists for this email and scope.
@@ -461,12 +471,12 @@ export function Auth(config_: ConvexAuthConfig) {
461
471
  ctx: ComponentCtx,
462
472
  data: {
463
473
  groupId?: string;
464
- invitedByUserId: string;
465
- email: string;
474
+ invitedByUserId?: string;
475
+ email?: string;
466
476
  tokenHash: string;
467
477
  role?: string;
468
478
  status: "pending" | "accepted" | "revoked" | "expired";
469
- expiresTime: number;
479
+ expiresTime?: number;
470
480
  extend?: Record<string, unknown>;
471
481
  },
472
482
  ): Promise<string> => {
@@ -478,6 +488,12 @@ export function Auth(config_: ConvexAuthConfig) {
478
488
  get: async (ctx: ComponentReadCtx, inviteId: string) => {
479
489
  return await ctx.runQuery(config.component.public.inviteGet, { inviteId });
480
490
  },
491
+ /**
492
+ * Retrieve an invite by its token hash. Returns `null` if not found.
493
+ */
494
+ getByTokenHash: async (ctx: ComponentReadCtx, tokenHash: string) => {
495
+ return await ctx.runQuery(config.component.public.inviteGetByTokenHash, { tokenHash });
496
+ },
481
497
  /**
482
498
  * List invites, optionally filtered by group and/or status.
483
499
  */
@@ -524,8 +540,11 @@ export function Auth(config_: ConvexAuthConfig) {
524
540
  * });
525
541
  * ```
526
542
  */
527
- accept: async (ctx: ComponentCtx, inviteId: string) => {
528
- await ctx.runMutation(config.component.public.inviteAccept, { inviteId });
543
+ accept: async (ctx: ComponentCtx, inviteId: string, acceptedByUserId?: string) => {
544
+ await ctx.runMutation(config.component.public.inviteAccept, {
545
+ inviteId,
546
+ ...(acceptedByUserId ? { acceptedByUserId } : {}),
547
+ });
529
548
  },
530
549
  /**
531
550
  * Revoke a pending invitation.
@@ -539,6 +558,271 @@ export function Auth(config_: ConvexAuthConfig) {
539
558
  await ctx.runMutation(config.component.public.inviteRevoke, { inviteId });
540
559
  },
541
560
  },
561
+ /**
562
+ * Manage passkey credentials for users.
563
+ *
564
+ * ```ts
565
+ * const passkeys = await auth.passkey.list(ctx, { userId });
566
+ * await auth.passkey.rename(ctx, passkeyId, "MacBook Touch ID");
567
+ * await auth.passkey.remove(ctx, passkeyId);
568
+ * ```
569
+ */
570
+ passkey: {
571
+ /**
572
+ * List all passkeys for a user.
573
+ *
574
+ * @param opts.userId - The user whose passkeys to list.
575
+ * @returns Array of passkey records with credentialId, name, deviceType,
576
+ * backedUp, createdAt, and lastUsedAt.
577
+ */
578
+ list: async (ctx: ComponentReadCtx, opts: { userId: string }) => {
579
+ return await ctx.runQuery(
580
+ config.component.public.passkeyListByUserId,
581
+ opts,
582
+ );
583
+ },
584
+ /**
585
+ * Rename a passkey (set a user-friendly display name).
586
+ *
587
+ * @param passkeyId - The passkey document ID.
588
+ * @param name - New display name (e.g. "MacBook Touch ID").
589
+ */
590
+ rename: async (ctx: ComponentCtx, passkeyId: string, name: string) => {
591
+ await ctx.runMutation(
592
+ config.component.public.passkeyUpdateMeta,
593
+ { passkeyId, data: { name } },
594
+ );
595
+ },
596
+ /**
597
+ * Delete a passkey credential.
598
+ *
599
+ * @param passkeyId - The passkey document ID to remove.
600
+ */
601
+ remove: async (ctx: ComponentCtx, passkeyId: string) => {
602
+ await ctx.runMutation(
603
+ config.component.public.passkeyDelete,
604
+ { passkeyId },
605
+ );
606
+ },
607
+ },
608
+ /**
609
+ * Manage TOTP two-factor authentication enrollments for users.
610
+ *
611
+ * ```ts
612
+ * const enrollments = await auth.totp.list(ctx, { userId });
613
+ * await auth.totp.remove(ctx, totpId);
614
+ * ```
615
+ */
616
+ totp: {
617
+ /**
618
+ * List all TOTP enrollments for a user.
619
+ *
620
+ * @param opts.userId - The user whose enrollments to list.
621
+ * @returns Array of TOTP enrollment records.
622
+ */
623
+ list: async (ctx: ComponentReadCtx, opts: { userId: string }) => {
624
+ return await ctx.runQuery(
625
+ config.component.public.totpListByUserId,
626
+ opts,
627
+ );
628
+ },
629
+ /**
630
+ * Delete a TOTP enrollment.
631
+ *
632
+ * @param totpId - The TOTP document ID to remove.
633
+ */
634
+ remove: async (ctx: ComponentCtx, totpId: string) => {
635
+ await ctx.runMutation(
636
+ config.component.public.totpDelete,
637
+ { totpId },
638
+ );
639
+ },
640
+ },
641
+ /**
642
+ * Manage API keys for programmatic access.
643
+ *
644
+ * Keys use SHA-256 hashing (via `@oslojs/crypto`) and support
645
+ * scoped resource:action permissions with optional per-key rate limiting.
646
+ *
647
+ * ```ts
648
+ * const { keyId, raw } = await auth.key.create(ctx, {
649
+ * userId,
650
+ * name: "CI Pipeline",
651
+ * scopes: [{ resource: "users", actions: ["read", "list"] }],
652
+ * });
653
+ * // raw = "sk_live_abc123..." — show once, never stored
654
+ *
655
+ * const result = await auth.key.verify(ctx, rawKey);
656
+ * result.scopes.can("users", "read"); // true
657
+ * ```
658
+ */
659
+ key: {
660
+ /**
661
+ * Create a new API key. Returns the raw key **once** — it cannot
662
+ * be retrieved again after creation.
663
+ *
664
+ * @param opts.userId - The user this key belongs to.
665
+ * @param opts.name - Human-readable name (e.g. "CI Pipeline").
666
+ * @param opts.scopes - Resource:action permissions for this key.
667
+ * @param opts.rateLimit - Optional per-key rate limit override.
668
+ * @param opts.expiresAt - Optional expiration timestamp.
669
+ * @returns `{ keyId, raw }` where `raw` is the full key string.
670
+ */
671
+ create: async (
672
+ ctx: ComponentCtx,
673
+ opts: {
674
+ userId: string;
675
+ name: string;
676
+ scopes: import("../types.js").KeyScope[];
677
+ rateLimit?: { maxRequests: number; windowMs: number };
678
+ expiresAt?: number;
679
+ },
680
+ ): Promise<{ keyId: string; raw: string }> => {
681
+ const prefix = config.apiKeys?.prefix ?? "sk_live_";
682
+
683
+ // Validate scopes against config if defined
684
+ validateScopes(opts.scopes, config.apiKeys?.scopes);
685
+
686
+ const { raw, hashedKey, displayPrefix } = await generateApiKey(prefix);
687
+
688
+ const keyId = await ctx.runMutation(
689
+ config.component.public.keyInsert,
690
+ {
691
+ userId: opts.userId as any,
692
+ prefix: displayPrefix,
693
+ hashedKey,
694
+ name: opts.name,
695
+ scopes: opts.scopes,
696
+ rateLimit: opts.rateLimit ?? config.apiKeys?.defaultRateLimit,
697
+ expiresAt: opts.expiresAt,
698
+ },
699
+ );
700
+
701
+ return { keyId: keyId as string, raw };
702
+ },
703
+
704
+ /**
705
+ * Verify a raw API key string. Returns the userId and a scope checker
706
+ * if the key is valid, not revoked, not expired, and not rate-limited.
707
+ *
708
+ * Also updates `lastUsedAt` and rate limit state as a side effect.
709
+ *
710
+ * @throws Error if the key is invalid, revoked, expired, or rate-limited.
711
+ */
712
+ verify: async (
713
+ ctx: ComponentCtx,
714
+ rawKey: string,
715
+ ): Promise<{
716
+ userId: string;
717
+ keyId: string;
718
+ scopes: import("../types.js").ScopeChecker;
719
+ }> => {
720
+ const hashedKey = await hashApiKey(rawKey);
721
+
722
+ const key = await ctx.runQuery(
723
+ config.component.public.keyGetByHashedKey,
724
+ { hashedKey },
725
+ );
726
+ if (!key) {
727
+ throw new Error("Invalid API key");
728
+ }
729
+ if (key.revoked) {
730
+ throw new Error("API key has been revoked");
731
+ }
732
+ if (key.expiresAt && key.expiresAt < Date.now()) {
733
+ throw new Error("API key has expired");
734
+ }
735
+
736
+ // Check per-key rate limit
737
+ const patchData: Record<string, any> = { lastUsedAt: Date.now() };
738
+
739
+ if (key.rateLimit) {
740
+ const { limited, newState } = checkKeyRateLimit(
741
+ key.rateLimit,
742
+ key.rateLimitState ?? undefined,
743
+ );
744
+ if (limited) {
745
+ throw new Error("API key rate limit exceeded");
746
+ }
747
+ patchData.rateLimitState = newState;
748
+ }
749
+
750
+ // Update lastUsedAt (and rate limit state if applicable)
751
+ await ctx.runMutation(config.component.public.keyPatch, {
752
+ keyId: key._id,
753
+ data: patchData,
754
+ });
755
+
756
+ return {
757
+ userId: key.userId as string,
758
+ keyId: key._id as string,
759
+ scopes: buildScopeChecker(key.scopes),
760
+ };
761
+ },
762
+
763
+ /**
764
+ * List all API keys for a user.
765
+ * Never includes the raw key — only the display prefix.
766
+ */
767
+ list: async (ctx: ComponentReadCtx, opts: { userId: string }) => {
768
+ return await ctx.runQuery(
769
+ config.component.public.keyListByUserId,
770
+ { userId: opts.userId as any },
771
+ );
772
+ },
773
+
774
+ /**
775
+ * Get a single API key by its document ID.
776
+ * Returns `null` if not found.
777
+ */
778
+ get: async (ctx: ComponentReadCtx, keyId: string) => {
779
+ return await ctx.runQuery(
780
+ config.component.public.keyGetById,
781
+ { keyId: keyId as any },
782
+ );
783
+ },
784
+
785
+ /**
786
+ * Update an API key's metadata (name, scopes, rate limit).
787
+ */
788
+ update: async (
789
+ ctx: ComponentCtx,
790
+ keyId: string,
791
+ data: {
792
+ name?: string;
793
+ scopes?: import("../types.js").KeyScope[];
794
+ rateLimit?: { maxRequests: number; windowMs: number };
795
+ },
796
+ ) => {
797
+ if (data.scopes) {
798
+ validateScopes(data.scopes, config.apiKeys?.scopes);
799
+ }
800
+ await ctx.runMutation(config.component.public.keyPatch, {
801
+ keyId: keyId as any,
802
+ data,
803
+ });
804
+ },
805
+
806
+ /**
807
+ * Revoke an API key (soft delete). The key record is preserved
808
+ * for audit purposes but can no longer be used for authentication.
809
+ */
810
+ revoke: async (ctx: ComponentCtx, keyId: string) => {
811
+ await ctx.runMutation(config.component.public.keyPatch, {
812
+ keyId: keyId as any,
813
+ data: { revoked: true },
814
+ });
815
+ },
816
+
817
+ /**
818
+ * Hard delete an API key record.
819
+ */
820
+ remove: async (ctx: ComponentCtx, keyId: string) => {
821
+ await ctx.runMutation(config.component.public.keyDelete, {
822
+ keyId: keyId as any,
823
+ });
824
+ },
825
+ },
542
826
  /**
543
827
  * Add HTTP actions for JWT verification and OAuth sign-in.
544
828
  *
@@ -791,6 +1075,9 @@ export function Auth(config_: ConvexAuthConfig) {
791
1075
  verifier?: string;
792
1076
  tokens?: Tokens | null;
793
1077
  started?: boolean;
1078
+ options?: Record<string, any>;
1079
+ totpRequired?: boolean;
1080
+ totpSetup?: { uri: string; secret: string; totpId: string };
794
1081
  }> => {
795
1082
  if (args.calledBy !== undefined) {
796
1083
  logWithLevel("INFO", `\`auth:signIn\` called by ${args.calledBy}`);
@@ -811,6 +1098,12 @@ export function Auth(config_: ConvexAuthConfig) {
811
1098
  return { tokens: result.signedIn?.tokens ?? null };
812
1099
  case "started":
813
1100
  return { started: true };
1101
+ case "passkeyOptions":
1102
+ return { options: result.options, verifier: result.verifier };
1103
+ case "totpRequired":
1104
+ return { totpRequired: true, verifier: result.verifier };
1105
+ case "totpSetup":
1106
+ return { totpSetup: { uri: result.uri, secret: result.secret, totpId: result.totpId }, verifier: result.verifier };
814
1107
  default: {
815
1108
  const _typecheck: never = result;
816
1109
  throw new Error(`Unexpected result from signIn, ${result as any}`);