@robelest/convex-auth 0.0.4-preview.28 → 0.0.4-preview.29

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 (50) hide show
  1. package/dist/bin.js +4 -1
  2. package/dist/component/_generated/component.d.ts +344 -0
  3. package/dist/component/convex.config.d.ts +2 -2
  4. package/dist/component/model.d.ts +25 -25
  5. package/dist/component/model.js +1 -0
  6. package/dist/component/public/factors/totp.js +12 -0
  7. package/dist/component/public/groups/core.js +89 -1
  8. package/dist/component/public/groups/members.js +39 -1
  9. package/dist/component/public/identity/accounts.js +22 -3
  10. package/dist/component/public/identity/users.js +79 -5
  11. package/dist/component/public/sso/audit.js +5 -2
  12. package/dist/component/public/sso/core.js +3 -3
  13. package/dist/component/public/sso/domains.js +7 -3
  14. package/dist/component/public/sso/scim.js +36 -1
  15. package/dist/component/public.js +5 -5
  16. package/dist/component/schema.d.ts +293 -289
  17. package/dist/component/schema.js +2 -1
  18. package/dist/core/index.d.ts +63 -27
  19. package/dist/core/index.js +1 -0
  20. package/dist/providers/credentials.d.ts +12 -0
  21. package/dist/providers/password.js +28 -7
  22. package/dist/server/auth-context.d.ts +17 -0
  23. package/dist/server/auth-context.js +1 -1
  24. package/dist/server/auth.d.ts +18 -6
  25. package/dist/server/auth.js +1 -0
  26. package/dist/server/context.js +11 -5
  27. package/dist/server/contract.js +44 -12
  28. package/dist/server/core.js +146 -149
  29. package/dist/server/ctxCache.js +94 -0
  30. package/dist/server/limits.js +39 -9
  31. package/dist/server/mounts.d.ts +85 -85
  32. package/dist/server/mutations/credentialsSignIn.js +114 -0
  33. package/dist/server/mutations/index.js +2 -1
  34. package/dist/server/mutations/refresh.js +25 -12
  35. package/dist/server/mutations/retrieve.js +2 -1
  36. package/dist/server/mutations/signin.js +27 -9
  37. package/dist/server/mutations/store.js +5 -0
  38. package/dist/server/mutations/verify.js +32 -8
  39. package/dist/server/runtime.d.ts +81 -47
  40. package/dist/server/services/group.js +23 -16
  41. package/dist/server/sessions.d.ts +21 -0
  42. package/dist/server/sessions.js +37 -27
  43. package/dist/server/signin.js +32 -9
  44. package/dist/server/sso/domain.js +1 -2
  45. package/dist/server/sso/oidc.js +1 -1
  46. package/dist/server/tokens.js +19 -3
  47. package/dist/server/users.js +3 -2
  48. package/dist/server/utils/span.js +18 -0
  49. package/package.json +1 -1
  50. package/README.md +0 -161
@@ -1,4 +1,5 @@
1
1
  import { getSessionUserId } from "./context.js";
2
+ import { cached, ctxCacheHas, invalidateCtxCache } from "./ctxCache.js";
2
3
  import { generateRandomString, sha256 } from "./random.js";
3
4
  import { buildScopeChecker, checkKeyRateLimit, generateApiKey, hashApiKey } from "./keys.js";
4
5
  import { TOKEN_SUB_CLAIM_DIVIDER } from "./constants.js";
@@ -34,34 +35,6 @@ function createCoreDomains(deps) {
34
35
  });
35
36
  return normalized;
36
37
  };
37
- const listAllKeysByUser = async (ctx, userId) => {
38
- const items = [];
39
- let cursor = null;
40
- do {
41
- const page = await ctx.runQuery(config.component.public.keyList, {
42
- where: { userId },
43
- limit: 100,
44
- cursor
45
- });
46
- items.push(...page.items);
47
- cursor = page.nextCursor;
48
- } while (cursor !== null);
49
- return items;
50
- };
51
- const listAllMembersByUser = async (ctx, userId) => {
52
- const items = [];
53
- let cursor = null;
54
- do {
55
- const page = await ctx.runQuery(config.component.public.memberList, {
56
- where: { userId },
57
- limit: 100,
58
- cursor
59
- });
60
- items.push(...page.items);
61
- cursor = page.nextCursor;
62
- } while (cursor !== null);
63
- return items;
64
- };
65
38
  const resolveGrantedPermissions = (roleIds) => {
66
39
  const grants = /* @__PURE__ */ new Set();
67
40
  for (const roleId of roleIds ?? []) {
@@ -71,23 +44,27 @@ function createCoreDomains(deps) {
71
44
  }
72
45
  return Array.from(grants).sort();
73
46
  };
74
- const AUTH_CACHE = Symbol("__convexAuthCache");
75
- function cache(ctx) {
76
- const cachedCtx = ctx;
77
- if (!cachedCtx[AUTH_CACHE]) cachedCtx[AUTH_CACHE] = {
78
- users: /* @__PURE__ */ new Map(),
79
- groups: /* @__PURE__ */ new Map()
80
- };
81
- return cachedCtx[AUTH_CACHE];
47
+ async function userGet(ctx, input) {
48
+ if (typeof input === "string") return await cached(ctx, `user:${input}`, () => ctx.runQuery(config.component.public.userGetById, { userId: input }));
49
+ const userIds = input;
50
+ if (userIds.length === 0) return [];
51
+ const unique = Array.from(new Set(userIds));
52
+ const toFetch = [];
53
+ for (const id of unique) if (!ctxCacheHas(ctx, `user:${id}`)) toFetch.push(id);
54
+ if (toFetch.length > 0) {
55
+ const sharedFetch = ctx.runQuery(config.component.public["userGetMany"], { userIds: toFetch });
56
+ for (let i = 0; i < toFetch.length; i += 1) {
57
+ const id = toFetch[i];
58
+ const indexInBatch = i;
59
+ cached(ctx, `user:${id}`, async () => {
60
+ return (await sharedFetch)[indexInBatch] ?? null;
61
+ });
62
+ }
63
+ }
64
+ return await Promise.all(userIds.map((id) => cached(ctx, `user:${id}`, () => ctx.runQuery(config.component.public.userGetById, { userId: id }))));
82
65
  }
83
66
  const user = {
84
- get: async (ctx, userId) => {
85
- const c = cache(ctx);
86
- if (c.users.has(userId)) return c.users.get(userId);
87
- const result = await ctx.runQuery(config.component.public.userGetById, { userId });
88
- c.users.set(userId, result);
89
- return result;
90
- },
67
+ get: userGet,
91
68
  list: async (ctx, opts = {}) => {
92
69
  return await ctx.runQuery(config.component.public.userList, opts);
93
70
  },
@@ -101,6 +78,7 @@ function createCoreDomains(deps) {
101
78
  userId,
102
79
  data
103
80
  });
81
+ invalidateCtxCache(ctx, `user:${userId}`);
104
82
  return { userId };
105
83
  },
106
84
  setActiveGroup: async (ctx, opts) => {
@@ -132,29 +110,11 @@ function createCoreDomains(deps) {
132
110
  return null;
133
111
  },
134
112
  delete: async (ctx, userId, opts) => {
135
- const cascade = opts?.cascade !== false;
136
- const [sessions, accounts, keys, members, passkeys, totps] = await Promise.all([
137
- ctx.runQuery(config.component.public.sessionListByUser, { userId }),
138
- ctx.runQuery(config.component.public.accountListByUser, { userId }),
139
- listAllKeysByUser(ctx, userId),
140
- listAllMembersByUser(ctx, userId),
141
- ctx.runQuery(config.component.public.passkeyListByUserId, { userId }),
142
- ctx.runQuery(config.component.public.totpListByUserId, { userId })
143
- ]);
144
- const totalLinked = sessions.length + accounts.length + keys.length + members.length + passkeys.length + totps.length;
145
- if (!cascade && totalLinked > 0) throw new ConvexError({
146
- code: "INVALID_PARAMETERS",
147
- message: "The provided parameters are invalid."
113
+ await ctx.runMutation(config.component.public.userDelete, {
114
+ userId,
115
+ cascade: opts?.cascade !== false
148
116
  });
149
- const deletions = [];
150
- for (const s of sessions) deletions.push(ctx.runMutation(config.component.public.sessionDelete, { sessionId: s._id }));
151
- for (const a of accounts) deletions.push(ctx.runMutation(config.component.public.accountDelete, { accountId: a._id }));
152
- for (const k of keys) deletions.push(ctx.runMutation(config.component.public.keyDelete, { keyId: k._id }));
153
- for (const m of members) deletions.push(ctx.runMutation(config.component.public.memberRemove, { memberId: m._id }));
154
- for (const p of passkeys) deletions.push(ctx.runMutation(config.component.public.passkeyDelete, { passkeyId: p._id }));
155
- for (const t of totps) deletions.push(ctx.runMutation(config.component.public.totpDelete, { totpId: t._id }));
156
- await Promise.all(deletions);
157
- await ctx.runMutation(config.component.public.userDelete, { userId });
117
+ invalidateCtxCache(ctx);
158
118
  return { userId };
159
119
  }
160
120
  };
@@ -165,6 +125,12 @@ function createCoreDomains(deps) {
165
125
  const [, sessionId] = identity.subject.split(TOKEN_SUB_CLAIM_DIVIDER);
166
126
  return sessionId;
167
127
  },
128
+ userId: async (ctx) => {
129
+ const identity = await ctx.auth.getUserIdentity();
130
+ if (identity === null) return null;
131
+ const [userId] = identity.subject.split(TOKEN_SUB_CLAIM_DIVIDER);
132
+ return userId ?? null;
133
+ },
168
134
  invalidate: async (ctx, args) => {
169
135
  await callInvalidateSessions(ctx, args);
170
136
  return {
@@ -173,7 +139,7 @@ function createCoreDomains(deps) {
173
139
  };
174
140
  },
175
141
  get: async (ctx, sessionId) => {
176
- return await ctx.runQuery(config.component.public.sessionGetById, { sessionId });
142
+ return await cached(ctx, `session:${sessionId}`, () => ctx.runQuery(config.component.public.sessionGetById, { sessionId }));
177
143
  },
178
144
  list: async (ctx, opts) => {
179
145
  return await ctx.runQuery(config.component.public.sessionListByUser, { userId: opts.userId });
@@ -193,16 +159,10 @@ function createCoreDomains(deps) {
193
159
  return { accountId: args.account.id };
194
160
  },
195
161
  delete: async (ctx, accountId) => {
196
- const doc = await ctx.runQuery(config.component.public.accountGetById, { accountId });
197
- if (doc === null) throw new ConvexError({
198
- code: "ACCOUNT_NOT_FOUND",
199
- message: "Account not found."
162
+ await ctx.runMutation(config.component.public.accountDelete, {
163
+ accountId,
164
+ requireOtherAccount: true
200
165
  });
201
- if ((await ctx.runQuery(config.component.public.accountListByUser, { userId: doc.userId })).length <= 1) throw new ConvexError({
202
- code: "INVALID_PARAMETERS",
203
- message: "The provided parameters are invalid."
204
- });
205
- await ctx.runMutation(config.component.public.accountDelete, { accountId });
206
166
  return { accountId };
207
167
  },
208
168
  listPasskeys: async (ctx, opts) => {
@@ -230,17 +190,30 @@ function createCoreDomains(deps) {
230
190
  const provider = { signIn: deps.signInForProvider ? async (ctx, providerConfig, args) => {
231
191
  return deps.signInForProvider(ctx, providerConfig, args);
232
192
  } : void 0 };
193
+ async function groupGet(ctx, input) {
194
+ if (typeof input === "string") return await cached(ctx, `group:${input}`, () => ctx.runQuery(config.component.public.groupGet, { groupId: input }));
195
+ const groupIds = input;
196
+ if (groupIds.length === 0) return [];
197
+ const unique = Array.from(new Set(groupIds));
198
+ const toFetch = [];
199
+ for (const id of unique) if (!ctxCacheHas(ctx, `group:${id}`)) toFetch.push(id);
200
+ if (toFetch.length > 0) {
201
+ const sharedFetch = ctx.runQuery(config.component.public["groupGetMany"], { groupIds: toFetch });
202
+ for (let i = 0; i < toFetch.length; i += 1) {
203
+ const id = toFetch[i];
204
+ const indexInBatch = i;
205
+ cached(ctx, `group:${id}`, async () => {
206
+ return (await sharedFetch)[indexInBatch] ?? null;
207
+ });
208
+ }
209
+ }
210
+ return await Promise.all(groupIds.map((id) => cached(ctx, `group:${id}`, () => ctx.runQuery(config.component.public.groupGet, { groupId: id }))));
211
+ }
233
212
  const group = {
234
213
  create: async (ctx, data) => {
235
214
  return { groupId: await ctx.runMutation(config.component.public.groupCreate, data) };
236
215
  },
237
- get: async (ctx, groupId) => {
238
- const c = cache(ctx);
239
- if (c.groups.has(groupId)) return c.groups.get(groupId);
240
- const result = await ctx.runQuery(config.component.public.groupGet, { groupId });
241
- c.groups.set(groupId, result);
242
- return result;
243
- },
216
+ get: groupGet,
244
217
  list: async (ctx, opts) => {
245
218
  return await ctx.runQuery(config.component.public.groupList, {
246
219
  where: opts?.where,
@@ -255,61 +228,109 @@ function createCoreDomains(deps) {
255
228
  groupId,
256
229
  data
257
230
  });
231
+ invalidateCtxCache(ctx, `group:${groupId}`);
258
232
  return { groupId };
259
233
  },
260
234
  delete: async (ctx, groupId) => {
261
235
  await ctx.runMutation(config.component.public.groupDelete, { groupId });
236
+ invalidateCtxCache(ctx, `group:${groupId}`);
237
+ invalidateCtxCache(ctx, "member");
238
+ invalidateCtxCache(ctx, "member-inspect");
262
239
  return { groupId };
263
240
  },
264
241
  ancestors: async (ctx, opts) => {
265
- const maxDepth = Math.max(0, Math.floor(opts.maxDepth ?? 32));
266
- const visited = /* @__PURE__ */ new Set();
267
- const ancestors = [];
268
- let cycleDetected = false;
269
- let maxDepthReached = false;
270
- let currentGroupId = opts.groupId;
271
- let depth = 0;
272
- let isFirst = true;
273
- while (currentGroupId !== void 0) {
274
- if (depth > maxDepth) {
275
- maxDepthReached = true;
276
- break;
277
- }
278
- if (visited.has(currentGroupId)) {
279
- cycleDetected = true;
280
- break;
281
- }
282
- visited.add(currentGroupId);
283
- const doc = await group.get(ctx, currentGroupId);
284
- if (doc === null) break;
285
- if (isFirst) {
286
- isFirst = false;
287
- if (opts.includeSelf) ancestors.push(doc);
288
- currentGroupId = doc.parentGroupId;
289
- depth += 1;
290
- continue;
291
- }
292
- ancestors.push(doc);
293
- currentGroupId = doc.parentGroupId;
294
- depth += 1;
242
+ const ancestorsRef = config.component.public["groupAncestors"];
243
+ const result = await ctx.runQuery(ancestorsRef, {
244
+ groupId: opts.groupId,
245
+ maxDepth: opts.maxDepth,
246
+ includeSelf: opts.includeSelf
247
+ });
248
+ for (const ancestor of result.ancestors) {
249
+ const id = ancestor._id;
250
+ cached(ctx, `group:${id}`, () => Promise.resolve(ancestor));
295
251
  }
296
- return {
297
- ancestors,
298
- cycleDetected,
299
- maxDepthReached
300
- };
252
+ return result;
301
253
  }
302
254
  };
255
+ async function memberInspect(ctx, opts) {
256
+ if ("groupIds" in opts) {
257
+ const { userId, groupIds } = opts;
258
+ if (groupIds.length === 0) return [];
259
+ const unique = Array.from(new Set(groupIds));
260
+ const toFetch = [];
261
+ for (const groupId of unique) if (!ctxCacheHas(ctx, `member-inspect:${userId}:${groupId}:n`)) toFetch.push(groupId);
262
+ if (toFetch.length > 0) {
263
+ const sharedFetch = ctx.runQuery(config.component.public["memberGetByGroupAndUserMany"], {
264
+ userId,
265
+ groupIds: toFetch
266
+ });
267
+ for (let i = 0; i < toFetch.length; i += 1) {
268
+ const groupId = toFetch[i];
269
+ const indexInBatch = i;
270
+ cached(ctx, `member-inspect:${userId}:${groupId}:n`, async () => {
271
+ return (await sharedFetch)[indexInBatch] ?? null;
272
+ });
273
+ }
274
+ }
275
+ return await Promise.all(groupIds.map(async (groupId) => {
276
+ const membership$1 = await cached(ctx, `member-inspect:${userId}:${groupId}:n`, () => ctx.runQuery(config.component.public.memberGetByGroupAndUser, {
277
+ userId,
278
+ groupId
279
+ }));
280
+ if (membership$1 === null) return {
281
+ membership: null,
282
+ roleIds: [],
283
+ grants: []
284
+ };
285
+ const membershipRoleIds$1 = membership$1.roleIds ?? [];
286
+ return {
287
+ membership: membership$1,
288
+ roleIds: membershipRoleIds$1,
289
+ grants: resolveGrantedPermissions(membershipRoleIds$1)
290
+ };
291
+ }));
292
+ }
293
+ const useAncestry = opts.ancestry === true;
294
+ const maxDepth = useAncestry ? Math.max(0, Math.floor(opts.maxDepth ?? 32)) : 0;
295
+ const cacheKey = `member-inspect:${opts.userId}:${opts.groupId}:${useAncestry ? `a${maxDepth}` : "n"}`;
296
+ let membership = null;
297
+ if (useAncestry) {
298
+ const memberResolveRef = config.component.public["memberResolve"];
299
+ membership = (await cached(ctx, cacheKey, () => ctx.runQuery(memberResolveRef, {
300
+ userId: opts.userId,
301
+ groupId: opts.groupId,
302
+ maxDepth,
303
+ ancestry: true
304
+ }))).membership;
305
+ } else membership = await cached(ctx, cacheKey, () => ctx.runQuery(config.component.public.memberGetByGroupAndUser, {
306
+ userId: opts.userId,
307
+ groupId: opts.groupId
308
+ }));
309
+ if (membership === null) return {
310
+ membership: null,
311
+ roleIds: [],
312
+ grants: []
313
+ };
314
+ const membershipRoleIds = membership.roleIds ?? [];
315
+ const membershipGrants = resolveGrantedPermissions(membershipRoleIds);
316
+ return {
317
+ membership,
318
+ roleIds: membershipRoleIds,
319
+ grants: membershipGrants
320
+ };
321
+ }
303
322
  const member = {
304
323
  create: async (ctx, data) => {
305
324
  const roleIds = normalizeRoleIds(data.roleIds);
306
- return { memberId: await ctx.runMutation(config.component.public.memberAdd, {
325
+ const memberId = await ctx.runMutation(config.component.public.memberAdd, {
307
326
  ...data,
308
327
  roleIds
309
- }) };
328
+ });
329
+ invalidateCtxCache(ctx, `member-inspect:${data.userId}:${data.groupId}`);
330
+ return { memberId };
310
331
  },
311
332
  get: async (ctx, memberId) => {
312
- return await ctx.runQuery(config.component.public.memberGet, { memberId });
333
+ return await cached(ctx, `member:${memberId}`, () => ctx.runQuery(config.component.public.memberGet, { memberId }));
313
334
  },
314
335
  list: async (ctx, opts) => {
315
336
  return await ctx.runQuery(config.component.public.memberList, {
@@ -322,6 +343,8 @@ function createCoreDomains(deps) {
322
343
  },
323
344
  delete: async (ctx, memberId) => {
324
345
  await ctx.runMutation(config.component.public.memberRemove, { memberId });
346
+ invalidateCtxCache(ctx, "member");
347
+ invalidateCtxCache(ctx, "member-inspect");
325
348
  return { memberId };
326
349
  },
327
350
  update: async (ctx, memberId, data) => {
@@ -331,37 +354,11 @@ function createCoreDomains(deps) {
331
354
  memberId,
332
355
  data: nextData
333
356
  });
357
+ invalidateCtxCache(ctx, `member:${memberId}`);
358
+ invalidateCtxCache(ctx, "member-inspect");
334
359
  return { memberId };
335
360
  },
336
- inspect: async (ctx, opts) => {
337
- const useAncestry = opts.ancestry === true;
338
- let membership = null;
339
- if (useAncestry) {
340
- const maxDepth = Math.max(0, Math.floor(opts.maxDepth ?? 32));
341
- const memberResolveRef = config.component.public["memberResolve"];
342
- membership = (await ctx.runQuery(memberResolveRef, {
343
- userId: opts.userId,
344
- groupId: opts.groupId,
345
- maxDepth,
346
- ancestry: true
347
- })).membership;
348
- } else membership = await ctx.runQuery(config.component.public.memberGetByGroupAndUser, {
349
- userId: opts.userId,
350
- groupId: opts.groupId
351
- });
352
- if (membership === null) return {
353
- membership: null,
354
- roleIds: [],
355
- grants: []
356
- };
357
- const membershipRoleIds = membership.roleIds ?? [];
358
- const membershipGrants = resolveGrantedPermissions(membershipRoleIds);
359
- return {
360
- membership,
361
- roleIds: membershipRoleIds,
362
- grants: membershipGrants
363
- };
364
- },
361
+ inspect: memberInspect,
365
362
  require: async (ctx, opts) => {
366
363
  const validatedRoleIds = normalizeRoleIds(opts.roleIds);
367
364
  const requiredGrants = Array.from(new Set(opts.grants ?? []));
@@ -0,0 +1,94 @@
1
+ //#region src/server/ctxCache.ts
2
+ /**
3
+ * Per-execution cache for Convex auth component reads.
4
+ *
5
+ * Convex components are invoked across a function boundary — every
6
+ * `auth.user.get`, `auth.member.inspect`, `getGroupConnection`, etc. is
7
+ * `ctx.runQuery(components.auth.…)`. Each crossing costs ~10–30ms.
8
+ * Inside a single handler we often fetch the same entity several times
9
+ * (outer `auth.ctx()` resolver + inner `auth.member.require` calls +
10
+ * SSO admin authorizer target resolution + the handler body itself).
11
+ *
12
+ * This module attaches a `Map<string, Promise<unknown>>` to the ctx via
13
+ * a Symbol so that concurrent and repeated reads of the same key within
14
+ * a single function invocation share one component round-trip. The cache
15
+ * lives for the lifetime of the ctx object and dies with it.
16
+ *
17
+ * Semantics:
18
+ * - Keys are namespaced strings (`user:abc`, `group:xyz`, etc.).
19
+ * - Promises are stored — two concurrent callers dedup correctly.
20
+ * - Rejected promises are evicted so a retry can succeed.
21
+ * - Writes do **not** automatically invalidate reads; this matches the
22
+ * Convex transactional model where a mutation sees its own writes only
23
+ * on subsequent calls. If a handler writes and then re-reads an entity
24
+ * expecting fresh data, it must call {@link invalidateCtxCache} with the
25
+ * appropriate key (or key prefix).
26
+ *
27
+ * @module
28
+ * @internal
29
+ */
30
+ const CACHE_SYMBOL = Symbol.for("@robelest/convex-auth/ctxCache");
31
+ /**
32
+ * Get (or lazily create) the cache store attached to a Convex ctx.
33
+ * @internal
34
+ */
35
+ function getCtxCache(ctx) {
36
+ const c = ctx;
37
+ let store = c[CACHE_SYMBOL];
38
+ if (store === void 0) {
39
+ store = /* @__PURE__ */ new Map();
40
+ c[CACHE_SYMBOL] = store;
41
+ }
42
+ return store;
43
+ }
44
+ /**
45
+ * Check whether the ctx cache already holds an entry under `key`.
46
+ *
47
+ * Useful before a batched fetch: callers can short-circuit the RPC if every
48
+ * needed ID is already cached, or compute the exact subset to request.
49
+ *
50
+ * @internal
51
+ */
52
+ function ctxCacheHas(ctx, key) {
53
+ return getCtxCache(ctx).has(key);
54
+ }
55
+ /**
56
+ * Memoize an async read against the ctx cache.
57
+ *
58
+ * The returned promise is cached under `key`; concurrent callers get the
59
+ * same in-flight promise. If the promise rejects, the entry is removed so a
60
+ * later retry can succeed.
61
+ *
62
+ * @internal
63
+ */
64
+ function cached(ctx, key, fn) {
65
+ const store = getCtxCache(ctx);
66
+ const hit = store.get(key);
67
+ if (hit !== void 0) return hit;
68
+ const promise = fn().catch((err) => {
69
+ if (store.get(key) === promise) store.delete(key);
70
+ throw err;
71
+ });
72
+ store.set(key, promise);
73
+ return promise;
74
+ }
75
+ /**
76
+ * Drop a single cache entry (or all entries matching a prefix).
77
+ *
78
+ * Use this from mutations that write to an entity the cache already holds.
79
+ * Passing no prefix clears the entire cache.
80
+ *
81
+ * @internal
82
+ */
83
+ function invalidateCtxCache(ctx, keyPrefix) {
84
+ const store = getCtxCache(ctx);
85
+ if (keyPrefix === void 0 || keyPrefix === "") {
86
+ store.clear();
87
+ return;
88
+ }
89
+ for (const key of store.keys()) if (key === keyPrefix || key.startsWith(`${keyPrefix}:`)) store.delete(key);
90
+ }
91
+
92
+ //#endregion
93
+ export { cached, ctxCacheHas, invalidateCtxCache };
94
+ //# sourceMappingURL=ctxCache.js.map
@@ -3,21 +3,45 @@ import { authDb } from "./db.js";
3
3
  //#region src/server/limits.ts
4
4
  const DEFAULT_MAX_SIGN_IN_ATTEMPTS_PER_HOUR = 10;
5
5
  /**
6
+ * Fetch and compute the live rate-limit state for a sign-in identifier.
7
+ *
8
+ * Returns `null` when no rate-limit document exists yet (the identifier
9
+ * has never seen a failed attempt). The returned `limit` document carries
10
+ * the `_id` needed for any subsequent `patch`/`delete` — letting the
11
+ * caller reuse the same fetch instead of refetching inside each mutator.
12
+ *
13
+ * @internal
14
+ */
15
+ async function getSignInRateLimitState(ctx, identifier, config) {
16
+ return await getRateLimitState(ctx, identifier, config);
17
+ }
18
+ /**
6
19
  * Check whether the given identifier is currently rate-limited.
7
20
  * @internal
8
21
  */
9
22
  async function isSignInRateLimited(ctx, identifier, config) {
10
- const state = await getRateLimitState(ctx, identifier, config);
23
+ return isStateRateLimited(await getRateLimitState(ctx, identifier, config));
24
+ }
25
+ /**
26
+ * Test a previously-loaded rate-limit state without re-reading the doc.
27
+ * @internal
28
+ */
29
+ function isStateRateLimited(state) {
11
30
  return state !== null && state.attemptsLeft < 1;
12
31
  }
13
32
  /**
14
33
  * Record a failed sign-in attempt for the given identifier.
34
+ *
35
+ * Accepts an optional pre-loaded `state` so callers that already fetched
36
+ * the rate-limit doc (typically via {@link getSignInRateLimitState}) can
37
+ * avoid a second component round-trip.
38
+ *
15
39
  * @internal
16
40
  */
17
- async function recordFailedSignIn(ctx, identifier, config) {
18
- const state = await getRateLimitState(ctx, identifier, config);
19
- if (state !== null) await authDb(ctx, config).rateLimits.patch(state.limit._id, {
20
- attemptsLeft: state.attemptsLeft - 1,
41
+ async function recordFailedSignIn(ctx, identifier, config, state) {
42
+ const resolved = state !== void 0 ? state : await getRateLimitState(ctx, identifier, config);
43
+ if (resolved !== null) await authDb(ctx, config).rateLimits.patch(resolved.limit._id, {
44
+ attemptsLeft: resolved.attemptsLeft - 1,
21
45
  lastAttemptTime: Date.now()
22
46
  });
23
47
  else await authDb(ctx, config).rateLimits.create({
@@ -28,11 +52,17 @@ async function recordFailedSignIn(ctx, identifier, config) {
28
52
  }
29
53
  /**
30
54
  * Reset the rate limit for the given identifier.
55
+ *
56
+ * Accepts an optional pre-loaded `state` so callers with a cached state
57
+ * (e.g. from an earlier {@link isSignInRateLimited} / {@link getSignInRateLimitState}
58
+ * call) can skip the redundant read. Previously, the verify flow was loading
59
+ * the same rate-limit doc twice per successful sign-in.
60
+ *
31
61
  * @internal
32
62
  */
33
- async function resetSignInRateLimit(ctx, identifier, config) {
34
- const state = await getRateLimitState(ctx, identifier, config);
35
- if (state !== null) await authDb(ctx, config).rateLimits.delete(state.limit._id);
63
+ async function resetSignInRateLimit(ctx, identifier, config, state) {
64
+ const resolved = state !== void 0 ? state : await getRateLimitState(ctx, identifier, config);
65
+ if (resolved !== null) await authDb(ctx, config).rateLimits.delete(resolved.limit._id);
36
66
  }
37
67
  async function getRateLimitState(ctx, identifier, config) {
38
68
  const typedLimit = await authDb(ctx, config).rateLimits.get(identifier);
@@ -48,5 +78,5 @@ async function getRateLimitState(ctx, identifier, config) {
48
78
  }
49
79
 
50
80
  //#endregion
51
- export { isSignInRateLimited, recordFailedSignIn, resetSignInRateLimit };
81
+ export { getSignInRateLimitState, isSignInRateLimited, isStateRateLimited, recordFailedSignIn, resetSignInRateLimit };
52
82
  //# sourceMappingURL=limits.js.map