@web-ts-toolkit/moo 0.42.2 → 0.47.1

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.
@@ -77,12 +77,24 @@ var normalizeAttributes = (attributes) => {
77
77
  return normalizedAttributes;
78
78
  };
79
79
  var getDocumentValue = (document, paths, field) => document.get(paths[field]);
80
+ var dependencyPathsFor = (options, field) => {
81
+ if (!options.syncFields[field]) return [];
82
+ if (field === "attributes") return options.attributePaths ?? [];
83
+ if (field === "roles") return options.rolePaths ?? [];
84
+ return options.passwordPaths ?? [];
85
+ };
86
+ var hasDependencyChanged = (options, field, isModified) => dependencyPathsFor(options, field).some(isModified);
87
+ var collectDependencyPaths = (options) => [
88
+ ...dependencyPathsFor(options, "attributes"),
89
+ ...dependencyPathsFor(options, "roles"),
90
+ ...dependencyPathsFor(options, "password")
91
+ ];
80
92
  var buildTrackedPaths = (options) => uniqueStrings([
81
93
  options.paths.providerId,
82
94
  ...Object.entries(options.syncFields).filter(([, enabled]) => enabled).flatMap(
83
95
  ([field]) => field === "enabled" ? [options.paths.enabled, options.paths.archived] : [options.paths[field]]
84
96
  ),
85
- ...options.syncFields.attributes && options.attributePaths ? options.attributePaths : []
97
+ ...collectDependencyPaths(options)
86
98
  ]);
87
99
  var planChangedFields = (options, isNew, isModified, currentProviderId) => {
88
100
  const changedFields = /* @__PURE__ */ new Set();
@@ -92,7 +104,9 @@ var planChangedFields = (options, isNew, isModified, currentProviderId) => {
92
104
  const paths = field === "enabled" ? [options.paths.enabled, options.paths.archived] : [options.paths[field]];
93
105
  if (paths.some(isModified)) changedFields.add(field);
94
106
  }
95
- if (options.syncFields.attributes && options.attributePaths?.some(isModified)) changedFields.add("attributes");
107
+ if (hasDependencyChanged(options, "attributes", isModified)) changedFields.add("attributes");
108
+ if (hasDependencyChanged(options, "roles", isModified)) changedFields.add("roles");
109
+ if (hasDependencyChanged(options, "password", isModified)) changedFields.add("password");
96
110
  const shouldPersistMissingProviderId = stringValue(currentProviderId) === null;
97
111
  const shouldSync = isNew || shouldPersistMissingProviderId || changedFields.size > 0;
98
112
  return {
@@ -311,6 +325,8 @@ var normalizeOptions = (schema, options) => {
311
325
  paths,
312
326
  syncFields,
313
327
  attributePaths: normalizeOptionalStringList(options.attributePaths, "attributePaths"),
328
+ rolePaths: normalizeOptionalStringList(options.rolePaths, "rolePaths"),
329
+ passwordPaths: normalizeOptionalStringList(options.passwordPaths, "passwordPaths"),
314
330
  managedAttributes: normalizeOptionalStringList(options.managedAttributes, "managedAttributes"),
315
331
  managedRoles: normalizeOptionalStringList(options.managedRoles, "managedRoles"),
316
332
  maxRolesPerSync: normalizeMaxRolesPerSync(options.maxRolesPerSync)
@@ -334,6 +350,26 @@ var normalizeOptions = (schema, options) => {
334
350
  return normalized;
335
351
  };
336
352
  var getState = (document) => document.$locals[syncStateKey];
353
+ var loggerTokenPattern = /^[A-Za-z][A-Za-z0-9_.-]{0,63}$/;
354
+ var toLoggerToken = (value) => {
355
+ if (typeof value !== "string") return void 0;
356
+ if (value.length === 0 || value.length > 64) return void 0;
357
+ if (!loggerTokenPattern.test(value)) return void 0;
358
+ return value;
359
+ };
360
+ var toLoggedError = (error) => {
361
+ if (typeof error !== "object" || error === null) return { name: "UnknownError" };
362
+ try {
363
+ const candidate = error;
364
+ const name = toLoggerToken(candidate.name) ?? "Error";
365
+ const code = toLoggerToken(candidate.code);
366
+ const rawStatus = candidate.status ?? candidate.statusCode;
367
+ const status = typeof rawStatus === "number" && Number.isSafeInteger(rawStatus) && rawStatus >= 0 && rawStatus <= 999 ? rawStatus : void 0;
368
+ return { name, ...code === void 0 ? {} : { code }, ...status === void 0 ? {} : { status } };
369
+ } catch {
370
+ return { name: "Error" };
371
+ }
372
+ };
337
373
  var getDocumentSession = (document) => document.$session?.() ?? null;
338
374
  var assertNoDocumentSession = (document, operation) => {
339
375
  if (getDocumentSession(document)) {
@@ -354,6 +390,7 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
354
390
  const identities = options.identifyBy;
355
391
  const realmHandle = options.client.realm(options.realm);
356
392
  const trackedPaths = buildTrackedPaths(options);
393
+ const identitySnapshotPaths = uniqueStrings([...trackedPaths, paths.providerId, paths.username, paths.email]);
357
394
  const buildSafeErrorContext = (operation, document) => ({
358
395
  operation,
359
396
  localDocumentId: document._id === void 0 || document._id === null ? void 0 : String(document._id)
@@ -362,7 +399,10 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
362
399
  const safeContext = buildSafeErrorContext(operation, document);
363
400
  const logger = options.logger === false ? null : options.logger ?? console;
364
401
  try {
365
- await logger?.error(`Keycloak user sync failed during ${operation}`, { ...safeContext, error });
402
+ await logger?.error(`Keycloak user sync failed during ${operation}`, {
403
+ ...safeContext,
404
+ error: toLoggedError(error)
405
+ });
366
406
  } catch {
367
407
  }
368
408
  try {
@@ -375,7 +415,8 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
375
415
  const readDocumentValue = (document, field) => getDocumentValue(document, paths, field);
376
416
  const getPreviousValue = (previous, field) => getPathValue(previous, paths[field]);
377
417
  const getUsername = (document) => stringValue(readDocumentValue(document, "username")) ?? stringValue(readDocumentValue(document, "email"));
378
- const resolveUser = async (document, previous) => {
418
+ const readPersistedSnapshot = async (document) => document.constructor.findById(document._id).select(identitySnapshotPaths).session(getDocumentSession(document)).lean();
419
+ const resolveUser = async (document, snapshot, wasNew) => {
379
420
  const realm = await realmHandle.get();
380
421
  if (!realm) throw new Error(`Keycloak realm "${options.realm}" was not found`);
381
422
  const duplicateEmailsAllowed = realm.duplicateEmailsAllowed === true;
@@ -384,40 +425,133 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
384
425
  if (!user.id) return user;
385
426
  return await realmHandle.userById(user.id).get() ?? user;
386
427
  };
387
- for (const identity of orderedIdentities) {
388
- if (identity === "providerId") {
389
- for (const providerId of uniqueStrings([
390
- readDocumentValue(document, "providerId"),
391
- getPreviousValue(previous, "providerId")
392
- ])) {
393
- const user = await realmHandle.userById(providerId).get();
394
- if (user) return { user, duplicateEmailsAllowed };
395
- }
428
+ const findByProviderId = async (providerId) => realmHandle.userById(providerId).get();
429
+ const findByUsername = async (username) => realmHandle.user(username).get();
430
+ const findByEmail = async (email) => {
431
+ const matches = (await realmHandle.searchUsers(email, { attribute: "email", exact: true, first: 0, max: 2 })).filter((user) => normalizeEmail(user.email) === normalizeEmail(email));
432
+ if (matches.length > 1) {
433
+ throw new Error("Cannot identify a unique Keycloak user for the configured email identity");
396
434
  }
397
- if (identity === "username") {
398
- for (const username of uniqueStrings([
399
- readDocumentValue(document, "username"),
400
- getPreviousValue(previous, "username")
435
+ if (matches.length === 1) return getCompleteUserById(matches[0]);
436
+ return null;
437
+ };
438
+ const findByIdentity = async (identity, value) => {
439
+ if (identity === "providerId") return findByProviderId(value);
440
+ if (identity === "username") return findByUsername(value);
441
+ return findByEmail(value);
442
+ };
443
+ const identityConflict = () => new Error(
444
+ "keycloakUserSyncPlugin detected a Keycloak identity conflict; refusing to relink to a different remote user"
445
+ );
446
+ if (wasNew) {
447
+ for (const identity of orderedIdentities) {
448
+ for (const value of uniqueStrings([
449
+ readDocumentValue(document, identity),
450
+ getPreviousValue(snapshot, identity)
401
451
  ])) {
402
- const user = await realmHandle.user(username).get();
452
+ const user = await findByIdentity(identity, value);
403
453
  if (user) return { user, duplicateEmailsAllowed };
404
454
  }
405
455
  }
406
- if (identity === "email") {
407
- for (const email of uniqueStrings([
408
- readDocumentValue(document, "email"),
409
- getPreviousValue(previous, "email")
410
- ])) {
411
- const matches = (await realmHandle.searchUsers(email, { attribute: "email", exact: true, first: 0, max: 2 })).filter((user) => normalizeEmail(user.email) === normalizeEmail(email));
412
- if (matches.length > 1) {
413
- throw new Error("Cannot identify a unique Keycloak user for the configured email identity");
414
- }
415
- if (matches.length === 1) return { user: await getCompleteUserById(matches[0]), duplicateEmailsAllowed };
416
- }
456
+ return { user: null, duplicateEmailsAllowed };
457
+ }
458
+ if (!snapshot) {
459
+ throw new Error(
460
+ "keycloakUserSyncPlugin cannot verify the persisted Keycloak identity for this document; refusing to sync a possibly relinked account"
461
+ );
462
+ }
463
+ const persistedValue = (identity) => stringValue(getPreviousValue(snapshot, identity));
464
+ const currentValue = (identity) => stringValue(readDocumentValue(document, identity));
465
+ const persistedProviderId = persistedValue("providerId");
466
+ const currentProviderId = currentValue("providerId");
467
+ if (persistedProviderId && currentProviderId && persistedProviderId !== currentProviderId) {
468
+ throw new Error("keycloakUserSyncPlugin providerId is server-controlled and cannot be changed after persistence");
469
+ }
470
+ const isRelinkProtected = (identity) => identity === "username" || identity === "email" && !duplicateEmailsAllowed;
471
+ const checkCurrentDoesNotRelink = async (identity, boundId) => {
472
+ if (!identities.includes(identity) || !isRelinkProtected(identity)) return;
473
+ const current = currentValue(identity);
474
+ if (!current) return;
475
+ const persisted = persistedValue(identity);
476
+ const unchanged = identity === "email" ? persisted !== null && normalizeEmail(persisted) === normalizeEmail(current) : persisted === current;
477
+ if (persisted !== null && unchanged) return;
478
+ const user = await findByIdentity(identity, current);
479
+ if (user?.id && user.id !== boundId) throw identityConflict();
480
+ };
481
+ if (persistedProviderId) {
482
+ const bound2 = await findByProviderId(persistedProviderId);
483
+ if (!bound2?.id) {
484
+ throw new Error(
485
+ "keycloakUserSyncPlugin persisted Keycloak identity no longer exists; refusing to relink to a different remote user"
486
+ );
487
+ }
488
+ await checkCurrentDoesNotRelink("username", bound2.id);
489
+ await checkCurrentDoesNotRelink("email", bound2.id);
490
+ return { user: bound2, duplicateEmailsAllowed };
491
+ }
492
+ let bound = null;
493
+ for (const identity of orderedIdentities) {
494
+ if (identity === "providerId") continue;
495
+ const persisted = persistedValue(identity);
496
+ if (!persisted) continue;
497
+ const user = await findByIdentity(identity, persisted);
498
+ if (!user?.id) continue;
499
+ if (bound?.id && bound.id !== user.id) throw identityConflict();
500
+ bound ??= user;
501
+ }
502
+ if (bound?.id) {
503
+ await checkCurrentDoesNotRelink("username", bound.id);
504
+ await checkCurrentDoesNotRelink("email", bound.id);
505
+ if (currentProviderId) {
506
+ const owner = await findByProviderId(currentProviderId);
507
+ if (owner?.id && owner.id !== bound.id) throw identityConflict();
417
508
  }
509
+ return { user: bound, duplicateEmailsAllowed };
510
+ }
511
+ if (currentProviderId) {
512
+ const owner = await findByProviderId(currentProviderId);
513
+ if (owner?.id) throw identityConflict();
514
+ }
515
+ for (const identity of orderedIdentities) {
516
+ if (identity === "providerId") continue;
517
+ const current = currentValue(identity);
518
+ if (!current) continue;
519
+ const user = await findByIdentity(identity, current);
520
+ if (user?.id) throw identityConflict();
418
521
  }
419
522
  return { user: null, duplicateEmailsAllowed };
420
523
  };
524
+ const getProvisioningFlags = (error) => {
525
+ if (typeof error !== "object" || error === null) return null;
526
+ const candidate = error;
527
+ if (candidate.name !== "UserPasswordProvisioningError" && !("accountPersists" in candidate) && !("initialProvisioning" in candidate)) {
528
+ return null;
529
+ }
530
+ return candidate;
531
+ };
532
+ const isAlreadyExistsError = (error) => error instanceof Error && /already exists in realm/.test(error.message);
533
+ const readIntendedEnabled = (document) => {
534
+ if (!syncFields.enabled) return void 0;
535
+ const archived = readDocumentValue(document, "archived");
536
+ if (typeof archived === "boolean") return !archived;
537
+ const enabled = readDocumentValue(document, "enabled");
538
+ if (typeof enabled === "boolean") return enabled;
539
+ return void 0;
540
+ };
541
+ const recoverPartialCreation = async (document, username, creationError, desiredPassword) => {
542
+ if (identities.length === 0) throw creationError;
543
+ if (isAlreadyExistsError(creationError)) throw creationError;
544
+ const flags = getProvisioningFlags(creationError);
545
+ if (flags && flags.accountPersists === false) throw creationError;
546
+ if (desiredPassword && !(flags && flags.accountPersists === true)) throw creationError;
547
+ const recoveredUser = await resolveCreatedUser(document, username);
548
+ if (!recoveredUser?.id) throw creationError;
549
+ if (desiredPassword) {
550
+ const candidateUsername = stringValue(recoveredUser.username);
551
+ if (!candidateUsername || candidateUsername !== username) throw creationError;
552
+ }
553
+ return { user: recoveredUser, passwordAlreadyApplied: flags?.passwordApplied === true };
554
+ };
421
555
  const resolveCreatedUser = async (document, username) => {
422
556
  const userByUsername = await realmHandle.user(username).get();
423
557
  if (userByUsername) return userByUsername;
@@ -455,33 +589,46 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
455
589
  document.unmarkModified(paths.providerId);
456
590
  };
457
591
  const syncDocument = async (document, state) => {
458
- const resolved = await resolveUser(document, state.previous);
592
+ const resolved = await resolveUser(document, state.previous, state.wasNew);
459
593
  let user = resolved.user;
460
594
  let created = false;
595
+ let recoveredPartial = false;
596
+ let passwordAlreadyApplied = false;
461
597
  const shouldSyncProfile = state.wasNew || ["username", "email", "emailVerified", "firstName", "lastName", "enabled"].some(
462
598
  (field) => state.changedFields.has(field)
463
599
  );
464
600
  const shouldSyncAttributes = state.wasNew || state.changedFields.has("attributes");
465
601
  const payload = shouldSyncProfile ? buildProfilePayload(document, options, Boolean(resolved.user), state.wasNew ? void 0 : state.changedFields) : {};
466
- const desiredPassword = getDesiredPassword(document, options);
602
+ let cachedPassword;
603
+ let passwordEvaluated = false;
604
+ const readDesiredPassword = () => {
605
+ if (!passwordEvaluated) {
606
+ cachedPassword = getDesiredPassword(document, options);
607
+ passwordEvaluated = true;
608
+ }
609
+ return cachedPassword ?? null;
610
+ };
467
611
  if (!user) {
468
612
  const username = getUsername(document);
469
613
  if (!username) throw new Error("Cannot create a Keycloak user without a username or email");
470
614
  payload.attributes = mergeAttributes(null, document, options);
615
+ const creationPassword = readDesiredPassword();
471
616
  try {
472
617
  user = await realmHandle.user(username).create({
618
+ username,
473
619
  ...payload,
474
- ...desiredPassword && {
475
- password: desiredPassword,
620
+ ...creationPassword && {
621
+ password: creationPassword,
476
622
  passwordTemporary: options.passwordTemporary ?? false
477
623
  }
478
624
  }) ?? null;
479
625
  created = true;
480
626
  } catch (error) {
481
- if (identities.length === 0) throw error;
482
- const recoveredUser = await resolveCreatedUser(document, username);
483
- if (!recoveredUser) throw error;
484
- user = recoveredUser;
627
+ if (!state.wasNew) throw error;
628
+ const recovered = await recoverPartialCreation(document, username, error, creationPassword);
629
+ user = recovered.user;
630
+ recoveredPartial = true;
631
+ passwordAlreadyApplied = recovered.passwordAlreadyApplied;
485
632
  }
486
633
  }
487
634
  if (!user?.id) throw new Error("Keycloak did not return a user ID");
@@ -496,6 +643,8 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
496
643
  currentEmail: readDocumentValue(document, "email"),
497
644
  remoteEmail: user.email
498
645
  });
646
+ const desiredPassword = !created && !passwordAlreadyApplied && (recoveredPartial || state.passwordChanged) ? readDesiredPassword() : cachedPassword ?? null;
647
+ const willResetPassword = Boolean(desiredPassword) && !created && !passwordAlreadyApplied && (recoveredPartial || state.passwordChanged);
499
648
  if (!created) {
500
649
  if (emailVerificationPlan.initialLinkSameEmail) delete payload.emailVerified;
501
650
  if (emailVerificationPlan.changed) {
@@ -504,21 +653,35 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
504
653
  payload.emailVerified = false;
505
654
  }
506
655
  if (shouldSyncAttributes) payload.attributes = mergeAttributes(user, document, options);
507
- if (Object.keys(payload).length > 0) {
508
- await realmHandle.userById(userId).update(payload);
509
- user = { ...user, ...payload };
656
+ if (willResetPassword) {
657
+ const { enabled: deferredEnabled, ...profileWithoutEnabled } = payload;
658
+ const intendedEnabled = typeof deferredEnabled === "boolean" ? deferredEnabled : readIntendedEnabled(document) ?? true;
659
+ if (Object.keys(profileWithoutEnabled).length > 0) {
660
+ await realmHandle.userById(userId).update(profileWithoutEnabled);
661
+ user = { ...user, ...profileWithoutEnabled };
662
+ }
663
+ await realmHandle.userById(userId).resetPassword(desiredPassword, { temporary: options.passwordTemporary ?? false });
664
+ user = { ...user };
665
+ await realmHandle.userById(userId).update({ enabled: intendedEnabled });
666
+ user = { ...user, enabled: intendedEnabled };
667
+ } else {
668
+ if (Object.keys(payload).length > 0) {
669
+ await realmHandle.userById(userId).update(payload);
670
+ user = { ...user, ...payload };
671
+ }
510
672
  }
511
673
  }
512
- if (!created && state.passwordChanged && desiredPassword) {
513
- await realmHandle.userById(userId).resetPassword(desiredPassword, { temporary: options.passwordTemporary ?? false });
514
- }
515
674
  if (created || state.changedFields.has("roles")) await syncRoles(user, document);
516
675
  if (emailVerificationPlan.changed && options.sendVerificationEmailOnChange !== false) {
517
676
  await realmHandle.userById(userId).sendVerifyEmail();
518
677
  }
519
678
  };
520
679
  const deleteDocument = async (document) => {
521
- const { user } = await resolveUser(document);
680
+ if (document.isNew) {
681
+ throw new Error("keycloakUserSyncPlugin cannot delete a Keycloak user for a document that was never persisted");
682
+ }
683
+ const snapshot = await readPersistedSnapshot(document);
684
+ const { user } = await resolveUser(document, snapshot, false);
522
685
  if (user?.id) await realmHandle.userById(user.id).delete();
523
686
  };
524
687
  schema.pre("save", async function keycloakUserSyncPreSave() {
@@ -533,7 +696,7 @@ function keycloakUserSyncPlugin(schema, rawOptions) {
533
696
  const state = { ...changePlan, wasNew: this.isNew };
534
697
  this.$locals[syncStateKey] = state;
535
698
  if (!state.shouldSync || this.isNew) return;
536
- state.previous = await this.constructor.findById(this._id).select(trackedPaths).session(getDocumentSession(this)).lean();
699
+ state.previous = await readPersistedSnapshot(this);
537
700
  if (providerIdChanged) {
538
701
  const previousProviderId = stringValue(getPreviousValue(state.previous, "providerId"));
539
702
  const currentProviderId = stringValue(readDocumentValue(this, "providerId"));