@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.
- package/dist/bin.js +4 -1
- package/dist/component/_generated/component.d.ts +344 -0
- package/dist/component/convex.config.d.ts +2 -2
- package/dist/component/model.d.ts +25 -25
- package/dist/component/model.js +1 -0
- package/dist/component/public/factors/totp.js +12 -0
- package/dist/component/public/groups/core.js +89 -1
- package/dist/component/public/groups/members.js +39 -1
- package/dist/component/public/identity/accounts.js +22 -3
- package/dist/component/public/identity/users.js +79 -5
- package/dist/component/public/sso/audit.js +5 -2
- package/dist/component/public/sso/core.js +3 -3
- package/dist/component/public/sso/domains.js +7 -3
- package/dist/component/public/sso/scim.js +36 -1
- package/dist/component/public.js +5 -5
- package/dist/component/schema.d.ts +293 -289
- package/dist/component/schema.js +2 -1
- package/dist/core/index.d.ts +63 -27
- package/dist/core/index.js +1 -0
- package/dist/providers/credentials.d.ts +12 -0
- package/dist/providers/password.js +28 -7
- package/dist/server/auth-context.d.ts +17 -0
- package/dist/server/auth-context.js +1 -1
- package/dist/server/auth.d.ts +18 -6
- package/dist/server/auth.js +1 -0
- package/dist/server/context.js +11 -5
- package/dist/server/contract.js +44 -12
- package/dist/server/core.js +146 -149
- package/dist/server/ctxCache.js +94 -0
- package/dist/server/limits.js +39 -9
- package/dist/server/mounts.d.ts +85 -85
- package/dist/server/mutations/credentialsSignIn.js +114 -0
- package/dist/server/mutations/index.js +2 -1
- package/dist/server/mutations/refresh.js +25 -12
- package/dist/server/mutations/retrieve.js +2 -1
- package/dist/server/mutations/signin.js +27 -9
- package/dist/server/mutations/store.js +5 -0
- package/dist/server/mutations/verify.js +32 -8
- package/dist/server/runtime.d.ts +81 -47
- package/dist/server/services/group.js +23 -16
- package/dist/server/sessions.d.ts +21 -0
- package/dist/server/sessions.js +37 -27
- package/dist/server/signin.js +32 -9
- package/dist/server/sso/domain.js +1 -2
- package/dist/server/sso/oidc.js +1 -1
- package/dist/server/tokens.js +19 -3
- package/dist/server/users.js +3 -2
- package/dist/server/utils/span.js +18 -0
- package/package.json +1 -1
- package/README.md +0 -161
package/dist/server/core.js
CHANGED
|
@@ -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
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
};
|
|
81
|
-
|
|
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:
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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:
|
|
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
|
|
266
|
-
const
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
package/dist/server/limits.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
19
|
-
if (
|
|
20
|
-
attemptsLeft:
|
|
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
|
|
35
|
-
if (
|
|
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
|