@web-ts-toolkit/moo 0.37.0 → 0.39.0

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.
@@ -0,0 +1,644 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/plugins/keycloak-user-sync.ts
21
+ var keycloak_user_sync_exports = {};
22
+ __export(keycloak_user_sync_exports, {
23
+ keycloakUserSyncPlugin: () => keycloakUserSyncPlugin
24
+ });
25
+ module.exports = __toCommonJS(keycloak_user_sync_exports);
26
+
27
+ // src/plugins/keycloak-user-sync/adapter.ts
28
+ var createKeycloakUserSyncAdapter = (client, realm) => {
29
+ const supportedClient = client;
30
+ const realmHandle = () => supportedClient.realm(realm);
31
+ const users = supportedClient.core.users;
32
+ return {
33
+ async getRealm() {
34
+ return realmHandle().get();
35
+ },
36
+ async getUserById(username, id) {
37
+ return realmHandle().user(username).getById(id);
38
+ },
39
+ async getUserByUsername(username) {
40
+ return realmHandle().user(username).get();
41
+ },
42
+ async findUsersByEmail(email) {
43
+ return users.find({ realm, email, exact: true, first: 0, max: 2 });
44
+ },
45
+ async createUser(username, payload) {
46
+ return realmHandle().user(username).create(payload);
47
+ },
48
+ async updateUser(id, payload) {
49
+ await users.update({ realm, id }, payload);
50
+ },
51
+ async deleteUser(id) {
52
+ await users.del({ realm, id });
53
+ },
54
+ async sendVerifyEmail(id) {
55
+ await users.sendVerifyEmail({ realm, id });
56
+ },
57
+ async resetPassword(id, value, temporary) {
58
+ await users.resetPassword({ realm, id, credential: { temporary, type: "password", value } });
59
+ },
60
+ async ensureRole(name) {
61
+ await realmHandle().role(name).ensure({});
62
+ },
63
+ async getRole(name) {
64
+ return realmHandle().role(name).get();
65
+ },
66
+ async listRealmRoleMappings(id) {
67
+ return users.listRealmRoleMappings({ realm, id });
68
+ },
69
+ async addRealmRoleMappings(id, roles) {
70
+ await users.addRealmRoleMappings({ realm, id, roles });
71
+ },
72
+ async delRealmRoleMappings(id, roles) {
73
+ await users.delRealmRoleMappings({ realm, id, roles });
74
+ }
75
+ };
76
+ };
77
+
78
+ // src/plugins/keycloak-user-sync/planner.ts
79
+ var dangerousAttributeKeys = /* @__PURE__ */ new Set(["__proto__", "prototype", "constructor"]);
80
+ var assertSafeAttributeKey = (key) => {
81
+ if (dangerousAttributeKeys.has(key)) {
82
+ throw new Error(`keycloakUserSyncPlugin attribute key "${key}" is not supported`);
83
+ }
84
+ };
85
+ var stringValue = (value) => {
86
+ if (typeof value !== "string") return null;
87
+ const trimmed = value.trim();
88
+ return trimmed || null;
89
+ };
90
+ var normalizeEmail = (value) => stringValue(value)?.toLowerCase() ?? null;
91
+ var uniqueStrings = (values) => [
92
+ ...new Set(values.map(stringValue).filter((value) => value !== null))
93
+ ];
94
+ var getPathValue = (value, path) => path.split(".").reduce((current, part) => {
95
+ if (typeof current !== "object" || current === null) return void 0;
96
+ return current[part];
97
+ }, value);
98
+ var normalizeProfileString = (value, allowClearing) => {
99
+ if (value === null) return allowClearing ? "" : null;
100
+ if (typeof value !== "string") return null;
101
+ const trimmed = value.trim();
102
+ if (trimmed) return trimmed;
103
+ return allowClearing ? "" : null;
104
+ };
105
+ var normalizeAttributeValue = (value) => {
106
+ if (value === null || value === void 0) return null;
107
+ const values = Array.isArray(value) ? value : [value];
108
+ const normalized = values.map((item) => {
109
+ if (item instanceof Date) return item.toISOString();
110
+ if (typeof item === "string") return item;
111
+ if (typeof item === "number" || typeof item === "boolean" || typeof item === "bigint") return String(item);
112
+ return null;
113
+ }).filter((item) => item !== null);
114
+ return normalized.length ? normalized : null;
115
+ };
116
+ var normalizeAttributes = (attributes) => {
117
+ const normalizedAttributes = /* @__PURE__ */ Object.create(null);
118
+ if (typeof attributes !== "object" || attributes === null || Array.isArray(attributes)) return normalizedAttributes;
119
+ for (const [key, value] of Object.entries(attributes)) {
120
+ const normalizedKey = stringValue(key);
121
+ if (!normalizedKey) continue;
122
+ assertSafeAttributeKey(normalizedKey);
123
+ const normalizedValue = normalizeAttributeValue(value);
124
+ if (normalizedValue) normalizedAttributes[normalizedKey] = normalizedValue;
125
+ }
126
+ return normalizedAttributes;
127
+ };
128
+ var getDocumentValue = (document, paths, field) => document.get(paths[field]);
129
+ var buildTrackedPaths = (options) => uniqueStrings([
130
+ options.paths.providerId,
131
+ ...Object.entries(options.syncFields).filter(([, enabled]) => enabled).flatMap(
132
+ ([field]) => field === "enabled" ? [options.paths.enabled, options.paths.archived] : [options.paths[field]]
133
+ ),
134
+ ...options.syncFields.attributes && options.attributePaths ? options.attributePaths : []
135
+ ]);
136
+ var planChangedFields = (options, isNew, isModified, currentProviderId) => {
137
+ const changedFields = /* @__PURE__ */ new Set();
138
+ if (isModified(options.paths.providerId)) changedFields.add("providerId");
139
+ for (const [field, enabled] of Object.entries(options.syncFields)) {
140
+ if (!enabled) continue;
141
+ const paths = field === "enabled" ? [options.paths.enabled, options.paths.archived] : [options.paths[field]];
142
+ if (paths.some(isModified)) changedFields.add(field);
143
+ }
144
+ if (options.syncFields.attributes && options.attributePaths?.some(isModified)) changedFields.add("attributes");
145
+ const shouldPersistMissingProviderId = stringValue(currentProviderId) === null;
146
+ const shouldSync = isNew || shouldPersistMissingProviderId || changedFields.size > 0;
147
+ return {
148
+ changedFields,
149
+ trackedPaths: buildTrackedPaths(options),
150
+ shouldSync,
151
+ passwordChanged: changedFields.has("password")
152
+ };
153
+ };
154
+ var buildProfilePayload = (document, options, allowClearing = false, changedFields) => {
155
+ const payload = {};
156
+ const shouldInclude = (field) => !changedFields || changedFields.has(field);
157
+ const copyString = (field) => {
158
+ if (!options.syncFields[field] || !shouldInclude(field)) return;
159
+ const value = normalizeProfileString(getDocumentValue(document, options.paths, field), allowClearing);
160
+ if (value !== null) payload[field] = value;
161
+ };
162
+ copyString("username");
163
+ copyString("email");
164
+ copyString("firstName");
165
+ copyString("lastName");
166
+ if (options.syncFields.emailVerified && shouldInclude("emailVerified") && typeof getDocumentValue(document, options.paths, "emailVerified") === "boolean") {
167
+ payload.emailVerified = getDocumentValue(document, options.paths, "emailVerified");
168
+ }
169
+ if (options.syncFields.enabled && shouldInclude("enabled")) {
170
+ const archived = getDocumentValue(document, options.paths, "archived");
171
+ const enabled = getDocumentValue(document, options.paths, "enabled");
172
+ if (typeof archived === "boolean") payload.enabled = !archived;
173
+ else if (typeof enabled === "boolean") payload.enabled = enabled;
174
+ }
175
+ return payload;
176
+ };
177
+ var getDesiredRoles = (document, options) => {
178
+ if (!options.syncFields.roles) return null;
179
+ const source = getDocumentValue(document, options.paths, "roles");
180
+ const mapped = options.mapRoles ? options.mapRoles(source, document) : Array.isArray(source) ? source : null;
181
+ if (!mapped) return null;
182
+ return uniqueStrings(mapped);
183
+ };
184
+ var getDesiredAttributes = (document, options) => {
185
+ if (!options.syncFields.attributes) return null;
186
+ const source = options.mapAttributes ? options.mapAttributes(document) : getDocumentValue(document, options.paths, "attributes");
187
+ return normalizeAttributes(source);
188
+ };
189
+ var getDesiredPassword = (document, options) => {
190
+ if (!options.syncFields.password) return null;
191
+ const value = options.mapPassword ? options.mapPassword(document) : getDocumentValue(document, options.paths, "password");
192
+ return typeof value === "string" && value.length > 0 ? value : null;
193
+ };
194
+ var mergeAttributes = (user, document, options) => {
195
+ const desiredAttributes = getDesiredAttributes(document, options);
196
+ if (!desiredAttributes) return void 0;
197
+ const currentAttributes = Object.assign(/* @__PURE__ */ Object.create(null), user?.attributes ?? {});
198
+ if (options.managedAttributes) {
199
+ for (const key of options.managedAttributes) {
200
+ delete currentAttributes[key];
201
+ }
202
+ }
203
+ return Object.assign(/* @__PURE__ */ Object.create(null), currentAttributes, desiredAttributes);
204
+ };
205
+ var planEmailVerification = ({
206
+ created,
207
+ wasNew,
208
+ syncEmail,
209
+ syncEmailVerified,
210
+ previousEmail,
211
+ currentEmail,
212
+ remoteEmail
213
+ }) => {
214
+ const normalizedPreviousEmail = normalizeEmail(previousEmail);
215
+ const normalizedCurrentEmail = normalizeEmail(currentEmail);
216
+ const normalizedRemoteEmail = normalizeEmail(remoteEmail);
217
+ const emailSyncTargetChanged = !created && syncEmail && normalizedCurrentEmail !== null && normalizedRemoteEmail !== normalizedCurrentEmail;
218
+ const persistedLocalEmailChanged = !created && !wasNew && syncEmail && normalizedCurrentEmail !== null && normalizedPreviousEmail !== normalizedCurrentEmail;
219
+ const initialLinkSameEmail = wasNew && !created && normalizedCurrentEmail !== null && normalizedRemoteEmail === normalizedCurrentEmail;
220
+ return {
221
+ changed: syncEmailVerified && (persistedLocalEmailChanged || emailSyncTargetChanged && !initialLinkSameEmail),
222
+ initialLinkSameEmail
223
+ };
224
+ };
225
+ var planRoleDiff = (desiredNames, assignedRoles, desiredRoles, managedRoles) => {
226
+ const managedNames = managedRoles ? new Set(managedRoles) : null;
227
+ const effectiveDesiredNames = managedNames ? desiredNames.filter((name) => managedNames.has(name)) : desiredNames;
228
+ const assignedNames = new Set(assignedRoles.map((role) => role.name).filter((name) => Boolean(name)));
229
+ const desiredNameSet = new Set(effectiveDesiredNames);
230
+ return {
231
+ desiredNames,
232
+ effectiveDesiredNames,
233
+ toAdd: desiredRoles.filter((role) => role.name && !assignedNames.has(role.name)),
234
+ toRemove: managedNames ? assignedRoles.filter((role) => role.name && !desiredNameSet.has(role.name) && managedNames.has(role.name)) : []
235
+ };
236
+ };
237
+
238
+ // src/plugins/keycloak-user-sync.ts
239
+ var syncStateKey = "keycloakUserSync";
240
+ var pluginRegisteredKey = /* @__PURE__ */ Symbol("keycloakUserSyncPluginRegistered");
241
+ var defaultPaths = {
242
+ providerId: "providerId",
243
+ username: "username",
244
+ email: "email",
245
+ emailVerified: "emailVerified",
246
+ firstName: "firstName",
247
+ lastName: "lastName",
248
+ enabled: "enabled",
249
+ archived: "archived",
250
+ roles: "roles",
251
+ attributes: "attributes",
252
+ password: "password"
253
+ // pragma: allowlist secret
254
+ };
255
+ var defaultSyncFields = {
256
+ username: true,
257
+ email: true,
258
+ emailVerified: true,
259
+ firstName: true,
260
+ lastName: true,
261
+ enabled: true,
262
+ roles: true,
263
+ attributes: true,
264
+ password: false
265
+ };
266
+ var identityFields = ["providerId", "username", "email"];
267
+ var syncFieldNames = [
268
+ "username",
269
+ "email",
270
+ "emailVerified",
271
+ "firstName",
272
+ "lastName",
273
+ "enabled",
274
+ "roles",
275
+ "attributes",
276
+ "password"
277
+ ];
278
+ var pathNames = [
279
+ "providerId",
280
+ "username",
281
+ "email",
282
+ "emailVerified",
283
+ "firstName",
284
+ "lastName",
285
+ "enabled",
286
+ "archived",
287
+ "roles",
288
+ "attributes",
289
+ "password"
290
+ ];
291
+ var isIdentityField = (value) => typeof value === "string" && identityFields.includes(value);
292
+ var hasSchemaPath = (schema, path) => {
293
+ const schemaWithVirtuals = schema;
294
+ return Boolean(schema.path(path) ?? schemaWithVirtuals.virtualpath?.(path));
295
+ };
296
+ var requiredString = (value, name) => {
297
+ const normalized = stringValue(value);
298
+ if (!normalized) throw new Error(`keycloakUserSyncPlugin requires a non-empty ${name}`);
299
+ return normalized;
300
+ };
301
+ var validateOptionKeys = (value, allowed, name) => {
302
+ if (value === void 0) return;
303
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
304
+ throw new Error(`keycloakUserSyncPlugin ${name} must be an object`);
305
+ }
306
+ for (const key of Object.keys(value)) {
307
+ if (!allowed.includes(key)) throw new Error(`keycloakUserSyncPlugin received unsupported ${name} key "${key}"`);
308
+ }
309
+ };
310
+ var normalizeOptionalStringList = (values, name) => {
311
+ if (values === void 0) return void 0;
312
+ if (!Array.isArray(values)) throw new Error(`keycloakUserSyncPlugin ${name} must be an array of non-empty strings`);
313
+ return Object.freeze(
314
+ uniqueStrings(
315
+ values.map((value, index) => {
316
+ const normalized = stringValue(value);
317
+ if (!normalized) throw new Error(`keycloakUserSyncPlugin ${name}[${index}] must be a non-empty string`);
318
+ if (name === "managedAttributes") assertSafeAttributeKey(normalized);
319
+ return normalized;
320
+ })
321
+ )
322
+ );
323
+ };
324
+ var normalizeMaxRolesPerSync = (value) => {
325
+ if (value === void 0) return void 0;
326
+ if (!Number.isSafeInteger(value) || value <= 0) {
327
+ throw new Error("keycloakUserSyncPlugin maxRolesPerSync must be a positive safe integer");
328
+ }
329
+ return value;
330
+ };
331
+ var normalizeIdentityList = (identifyBy) => {
332
+ const configured = identifyBy === void 0 ? ["providerId", "username", "email"] : typeof identifyBy === "string" ? [identifyBy] : identifyBy;
333
+ if (!Array.isArray(configured)) {
334
+ throw new Error("keycloakUserSyncPlugin identifyBy must be a supported identity or non-empty identity array");
335
+ }
336
+ const identities = configured.map((identity, index) => {
337
+ if (!isIdentityField(identity)) {
338
+ throw new Error(`keycloakUserSyncPlugin identifyBy[${index}] must be providerId, username, or email`);
339
+ }
340
+ return identity;
341
+ });
342
+ const uniqueIdentities = Object.freeze([...new Set(identities)]);
343
+ if (uniqueIdentities.length === 0) throw new Error("keycloakUserSyncPlugin identifyBy must not be empty");
344
+ return uniqueIdentities;
345
+ };
346
+ var normalizePaths = (paths) => {
347
+ validateOptionKeys(paths, pathNames, "paths");
348
+ const normalized = { ...defaultPaths };
349
+ for (const name of pathNames) {
350
+ const value = paths?.[name];
351
+ if (value !== void 0) normalized[name] = requiredString(value, `paths.${name}`);
352
+ }
353
+ return Object.freeze(normalized);
354
+ };
355
+ var normalizeSyncFields = (syncFields) => {
356
+ validateOptionKeys(syncFields, syncFieldNames, "syncFields");
357
+ return Object.freeze({ ...defaultSyncFields, ...syncFields });
358
+ };
359
+ var validateConfiguredSchemaPath = (schema, path, name) => {
360
+ if (!hasSchemaPath(schema, path)) {
361
+ throw new Error(`keycloakUserSyncPlugin ${name} path "${path}" does not exist in the schema`);
362
+ }
363
+ };
364
+ var normalizeOptions = (schema, options) => {
365
+ if (!options?.client) throw new Error("keycloakUserSyncPlugin requires a client option");
366
+ const paths = normalizePaths(options.paths);
367
+ const syncFields = normalizeSyncFields(options.syncFields);
368
+ const normalized = Object.freeze({
369
+ ...options,
370
+ realm: requiredString(options.realm, "realm option"),
371
+ identifyBy: normalizeIdentityList(options.identifyBy),
372
+ paths,
373
+ syncFields,
374
+ attributePaths: normalizeOptionalStringList(options.attributePaths, "attributePaths"),
375
+ managedAttributes: normalizeOptionalStringList(options.managedAttributes, "managedAttributes"),
376
+ managedRoles: normalizeOptionalStringList(options.managedRoles, "managedRoles"),
377
+ maxRolesPerSync: normalizeMaxRolesPerSync(options.maxRolesPerSync)
378
+ });
379
+ validateConfiguredSchemaPath(schema, paths.providerId, "paths.providerId");
380
+ for (const identity of normalized.identifyBy)
381
+ validateConfiguredSchemaPath(schema, paths[identity], `paths.${identity}`);
382
+ if (syncFields.emailVerified) validateConfiguredSchemaPath(schema, paths.emailVerified, "paths.emailVerified");
383
+ if (syncFields.firstName) validateConfiguredSchemaPath(schema, paths.firstName, "paths.firstName");
384
+ if (syncFields.lastName) validateConfiguredSchemaPath(schema, paths.lastName, "paths.lastName");
385
+ if (syncFields.enabled && !hasSchemaPath(schema, paths.enabled) && !hasSchemaPath(schema, paths.archived)) {
386
+ throw new Error(
387
+ `keycloakUserSyncPlugin requires either paths.enabled "${paths.enabled}" or paths.archived "${paths.archived}" in the schema`
388
+ );
389
+ }
390
+ if (syncFields.roles && !normalized.mapRoles) validateConfiguredSchemaPath(schema, paths.roles, "paths.roles");
391
+ if (syncFields.attributes && !normalized.mapAttributes)
392
+ validateConfiguredSchemaPath(schema, paths.attributes, "paths.attributes");
393
+ if (syncFields.password && !normalized.mapPassword)
394
+ validateConfiguredSchemaPath(schema, paths.password, "paths.password");
395
+ return normalized;
396
+ };
397
+ var getState = (document) => document.$locals[syncStateKey];
398
+ var getDocumentSession = (document) => document.$session?.() ?? null;
399
+ var assertNoDocumentSession = (document, operation) => {
400
+ if (getDocumentSession(document)) {
401
+ throw new Error(
402
+ `keycloakUserSyncPlugin direct ${operation} hooks do not support Mongoose sessions or transactions; use an application-owned outbox/worker for transactional delivery`
403
+ );
404
+ }
405
+ };
406
+ function keycloakUserSyncPlugin(schema, rawOptions) {
407
+ const schemaWithMarker = schema;
408
+ if (schemaWithMarker[pluginRegisteredKey]) {
409
+ throw new Error("keycloakUserSyncPlugin is already registered on this schema");
410
+ }
411
+ const options = normalizeOptions(schema, rawOptions);
412
+ schemaWithMarker[pluginRegisteredKey] = true;
413
+ const paths = options.paths;
414
+ const syncFields = options.syncFields;
415
+ const identities = options.identifyBy;
416
+ const adapter = createKeycloakUserSyncAdapter(options.client, options.realm);
417
+ const trackedPaths = buildTrackedPaths(options);
418
+ const buildSafeErrorContext = (operation, document) => ({
419
+ operation,
420
+ localDocumentId: document._id === void 0 || document._id === null ? void 0 : String(document._id)
421
+ });
422
+ const handleError = async (error, operation, document) => {
423
+ const safeContext = buildSafeErrorContext(operation, document);
424
+ const logger = options.logger === false ? null : options.logger ?? console;
425
+ try {
426
+ await logger?.error(`Keycloak user sync failed during ${operation}`, { ...safeContext, error });
427
+ } catch {
428
+ }
429
+ try {
430
+ const callbackContext = options.includeDocumentInErrorContext ? { ...safeContext, document } : safeContext;
431
+ await options.onError?.(error, callbackContext);
432
+ } catch {
433
+ }
434
+ if (options.throwOnError !== false) throw error;
435
+ };
436
+ const readDocumentValue = (document, field) => getDocumentValue(document, paths, field);
437
+ const getPreviousValue = (previous, field) => getPathValue(previous, paths[field]);
438
+ const getUsername = (document) => stringValue(readDocumentValue(document, "username")) ?? stringValue(readDocumentValue(document, "email"));
439
+ const resolveUser = async (document, previous) => {
440
+ const realm = await adapter.getRealm();
441
+ if (!realm) throw new Error(`Keycloak realm "${options.realm}" was not found`);
442
+ const duplicateEmailsAllowed = realm.duplicateEmailsAllowed === true;
443
+ const orderedIdentities = duplicateEmailsAllowed ? ["providerId", "username", "email"].filter((identity) => identities.includes(identity)) : identities;
444
+ const getCompleteUserById = async (user) => {
445
+ if (!user.id) return user;
446
+ return await adapter.getUserById(user.username ?? user.id, user.id) ?? user;
447
+ };
448
+ for (const identity of orderedIdentities) {
449
+ if (identity === "providerId") {
450
+ for (const providerId of uniqueStrings([
451
+ readDocumentValue(document, "providerId"),
452
+ getPreviousValue(previous, "providerId")
453
+ ])) {
454
+ const user = await adapter.getUserById(getUsername(document) ?? providerId, providerId);
455
+ if (user) return { user, duplicateEmailsAllowed };
456
+ }
457
+ }
458
+ if (identity === "username") {
459
+ for (const username of uniqueStrings([
460
+ readDocumentValue(document, "username"),
461
+ getPreviousValue(previous, "username")
462
+ ])) {
463
+ const user = await adapter.getUserByUsername(username);
464
+ if (user) return { user, duplicateEmailsAllowed };
465
+ }
466
+ }
467
+ if (identity === "email") {
468
+ for (const email of uniqueStrings([
469
+ readDocumentValue(document, "email"),
470
+ getPreviousValue(previous, "email")
471
+ ])) {
472
+ const matches = (await adapter.findUsersByEmail(email)).filter(
473
+ (user) => normalizeEmail(user.email) === normalizeEmail(email)
474
+ );
475
+ if (matches.length > 1) {
476
+ throw new Error("Cannot identify a unique Keycloak user for the configured email identity");
477
+ }
478
+ if (matches.length === 1) return { user: await getCompleteUserById(matches[0]), duplicateEmailsAllowed };
479
+ }
480
+ }
481
+ }
482
+ return { user: null, duplicateEmailsAllowed };
483
+ };
484
+ const resolveCreatedUser = async (document, username) => {
485
+ const userByUsername = await adapter.getUserByUsername(username);
486
+ if (userByUsername) return userByUsername;
487
+ const email = stringValue(readDocumentValue(document, "email"));
488
+ if (!email) return null;
489
+ const matches = (await adapter.findUsersByEmail(email)).filter(
490
+ (user) => normalizeEmail(user.email) === normalizeEmail(email)
491
+ );
492
+ if (matches.length > 1) throw new Error("Cannot identify a unique Keycloak user for the configured email identity");
493
+ const match = matches[0];
494
+ if (!match?.id) return match ?? null;
495
+ return await adapter.getUserById(match.username ?? match.id, match.id) ?? match;
496
+ };
497
+ const syncRoles = async (user, document) => {
498
+ if (!syncFields.roles || !user.id) return;
499
+ const desiredNames = getDesiredRoles(document, options);
500
+ if (desiredNames === null) return;
501
+ const maxRolesPerSync = options.maxRolesPerSync ?? 100;
502
+ if (desiredNames.length > maxRolesPerSync) {
503
+ throw new Error(`keycloakUserSyncPlugin supports at most ${maxRolesPerSync} desired roles per sync operation`);
504
+ }
505
+ const managedNames = options.managedRoles ? new Set(options.managedRoles) : null;
506
+ const effectiveDesiredNames = managedNames ? desiredNames.filter((name) => managedNames.has(name)) : desiredNames;
507
+ if (effectiveDesiredNames.length === 0 && !managedNames) return;
508
+ const desiredRoles = [];
509
+ for (const roleName of effectiveDesiredNames) {
510
+ if (options.ensureRoles !== false) await adapter.ensureRole(roleName);
511
+ const role = await adapter.getRole(roleName);
512
+ if (!role?.id) throw new Error(`Keycloak realm role "${roleName}" was not found`);
513
+ desiredRoles.push(role);
514
+ }
515
+ const assignedRoles = await adapter.listRealmRoleMappings(user.id);
516
+ const { toAdd, toRemove } = planRoleDiff(desiredNames, assignedRoles, desiredRoles, options.managedRoles);
517
+ if (toAdd.length) {
518
+ await adapter.addRealmRoleMappings(user.id, toAdd);
519
+ }
520
+ if (toRemove.length) {
521
+ await adapter.delRealmRoleMappings(user.id, toRemove);
522
+ }
523
+ };
524
+ const persistProviderId = async (document, providerId) => {
525
+ if (options.persistProviderId === false || readDocumentValue(document, "providerId") === providerId) return;
526
+ document.set(paths.providerId, providerId);
527
+ await document.constructor.updateOne(
528
+ { _id: document._id },
529
+ { $set: { [paths.providerId]: providerId } },
530
+ { session: getDocumentSession(document) }
531
+ );
532
+ document.unmarkModified(paths.providerId);
533
+ };
534
+ const syncDocument = async (document, state) => {
535
+ const resolved = await resolveUser(document, state.previous);
536
+ let user = resolved.user;
537
+ let created = false;
538
+ const shouldSyncProfile = state.wasNew || ["username", "email", "emailVerified", "firstName", "lastName", "enabled"].some(
539
+ (field) => state.changedFields.has(field)
540
+ );
541
+ const shouldSyncAttributes = state.wasNew || state.changedFields.has("attributes");
542
+ const payload = shouldSyncProfile ? buildProfilePayload(document, options, Boolean(resolved.user), state.wasNew ? void 0 : state.changedFields) : {};
543
+ const desiredPassword = getDesiredPassword(document, options);
544
+ if (!user) {
545
+ const username = getUsername(document);
546
+ if (!username) throw new Error("Cannot create a Keycloak user without a username or email");
547
+ payload.attributes = mergeAttributes(null, document, options);
548
+ try {
549
+ user = await adapter.createUser(username, payload) ?? null;
550
+ } catch (error) {
551
+ if (identities.length === 0) throw error;
552
+ const recoveredUser = await resolveCreatedUser(document, username);
553
+ if (!recoveredUser) throw error;
554
+ user = recoveredUser;
555
+ }
556
+ created = true;
557
+ }
558
+ if (!user?.id) throw new Error("Keycloak did not return a user ID");
559
+ const userId = user.id;
560
+ await persistProviderId(document, userId);
561
+ const emailVerificationPlan = planEmailVerification({
562
+ created,
563
+ wasNew: state.wasNew,
564
+ syncEmail: syncFields.email,
565
+ syncEmailVerified: syncFields.emailVerified,
566
+ previousEmail: getPreviousValue(state.previous, "email"),
567
+ currentEmail: readDocumentValue(document, "email"),
568
+ remoteEmail: user.email
569
+ });
570
+ if (!created) {
571
+ if (emailVerificationPlan.initialLinkSameEmail) delete payload.emailVerified;
572
+ if (emailVerificationPlan.changed) {
573
+ const email = stringValue(readDocumentValue(document, "email"));
574
+ if (syncFields.email && email) payload.email = email;
575
+ payload.emailVerified = false;
576
+ }
577
+ if (shouldSyncAttributes) payload.attributes = mergeAttributes(user, document, options);
578
+ if (Object.keys(payload).length > 0) {
579
+ await adapter.updateUser(userId, payload);
580
+ user = { ...user, ...payload };
581
+ }
582
+ }
583
+ if ((created || state.passwordChanged) && desiredPassword) {
584
+ await adapter.resetPassword(userId, desiredPassword, options.passwordTemporary ?? false);
585
+ }
586
+ if (created || state.changedFields.has("roles")) await syncRoles(user, document);
587
+ if (emailVerificationPlan.changed && options.sendVerificationEmailOnChange !== false) {
588
+ await adapter.sendVerifyEmail(userId);
589
+ }
590
+ };
591
+ const deleteDocument = async (document) => {
592
+ const { user } = await resolveUser(document);
593
+ if (user?.id) await adapter.deleteUser(user.id);
594
+ };
595
+ schema.pre("save", async function keycloakUserSyncPreSave() {
596
+ assertNoDocumentSession(this, "save");
597
+ const providerIdChanged = !this.isNew && this.isModified(paths.providerId);
598
+ const changePlan = planChangedFields(
599
+ options,
600
+ this.isNew,
601
+ (path) => this.isModified(path),
602
+ options.persistProviderId === false ? "disabled" : this.get(paths.providerId)
603
+ );
604
+ const state = { ...changePlan, wasNew: this.isNew };
605
+ this.$locals[syncStateKey] = state;
606
+ if (!state.shouldSync || this.isNew) return;
607
+ state.previous = await this.constructor.findById(this._id).select(trackedPaths).session(getDocumentSession(this)).lean();
608
+ if (providerIdChanged) {
609
+ const previousProviderId = stringValue(getPreviousValue(state.previous, "providerId"));
610
+ const currentProviderId = stringValue(readDocumentValue(this, "providerId"));
611
+ if (previousProviderId !== currentProviderId) {
612
+ throw new Error(
613
+ "keycloakUserSyncPlugin providerId is server-controlled and cannot be changed after persistence"
614
+ );
615
+ }
616
+ }
617
+ });
618
+ schema.post("save", async function keycloakUserSyncPostSave() {
619
+ const state = getState(this);
620
+ if (!state?.shouldSync) return;
621
+ try {
622
+ await syncDocument(this, state);
623
+ } catch (error) {
624
+ await handleError(error, "save", this);
625
+ }
626
+ });
627
+ schema.pre(
628
+ "deleteOne",
629
+ { document: true, query: false },
630
+ async function keycloakUserSyncPreDelete() {
631
+ assertNoDocumentSession(this, "delete");
632
+ try {
633
+ await deleteDocument(this);
634
+ } catch (error) {
635
+ await handleError(error, "delete", this);
636
+ throw error;
637
+ }
638
+ }
639
+ );
640
+ }
641
+ // Annotate the CommonJS export names for ESM import in node:
642
+ 0 && (module.exports = {
643
+ keycloakUserSyncPlugin
644
+ });