@robelest/convex-auth 0.0.4-preview.16 → 0.0.4-preview.18

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 (88) hide show
  1. package/dist/authorization/index.d.ts +17 -0
  2. package/dist/authorization/index.d.ts.map +1 -0
  3. package/dist/authorization/index.js +17 -0
  4. package/dist/authorization/index.js.map +1 -0
  5. package/dist/client/index.js +73 -0
  6. package/dist/client/index.js.map +1 -1
  7. package/dist/component/_generated/component.d.ts +52 -0
  8. package/dist/component/_generated/component.d.ts.map +1 -1
  9. package/dist/component/convex.config.d.ts +2 -2
  10. package/dist/component/convex.config.d.ts.map +1 -1
  11. package/dist/component/index.js +2 -2
  12. package/dist/component/model.d.ts +75 -75
  13. package/dist/component/model.d.ts.map +1 -1
  14. package/dist/component/model.js +2 -0
  15. package/dist/component/model.js.map +1 -1
  16. package/dist/component/public/factors.d.ts.map +1 -1
  17. package/dist/component/public/factors.js +1 -1
  18. package/dist/component/public/factors.js.map +1 -1
  19. package/dist/component/public/groups.d.ts +10 -2
  20. package/dist/component/public/groups.d.ts.map +1 -1
  21. package/dist/component/public/groups.js +92 -8
  22. package/dist/component/public/groups.js.map +1 -1
  23. package/dist/component/public/identity.d.ts.map +1 -1
  24. package/dist/component/public/identity.js +3 -3
  25. package/dist/component/public/identity.js.map +1 -1
  26. package/dist/component/public/shared.d.ts +4 -4
  27. package/dist/component/public/shared.d.ts.map +1 -1
  28. package/dist/component/public.d.ts +3 -3
  29. package/dist/component/public.js +2 -2
  30. package/dist/component/schema.d.ts +13 -2
  31. package/dist/component/schema.js +7 -5
  32. package/dist/component/schema.js.map +1 -1
  33. package/dist/component/server/auth.d.ts +4 -25
  34. package/dist/component/server/auth.d.ts.map +1 -1
  35. package/dist/component/server/auth.js +4 -10
  36. package/dist/component/server/auth.js.map +1 -1
  37. package/dist/component/server/domains/core.js +196 -131
  38. package/dist/component/server/domains/core.js.map +1 -1
  39. package/dist/component/server/factory.d.ts +3 -3
  40. package/dist/component/server/signin.js +20 -1
  41. package/dist/component/server/signin.js.map +1 -1
  42. package/dist/server/auth.d.ts +4 -25
  43. package/dist/server/auth.d.ts.map +1 -1
  44. package/dist/server/auth.js +4 -10
  45. package/dist/server/auth.js.map +1 -1
  46. package/dist/server/domains/core.d.ts +73 -58
  47. package/dist/server/domains/core.d.ts.map +1 -1
  48. package/dist/server/domains/core.js +196 -131
  49. package/dist/server/domains/core.js.map +1 -1
  50. package/dist/server/http.d.ts +2 -2
  51. package/dist/server/http.d.ts.map +1 -1
  52. package/dist/server/index.d.ts +108 -69
  53. package/dist/server/index.d.ts.map +1 -1
  54. package/dist/server/index.js +138 -54
  55. package/dist/server/index.js.map +1 -1
  56. package/dist/server/mutations/code.d.ts +9 -9
  57. package/dist/server/mutations/index.d.ts +69 -69
  58. package/dist/server/mutations/invalidate.d.ts +4 -4
  59. package/dist/server/mutations/invalidate.d.ts.map +1 -1
  60. package/dist/server/mutations/oauth.d.ts +7 -7
  61. package/dist/server/mutations/register.d.ts +9 -9
  62. package/dist/server/mutations/retrieve.d.ts +6 -6
  63. package/dist/server/mutations/retrieve.d.ts.map +1 -1
  64. package/dist/server/mutations/signature.d.ts +4 -4
  65. package/dist/server/mutations/signature.d.ts.map +1 -1
  66. package/dist/server/mutations/signin.d.ts +5 -5
  67. package/dist/server/mutations/signin.d.ts.map +1 -1
  68. package/dist/server/mutations/verify.d.ts +7 -7
  69. package/dist/server/signin.js +20 -1
  70. package/dist/server/signin.js.map +1 -1
  71. package/dist/server/version.d.ts +1 -1
  72. package/dist/server/version.js +1 -1
  73. package/dist/server/version.js.map +1 -1
  74. package/package.json +9 -5
  75. package/src/authorization/index.ts +37 -0
  76. package/src/client/index.ts +122 -0
  77. package/src/component/_generated/component.ts +64 -0
  78. package/src/component/index.ts +1 -1
  79. package/src/component/model.ts +2 -0
  80. package/src/component/public/factors.ts +3 -2
  81. package/src/component/public/groups.ts +142 -8
  82. package/src/component/public/identity.ts +9 -6
  83. package/src/component/schema.ts +14 -2
  84. package/src/server/auth.ts +9 -61
  85. package/src/server/domains/core.ts +235 -210
  86. package/src/server/index.ts +158 -75
  87. package/src/server/signin.ts +52 -0
  88. package/src/server/version.ts +1 -1
@@ -1,7 +1,6 @@
1
1
  import { Auth, GenericActionCtx, GenericDataModel } from "convex/server";
2
2
  import { GenericId } from "convex/values";
3
3
 
4
- import { AuthError, Fx } from "../fx";
5
4
  import {
6
5
  buildScopeChecker,
7
6
  checkKeyRateLimit,
@@ -92,28 +91,27 @@ export function createCoreDomains(deps: CoreDeps) {
92
91
  >;
93
92
 
94
93
  const getRoleDefinition = (roleId: string) => {
95
- const role = roleDefinitions[roleId];
96
- if (!role) {
97
- throw new AuthError(
98
- "INVALID_PARAMETERS",
99
- `Unknown roleId "${roleId}".`,
100
- ).toConvexError();
101
- }
102
- return role;
94
+ return roleDefinitions[roleId] ?? null;
103
95
  };
104
96
 
105
- const normalizeRoleIds = (roleIds?: string[]) => {
97
+ const normalizeRoleIds = (
98
+ roleIds?: string[],
99
+ ):
100
+ | { ok: true; roleIds: string[] }
101
+ | { ok: false; invalidRoleIds: string[] } => {
106
102
  const normalized = Array.from(new Set(roleIds ?? []));
107
- for (const roleId of normalized) {
108
- getRoleDefinition(roleId);
103
+ const invalid = normalized.filter((id) => getRoleDefinition(id) === null);
104
+ if (invalid.length > 0) {
105
+ return { ok: false, invalidRoleIds: invalid };
109
106
  }
110
- return normalized;
107
+ return { ok: true, roleIds: normalized };
111
108
  };
112
109
 
113
110
  const resolveGrantedPermissions = (roleIds?: string[]) => {
114
111
  const grants = new Set<string>();
115
112
  for (const roleId of roleIds ?? []) {
116
113
  const role = getRoleDefinition(roleId);
114
+ if (role === null) continue;
117
115
  for (const grant of role.grants) {
118
116
  grants.add(grant);
119
117
  }
@@ -121,8 +119,25 @@ export function createCoreDomains(deps: CoreDeps) {
121
119
  return Array.from(grants).sort();
122
120
  };
123
121
 
122
+ // Per-execution cache — attached to ctx so each function invocation has its own.
123
+ // Eliminates redundant cross-component RPCs for the same entity within a handler.
124
+ type CtxCache = {
125
+ users: Map<string, any>;
126
+ groups: Map<string, any>;
127
+ };
128
+ const AUTH_CACHE = Symbol("__convexAuthCache");
129
+ function cache(ctx: any): CtxCache {
130
+ if (!ctx[AUTH_CACHE]) {
131
+ ctx[AUTH_CACHE] = {
132
+ users: new Map(),
133
+ groups: new Map(),
134
+ } satisfies CtxCache;
135
+ }
136
+ return ctx[AUTH_CACHE];
137
+ }
138
+
124
139
  const user = {
125
- current: async (
140
+ id: async (
126
141
  ctx: { auth: Auth } & Partial<ComponentCtx>,
127
142
  request?: Request,
128
143
  ): Promise<string | null> => {
@@ -135,33 +150,26 @@ export function createCoreDomains(deps: CoreDeps) {
135
150
  const authHeader = request.headers.get("Authorization");
136
151
  if (authHeader?.startsWith("Bearer sk_")) {
137
152
  const rawKey = authHeader.slice(7);
138
- try {
139
- const result = await getAuth().key.verify(
140
- ctx as ComponentCtx,
141
- rawKey,
142
- );
153
+ const result = await getAuth().key.verify(
154
+ ctx as ComponentCtx,
155
+ rawKey,
156
+ );
157
+ if (result.ok) {
143
158
  return result.userId;
144
- } catch {
145
- return null;
146
159
  }
160
+ return null;
147
161
  }
148
162
  }
149
163
  return null;
150
164
  },
151
- require: async (
152
- ctx: { auth: Auth } & Partial<ComponentCtx>,
153
- request?: Request,
154
- ): Promise<string> => {
155
- const userId = await user.current(ctx, request);
156
- if (userId === null) {
157
- throw new AuthError("NOT_SIGNED_IN").toConvexError();
158
- }
159
- return userId;
160
- },
161
165
  get: async (ctx: ComponentReadCtx, userId: string) => {
162
- return await ctx.runQuery(config.component.public.userGetById, {
166
+ const c = cache(ctx);
167
+ if (c.users.has(userId)) return c.users.get(userId);
168
+ const result = await ctx.runQuery(config.component.public.userGetById, {
163
169
  userId,
164
170
  });
171
+ c.users.set(userId, result);
172
+ return result;
165
173
  },
166
174
  list: async (
167
175
  ctx: ComponentReadCtx,
@@ -176,11 +184,9 @@ export function createCoreDomains(deps: CoreDeps) {
176
184
  return await ctx.runQuery(config.component.public.userList, opts);
177
185
  },
178
186
  viewer: async (ctx: ComponentAuthReadCtx) => {
179
- const userId = await user.current(ctx);
187
+ const userId = await user.id(ctx);
180
188
  if (userId === null) return null;
181
- return await ctx.runQuery(config.component.public.userGetById, {
182
- userId,
183
- });
189
+ return await user.get(ctx, userId);
184
190
  },
185
191
  update: async (
186
192
  ctx: ComponentCtx,
@@ -266,10 +272,7 @@ export function createCoreDomains(deps: CoreDeps) {
266
272
  passkeys.length +
267
273
  totps.length;
268
274
  if (!cascade && totalLinked > 0) {
269
- throw new AuthError(
270
- "INVALID_PARAMETERS",
271
- `Cannot delete user with ${totalLinked} linked records. Pass { cascade: true } to delete all linked records, or remove them manually first.`,
272
- ).toConvexError();
275
+ return { ok: false as const, code: "INVALID_PARAMETERS" as const };
273
276
  }
274
277
  const deletions: Promise<unknown>[] = [];
275
278
  for (const s of sessions)
@@ -356,7 +359,7 @@ export function createCoreDomains(deps: CoreDeps) {
356
359
  ) => {
357
360
  const result = await callRetrieveAccountWithCredentials(ctx, args);
358
361
  if (typeof result === "string") {
359
- throw new AuthError("ACCOUNT_NOT_FOUND", result).toConvexError();
362
+ return null;
360
363
  }
361
364
  return result;
362
365
  },
@@ -372,20 +375,14 @@ export function createCoreDomains(deps: CoreDeps) {
372
375
  accountId,
373
376
  });
374
377
  if (doc === null) {
375
- throw new AuthError(
376
- "ACCOUNT_NOT_FOUND",
377
- "Account not found.",
378
- ).toConvexError();
378
+ return { ok: false as const, code: "ACCOUNT_NOT_FOUND" as const };
379
379
  }
380
380
  const allAccounts = (await ctx.runQuery(
381
381
  config.component.public.accountListByUser,
382
382
  { userId: (doc as any).userId },
383
383
  )) as Array<{ _id: string }>;
384
384
  if (allAccounts.length <= 1) {
385
- throw new AuthError(
386
- "INVALID_PARAMETERS",
387
- "Cannot unlink the user's only account. This would lock them out.",
388
- ).toConvexError();
385
+ return { ok: false as const, code: "INVALID_PARAMETERS" as const };
389
386
  }
390
387
  await ctx.runMutation(config.component.public.accountDelete, {
391
388
  accountId,
@@ -472,7 +469,13 @@ export function createCoreDomains(deps: CoreDeps) {
472
469
  return { ok: true, groupId };
473
470
  },
474
471
  get: async (ctx: ComponentReadCtx, groupId: string) => {
475
- return await ctx.runQuery(config.component.public.groupGet, { groupId });
472
+ const c = cache(ctx);
473
+ if (c.groups.has(groupId)) return c.groups.get(groupId);
474
+ const result = await ctx.runQuery(config.component.public.groupGet, {
475
+ groupId,
476
+ });
477
+ c.groups.set(groupId, result);
478
+ return result;
476
479
  },
477
480
  list: async (
478
481
  ctx: ComponentReadCtx,
@@ -564,13 +567,19 @@ export function createCoreDomains(deps: CoreDeps) {
564
567
  status?: string;
565
568
  extend?: Record<string, unknown>;
566
569
  },
567
- ): Promise<{ ok: true; memberId: string }> => {
568
- const roleIds = normalizeRoleIds(data.roleIds);
570
+ ) => {
571
+ const normalized = normalizeRoleIds(data.roleIds);
572
+ if (!normalized.ok)
573
+ return {
574
+ ok: false as const,
575
+ code: "INVALID_ROLE_IDS" as const,
576
+ invalidRoleIds: normalized.invalidRoleIds,
577
+ };
569
578
  const memberId = (await ctx.runMutation(
570
579
  config.component.public.memberAdd,
571
- { ...data, roleIds },
580
+ { ...data, roleIds: normalized.roleIds },
572
581
  )) as string;
573
- return { ok: true, memberId };
582
+ return { ok: true as const, memberId };
574
583
  },
575
584
  get: async (ctx: ComponentReadCtx, memberId: string) => {
576
585
  return await ctx.runQuery(config.component.public.memberGet, {
@@ -620,11 +629,18 @@ export function createCoreDomains(deps: CoreDeps) {
620
629
  ) => {
621
630
  const nextData = { ...data };
622
631
  if ("roleIds" in nextData) {
623
- nextData.roleIds = normalizeRoleIds(
632
+ const normalized = normalizeRoleIds(
624
633
  Array.isArray(nextData.roleIds)
625
634
  ? (nextData.roleIds as string[])
626
635
  : undefined,
627
636
  );
637
+ if (!normalized.ok)
638
+ return {
639
+ ok: false as const,
640
+ code: "INVALID_ROLE_IDS" as const,
641
+ invalidRoleIds: normalized.invalidRoleIds,
642
+ };
643
+ nextData.roleIds = normalized.roleIds;
628
644
  }
629
645
  await ctx.runMutation(config.component.public.memberUpdate, {
630
646
  memberId,
@@ -632,7 +648,7 @@ export function createCoreDomains(deps: CoreDeps) {
632
648
  });
633
649
  return { ok: true as const, memberId };
634
650
  },
635
- inherit: async (
651
+ resolve: async (
636
652
  ctx: ComponentReadCtx,
637
653
  opts: {
638
654
  userId: string;
@@ -642,102 +658,105 @@ export function createCoreDomains(deps: CoreDeps) {
642
658
  maxDepth?: number;
643
659
  },
644
660
  ) => {
645
- const requestedRoleIds = normalizeRoleIds(opts.roleIds);
661
+ const normalized = normalizeRoleIds(opts.roleIds);
662
+ if (!normalized.ok)
663
+ return {
664
+ ok: false as const,
665
+ code: "INVALID_ROLE_IDS" as const,
666
+ invalidRoleIds: normalized.invalidRoleIds,
667
+ };
668
+ const requestedRoleIds = normalized.roleIds;
646
669
  const roleFilter =
647
670
  requestedRoleIds.length > 0 ? new Set(requestedRoleIds) : null;
648
671
  const requiredGrants = Array.from(new Set(opts.grants ?? []));
649
672
  const maxDepth = Math.max(0, Math.floor(opts.maxDepth ?? 32));
650
- const visited = new Set<string>();
651
- const traversedGroupIds: string[] = [];
652
- let currentGroupId: string | undefined = opts.groupId;
653
- let depth = 0;
654
- let cycleDetected = false;
655
- let maxDepthReached = false;
656
- while (currentGroupId !== undefined) {
657
- if (depth > maxDepth) {
658
- maxDepthReached = true;
659
- break;
660
- }
661
- if (visited.has(currentGroupId)) {
662
- cycleDetected = true;
663
- break;
664
- }
665
- visited.add(currentGroupId);
666
- traversedGroupIds.push(currentGroupId);
667
- const membership = await member.getByUserAndGroup(ctx, {
668
- userId: opts.userId,
669
- groupId: currentGroupId,
670
- });
671
- const membershipRoleIds = membership?.roleIds ?? [];
672
- const membershipGrants = resolveGrantedPermissions(membershipRoleIds);
673
- if (
674
- membership !== null &&
675
- (roleFilter === null ||
676
- membershipRoleIds.some((roleId: string) =>
677
- roleFilter.has(roleId),
678
- )) &&
679
- requiredGrants.every((grant) => membershipGrants.includes(grant))
680
- ) {
681
- return {
682
- requestedGroupId: opts.groupId,
683
- matchedGroupId: currentGroupId,
684
- membership,
685
- roleIds: membershipRoleIds,
686
- grants: membershipGrants,
687
- missingGrants: [] as string[],
688
- depth,
689
- isDirect: depth === 0,
690
- isInherited: depth > 0,
691
- traversedGroupIds,
692
- cycleDetected: false,
693
- maxDepthReached: false,
694
- };
695
- }
696
- const doc = await group.get(ctx, currentGroupId);
697
- if (doc === null || doc.parentGroupId === undefined) break;
698
- currentGroupId = doc.parentGroupId;
699
- depth += 1;
700
- }
701
- return {
702
- requestedGroupId: opts.groupId,
703
- matchedGroupId: null,
704
- membership: null,
705
- roleIds: [] as string[],
706
- grants: [] as string[],
707
- missingGrants: requiredGrants,
708
- depth: null,
709
- isDirect: false,
710
- isInherited: false,
711
- traversedGroupIds,
712
- cycleDetected,
713
- maxDepthReached,
714
- };
715
- },
716
- require: async (
717
- ctx: ComponentReadCtx,
718
- opts: {
719
- userId: string;
720
- groupId: string;
721
- roleIds?: string[];
722
- grants?: string[];
723
- maxDepth?: number;
724
- },
725
- ) => {
726
- const result = await member.inherit(ctx, opts);
673
+
674
+ // Single cross-component RPC — hierarchy walk happens inside the component
675
+ const result = await ctx.runQuery(config.component.public.memberResolve, {
676
+ userId: opts.userId,
677
+ groupId: opts.groupId,
678
+ maxDepth,
679
+ ancestry: true,
680
+ });
681
+
682
+ const traversedGroupIds = result.traversedGroupIds ?? [];
683
+
727
684
  if (result.membership === null) {
728
- throw new AuthError(
729
- "FORBIDDEN",
730
- `User ${opts.userId} has no membership on group ${opts.groupId} or its ancestors.`,
731
- ).toConvexError();
685
+ return {
686
+ requestedGroupId: opts.groupId,
687
+ matchedGroupId: null,
688
+ membership: null,
689
+ roleIds: [] as string[],
690
+ grants: [] as string[],
691
+ missingGrants: requiredGrants,
692
+ depth: null,
693
+ isDirect: false,
694
+ isInherited: false,
695
+ traversedGroupIds,
696
+ cycleDetected: false,
697
+ maxDepthReached: false,
698
+ };
699
+ }
700
+
701
+ // Grant resolution uses app-defined roles — stays server-side
702
+ const membershipRoleIds = (result.membership as any).roleIds ?? [];
703
+ const membershipGrants = resolveGrantedPermissions(membershipRoleIds);
704
+
705
+ // Check role filter
706
+ if (
707
+ roleFilter !== null &&
708
+ !membershipRoleIds.some((roleId: string) => roleFilter.has(roleId))
709
+ ) {
710
+ return {
711
+ requestedGroupId: opts.groupId,
712
+ matchedGroupId: null,
713
+ membership: null,
714
+ roleIds: [] as string[],
715
+ grants: [] as string[],
716
+ missingGrants: requiredGrants,
717
+ depth: null,
718
+ isDirect: false,
719
+ isInherited: false,
720
+ traversedGroupIds,
721
+ cycleDetected: false,
722
+ maxDepthReached: false,
723
+ };
724
+ }
725
+
726
+ // Check required grants
727
+ const missingGrants = requiredGrants.filter(
728
+ (grant) => !membershipGrants.includes(grant),
729
+ );
730
+ if (missingGrants.length > 0) {
731
+ return {
732
+ requestedGroupId: opts.groupId,
733
+ matchedGroupId: result.matchedGroupId,
734
+ membership: result.membership,
735
+ roleIds: membershipRoleIds,
736
+ grants: membershipGrants,
737
+ missingGrants,
738
+ depth: result.depth,
739
+ isDirect: result.isDirect,
740
+ isInherited: result.isInherited,
741
+ traversedGroupIds,
742
+ cycleDetected: false,
743
+ maxDepthReached: false,
744
+ };
732
745
  }
746
+
733
747
  return {
734
- membership: result.membership,
748
+ requestedGroupId: opts.groupId,
735
749
  matchedGroupId: result.matchedGroupId,
736
- roleIds: result.roleIds,
737
- grants: result.grants,
750
+ membership: result.membership,
751
+ roleIds: membershipRoleIds,
752
+ grants: membershipGrants,
753
+ missingGrants: [] as string[],
754
+ depth: result.depth,
738
755
  isDirect: result.isDirect,
739
756
  isInherited: result.isInherited,
740
- depth: result.depth,
757
+ traversedGroupIds,
758
+ cycleDetected: false,
759
+ maxDepthReached: false,
741
760
  };
742
761
  },
743
762
  };
@@ -753,12 +772,25 @@ export function createCoreDomains(deps: CoreDeps) {
753
772
  },
754
773
  ) => {
755
774
  const requiredGrants = Array.from(new Set(opts.grants));
756
- const result = await member.inherit(ctx, {
775
+ const result = await member.resolve(ctx, {
757
776
  userId: opts.userId,
758
777
  groupId: opts.groupId,
759
778
  grants: requiredGrants,
760
779
  maxDepth: opts.maxDepth,
761
780
  });
781
+ if ("code" in result && result.code === "INVALID_ROLE_IDS") {
782
+ return {
783
+ ok: false,
784
+ membership: null,
785
+ matchedGroupId: null,
786
+ roleIds: [] as string[],
787
+ grants: [] as string[],
788
+ missingGrants: requiredGrants,
789
+ isDirect: false,
790
+ isInherited: false,
791
+ depth: null,
792
+ };
793
+ }
762
794
  const missingGrants = requiredGrants.filter(
763
795
  (grant) => !result.grants.includes(grant),
764
796
  );
@@ -774,24 +806,6 @@ export function createCoreDomains(deps: CoreDeps) {
774
806
  depth: result.depth,
775
807
  };
776
808
  },
777
- require: async (
778
- ctx: ComponentReadCtx,
779
- opts: {
780
- userId: string;
781
- groupId: string;
782
- grants: string[];
783
- maxDepth?: number;
784
- },
785
- ) => {
786
- const result = await access.check(ctx, opts);
787
- if (!result.ok) {
788
- throw new AuthError(
789
- "FORBIDDEN",
790
- `User ${opts.userId} is missing required grants on group ${opts.groupId}: ${result.missingGrants.join(", ")}.`,
791
- ).toConvexError();
792
- }
793
- return result;
794
- },
795
809
  };
796
810
 
797
811
  const invite = {
@@ -805,8 +819,14 @@ export function createCoreDomains(deps: CoreDeps) {
805
819
  expiresTime?: number;
806
820
  extend?: Record<string, unknown>;
807
821
  },
808
- ): Promise<{ ok: true; inviteId: string; token: string }> => {
809
- const roleIds = normalizeRoleIds(data.roleIds);
822
+ ) => {
823
+ const normalized = normalizeRoleIds(data.roleIds);
824
+ if (!normalized.ok)
825
+ return {
826
+ ok: false as const,
827
+ code: "INVALID_ROLE_IDS" as const,
828
+ invalidRoleIds: normalized.invalidRoleIds,
829
+ };
810
830
  const token = generateRandomString(
811
831
  inviteTokenLength,
812
832
  inviteTokenAlphabet,
@@ -814,9 +834,9 @@ export function createCoreDomains(deps: CoreDeps) {
814
834
  const tokenHash = await sha256(token);
815
835
  const inviteId = (await ctx.runMutation(
816
836
  config.component.public.inviteCreate,
817
- { ...data, roleIds, tokenHash, status: "pending" },
837
+ { ...data, roleIds: normalized.roleIds, tokenHash, status: "pending" },
818
838
  )) as string;
819
- return { ok: true, inviteId, token };
839
+ return { ok: true as const, inviteId, token };
820
840
  },
821
841
  get: async (ctx: ComponentReadCtx, inviteId: string) => {
822
842
  return await ctx.runQuery(config.component.public.inviteGet, {
@@ -923,46 +943,53 @@ export function createCoreDomains(deps: CoreDeps) {
923
943
  verify: async (
924
944
  ctx: ComponentCtx,
925
945
  rawKey: string,
926
- ): Promise<{ userId: string; keyId: string; scopes: ScopeChecker }> => {
946
+ ): Promise<
947
+ | { ok: true; userId: string; keyId: string; scopes: ScopeChecker }
948
+ | {
949
+ ok: false;
950
+ code:
951
+ | "INVALID_API_KEY"
952
+ | "API_KEY_REVOKED"
953
+ | "API_KEY_EXPIRED"
954
+ | "API_KEY_RATE_LIMITED";
955
+ }
956
+ > => {
927
957
  const hashedKey = await hashApiKey(rawKey);
928
958
  const doc = (await ctx.runQuery(
929
959
  config.component.public.keyGetByHashedKey,
930
960
  { hashedKey },
931
961
  )) as KeyDoc | null;
932
- return Fx.run(
933
- Fx.gen(function* () {
934
- yield* Fx.guard(!doc, Fx.fail(new AuthError("INVALID_API_KEY")));
935
- const k = doc!;
936
- yield* Fx.guard(k.revoked, Fx.fail(new AuthError("API_KEY_REVOKED")));
937
- yield* Fx.guard(
938
- !!(k.expiresAt && k.expiresAt < Date.now()),
939
- Fx.fail(new AuthError("API_KEY_EXPIRED")),
940
- );
941
- const patchData: Record<string, unknown> = { lastUsedAt: Date.now() };
942
- if (k.rateLimit) {
943
- const { limited, newState } = checkKeyRateLimit(
944
- k.rateLimit,
945
- k.rateLimitState ?? undefined,
946
- );
947
- yield* Fx.guard(
948
- limited,
949
- Fx.fail(new AuthError("API_KEY_RATE_LIMITED")),
950
- );
951
- patchData.rateLimitState = newState;
952
- }
953
- yield* Fx.promise(() =>
954
- ctx.runMutation(config.component.public.keyPatch, {
955
- keyId: k._id,
956
- data: patchData,
957
- }),
958
- );
959
- return {
960
- userId: k.userId,
961
- keyId: k._id,
962
- scopes: buildScopeChecker(k.scopes),
963
- };
964
- }).pipe(Fx.recover((e) => Fx.fatal(e.toConvexError()))),
965
- );
962
+ if (!doc) {
963
+ return { ok: false as const, code: "INVALID_API_KEY" as const };
964
+ }
965
+ const k = doc;
966
+ if (k.revoked) {
967
+ return { ok: false as const, code: "API_KEY_REVOKED" as const };
968
+ }
969
+ if (k.expiresAt && k.expiresAt < Date.now()) {
970
+ return { ok: false as const, code: "API_KEY_EXPIRED" as const };
971
+ }
972
+ const patchData: Record<string, unknown> = { lastUsedAt: Date.now() };
973
+ if (k.rateLimit) {
974
+ const { limited, newState } = checkKeyRateLimit(
975
+ k.rateLimit,
976
+ k.rateLimitState ?? undefined,
977
+ );
978
+ if (limited) {
979
+ return { ok: false as const, code: "API_KEY_RATE_LIMITED" as const };
980
+ }
981
+ patchData.rateLimitState = newState;
982
+ }
983
+ await ctx.runMutation(config.component.public.keyPatch, {
984
+ keyId: k._id,
985
+ data: patchData,
986
+ });
987
+ return {
988
+ ok: true as const,
989
+ userId: k.userId,
990
+ keyId: k._id,
991
+ scopes: buildScopeChecker(k.scopes),
992
+ };
966
993
  },
967
994
  list: async (
968
995
  ctx: ComponentReadCtx,
@@ -1027,20 +1054,18 @@ export function createCoreDomains(deps: CoreDeps) {
1027
1054
  ctx: ComponentCtx,
1028
1055
  keyId: string,
1029
1056
  opts?: { name?: string; expiresAt?: number },
1030
- ): Promise<{ ok: true; keyId: string; secret: string }> => {
1057
+ ): Promise<
1058
+ | { ok: true; keyId: string; secret: string }
1059
+ | { ok: false; code: "INVALID_PARAMETERS" | "API_KEY_REVOKED" }
1060
+ > => {
1031
1061
  const existing = await ctx.runQuery(config.component.public.keyGetById, {
1032
1062
  keyId,
1033
1063
  });
1034
- if (!existing)
1035
- throw new AuthError(
1036
- "INVALID_PARAMETERS",
1037
- "API key not found.",
1038
- ).toConvexError();
1064
+ if (!existing) {
1065
+ return { ok: false as const, code: "INVALID_PARAMETERS" as const };
1066
+ }
1039
1067
  if ((existing as any).revoked === true) {
1040
- throw new AuthError(
1041
- "API_KEY_REVOKED",
1042
- "Cannot rotate a key that is already revoked.",
1043
- ).toConvexError();
1068
+ return { ok: false as const, code: "API_KEY_REVOKED" as const };
1044
1069
  }
1045
1070
  await ctx.runMutation(config.component.public.keyPatch, {
1046
1071
  keyId,