@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
@@ -5,6 +5,14 @@ import { mutation, query } from "./_generated/server";
5
5
  // Users
6
6
  // ============================================================================
7
7
 
8
+ /** List all users. */
9
+ export const userList = query({
10
+ args: {},
11
+ handler: async (ctx) => {
12
+ return await ctx.db.query("user").collect();
13
+ },
14
+ });
15
+
8
16
  /** Retrieve a user by their document ID. */
9
17
  export const userGetById = query({
10
18
  args: { userId: v.id("user") },
@@ -79,6 +87,17 @@ export const userPatch = mutation({
79
87
  // Accounts
80
88
  // ============================================================================
81
89
 
90
+ /** List all accounts for a user. */
91
+ export const accountListByUser = query({
92
+ args: { userId: v.id("user") },
93
+ handler: async (ctx, { userId }) => {
94
+ return await ctx.db
95
+ .query("account")
96
+ .withIndex("userIdAndProvider", (q) => q.eq("userId", userId as any))
97
+ .collect();
98
+ },
99
+ });
100
+
82
101
  /** Look up an account by provider and provider-specific account ID. */
83
102
  export const accountGet = query({
84
103
  args: { provider: v.string(), providerAccountId: v.string() },
@@ -133,6 +152,14 @@ export const accountDelete = mutation({
133
152
  // Sessions
134
153
  // ============================================================================
135
154
 
155
+ /** List all sessions. */
156
+ export const sessionList = query({
157
+ args: {},
158
+ handler: async (ctx) => {
159
+ return await ctx.db.query("session").collect();
160
+ },
161
+ });
162
+
136
163
  /** Create a new session for a user with an expiration time. */
137
164
  export const sessionCreate = mutation({
138
165
  args: { userId: v.id("user"), expirationTime: v.number() },
@@ -360,6 +387,150 @@ export const refreshTokenGetActive = query({
360
387
  },
361
388
  });
362
389
 
390
+ // ============================================================================
391
+ // Passkeys
392
+ // ============================================================================
393
+
394
+ /** Store a new passkey credential for a user. */
395
+ export const passkeyInsert = mutation({
396
+ args: {
397
+ userId: v.id("user"),
398
+ credentialId: v.string(),
399
+ publicKey: v.bytes(),
400
+ algorithm: v.number(),
401
+ counter: v.number(),
402
+ transports: v.optional(v.array(v.string())),
403
+ deviceType: v.string(),
404
+ backedUp: v.boolean(),
405
+ name: v.optional(v.string()),
406
+ createdAt: v.number(),
407
+ },
408
+ handler: async (ctx, args) => {
409
+ return await ctx.db.insert("passkey", args);
410
+ },
411
+ });
412
+
413
+ /** Look up a passkey by its credential ID. */
414
+ export const passkeyGetByCredentialId = query({
415
+ args: { credentialId: v.string() },
416
+ handler: async (ctx, { credentialId }) => {
417
+ return await ctx.db
418
+ .query("passkey")
419
+ .withIndex("credentialId", (q) => q.eq("credentialId", credentialId))
420
+ .unique();
421
+ },
422
+ });
423
+
424
+ /** List all passkeys for a user. */
425
+ export const passkeyListByUserId = query({
426
+ args: { userId: v.id("user") },
427
+ handler: async (ctx, { userId }) => {
428
+ return await ctx.db
429
+ .query("passkey")
430
+ .withIndex("userId", (q) => q.eq("userId", userId))
431
+ .collect();
432
+ },
433
+ });
434
+
435
+ /** Update a passkey's counter and last used timestamp after authentication. */
436
+ export const passkeyUpdateCounter = mutation({
437
+ args: { passkeyId: v.id("passkey"), counter: v.number(), lastUsedAt: v.number() },
438
+ handler: async (ctx, { passkeyId, counter, lastUsedAt }) => {
439
+ await ctx.db.patch(passkeyId, { counter, lastUsedAt });
440
+ },
441
+ });
442
+
443
+ /** Update a passkey's metadata (name). */
444
+ export const passkeyUpdateMeta = mutation({
445
+ args: { passkeyId: v.id("passkey"), data: v.any() },
446
+ handler: async (ctx, { passkeyId, data }) => {
447
+ await ctx.db.patch(passkeyId, data);
448
+ },
449
+ });
450
+
451
+ /** Delete a passkey credential. */
452
+ export const passkeyDelete = mutation({
453
+ args: { passkeyId: v.id("passkey") },
454
+ handler: async (ctx, { passkeyId }) => {
455
+ await ctx.db.delete(passkeyId);
456
+ },
457
+ });
458
+
459
+ // ============================================================================
460
+ // TOTP Two-Factor Authentication
461
+ // ============================================================================
462
+
463
+ /** Store a new TOTP enrollment for a user. */
464
+ export const totpInsert = mutation({
465
+ args: {
466
+ userId: v.id("user"),
467
+ secret: v.bytes(),
468
+ digits: v.number(),
469
+ period: v.number(),
470
+ verified: v.boolean(),
471
+ name: v.optional(v.string()),
472
+ createdAt: v.number(),
473
+ },
474
+ handler: async (ctx, args) => {
475
+ return await ctx.db.insert("totp", args);
476
+ },
477
+ });
478
+
479
+ /** Get a verified TOTP enrollment for a user (returns first match). */
480
+ export const totpGetVerifiedByUserId = query({
481
+ args: { userId: v.id("user") },
482
+ handler: async (ctx, { userId }) => {
483
+ return await ctx.db
484
+ .query("totp")
485
+ .withIndex("userId", (q) => q.eq("userId", userId))
486
+ .filter((q) => q.eq(q.field("verified"), true))
487
+ .first();
488
+ },
489
+ });
490
+
491
+ /** List all TOTP enrollments for a user. */
492
+ export const totpListByUserId = query({
493
+ args: { userId: v.id("user") },
494
+ handler: async (ctx, { userId }) => {
495
+ return await ctx.db
496
+ .query("totp")
497
+ .withIndex("userId", (q) => q.eq("userId", userId))
498
+ .collect();
499
+ },
500
+ });
501
+
502
+ /** Get a TOTP enrollment by its ID. */
503
+ export const totpGetById = query({
504
+ args: { totpId: v.id("totp") },
505
+ handler: async (ctx, { totpId }) => {
506
+ return await ctx.db.get(totpId);
507
+ },
508
+ });
509
+
510
+ /** Mark a TOTP enrollment as verified (setup complete). */
511
+ export const totpMarkVerified = mutation({
512
+ args: { totpId: v.id("totp"), lastUsedAt: v.number() },
513
+ handler: async (ctx, { totpId, lastUsedAt }) => {
514
+ await ctx.db.patch(totpId, { verified: true, lastUsedAt });
515
+ },
516
+ });
517
+
518
+ /** Update a TOTP enrollment's last used timestamp. */
519
+ export const totpUpdateLastUsed = mutation({
520
+ args: { totpId: v.id("totp"), lastUsedAt: v.number() },
521
+ handler: async (ctx, { totpId, lastUsedAt }) => {
522
+ await ctx.db.patch(totpId, { lastUsedAt });
523
+ },
524
+ });
525
+
526
+ /** Delete a TOTP enrollment. */
527
+ export const totpDelete = mutation({
528
+ args: { totpId: v.id("totp") },
529
+ handler: async (ctx, { totpId }) => {
530
+ await ctx.db.delete(totpId);
531
+ },
532
+ });
533
+
363
534
  // ============================================================================
364
535
  // Rate Limits
365
536
  // ============================================================================
@@ -629,8 +800,8 @@ export const memberUpdate = mutation({
629
800
  export const inviteCreate = mutation({
630
801
  args: {
631
802
  groupId: v.optional(v.id("group")),
632
- invitedByUserId: v.id("user"),
633
- email: v.string(),
803
+ invitedByUserId: v.optional(v.id("user")),
804
+ email: v.optional(v.string()),
634
805
  tokenHash: v.string(),
635
806
  role: v.optional(v.string()),
636
807
  status: v.union(
@@ -639,42 +810,48 @@ export const inviteCreate = mutation({
639
810
  v.literal("revoked"),
640
811
  v.literal("expired"),
641
812
  ),
642
- expiresTime: v.number(),
813
+ expiresTime: v.optional(v.number()),
643
814
  extend: v.optional(v.any()),
644
815
  },
645
816
  handler: async (ctx, args) => {
646
- if (args.groupId !== undefined) {
647
- const existingGroupInvite = await ctx.db
648
- .query("invite")
649
- .withIndex("groupIdAndStatus", (q) =>
650
- q.eq("groupId", args.groupId).eq("status", "pending"),
651
- )
652
- .filter((q) => q.eq(q.field("email"), args.email))
653
- .first();
654
- if (existingGroupInvite !== null) {
655
- throw new ConvexError({
656
- code: "DUPLICATE_INVITE",
657
- message: "A pending invite already exists for this email in this group",
658
- email: args.email,
659
- groupId: args.groupId,
660
- existingInviteId: existingGroupInvite._id,
661
- });
662
- }
663
- } else {
664
- const existingPlatformInvite = await ctx.db
665
- .query("invite")
666
- .withIndex("emailAndStatus", (q) =>
667
- q.eq("email", args.email).eq("status", "pending"),
668
- )
669
- .filter((q) => q.eq(q.field("groupId"), undefined))
670
- .first();
671
- if (existingPlatformInvite !== null) {
672
- throw new ConvexError({
673
- code: "DUPLICATE_INVITE",
674
- message: "A pending platform invite already exists for this email",
675
- email: args.email,
676
- existingInviteId: existingPlatformInvite._id,
677
- });
817
+ // Only check for duplicates when an email is provided.
818
+ // CLI-generated invites (no email) are always allowed.
819
+ if (args.email !== undefined) {
820
+ if (args.groupId !== undefined) {
821
+ const existingGroupInvite = await ctx.db
822
+ .query("invite")
823
+ .withIndex("groupIdAndStatus", (q) =>
824
+ q.eq("groupId", args.groupId).eq("status", "pending"),
825
+ )
826
+ .filter((q) => q.eq(q.field("email"), args.email))
827
+ .first();
828
+ if (existingGroupInvite !== null) {
829
+ throw new ConvexError({
830
+ code: "DUPLICATE_INVITE",
831
+ message:
832
+ "A pending invite already exists for this email in this group",
833
+ email: args.email,
834
+ groupId: args.groupId,
835
+ existingInviteId: existingGroupInvite._id,
836
+ });
837
+ }
838
+ } else {
839
+ const existingPlatformInvite = await ctx.db
840
+ .query("invite")
841
+ .withIndex("emailAndStatus", (q) =>
842
+ q.eq("email", args.email).eq("status", "pending"),
843
+ )
844
+ .filter((q) => q.eq(q.field("groupId"), undefined))
845
+ .first();
846
+ if (existingPlatformInvite !== null) {
847
+ throw new ConvexError({
848
+ code: "DUPLICATE_INVITE",
849
+ message:
850
+ "A pending platform invite already exists for this email",
851
+ email: args.email,
852
+ existingInviteId: existingPlatformInvite._id,
853
+ });
854
+ }
678
855
  }
679
856
  }
680
857
  return await ctx.db.insert("invite", args);
@@ -689,6 +866,17 @@ export const inviteGet = query({
689
866
  },
690
867
  });
691
868
 
869
+ /** Retrieve an invite by its token hash. Returns `null` if not found. */
870
+ export const inviteGetByTokenHash = query({
871
+ args: { tokenHash: v.string() },
872
+ handler: async (ctx, { tokenHash }) => {
873
+ return await ctx.db
874
+ .query("invite")
875
+ .withIndex("tokenHash", (q) => q.eq("tokenHash", tokenHash))
876
+ .unique();
877
+ },
878
+ });
879
+
692
880
  /**
693
881
  * List invites, optionally filtered by group and/or status.
694
882
  * Both `groupId` and `status` are optional filters.
@@ -740,8 +928,11 @@ export const inviteList = query({
740
928
  * The caller is responsible for creating the corresponding member record.
741
929
  */
742
930
  export const inviteAccept = mutation({
743
- args: { inviteId: v.id("invite") },
744
- handler: async (ctx, { inviteId }) => {
931
+ args: {
932
+ inviteId: v.id("invite"),
933
+ acceptedByUserId: v.optional(v.id("user")),
934
+ },
935
+ handler: async (ctx, { inviteId, acceptedByUserId }) => {
745
936
  const invite = await ctx.db.get(inviteId);
746
937
  if (invite === null) {
747
938
  throw new ConvexError({
@@ -761,6 +952,7 @@ export const inviteAccept = mutation({
761
952
  await ctx.db.patch(inviteId, {
762
953
  status: "accepted",
763
954
  acceptedTime: Date.now(),
955
+ ...(acceptedByUserId ? { acceptedByUserId } : {}),
764
956
  });
765
957
  },
766
958
  });
@@ -793,3 +985,147 @@ export const inviteRevoke = mutation({
793
985
  await ctx.db.patch(inviteId, { status: "revoked" });
794
986
  },
795
987
  });
988
+
989
+ // ============================================================================
990
+ // API Keys
991
+ // ============================================================================
992
+
993
+ /**
994
+ * Insert a new API key record.
995
+ *
996
+ * The caller is responsible for hashing the raw key before passing it here —
997
+ * this function only stores the hash and metadata.
998
+ */
999
+ export const keyInsert = mutation({
1000
+ args: {
1001
+ userId: v.id("user"),
1002
+ prefix: v.string(),
1003
+ hashedKey: v.string(),
1004
+ name: v.string(),
1005
+ scopes: v.array(
1006
+ v.object({
1007
+ resource: v.string(),
1008
+ actions: v.array(v.string()),
1009
+ }),
1010
+ ),
1011
+ rateLimit: v.optional(
1012
+ v.object({
1013
+ maxRequests: v.number(),
1014
+ windowMs: v.number(),
1015
+ }),
1016
+ ),
1017
+ expiresAt: v.optional(v.number()),
1018
+ },
1019
+ handler: async (ctx, args) => {
1020
+ return await ctx.db.insert("key", {
1021
+ ...args,
1022
+ createdAt: Date.now(),
1023
+ revoked: false,
1024
+ });
1025
+ },
1026
+ });
1027
+
1028
+ /**
1029
+ * Look up an API key by its SHA-256 hash.
1030
+ *
1031
+ * Used during Bearer token verification. Returns the full key record
1032
+ * (including rate limit state) or `null` if not found.
1033
+ */
1034
+ export const keyGetByHashedKey = query({
1035
+ args: { hashedKey: v.string() },
1036
+ handler: async (ctx, { hashedKey }) => {
1037
+ return await ctx.db
1038
+ .query("key")
1039
+ .withIndex("hashedKey", (q) => q.eq("hashedKey", hashedKey))
1040
+ .first();
1041
+ },
1042
+ });
1043
+
1044
+ /** List all API keys for a user. */
1045
+ export const keyListByUserId = query({
1046
+ args: { userId: v.id("user") },
1047
+ handler: async (ctx, { userId }) => {
1048
+ return await ctx.db
1049
+ .query("key")
1050
+ .withIndex("userId", (q) => q.eq("userId", userId))
1051
+ .collect();
1052
+ },
1053
+ });
1054
+
1055
+ /** List all API keys across all users (for portal admin). */
1056
+ export const keyList = query({
1057
+ args: {},
1058
+ handler: async (ctx) => {
1059
+ return await ctx.db.query("key").collect();
1060
+ },
1061
+ });
1062
+
1063
+ /** Get a single API key by document ID. */
1064
+ export const keyGetById = query({
1065
+ args: { keyId: v.id("key") },
1066
+ handler: async (ctx, { keyId }) => {
1067
+ return await ctx.db.get(keyId);
1068
+ },
1069
+ });
1070
+
1071
+ /**
1072
+ * Patch an API key record. Used for updating name, scopes, rate limit config,
1073
+ * revocation, and lastUsedAt / rate limit state tracking.
1074
+ */
1075
+ export const keyPatch = mutation({
1076
+ args: {
1077
+ keyId: v.id("key"),
1078
+ data: v.object({
1079
+ name: v.optional(v.string()),
1080
+ scopes: v.optional(
1081
+ v.array(
1082
+ v.object({
1083
+ resource: v.string(),
1084
+ actions: v.array(v.string()),
1085
+ }),
1086
+ ),
1087
+ ),
1088
+ rateLimit: v.optional(
1089
+ v.object({
1090
+ maxRequests: v.number(),
1091
+ windowMs: v.number(),
1092
+ }),
1093
+ ),
1094
+ rateLimitState: v.optional(
1095
+ v.object({
1096
+ attemptsLeft: v.number(),
1097
+ lastAttemptTime: v.number(),
1098
+ }),
1099
+ ),
1100
+ revoked: v.optional(v.boolean()),
1101
+ lastUsedAt: v.optional(v.number()),
1102
+ }),
1103
+ },
1104
+ handler: async (ctx, { keyId, data }) => {
1105
+ const key = await ctx.db.get(keyId);
1106
+ if (key === null) {
1107
+ throw new ConvexError({
1108
+ code: "KEY_NOT_FOUND",
1109
+ message: "API key not found",
1110
+ keyId,
1111
+ });
1112
+ }
1113
+ await ctx.db.patch(keyId, data);
1114
+ },
1115
+ });
1116
+
1117
+ /** Hard delete an API key record. */
1118
+ export const keyDelete = mutation({
1119
+ args: { keyId: v.id("key") },
1120
+ handler: async (ctx, { keyId }) => {
1121
+ const key = await ctx.db.get(keyId);
1122
+ if (key === null) {
1123
+ throw new ConvexError({
1124
+ code: "KEY_NOT_FOUND",
1125
+ message: "API key not found",
1126
+ keyId,
1127
+ });
1128
+ }
1129
+ await ctx.db.delete(keyId);
1130
+ },
1131
+ });
@@ -96,6 +96,61 @@ export default defineSchema({
96
96
  signature: v.optional(v.string()),
97
97
  }).index("signature", ["signature"]),
98
98
 
99
+ /**
100
+ * WebAuthn passkey credentials. Each credential links a user to a
101
+ * registered authenticator (Touch ID, Face ID, security key, etc.).
102
+ * A user can have multiple passkeys across different devices.
103
+ */
104
+ passkey: defineTable({
105
+ userId: v.id("user"),
106
+ /** Base64url-encoded credential ID from the authenticator. */
107
+ credentialId: v.string(),
108
+ /** Public key bytes (SEC1 uncompressed for EC, SPKI for RSA). */
109
+ publicKey: v.bytes(),
110
+ /** COSE algorithm identifier (-7 for ES256, -257 for RS256, -8 for EdDSA). */
111
+ algorithm: v.number(),
112
+ /** Signature counter for clone detection. Many authenticators return 0. */
113
+ counter: v.number(),
114
+ /** Authenticator transport hints (e.g. "internal", "hybrid", "usb", "ble", "nfc"). */
115
+ transports: v.optional(v.array(v.string())),
116
+ /** Whether this is a single-device or multi-device (synced) credential. */
117
+ deviceType: v.string(),
118
+ /** Whether the credential is backed up (synced passkey). */
119
+ backedUp: v.boolean(),
120
+ /** User-assigned friendly name (e.g. "MacBook Touch ID"). */
121
+ name: v.optional(v.string()),
122
+ createdAt: v.number(),
123
+ lastUsedAt: v.optional(v.number()),
124
+ })
125
+ .index("userId", ["userId"])
126
+ .index("credentialId", ["credentialId"]),
127
+
128
+ /**
129
+ * TOTP two-factor authentication secrets. Each record links a user to
130
+ * an authenticator app. A user can have multiple TOTP enrollments
131
+ * (e.g. different authenticator apps) but typically has one.
132
+ *
133
+ * The `verified` flag indicates whether the user has completed setup
134
+ * by successfully entering a code from their authenticator app.
135
+ * Unverified enrollments are in-progress setup that can be discarded.
136
+ */
137
+ totp: defineTable({
138
+ userId: v.id("user"),
139
+ /** Raw TOTP secret key bytes. */
140
+ secret: v.bytes(),
141
+ /** Number of digits in each code (typically 6). */
142
+ digits: v.number(),
143
+ /** Time period in seconds for code rotation (typically 30). */
144
+ period: v.number(),
145
+ /** Whether setup has been confirmed with a valid code. */
146
+ verified: v.boolean(),
147
+ /** User-assigned friendly name (e.g. "Google Authenticator"). */
148
+ name: v.optional(v.string()),
149
+ createdAt: v.number(),
150
+ lastUsedAt: v.optional(v.number()),
151
+ })
152
+ .index("userId", ["userId"]),
153
+
99
154
  /**
100
155
  * Rate limit tracking for OTP and password sign-in attempts.
101
156
  */
@@ -136,14 +191,17 @@ export default defineSchema({
136
191
  .index("userId", ["userId"]),
137
192
 
138
193
  /**
139
- * Group invitations. Tracks pending, accepted, revoked, and expired
140
- * invitations to join a group. Uses a hashed token for secure
141
- * invitation links.
194
+ * Invitations. Tracks pending, accepted, revoked, and expired
195
+ * invitations. Optionally scoped to a group via `groupId`, or
196
+ * platform-level when `groupId` is omitted.
197
+ *
198
+ * `email` and `invitedByUserId` are optional to support CLI-generated
199
+ * invite links where neither is known upfront (e.g. portal admin invites).
142
200
  */
143
201
  invite: defineTable({
144
202
  groupId: v.optional(v.id("group")),
145
- invitedByUserId: v.id("user"),
146
- email: v.string(),
203
+ invitedByUserId: v.optional(v.id("user")),
204
+ email: v.optional(v.string()),
147
205
  tokenHash: v.string(),
148
206
  role: v.optional(v.string()),
149
207
  status: v.union(
@@ -152,7 +210,7 @@ export default defineSchema({
152
210
  v.literal("revoked"),
153
211
  v.literal("expired"),
154
212
  ),
155
- expiresTime: v.number(),
213
+ expiresTime: v.optional(v.number()),
156
214
  acceptedByUserId: v.optional(v.id("user")),
157
215
  acceptedTime: v.optional(v.number()),
158
216
  extend: v.optional(v.any()),
@@ -162,5 +220,62 @@ export default defineSchema({
162
220
  .index("emailAndStatus", ["email", "status"])
163
221
  .index("invitedByUserIdAndStatus", ["invitedByUserId", "status"])
164
222
  .index("groupId", ["groupId"])
165
- .index("groupIdAndStatus", ["groupId", "status"]),
223
+ .index("groupIdAndStatus", ["groupId", "status"])
224
+ .index("roleAndStatusAndAcceptedByUserId", [
225
+ "role",
226
+ "status",
227
+ "acceptedByUserId",
228
+ ]),
229
+
230
+ /**
231
+ * API keys for programmatic access. Each key links a user to a set of
232
+ * scoped permissions and optional per-key rate limiting.
233
+ *
234
+ * The raw key is never stored — only a SHA-256 hash. A short prefix
235
+ * (e.g. "sk_live_abc1...") is kept for display in the portal.
236
+ *
237
+ * Keys support:
238
+ * - **Scoped permissions**: resource:action pairs (e.g. users:read)
239
+ * - **Per-key rate limiting**: token-bucket with configurable window
240
+ * - **Expiration**: optional TTL
241
+ * - **Soft revocation**: `revoked` flag preserves audit trail
242
+ */
243
+ key: defineTable({
244
+ userId: v.id("user"),
245
+ /** First chars of the key for display (e.g. "sk_live_abc1..."). */
246
+ prefix: v.string(),
247
+ /** SHA-256 hex hash of the full raw key. */
248
+ hashedKey: v.string(),
249
+ /** User-assigned name (e.g. "CI Pipeline", "Production API"). */
250
+ name: v.string(),
251
+ /** Scoped permissions: [{ resource: "users", actions: ["read", "list"] }]. */
252
+ scopes: v.array(
253
+ v.object({
254
+ resource: v.string(),
255
+ actions: v.array(v.string()),
256
+ }),
257
+ ),
258
+ /** Optional per-key rate limit configuration. */
259
+ rateLimit: v.optional(
260
+ v.object({
261
+ maxRequests: v.number(),
262
+ windowMs: v.number(),
263
+ }),
264
+ ),
265
+ /** Rate limit state tracking (token-bucket). */
266
+ rateLimitState: v.optional(
267
+ v.object({
268
+ attemptsLeft: v.number(),
269
+ lastAttemptTime: v.number(),
270
+ }),
271
+ ),
272
+ /** Expiration timestamp. Null/undefined = never expires. */
273
+ expiresAt: v.optional(v.number()),
274
+ lastUsedAt: v.optional(v.number()),
275
+ createdAt: v.number(),
276
+ /** Soft-revoke flag. Revoked keys are kept for audit trail. */
277
+ revoked: v.boolean(),
278
+ })
279
+ .index("userId", ["userId"])
280
+ .index("hashedKey", ["hashedKey"]),
166
281
  });
@@ -0,0 +1,35 @@
1
+ import { PasskeyProviderConfig } from "../server/types.js";
2
+
3
+ /**
4
+ * Passkey (WebAuthn) authentication provider.
5
+ *
6
+ * Enables passwordless authentication via biometrics, security keys,
7
+ * and synced passkeys using the Web Authentication API.
8
+ *
9
+ * ```ts
10
+ * import passkey from "@robelest/convex-auth/providers/passkey";
11
+ *
12
+ * export const { auth, signIn, signOut, store } = Auth({
13
+ * component: components.auth,
14
+ * providers: [passkey()],
15
+ * });
16
+ * ```
17
+ *
18
+ * @param config Optional configuration for the relying party and credential options.
19
+ */
20
+ export default function passkey(
21
+ config?: Partial<PasskeyProviderConfig["options"]>,
22
+ ): PasskeyProviderConfig {
23
+ return {
24
+ id: "passkey",
25
+ type: "passkey",
26
+ options: {
27
+ attestation: "none",
28
+ userVerification: "required",
29
+ residentKey: "preferred",
30
+ algorithms: [-7, -257], // ES256, RS256
31
+ challengeExpirationMs: 300_000, // 5 minutes
32
+ ...config,
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,26 @@
1
+ import { TotpProviderConfig } from "../server/types.js";
2
+
3
+ /**
4
+ * Add TOTP (Time-based One-Time Password) authentication.
5
+ *
6
+ * ```ts
7
+ * import TOTP from "@robelest/convex-auth/providers/totp";
8
+ *
9
+ * export const { auth, signIn, signOut, store } = Auth({
10
+ * providers: [TOTP({ issuer: "My App" })],
11
+ * });
12
+ * ```
13
+ */
14
+ export default function totp(
15
+ config?: Partial<TotpProviderConfig["options"]>,
16
+ ): TotpProviderConfig {
17
+ return {
18
+ id: "totp",
19
+ type: "totp",
20
+ options: {
21
+ issuer: config?.issuer ?? "ConvexAuth",
22
+ digits: config?.digits ?? 6,
23
+ period: config?.period ?? 30,
24
+ },
25
+ };
26
+ }