@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
@@ -39,10 +39,23 @@ export const groupCreate = mutation({
39
39
  handler: async (ctx, args) => {
40
40
  const { tags: rawTags, ...rest } = args;
41
41
  const normalizedTags = rawTags ? normalizeTags(rawTags) : undefined;
42
+ const isRoot = !args.parentGroupId;
43
+ // Compute rootGroupId: root groups self-reference, children inherit from parent
44
+ let rootGroupId: Id<"Group"> | undefined;
45
+ if (!isRoot && args.parentGroupId) {
46
+ const parent = await ctx.db.get(args.parentGroupId);
47
+ rootGroupId = parent?.rootGroupId ?? args.parentGroupId;
48
+ }
42
49
  const groupId = await ctx.db.insert("Group", {
43
50
  ...rest,
44
51
  tags: normalizedTags,
52
+ isRoot,
53
+ rootGroupId: isRoot ? undefined : rootGroupId,
45
54
  });
55
+ // Self-reference for root groups (need the ID after insert)
56
+ if (isRoot) {
57
+ await ctx.db.patch(groupId, { rootGroupId: groupId });
58
+ }
46
59
  // Sync companion group_tag rows
47
60
  if (normalizedTags) {
48
61
  for (const tag of normalizedTags) {
@@ -178,6 +191,10 @@ export const groupList = query({
178
191
  .withIndex("parent_group_id", (idx) =>
179
192
  idx.eq("parentGroupId", where.parentGroupId!),
180
193
  );
194
+ } else if (where.isRoot !== undefined) {
195
+ q = ctx.db
196
+ .query("Group")
197
+ .withIndex("is_root", (idx) => idx.eq("isRoot", where.isRoot!));
181
198
  } else {
182
199
  q = ctx.db.query("Group");
183
200
  }
@@ -186,10 +203,13 @@ export const groupList = query({
186
203
  if (where.name !== undefined) {
187
204
  q = q.filter((f) => f.eq(f.field("name"), where.name!));
188
205
  }
189
- if (where.isRoot === true) {
190
- q = q.filter((f) => f.eq(f.field("parentGroupId"), undefined));
191
- } else if (where.isRoot === false) {
192
- q = q.filter((f) => f.neq(f.field("parentGroupId"), undefined));
206
+ // isRoot filter when not already used as primary index
207
+ if (
208
+ where.isRoot !== undefined &&
209
+ where.parentGroupId === undefined &&
210
+ (where.type !== undefined || where.slug !== undefined)
211
+ ) {
212
+ q = q.filter((f) => f.eq(f.field("isRoot"), where.isRoot!));
193
213
  }
194
214
  // slug filter when not used as index
195
215
  if (where.slug !== undefined && where.type !== undefined) {
@@ -226,6 +246,38 @@ export const groupUpdate = mutation({
226
246
  args: { groupId: v.id("Group"), data: v.any() },
227
247
  returns: v.null(),
228
248
  handler: async (ctx, { groupId, data }) => {
249
+ // If parentGroupId is changing, recompute rootGroupId + isRoot for this group and descendants
250
+ if (data.parentGroupId !== undefined) {
251
+ const oldGroup = await ctx.db.get("Group", groupId);
252
+ const oldRootGroupId = oldGroup?.rootGroupId;
253
+ const newParentGroupId = data.parentGroupId as Id<"Group"> | undefined;
254
+ const newIsRoot = !newParentGroupId;
255
+ let newRootGroupId: Id<"Group">;
256
+ if (newIsRoot) {
257
+ newRootGroupId = groupId;
258
+ } else {
259
+ const parent = await ctx.db.get("Group", newParentGroupId!);
260
+ newRootGroupId = parent?.rootGroupId ?? newParentGroupId!;
261
+ }
262
+ data.isRoot = newIsRoot;
263
+ data.rootGroupId = newRootGroupId;
264
+ // Cascade to descendants if rootGroupId changed
265
+ if (oldRootGroupId && oldRootGroupId !== newRootGroupId) {
266
+ const descendants = await ctx.db
267
+ .query("Group")
268
+ .withIndex("root_group_id", (q) =>
269
+ q.eq("rootGroupId", oldRootGroupId),
270
+ )
271
+ .collect();
272
+ for (const desc of descendants) {
273
+ if (desc._id !== groupId) {
274
+ await ctx.db.patch("Group", desc._id, {
275
+ rootGroupId: newRootGroupId,
276
+ });
277
+ }
278
+ }
279
+ }
280
+ }
229
281
  // If tags are being updated, normalize and replace the full tag set
230
282
  if (data.tags !== undefined) {
231
283
  const normalizedTags: TagPair[] = Array.isArray(data.tags)
@@ -401,6 +453,15 @@ export const memberList = query({
401
453
  .withIndex("group_id_user_id", (idx) =>
402
454
  idx.eq("groupId", where.groupId!).eq("userId", where.userId!),
403
455
  );
456
+ if (where.status !== undefined) {
457
+ q = q.filter((f) => f.eq(f.field("status"), where.status!));
458
+ }
459
+ } else if (where.groupId !== undefined && where.status !== undefined) {
460
+ q = ctx.db
461
+ .query("GroupMember")
462
+ .withIndex("group_id_status", (idx) =>
463
+ idx.eq("groupId", where.groupId!).eq("status", where.status!),
464
+ );
404
465
  } else if (where.groupId !== undefined) {
405
466
  q = ctx.db
406
467
  .query("GroupMember")
@@ -409,12 +470,14 @@ export const memberList = query({
409
470
  q = ctx.db
410
471
  .query("GroupMember")
411
472
  .withIndex("user_id", (idx) => idx.eq("userId", where.userId!));
473
+ if (where.status !== undefined) {
474
+ q = q.filter((f) => f.eq(f.field("status"), where.status!));
475
+ }
412
476
  } else {
413
477
  q = ctx.db.query("GroupMember");
414
- }
415
-
416
- if (where.status !== undefined) {
417
- q = q.filter((f) => f.eq(f.field("status"), where.status!));
478
+ if (where.status !== undefined) {
479
+ q = q.filter((f) => f.eq(f.field("status"), where.status!));
480
+ }
418
481
  }
419
482
 
420
483
  q = q.order(order);
@@ -470,6 +533,77 @@ export const memberGetByGroupAndUser = query({
470
533
  },
471
534
  });
472
535
 
536
+ /**
537
+ * Resolve a user's membership by walking the group hierarchy from the
538
+ * requested group up to the root. Returns the first matching membership
539
+ * found. When `ancestry` is true, includes the list of traversed group IDs.
540
+ *
541
+ * This runs entirely inside the component (no cross-component RPCs per level).
542
+ */
543
+ export const memberResolve = query({
544
+ args: {
545
+ userId: v.id("User"),
546
+ groupId: v.id("Group"),
547
+ maxDepth: v.optional(v.number()),
548
+ ancestry: v.optional(v.boolean()),
549
+ },
550
+ returns: v.object({
551
+ membership: v.union(vGroupMemberDoc, v.null()),
552
+ matchedGroupId: v.union(v.id("Group"), v.null()),
553
+ depth: v.union(v.number(), v.null()),
554
+ isDirect: v.boolean(),
555
+ isInherited: v.boolean(),
556
+ traversedGroupIds: v.optional(v.array(v.id("Group"))),
557
+ }),
558
+ handler: async (ctx, args) => {
559
+ const maxDepth = Math.max(0, Math.floor(args.maxDepth ?? 32));
560
+ const includeAncestry = args.ancestry ?? false;
561
+ const visited = new Set<string>();
562
+ const traversedGroupIds: Id<"Group">[] = [];
563
+ let currentGroupId: Id<"Group"> | undefined = args.groupId;
564
+ let depth = 0;
565
+
566
+ while (currentGroupId !== undefined && depth <= maxDepth) {
567
+ if (visited.has(currentGroupId)) break;
568
+ visited.add(currentGroupId);
569
+ if (includeAncestry) traversedGroupIds.push(currentGroupId);
570
+
571
+ const membership = await ctx.db
572
+ .query("GroupMember")
573
+ .withIndex("group_id_user_id", (q) =>
574
+ q.eq("groupId", currentGroupId!).eq("userId", args.userId),
575
+ )
576
+ .unique();
577
+
578
+ if (membership !== null) {
579
+ return {
580
+ membership,
581
+ matchedGroupId: currentGroupId,
582
+ depth,
583
+ isDirect: depth === 0,
584
+ isInherited: depth > 0,
585
+ ...(includeAncestry ? { traversedGroupIds } : {}),
586
+ };
587
+ }
588
+
589
+ const groupDoc: { parentGroupId?: Id<"Group"> } | null =
590
+ await ctx.db.get(currentGroupId);
591
+ if (!groupDoc?.parentGroupId) break;
592
+ currentGroupId = groupDoc.parentGroupId;
593
+ depth++;
594
+ }
595
+
596
+ return {
597
+ membership: null,
598
+ matchedGroupId: null,
599
+ depth: null,
600
+ isDirect: false,
601
+ isInherited: false,
602
+ ...(includeAncestry ? { traversedGroupIds } : {}),
603
+ };
604
+ },
605
+ });
606
+
473
607
  /** Remove a member from a group by deleting the member record. */
474
608
  export const memberRemove = mutation({
475
609
  args: { memberId: v.id("GroupMember") },
@@ -114,8 +114,9 @@ export const userFindByVerifiedEmail = query({
114
114
  handler: async (ctx, { email }) => {
115
115
  const users = await ctx.db
116
116
  .query("User")
117
- .withIndex("email", (q) => q.eq("email", email))
118
- .filter((q) => q.neq(q.field("emailVerificationTime"), undefined))
117
+ .withIndex("email_verified", (q) =>
118
+ q.eq("email", email).gt("emailVerificationTime", undefined),
119
+ )
119
120
  .take(2);
120
121
  return users.length === 1 ? users[0] : null;
121
122
  },
@@ -132,8 +133,9 @@ export const userFindByVerifiedPhone = query({
132
133
  handler: async (ctx, { phone }) => {
133
134
  const users = await ctx.db
134
135
  .query("User")
135
- .withIndex("phone", (q) => q.eq("phone", phone))
136
- .filter((q) => q.neq(q.field("phoneVerificationTime"), undefined))
136
+ .withIndex("phone_verified", (q) =>
137
+ q.eq("phone", phone).gt("phoneVerificationTime", undefined),
138
+ )
137
139
  .take(2);
138
140
  return users.length === 1 ? users[0] : null;
139
141
  },
@@ -558,8 +560,9 @@ export const refreshTokenGetActive = query({
558
560
  handler: async (ctx, { sessionId }) => {
559
561
  return await ctx.db
560
562
  .query("RefreshToken")
561
- .withIndex("session_id", (q) => q.eq("sessionId", sessionId as any))
562
- .filter((q) => q.eq(q.field("firstUsedTime"), undefined))
563
+ .withIndex("session_id_first_used", (q) =>
564
+ q.eq("sessionId", sessionId as any).eq("firstUsedTime", undefined),
565
+ )
563
566
  .order("desc")
564
567
  .first();
565
568
  },
@@ -42,7 +42,9 @@ export default defineSchema({
42
42
  extend: v.optional(v.any()),
43
43
  })
44
44
  .index("email", ["email"])
45
- .index("phone", ["phone"]),
45
+ .index("email_verified", ["email", "emailVerificationTime"])
46
+ .index("phone", ["phone"])
47
+ .index("phone_verified", ["phone", "phoneVerificationTime"]),
46
48
 
47
49
  /**
48
50
  * Active sessions. A single user can have multiple concurrent sessions
@@ -86,6 +88,7 @@ export default defineSchema({
86
88
  parentRefreshTokenId: v.optional(v.id("RefreshToken")),
87
89
  })
88
90
  .index("session_id", ["sessionId"])
91
+ .index("session_id_first_used", ["sessionId", "firstUsedTime"])
89
92
  .index("session_id_parent_refresh_token_id", [
90
93
  "sessionId",
91
94
  "parentRefreshTokenId",
@@ -167,7 +170,9 @@ export default defineSchema({
167
170
  name: v.optional(v.string()),
168
171
  createdAt: v.number(),
169
172
  lastUsedAt: v.optional(v.number()),
170
- }).index("user_id", ["userId"]),
173
+ })
174
+ .index("user_id", ["userId"])
175
+ .index("user_id_verified", ["userId", "verified"]),
171
176
 
172
177
  /**
173
178
  * Device authorization codes (RFC 8628). Each record tracks a pending
@@ -214,12 +219,18 @@ export default defineSchema({
214
219
  slug: v.optional(v.string()),
215
220
  type: v.optional(v.string()),
216
221
  parentGroupId: v.optional(v.id("Group")),
222
+ /** Denormalized root group ID. Self-referencing for root groups. */
223
+ rootGroupId: v.optional(v.id("Group")),
224
+ /** Denormalized flag: `true` when `parentGroupId` is absent. */
225
+ isRoot: v.optional(v.boolean()),
217
226
  /** Faceted classification tags. Normalized at write time (trimmed, lowercased). */
218
227
  tags: v.optional(v.array(vTag)),
219
228
  extend: v.optional(v.any()),
220
229
  })
221
230
  .index("slug", ["slug"])
222
231
  .index("parent_group_id", ["parentGroupId"])
232
+ .index("root_group_id", ["rootGroupId"])
233
+ .index("is_root", ["isRoot"])
223
234
  .index("type", ["type"])
224
235
  .index("type_parent_group_id", ["type", "parentGroupId"]),
225
236
 
@@ -252,6 +263,7 @@ export default defineSchema({
252
263
  })
253
264
  .index("group_id", ["groupId"])
254
265
  .index("group_id_user_id", ["groupId", "userId"])
266
+ .index("group_id_status", ["groupId", "status"])
255
267
  .index("user_id", ["userId"]),
256
268
 
257
269
  /**
@@ -39,7 +39,7 @@ type MemberApiWithAuthorization<
39
39
  TAuthorization extends AuthAuthorizationConfig | undefined,
40
40
  > = Omit<
41
41
  ReturnType<typeof AuthFactory>["auth"]["member"],
42
- "create" | "list" | "update" | "inherit" | "require"
42
+ "create" | "list" | "update" | "resolve"
43
43
  > & {
44
44
  create: (
45
45
  ctx: Parameters<
@@ -77,9 +77,9 @@ type MemberApiWithAuthorization<
77
77
  memberId: string,
78
78
  data: Record<string, unknown> & { roleIds?: AuthRoleId<TAuthorization>[] },
79
79
  ) => Promise<{ ok: true; memberId: string }>;
80
- inherit: (
80
+ resolve: (
81
81
  ctx: Parameters<
82
- ReturnType<typeof AuthFactory>["auth"]["member"]["inherit"]
82
+ ReturnType<typeof AuthFactory>["auth"]["member"]["resolve"]
83
83
  >[0],
84
84
  opts: {
85
85
  userId: string;
@@ -88,19 +88,7 @@ type MemberApiWithAuthorization<
88
88
  grants?: AuthGrant<TAuthorization>[];
89
89
  maxDepth?: number;
90
90
  },
91
- ) => ReturnType<ReturnType<typeof AuthFactory>["auth"]["member"]["inherit"]>;
92
- require: (
93
- ctx: Parameters<
94
- ReturnType<typeof AuthFactory>["auth"]["member"]["require"]
95
- >[0],
96
- opts: {
97
- userId: string;
98
- groupId: string;
99
- roleIds?: AuthRoleId<TAuthorization>[];
100
- grants?: AuthGrant<TAuthorization>[];
101
- maxDepth?: number;
102
- },
103
- ) => ReturnType<ReturnType<typeof AuthFactory>["auth"]["member"]["require"]>;
91
+ ) => ReturnType<ReturnType<typeof AuthFactory>["auth"]["member"]["resolve"]>;
104
92
  };
105
93
 
106
94
  type AccessApiWithAuthorization<
@@ -117,17 +105,6 @@ type AccessApiWithAuthorization<
117
105
  maxDepth?: number;
118
106
  },
119
107
  ) => ReturnType<ReturnType<typeof AuthFactory>["auth"]["access"]["check"]>;
120
- require: (
121
- ctx: Parameters<
122
- ReturnType<typeof AuthFactory>["auth"]["access"]["require"]
123
- >[0],
124
- opts: {
125
- userId: string;
126
- groupId: string;
127
- grants: AuthGrant<TAuthorization>[];
128
- maxDepth?: number;
129
- },
130
- ) => ReturnType<ReturnType<typeof AuthFactory>["auth"]["access"]["require"]>;
131
108
  };
132
109
 
133
110
  /** The base auth API surface, without conditional namespaces. */
@@ -488,38 +465,6 @@ export function createAuth<
488
465
  } as unknown as ConvexAuthResult<P, TAuthorization>;
489
466
  }
490
467
 
491
- export function defineRoles<
492
- const TRoles extends Record<
493
- string,
494
- { label?: string; grants: readonly string[] }
495
- >,
496
- >(
497
- roles: TRoles,
498
- ): {
499
- [K in keyof TRoles]: {
500
- id: K & string;
501
- label?: TRoles[K]["label"];
502
- grants: Array<TRoles[K]["grants"][number] & string>;
503
- };
504
- } {
505
- return Object.fromEntries(
506
- Object.entries(roles).map(([id, role]) => [
507
- id,
508
- {
509
- id,
510
- ...(role.label ? { label: role.label } : {}),
511
- grants: [...role.grants],
512
- },
513
- ]),
514
- ) as {
515
- [K in keyof TRoles]: {
516
- id: K & string;
517
- label?: TRoles[K]["label"];
518
- grants: Array<TRoles[K]["grants"][number] & string>;
519
- };
520
- };
521
- }
522
-
523
468
  // ============================================================================
524
469
  // AuthCtx — ctx enrichment for customQuery / customMutation
525
470
  // ============================================================================
@@ -593,7 +538,7 @@ export function AuthCtx(auth: AuthLike, config?: AuthCtxConfig<any>) {
593
538
  const userContext = await Fx.run(
594
539
  Fx.match(modeDispatch, modeDispatch.mode, {
595
540
  optional: async () => {
596
- const userId = await auth.user.current(ctx);
541
+ const userId = await auth.user.id(ctx);
597
542
  if (!userId) {
598
543
  return null;
599
544
  }
@@ -601,7 +546,10 @@ export function AuthCtx(auth: AuthLike, config?: AuthCtxConfig<any>) {
601
546
  return { userId, user };
602
547
  },
603
548
  required: async () => {
604
- const userId = await auth.user.require(ctx);
549
+ const userId = await auth.user.id(ctx);
550
+ if (!userId) {
551
+ return null;
552
+ }
605
553
  const user = await auth.user.get(ctx, userId);
606
554
  return { userId, user };
607
555
  },