emailengine-app 2.66.0 → 2.67.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.
@@ -19,7 +19,7 @@ jobs:
19
19
  uses: actions/checkout@v6
20
20
 
21
21
  - name: Use Node.js 24
22
- uses: actions/setup-node@v4
22
+ uses: actions/setup-node@v6
23
23
  with:
24
24
  node-version: 24
25
25
 
@@ -32,7 +32,7 @@ jobs:
32
32
  # a new release is created:
33
33
  if: ${{ steps.release.outputs.release_created }}
34
34
 
35
- - uses: actions/setup-node@v4
35
+ - uses: actions/setup-node@v6
36
36
  with:
37
37
  node-version: 24
38
38
  registry-url: "https://registry.npmjs.org"
@@ -51,7 +51,7 @@ jobs:
51
51
  id-token: write
52
52
  steps:
53
53
  - uses: actions/checkout@v6
54
- - uses: actions/setup-node@v4
54
+ - uses: actions/setup-node@v6
55
55
  with:
56
56
  node-version: 24
57
57
  registry-url: "https://registry.npmjs.org"
@@ -17,7 +17,7 @@ jobs:
17
17
  - uses: actions/checkout@v6
18
18
 
19
19
  - name: Use Node.js 24
20
- uses: actions/setup-node@v4
20
+ uses: actions/setup-node@v6
21
21
  with:
22
22
  node-version: 24
23
23
  - run: npm install
@@ -51,7 +51,7 @@ jobs:
51
51
  steps:
52
52
  - uses: actions/checkout@v6
53
53
  - name: Use Node.js ${{ matrix.node }}
54
- uses: actions/setup-node@v4
54
+ uses: actions/setup-node@v6
55
55
  with:
56
56
  node-version: ${{ matrix.node }}
57
57
  - name: Setup Redis CLI
package/.ncurc.js CHANGED
@@ -22,6 +22,9 @@ module.exports = {
22
22
  'joi',
23
23
 
24
24
  // @asamuzakjp/css-color >=4.1.2 pulls in @csstools/* v4 which are pure ESM and break pkg bundling
25
- '@asamuzakjp/css-color'
25
+ '@asamuzakjp/css-color',
26
+
27
+ // undici >=8.0.0 requires Node >=22.19.0; pin to last Node 20-compatible release
28
+ 'undici'
26
29
  ]
27
30
  };
package/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # Changelog
2
2
 
3
+ ## [2.67.1](https://github.com/postalsys/emailengine/compare/v2.67.0...v2.67.1) (2026-04-17)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * detect IMAP drift that landed during an in-progress sync ([8fece8a](https://github.com/postalsys/emailengine/commit/8fece8ad6f3acb5e7391da7a9740279099f62177))
9
+ * improve translation quality across 6 languages ([d7d338e](https://github.com/postalsys/emailengine/commit/d7d338e761152909d4c2ca4732fafb60529413ab))
10
+ * pin undici to 7.25.0 for Node 20 compatibility ([3fa80db](https://github.com/postalsys/emailengine/commit/3fa80db4d64d6fe089fa3a1a0e700a188aeb74f7))
11
+
12
+ ## [2.67.0](https://github.com/postalsys/emailengine/compare/v2.66.0...v2.67.0) (2026-03-31)
13
+
14
+
15
+ ### Features
16
+
17
+ * handle MS Graph 'missed' lifecycle event for Outlook notification recovery ([d55f48c](https://github.com/postalsys/emailengine/commit/d55f48c9a183f9e0108801563b1085c176dbeb06))
18
+ * show human-readable missing scopes on OAuth scope error page ([4ee2365](https://github.com/postalsys/emailengine/commit/4ee2365449e3cbece6f4a83d540ae63c18464535))
19
+
20
+
21
+ ### Bug Fixes
22
+
23
+ * add IMAP fetch retry timeout and harden passkey registration ([3a1b61b](https://github.com/postalsys/emailengine/commit/3a1b61b00e916c308cae5e7493a2207164d9d45f))
24
+ * close command client proactively after subconnection setup ([02d3687](https://github.com/postalsys/emailengine/commit/02d36874d8533beabe5ad0fba978851b3791cbb5))
25
+ * correct translation errors and remove EmailEngine branding from OAuth scope error ([9284ddd](https://github.com/postalsys/emailengine/commit/9284ddd1e28e40fd24abeea7296636b21c4b3878))
26
+ * use STATUS instead of NOOP as keepalive for 163.com IMAP ([3b3516f](https://github.com/postalsys/emailengine/commit/3b3516feb14e64bab5ac586378237f2160cdf010))
27
+
3
28
  ## [2.66.0](https://github.com/postalsys/emailengine/compare/v2.65.0...v2.66.0) (2026-03-29)
4
29
 
5
30
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "creationTime": "2026-03-27T15:45:58.000000",
2
+ "creationTime": "2026-04-16T14:45:56.000000",
3
3
  "prefixes": [
4
4
  {
5
5
  "ipv6Prefix": "2001:4860:4801:2008::/64"
package/lib/consts.js CHANGED
@@ -193,6 +193,8 @@ module.exports = {
193
193
  FETCH_RETRY_INTERVAL: 1000,
194
194
  FETCH_RETRY_EXPONENTIAL: 2,
195
195
  FETCH_RETRY_MAX: 60 * 1000,
196
+ FETCH_RETRY_MAX_TIME: 24 * 60 * 60 * 1000, // 1 day maximum retry duration
197
+ FETCH_RETRY_MIN_ATTEMPTS: 20, // minimum attempts before time limit applies
196
198
  DEFAULT_FETCH_BATCH_SIZE: 1000,
197
199
 
198
200
  // MS Graph webhook subscription settings
@@ -706,12 +706,20 @@ class Mailbox {
706
706
 
707
707
  /**
708
708
  * Checks if partial sync should run after EXISTS event
709
- * @returns {Boolean} True if message count differs from stored count
709
+ * @returns {Boolean} True if any of messages, uidNext or highestModseq drift from stored state
710
710
  */
711
711
  async shouldRunPartialSyncAfterExists() {
712
712
  let storedStatus = await this.getStoredStatus();
713
713
  let mailboxStatus = this.getMailboxStatus();
714
- return mailboxStatus.messages !== storedStatus.messages;
714
+ // Count-only comparison misses balanced churn (an arrival paired with an
715
+ // expunge in the same window) and does not work at all in 'fast' indexer
716
+ // mode where onExpunge never updates the stored count. uidNext and
717
+ // highestModseq catch those cases.
718
+ return (
719
+ mailboxStatus.messages !== storedStatus.messages ||
720
+ mailboxStatus.uidNext !== storedStatus.uidNext ||
721
+ mailboxStatus.highestModseq !== storedStatus.highestModseq
722
+ );
715
723
  }
716
724
 
717
725
  /**
@@ -3,7 +3,7 @@
3
3
  const crypto = require('crypto');
4
4
  const { compareExisting, calculateFetchBackoff, readEnvValue } = require('../../tools');
5
5
  const config = require('@zone-eu/wild-config');
6
- const { DEFAULT_FETCH_BATCH_SIZE } = require('../../consts');
6
+ const { DEFAULT_FETCH_BATCH_SIZE, FETCH_RETRY_MAX_TIME, FETCH_RETRY_MIN_ATTEMPTS } = require('../../consts');
7
7
 
8
8
  // Configurable batch size for fetching messages (default: 250)
9
9
  const FETCH_BATCH_SIZE = Number(readEnvValue('EENGINE_FETCH_BATCH_SIZE') || config.service.fetchBatchSize) || DEFAULT_FETCH_BATCH_SIZE;
@@ -11,6 +11,15 @@ const FETCH_BATCH_SIZE = Number(readEnvValue('EENGINE_FETCH_BATCH_SIZE') || conf
11
11
  // Do not check for flag updates using full sync more often than this value (30 minutes)
12
12
  const FULL_SYNC_DELAY = 30 * 60 * 1000;
13
13
 
14
+ function checkFetchRetryTimeout(retryCount, startTime) {
15
+ if (retryCount >= FETCH_RETRY_MIN_ATTEMPTS && Date.now() - startTime > FETCH_RETRY_MAX_TIME) {
16
+ const err = new Error('FETCH retry time limit exceeded');
17
+ err.code = 'FetchRetryTimeout';
18
+ err.statusCode = 504;
19
+ throw err;
20
+ }
21
+ }
22
+
14
23
  /**
15
24
  * Calculates the next range of sequence numbers to fetch based on the last fetched range
16
25
  * @param {Number} totalMessages - Total number of messages in the mailbox
@@ -234,6 +243,7 @@ class SyncOperations {
234
243
  // Fetch messages with retry logic
235
244
  let fetchCompleted = false;
236
245
  let fetchRetryCount = 0;
246
+ let fetchStartTime = Date.now();
237
247
 
238
248
  while (!fetchCompleted) {
239
249
  try {
@@ -330,11 +340,15 @@ class SyncOperations {
330
340
  connErr.statusCode = 503;
331
341
  throw connErr;
332
342
  }
343
+
344
+ checkFetchRetryTimeout(fetchRetryCount, fetchStartTime);
333
345
  }
334
346
  }
335
347
  }
336
348
 
337
- await this.mailbox.updateStoredStatus(this.mailbox.getMailboxStatus());
349
+ // Persist the pre-sync snapshot so any arrivals that landed
350
+ // during this FETCH are still detected as drift on the next trigger.
351
+ await this.mailbox.updateStoredStatus(mailboxStatus);
338
352
 
339
353
  await this.mailbox.publishSyncedEvents(storedStatus);
340
354
  } finally {
@@ -383,6 +397,7 @@ class SyncOperations {
383
397
  // only fetch messages if there are some
384
398
  let fetchCompleted = false;
385
399
  let fetchRetryCount = 0;
400
+ let fetchStartTime = Date.now();
386
401
  while (!fetchCompleted) {
387
402
  // Get fresh imapClient reference inside retry loop
388
403
  let imapClient = this.connection.imapClient;
@@ -483,11 +498,15 @@ class SyncOperations {
483
498
  connErr.statusCode = 503;
484
499
  throw connErr;
485
500
  }
501
+
502
+ checkFetchRetryTimeout(fetchRetryCount, fetchStartTime);
486
503
  }
487
504
  }
488
505
  }
489
506
 
490
- await this.mailbox.updateStoredStatus(this.mailbox.getMailboxStatus());
507
+ // Persist the pre-sync snapshot so any arrivals that landed
508
+ // during this FETCH are still detected as drift on the next trigger.
509
+ await this.mailbox.updateStoredStatus(mailboxStatus);
491
510
 
492
511
  await this.mailbox.publishSyncedEvents(storedStatus);
493
512
  } finally {
@@ -553,6 +572,8 @@ class SyncOperations {
553
572
  let range = false;
554
573
  let lastHighestUid = 0;
555
574
  let batchNumber = 0;
575
+ let fetchStartTime = Date.now();
576
+ let totalFetchRetryCount = 0;
556
577
  // process messages in batches
557
578
  while ((range = getFetchRange(mailboxStatus.messages, range))) {
558
579
  batchNumber++;
@@ -738,6 +759,7 @@ class SyncOperations {
738
759
  }
739
760
 
740
761
  // Retry with exponential backoff
762
+ totalFetchRetryCount++;
741
763
  const fetchRetryDelay = calculateFetchBackoff(++fetchRetryCount);
742
764
  this.logger.error({ msg: `FETCH failed, retrying in ${Math.round(fetchRetryDelay / 1000)}s`, loopId, batchNumber });
743
765
  await new Promise(r => setTimeout(r, fetchRetryDelay));
@@ -776,6 +798,8 @@ class SyncOperations {
776
798
  newMessages: mailboxStatus.messages,
777
799
  range
778
800
  });
801
+
802
+ checkFetchRetryTimeout(totalFetchRetryCount, fetchStartTime);
779
803
  }
780
804
  }
781
805
  }
@@ -806,9 +830,10 @@ class SyncOperations {
806
830
  }
807
831
  }
808
832
 
809
- // Update status with full sync timestamp
810
- let status = this.mailbox.getMailboxStatus();
811
- status.lastFullSync = new Date();
833
+ // Persist the pre-sync snapshot (plus the full-sync timestamp) so
834
+ // any arrivals that landed during this FETCH are still detected as
835
+ // drift on the next trigger.
836
+ let status = Object.assign({}, mailboxStatus, { lastFullSync: new Date() });
812
837
 
813
838
  await this.mailbox.updateStoredStatus(status);
814
839
  let storedStatus = await this.mailbox.getStoredStatus();
@@ -299,7 +299,9 @@ class IMAPClient extends BaseClient {
299
299
 
300
300
  // Set up error handling for the command connection
301
301
  const onErr = err => {
302
- commandClient?.log.warn({ msg: 'IMAP connection error', cid: commandCid, channel: 'command', account: this.account, err });
302
+ // Idle timeout (ETIMEOUT) is expected - the command client has no keepalive
303
+ const logLevel = err.code === 'ETIMEOUT' ? 'debug' : 'warn';
304
+ commandClient?.log[logLevel]({ msg: 'IMAP connection error', cid: commandCid, channel: 'command', account: this.account, err });
303
305
  commandClient.close();
304
306
  this.commandClient = null;
305
307
  };
@@ -1214,9 +1216,14 @@ class IMAPClient extends BaseClient {
1214
1216
  }
1215
1217
 
1216
1218
  // Provider-specific workarounds
1217
- if (/(\.rambler\.ru|\.163\.com)$/i.test(imapConfig.host)) {
1218
- // Special case for Rambler and 163. Break IDLE at least once a minute
1219
+ if (/\.rambler\.ru$/i.test(imapConfig.host)) {
1220
+ // Rambler supports IDLE but needs it restarted frequently
1219
1221
  imapConfig.maxIdleTime = 55 * 1000;
1222
+ } else if (/\.163\.com$/i.test(imapConfig.host)) {
1223
+ // 163.com (Coremail) does not support IDLE and ignores NOOP for idle timer reset.
1224
+ // Use STATUS as the keepalive command - it counts as real activity for the server.
1225
+ imapConfig.maxIdleTime = 55 * 1000;
1226
+ imapConfig.missingIdleCommand = 'STATUS';
1220
1227
  } else if (/\.yahoo\.com$/i.test(imapConfig.host)) {
1221
1228
  // Special case for Yahoo. Break IDLE at least once every three minutes
1222
1229
  imapConfig.maxIdleTime = 3 * 60 * 1000;
@@ -2468,6 +2475,9 @@ class IMAPClient extends BaseClient {
2468
2475
  const mailboxes = [];
2469
2476
 
2470
2477
  const listing = await this.getCurrentListing(false, { allowSecondary: true });
2478
+ // Capture reference so we can close it after the await-heavy loop below
2479
+ // without risking closure of a different command client obtained by a concurrent caller
2480
+ const commandClientForListing = this.commandClient;
2471
2481
 
2472
2482
  // Process each configured subconnection
2473
2483
  for (const path of accountData.subconnections || []) {
@@ -2595,6 +2605,12 @@ class IMAPClient extends BaseClient {
2595
2605
  await subconnection.init();
2596
2606
  }
2597
2607
 
2608
+ // Close the command client if it is still the same instance we captured above
2609
+ if (commandClientForListing && this.commandClient === commandClientForListing) {
2610
+ this.commandClient.close();
2611
+ this.commandClient = null;
2612
+ }
2613
+
2598
2614
  return this.subconnections.length;
2599
2615
  }
2600
2616
 
@@ -3857,8 +3857,11 @@ class OutlookClient extends BaseClient {
3857
3857
  async processHistory() {
3858
3858
  let event;
3859
3859
  let newMessageOptions;
3860
+ let processedAny = false;
3860
3861
 
3861
3862
  while ((event = await this.accountObject.pullQueueEvent()) !== null) {
3863
+ processedAny = true;
3864
+
3862
3865
  switch (event.type) {
3863
3866
  case 'updated':
3864
3867
  await this.processUpdatedMessage(event.message);
@@ -3900,6 +3903,157 @@ class OutlookClient extends BaseClient {
3900
3903
  break;
3901
3904
  }
3902
3905
  }
3906
+
3907
+ if (processedAny) {
3908
+ await this._updateLastNotificationTime();
3909
+ }
3910
+ }
3911
+
3912
+ async _updateLastNotificationTime() {
3913
+ try {
3914
+ await this.redis.hset(this.getAccountKey(), 'outlookLastNotification', Date.now().toString());
3915
+ } catch (err) {
3916
+ this.logger.error({ msg: 'Failed to update last notification time', err });
3917
+ }
3918
+ }
3919
+
3920
+ /**
3921
+ * Recover messages missed due to failed webhook delivery.
3922
+ * Triggered by MS Graph 'missed' lifecycle event.
3923
+ * Queries recent messages and processes any not already seen via normal webhooks.
3924
+ * @returns {boolean} Whether recovery ran (false if debounced)
3925
+ */
3926
+ async syncMissedMessages() {
3927
+ // Debounce: skip if recovery already ran recently
3928
+ const cooldownKey = `${REDIS_PREFIX}iam:${this.account}:missedRecovery`;
3929
+ const acquired = await this.redis.set(cooldownKey, '1', 'EX', 300, 'NX');
3930
+ if (!acquired) {
3931
+ this.logger.debug({ msg: 'Missed notification recovery skipped (cooldown active)', account: this.account });
3932
+ return false;
3933
+ }
3934
+
3935
+ // Determine lookback window
3936
+ let sinceTime;
3937
+ const lastNotification = await this.redis.hget(this.getAccountKey(), 'outlookLastNotification');
3938
+ if (lastNotification) {
3939
+ // 2-minute safety margin before last known good notification
3940
+ sinceTime = new Date(Number(lastNotification) - 2 * 60 * 1000);
3941
+ } else {
3942
+ sinceTime = new Date(Date.now() - 30 * 60 * 1000);
3943
+ }
3944
+
3945
+ this.logger.info({
3946
+ msg: 'Recovering missed notifications',
3947
+ account: this.account,
3948
+ since: sinceTime.toISOString()
3949
+ });
3950
+
3951
+ let messages = [];
3952
+ let reqUrl = `/${this.oauth2UserPath}/messages`;
3953
+ let reqPayload = {
3954
+ $filter: `receivedDateTime gt ${sinceTime.toISOString()}`,
3955
+ $select: 'id,parentFolderId',
3956
+ $top: 250,
3957
+ $orderby: 'receivedDateTime desc'
3958
+ };
3959
+
3960
+ try {
3961
+ while (reqUrl) {
3962
+ let pageResult = await this.request(reqUrl, 'get', reqPayload);
3963
+
3964
+ if (pageResult?.value?.length) {
3965
+ messages.push(...pageResult.value);
3966
+ if (messages.length >= 1000) {
3967
+ messages.length = 1000;
3968
+ break;
3969
+ }
3970
+ }
3971
+
3972
+ if (pageResult['@odata.nextLink']) {
3973
+ reqUrl = pageResult['@odata.nextLink'];
3974
+ reqPayload = null; // nextLink includes all query params
3975
+ } else {
3976
+ reqUrl = null;
3977
+ }
3978
+ }
3979
+ } catch (err) {
3980
+ this.logger.error({ msg: 'Failed to query recent messages for missed notification recovery', account: this.account, err });
3981
+ await this.redis.del(cooldownKey);
3982
+ return false;
3983
+ }
3984
+
3985
+ if (!messages.length) {
3986
+ this.logger.debug({ msg: 'No recent messages found during missed notification recovery', account: this.account });
3987
+ return true;
3988
+ }
3989
+
3990
+ this.logger.info({
3991
+ msg: 'Found recent messages for missed notification recovery',
3992
+ account: this.account,
3993
+ count: messages.length
3994
+ });
3995
+
3996
+ let newMessageOptions = await this.getMessageFetchOptions();
3997
+ newMessageOptions.showPath = true;
3998
+
3999
+ // Pre-resolve unique folder IDs once to avoid per-message Redis lookups
4000
+ let folderMap = new Map();
4001
+ let cachedListing = await this.getCachedMailboxListing();
4002
+ if (cachedListing) {
4003
+ for (let msg of messages) {
4004
+ if (msg.parentFolderId && !folderMap.has(msg.parentFolderId)) {
4005
+ let folder = cachedListing.find(entry => entry.id === msg.parentFolderId);
4006
+ if (folder) {
4007
+ folderMap.set(msg.parentFolderId, folder);
4008
+ }
4009
+ }
4010
+ }
4011
+ }
4012
+
4013
+ let recoveredCount = 0;
4014
+
4015
+ for (const msg of messages) {
4016
+ try {
4017
+ // Pre-filter: check rollingBucketLock BEFORE the expensive getMessage() call
4018
+ let preFilterChecked = false;
4019
+ let folder = folderMap.get(msg.parentFolderId);
4020
+ if (folder) {
4021
+ let recentlySeen = await this.rollingBucketLock(`${msg.id}:created`, folder.pathName);
4022
+ if (recentlySeen) {
4023
+ continue;
4024
+ }
4025
+ preFilterChecked = true;
4026
+ }
4027
+
4028
+ let messageData = await this.prepareNewMessage(msg.id, newMessageOptions);
4029
+ if (!messageData) {
4030
+ continue;
4031
+ }
4032
+
4033
+ if (!preFilterChecked) {
4034
+ let recentlySeen = await this.rollingBucketLock(`${messageData.id}:created`, messageData.path);
4035
+ if (recentlySeen) {
4036
+ continue;
4037
+ }
4038
+ }
4039
+
4040
+ await this.processNew(messageData, newMessageOptions);
4041
+ recoveredCount++;
4042
+ } catch (err) {
4043
+ this.logger.error({ msg: 'Failed to process message during missed notification recovery', emailId: msg.id, account: this.account, err });
4044
+ }
4045
+ }
4046
+
4047
+ this.logger.info({
4048
+ msg: 'Missed notification recovery completed',
4049
+ account: this.account,
4050
+ checked: messages.length,
4051
+ recovered: recoveredCount
4052
+ });
4053
+
4054
+ await this._updateLastNotificationTime();
4055
+
4056
+ return true;
3903
4057
  }
3904
4058
 
3905
4059
  /**
package/lib/passkeys.js CHANGED
@@ -48,6 +48,47 @@ async function fetchCredentialsBySet(setKey) {
48
48
  return credentials;
49
49
  }
50
50
 
51
+ redis.defineCommand('webauthnSaveIfUnderLimit', {
52
+ numberOfKeys: 3,
53
+ lua: `
54
+ local userSetKey = KEYS[1]
55
+ local credKey = KEYS[2]
56
+ local allSetKey = KEYS[3]
57
+ local maxCount = tonumber(ARGV[1])
58
+ local credId = ARGV[2]
59
+
60
+ local currentCount = redis.call('SCARD', userSetKey)
61
+ if currentCount >= maxCount then
62
+ return 0
63
+ end
64
+
65
+ redis.call('SADD', userSetKey, credId)
66
+ redis.call('SADD', allSetKey, credId)
67
+ redis.call('HSET', credKey, unpack(ARGV, 3))
68
+ return 1
69
+ `
70
+ });
71
+
72
+ function serializeCredential({ id, publicKey, counter, transports, name, user }) {
73
+ return {
74
+ id,
75
+ publicKey,
76
+ counter: String(counter),
77
+ transports: JSON.stringify(transports || []),
78
+ name: name || 'Unnamed passkey',
79
+ user,
80
+ createdAt: new Date().toISOString()
81
+ };
82
+ }
83
+
84
+ function credentialKeys(id, user) {
85
+ return {
86
+ credKey: `${KEY_PREFIX}cred:${id}`,
87
+ userSetKey: `${KEY_PREFIX}creds:${user}`,
88
+ allSetKey: `${KEY_PREFIX}all`
89
+ };
90
+ }
91
+
51
92
  module.exports = {
52
93
  async getRpConfig() {
53
94
  let serviceUrl = await settings.get('serviceUrl');
@@ -73,26 +114,26 @@ module.exports = {
73
114
  return challenge || null;
74
115
  },
75
116
 
76
- async saveCredential({ id, publicKey, counter, transports, name, user }) {
77
- let credKey = `${KEY_PREFIX}cred:${id}`;
78
- let userSetKey = `${KEY_PREFIX}creds:${user}`;
79
- let allSetKey = `${KEY_PREFIX}all`;
117
+ async saveCredential(cred) {
118
+ let { credKey, userSetKey, allSetKey } = credentialKeys(cred.id, cred.user);
80
119
 
81
120
  let pipeline = redis.pipeline();
82
- pipeline.hset(credKey, {
83
- id,
84
- publicKey,
85
- counter: String(counter),
86
- transports: JSON.stringify(transports || []),
87
- name: name || 'Unnamed passkey',
88
- user,
89
- createdAt: new Date().toISOString()
90
- });
91
- pipeline.sadd(userSetKey, id);
92
- pipeline.sadd(allSetKey, id);
121
+ pipeline.hset(credKey, serializeCredential(cred));
122
+ pipeline.sadd(userSetKey, cred.id);
123
+ pipeline.sadd(allSetKey, cred.id);
93
124
  await pipeline.exec();
94
125
  },
95
126
 
127
+ async saveCredentialIfUnderLimit(cred, maxCount) {
128
+ let { credKey, userSetKey, allSetKey } = credentialKeys(cred.id, cred.user);
129
+ let fields = serializeCredential(cred);
130
+ let fieldArgs = Object.entries(fields).flat();
131
+
132
+ let added = await redis.webauthnSaveIfUnderLimit(userSetKey, credKey, allSetKey, maxCount, cred.id, ...fieldArgs);
133
+
134
+ return !!added;
135
+ },
136
+
96
137
  async getCredential(credentialId) {
97
138
  if (!credentialId || typeof credentialId !== 'string') {
98
139
  return null;
package/lib/routes-ui.js CHANGED
@@ -4258,20 +4258,22 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
4258
4258
  }
4259
4259
 
4260
4260
  let authData = await settings.get('authData');
4261
- if (authData && authData.password) {
4262
- if (!request.payload.password) {
4263
- return h.response({ error: 'Current password is required' }).code(403);
4264
- }
4265
- let valid;
4266
- try {
4267
- valid = await pbkdf2.verify(authData.password, request.payload.password);
4268
- } catch (err) {
4269
- request.logger.error({ msg: 'Failed to verify password for passkey registration', err });
4270
- valid = false;
4271
- }
4272
- if (!valid) {
4273
- return h.response({ error: 'Invalid password' }).code(403);
4274
- }
4261
+ if (!authData || !authData.password) {
4262
+ return h.response({ error: 'Account password must be configured before registering passkeys' }).code(403);
4263
+ }
4264
+
4265
+ if (!request.payload.password) {
4266
+ return h.response({ error: 'Current password is required' }).code(403);
4267
+ }
4268
+ let valid;
4269
+ try {
4270
+ valid = await pbkdf2.verify(authData.password, request.payload.password);
4271
+ } catch (err) {
4272
+ request.logger.error({ msg: 'Failed to verify password for passkey registration', err });
4273
+ valid = false;
4274
+ }
4275
+ if (!valid) {
4276
+ return h.response({ error: 'Invalid password' }).code(403);
4275
4277
  }
4276
4278
 
4277
4279
  let { rpId, origin } = await passkeys.getRpConfig();
@@ -4359,19 +4361,26 @@ ${Buffer.from(data.content, 'base64url').toString('base64')}
4359
4361
  let { credential } = verification.registrationInfo;
4360
4362
  let user = (request.auth && request.auth.credentials && request.auth.credentials.user) || 'admin';
4361
4363
 
4362
- let credentialCount = await passkeys.countCredentials(user);
4363
- if (credentialCount >= consts.MAX_PASSKEYS_PER_USER) {
4364
- return h.response({ error: 'Maximum number of passkeys reached' }).code(400);
4364
+ let authData = await settings.get('authData');
4365
+ if (!authData || !authData.password) {
4366
+ return h.response({ error: 'Account password must be configured before registering passkeys' }).code(403);
4365
4367
  }
4366
4368
 
4367
- await passkeys.saveCredential({
4368
- id: credential.id,
4369
- publicKey: Buffer.from(credential.publicKey).toString('base64url'),
4370
- counter: credential.counter,
4371
- transports: credential.transports || [],
4372
- name: request.payload.name,
4373
- user
4374
- });
4369
+ let saved = await passkeys.saveCredentialIfUnderLimit(
4370
+ {
4371
+ id: credential.id,
4372
+ publicKey: Buffer.from(credential.publicKey).toString('base64url'),
4373
+ counter: credential.counter,
4374
+ transports: credential.transports || [],
4375
+ name: request.payload.name,
4376
+ user
4377
+ },
4378
+ consts.MAX_PASSKEYS_PER_USER
4379
+ );
4380
+
4381
+ if (!saved) {
4382
+ return h.response({ error: 'Maximum number of passkeys reached' }).code(400);
4383
+ }
4375
4384
 
4376
4385
  request.logger.info({
4377
4386
  msg: 'Passkey registered',