From 262007e0f25845595910f501f3b7338436649dcf Mon Sep 17 00:00:00 2001 From: Fiodar Morau Date: Thu, 22 Jan 2026 20:10:28 +0300 Subject: [PATCH 1/5] feature: handle async work with KeyStorage. Reports all login errors --- src/Data.ts | 6 +- src/index.ts | 1 + src/user/Accounts.ts | 8 +- src/user/User.ts | 181 ++++++++++++++++++++++++++++++++++--------- 4 files changed, 152 insertions(+), 44 deletions(-) diff --git a/src/Data.ts b/src/Data.ts index f819c50..06e4c3a 100644 --- a/src/Data.ts +++ b/src/Data.ts @@ -7,9 +7,9 @@ import { import type DDP from '../lib/ddp'; export type KeyStorage = { - getItem(key: string): Promise | string | null; - setItem(key: string, value: string): Promise | void; - removeItem(key: string): Promise | void; + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; }; export type LoggerPayload = object | string; diff --git a/src/index.ts b/src/index.ts index dce70c2..b27e06d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,4 +12,5 @@ const { useTracker, withTracker, Mongo, ReactiveDict } = MeteorAugmented; export { useTracker, Accounts, withTracker, Mongo, ReactiveDict, Tracker }; export { Vent } from './vent'; +export type { LoginFailurePayload } from './user/User'; export default MeteorAugmented; diff --git a/src/user/Accounts.ts b/src/user/Accounts.ts index 1591c21..3b98608 100644 --- a/src/user/Accounts.ts +++ b/src/user/Accounts.ts @@ -1,6 +1,6 @@ import Data from '../Data'; import call from '../Call'; -import User from './User'; +import User, { type LoginFailurePayload } from './User'; import { hashPassword } from '../../lib/utils'; import Meteor from '../Meteor'; @@ -31,7 +31,7 @@ class AccountsPassword { options.password = hashPassword(options.password) as any; User._startLoggingIn(); - call('createUser', options, (err: any, result: any) => { + call('createUser', options, async (err: any, result: any) => { if (Meteor.isVerbose) { let errText: string; if (err instanceof Error) { @@ -63,7 +63,7 @@ class AccountsPassword { } User._endLoggingIn(); - User._handleLoginCallback(err, result); + await User._handleLoginCallback(err, result); callback(err); }); }; @@ -180,7 +180,7 @@ class AccountsPassword { /** * Register a callback to be called after a login attempt fails. */ - onLoginFailure = (cb: (...args: any[]) => void) => { + onLoginFailure = (cb: (payload: LoginFailurePayload) => void) => { Data.on('onLoginFailure', cb); }; diff --git a/src/user/User.ts b/src/user/User.ts index 063d79e..ee2ac82 100644 --- a/src/user/User.ts +++ b/src/user/User.ts @@ -7,6 +7,17 @@ import type { Collection } from '../Collection'; type UserDoc = { _id: string } & Record & T; +export type LoginFailurePayload = { + error: number | string | undefined; + reason: string | undefined; + userId: string | undefined; + token: string | undefined; + details: Record | string | undefined; + message: string; + stack: string | undefined; + isLogoutTriggered: boolean; +}; + const TOKEN_KEY = 'Meteor.loginToken'; const TOKEN_EXPIRATION_KEY = 'Meteor.loginTokenExpires'; const USER_ID_KEY = 'Meteor.userId'; @@ -14,6 +25,73 @@ const Users = new (Mongo as any).Collection('users') as Collection< UserDoc >; +const DEFAULT_LOGIN_FAILURE_MESSAGE = 'unknown-login-failure'; + +const normalizeLoginFailure = ( + err: unknown, + isLogoutTriggered: boolean, + userId?: string | null, + token?: string | null +): LoginFailurePayload => { + const basePayload: LoginFailurePayload = { + error: undefined, + reason: undefined, + userId: typeof userId === 'string' ? userId : undefined, + token: typeof token === 'string' ? token : undefined, + details: undefined, + message: DEFAULT_LOGIN_FAILURE_MESSAGE, + stack: undefined, + isLogoutTriggered, + }; + + if (err instanceof Error) { + return { + ...basePayload, + message: err.message || basePayload.message, + stack: err.stack, + }; + } + + if (err && typeof err === 'object') { + const error = (err as { error?: unknown }).error; + const reason = (err as { reason?: unknown }).reason; + const details = (err as { details?: unknown }).details; + const message = (err as { message?: unknown }).message; + const stack = (err as { stack?: unknown }).stack; + + return { + ...basePayload, + error: + typeof error === 'number' || typeof error === 'string' + ? error + : undefined, + reason: typeof reason === 'string' ? reason : undefined, + details: + typeof details === 'string' || (details && typeof details === 'object') + ? (details as Record | string) + : undefined, + message: typeof message === 'string' ? message : basePayload.message, + stack: typeof stack === 'string' ? stack : undefined, + }; + } + + if (typeof err === 'string') { + return { + ...basePayload, + message: err, + }; + } + + if (err !== undefined && err !== null) { + return { + ...basePayload, + message: String(err), + }; + } + + return basePayload; +}; + /** * @namespace User * @type {object} @@ -83,13 +161,13 @@ const User = { }, logout(callback?: (err?: any) => void): void { - const finish = (err?: any) => { + const finish = async (err?: any) => { if (err) { User._endLoggingOut(); if (typeof callback === 'function') callback(err); return; } - User.handleLogout(); + await User.handleLogout(); if (typeof callback === 'function') callback(); }; @@ -105,10 +183,14 @@ const User = { } }, - handleLogout(): void { - Data._options.KeyStorage.removeItem(TOKEN_KEY); - Data._options.KeyStorage.removeItem(TOKEN_EXPIRATION_KEY); - Data._options.KeyStorage.removeItem(USER_ID_KEY); + async handleLogout(): Promise { + await Promise.all([ + Data._options.KeyStorage.removeItem(TOKEN_KEY).catch(() => undefined), + Data._options.KeyStorage.removeItem(TOKEN_EXPIRATION_KEY).catch( + () => undefined + ), + Data._options.KeyStorage.removeItem(USER_ID_KEY).catch(() => undefined), + ]); (Data as any)._tokenIdSaved = null; Meteor._reactiveDict.set('isLoggedIn', false); this._reactiveDict.set('_userIdSaved', null); @@ -139,8 +221,8 @@ const User = { user: sel, password: hashPassword(password), }, - (err: any, result: any) => { - User._handleLoginCallback(err, result); + async (err: any, result: any) => { + await User._handleLoginCallback(err, result); if (typeof callback === 'function') callback(err); } ); @@ -167,18 +249,18 @@ const User = { password: hashPassword(password), code, }, - (err: any, result: any) => { - User._handleLoginCallback(err, result); + async (err: any, result: any) => { + await User._handleLoginCallback(err, result); if (typeof callback === 'function') callback(err); } ); }, logoutOtherClients(callback: (err?: any) => void = () => {}): void { - Meteor.call('getNewToken', (err: any, res: any) => { + Meteor.call('getNewToken', async (err: any, res: any) => { if (err) return callback(err); - User._handleLoginCallback(err, res); + await User._handleLoginCallback(err, res); Meteor.call('removeOtherTokens', (err2: any) => { callback(err2); @@ -188,8 +270,8 @@ const User = { _login(user: any, callback?: (err?: any) => void): void { User._startLoggingIn(); - Meteor.call('login', user, (err: any, result: any) => { - User._handleLoginCallback(err, result); + Meteor.call('login', user, async (err: any, result: any) => { + await User._handleLoginCallback(err, result); if (typeof callback === 'function') callback(err); }); }, @@ -214,7 +296,7 @@ const User = { Data.notify('loggingOut'); }, - _handleLoginCallback(err: any, result: any): void { + async _handleLoginCallback(err: any, result: any): Promise { if (!err) { if (Meteor.isVerbose) { Meteor.logger( @@ -224,20 +306,22 @@ const User = { const normalizedExpiration = User._normalizeTokenExpiration(result?.tokenExpires) ?? null; - Data._options.KeyStorage.setItem(TOKEN_KEY, result.token); - if (result?.id !== null) { - Data._options.KeyStorage.setItem(USER_ID_KEY, String(result.id)); - } else { - Data._options.KeyStorage.removeItem(USER_ID_KEY); - } - if (normalizedExpiration) { - Data._options.KeyStorage.setItem( - TOKEN_EXPIRATION_KEY, - normalizedExpiration - ); - } else { - Data._options.KeyStorage.removeItem(TOKEN_EXPIRATION_KEY); - } + await Promise.all([ + Data._options.KeyStorage.setItem(TOKEN_KEY, result.token).catch( + () => undefined + ), + (result?.id !== null + ? Data._options.KeyStorage.setItem(USER_ID_KEY, String(result.id)) + : Data._options.KeyStorage.removeItem(USER_ID_KEY) + ).catch(() => undefined), + (normalizedExpiration + ? Data._options.KeyStorage.setItem( + TOKEN_EXPIRATION_KEY, + normalizedExpiration + ) + : Data._options.KeyStorage.removeItem(TOKEN_EXPIRATION_KEY) + ).catch(() => undefined), + ]); (Data as any)._tokenIdSaved = result.token; User._tokenExpirationSaved = normalizedExpiration; this._reactiveDict.set('_loginTokenExpires', normalizedExpiration); @@ -269,7 +353,10 @@ const User = { } User._endLoggingIn(); // we delegate the error to enable better logging - Data.notify('onLoginFailure', err); + Data.notify( + 'onLoginFailure', + normalizeLoginFailure(err, false, User._userIdSaved) + ); } Data.notify('change'); }, @@ -319,7 +406,7 @@ const User = { this._isCallingLogin = true; User._startLoggingIn(); - const respond = (err: any, result: any) => { + const respond = async (err: any, result: any) => { if (Meteor.isVerbose) { Meteor.logger( `User._loginWithToken::: respond err=${safeStringify( @@ -329,6 +416,7 @@ const User = { } this._isCallingLogin = false; let loginError = err; + const userIdSnapshot = User._userIdSaved; const missingToken = !result || typeof (result as any).token !== 'string' || @@ -352,7 +440,8 @@ const User = { const isResumeRejection = loginError?.error === 403 || loginError?.error === 'token-expired' || - loginError?.error === 'not-authorized'; + loginError?.error === 'not-authorized' || + loginError?.error === 'incorrect-auth-token'; if (Meteor.isVerbose && isResumeRejection) { Meteor.logger( @@ -387,6 +476,12 @@ const User = { } if (isRateLimited) { + const failurePayload = normalizeLoginFailure( + loginError, + false, + userIdSnapshot, + token + ); Meteor.isVerbose && Meteor.logger( `User._handleLoginCallback::: too many requests retrying: ${safeStringify( @@ -403,21 +498,33 @@ const User = { if (User._userIdSaved) return; this._loadInitialUser(); }, (time || 0) + 100); - Data.notify('onLoginFailure', loginError); + Data.notify('onLoginFailure', failurePayload); Data.notify('change'); } else if (isResumeRejection) { + const failurePayload = normalizeLoginFailure( + loginError, + true, + userIdSnapshot, + token + ); this._isTokenLogin = false; Meteor._reactiveDict.set('isLoggedIn', false); - User.handleLogout(); + await User.handleLogout(); User._endLoggingIn(); - Data.notify('onLoginFailure', loginError); + Data.notify('onLoginFailure', failurePayload); Data.notify('change'); } else if (loginError) { + const failurePayload = normalizeLoginFailure( + loginError, + false, + userIdSnapshot, + token + ); // Treat other errors (e.g. transient connection issues) as retryable this._isTokenLogin = true; Meteor._reactiveDict.set('isLoggedIn', false); User._endLoggingIn(); - Data.notify('onLoginFailure', loginError); + Data.notify('onLoginFailure', failurePayload); const retryToken = (Data as any)._tokenIdSaved || token; const delay = this._timeout; @@ -428,7 +535,7 @@ const User = { }, delay); Data.notify('change'); } else { - User._handleLoginCallback(loginError, result); + await User._handleLoginCallback(loginError, result); } callback?.(loginError, result); resolve(); From 5f454004a41fb50c54fa79df3de86c0a058c7ddf Mon Sep 17 00:00:00 2001 From: Fiodar Morau Date: Thu, 22 Jan 2026 20:10:46 +0300 Subject: [PATCH 2/5] release: v2.28.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6a782f5..5200802 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@meteorrn/core", - "version": "2.27.1", + "version": "2.28.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@meteorrn/core", - "version": "2.27.1", + "version": "2.28.0", "license": "MIT", "dependencies": { "@meteorrn/minimongo": "1.0.1", diff --git a/package.json b/package.json index 891e4b6..30490db 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@meteorrn/core", - "version": "2.27.1", + "version": "2.28.0", "description": "Meteor Client for React Native", "type": "module", "main": "dist/src/index.js", From dc42ea7ad0b0ffdca709e2d90629751a997fc38c Mon Sep 17 00:00:00 2001 From: Fiodar Morau Date: Thu, 22 Jan 2026 20:20:19 +0300 Subject: [PATCH 3/5] fix: comments --- src/user/User.ts | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/src/user/User.ts b/src/user/User.ts index ee2ac82..f7183c7 100644 --- a/src/user/User.ts +++ b/src/user/User.ts @@ -21,6 +21,12 @@ export type LoginFailurePayload = { const TOKEN_KEY = 'Meteor.loginToken'; const TOKEN_EXPIRATION_KEY = 'Meteor.loginTokenExpires'; const USER_ID_KEY = 'Meteor.userId'; +const RESUME_REJECTION_ERRORS = [ + 403, + 'token-expired', + 'not-authorized', + 'incorrect-auth-token', +] as const; const Users = new (Mongo as any).Collection('users') as Collection< UserDoc >; @@ -92,6 +98,14 @@ const normalizeLoginFailure = ( return basePayload; }; +const isResumeRejectionError = (value: unknown): boolean => { + if (typeof value !== 'string' && typeof value !== 'number') { + return false; + } + + return RESUME_REJECTION_ERRORS.includes(value); +}; + /** * @namespace User * @type {object} @@ -437,11 +451,7 @@ const User = { } const isRateLimited = loginError?.error == 'too-many-requests'; - const isResumeRejection = - loginError?.error === 403 || - loginError?.error === 'token-expired' || - loginError?.error === 'not-authorized' || - loginError?.error === 'incorrect-auth-token'; + const isResumeRejection = isResumeRejectionError(loginError?.error); if (Meteor.isVerbose && isResumeRejection) { Meteor.logger( From f52027666dbcc87d5ada07f2935da6efd7ccfa5c Mon Sep 17 00:00:00 2001 From: Fiodar Morau Date: Thu, 22 Jan 2026 20:26:49 +0300 Subject: [PATCH 4/5] fix: comments --- src/user/User.ts | 83 ++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 67 insertions(+), 16 deletions(-) diff --git a/src/user/User.ts b/src/user/User.ts index f7183c7..601b2f7 100644 --- a/src/user/User.ts +++ b/src/user/User.ts @@ -103,7 +103,34 @@ const isResumeRejectionError = (value: unknown): boolean => { return false; } - return RESUME_REJECTION_ERRORS.includes(value); + return RESUME_REJECTION_ERRORS.includes( + value as typeof RESUME_REJECTION_ERRORS[number] + ); +}; + +const formatKeyStorageError = (error: unknown): string => { + if (error instanceof Error) { + return error.stack || error.message || String(error); + } + if (typeof error === 'string') return error; + try { + return JSON.stringify(error); + } catch (_stringifyError) { + return String(error); + } +}; + +const logKeyStorageError = ( + operation: 'setItem' | 'removeItem', + key: string, + error: unknown +): void => { + Meteor.logger({ + event: 'key_storage_error', + key, + error: formatKeyStorageError(error), + operation, + }); }; /** @@ -199,11 +226,17 @@ const User = { async handleLogout(): Promise { await Promise.all([ - Data._options.KeyStorage.removeItem(TOKEN_KEY).catch(() => undefined), + Data._options.KeyStorage.removeItem(TOKEN_KEY).catch((error) => { + logKeyStorageError('removeItem', TOKEN_KEY, error); + }), Data._options.KeyStorage.removeItem(TOKEN_EXPIRATION_KEY).catch( - () => undefined + (error) => { + logKeyStorageError('removeItem', TOKEN_EXPIRATION_KEY, error); + } ), - Data._options.KeyStorage.removeItem(USER_ID_KEY).catch(() => undefined), + Data._options.KeyStorage.removeItem(USER_ID_KEY).catch((error) => { + logKeyStorageError('removeItem', USER_ID_KEY, error); + }), ]); (Data as any)._tokenIdSaved = null; Meteor._reactiveDict.set('isLoggedIn', false); @@ -320,21 +353,39 @@ const User = { const normalizedExpiration = User._normalizeTokenExpiration(result?.tokenExpires) ?? null; + const userIdStoragePromise = + result?.id !== null + ? Data._options.KeyStorage.setItem( + USER_ID_KEY, + String(result.id) + ).catch((error) => { + logKeyStorageError('setItem', USER_ID_KEY, error); + }) + : Data._options.KeyStorage.removeItem(USER_ID_KEY).catch((error) => { + logKeyStorageError('removeItem', USER_ID_KEY, error); + }); + + const expirationStoragePromise = normalizedExpiration + ? Data._options.KeyStorage.setItem( + TOKEN_EXPIRATION_KEY, + normalizedExpiration + ).catch((error) => { + logKeyStorageError('setItem', TOKEN_EXPIRATION_KEY, error); + }) + : Data._options.KeyStorage.removeItem(TOKEN_EXPIRATION_KEY).catch( + (error) => { + logKeyStorageError('removeItem', TOKEN_EXPIRATION_KEY, error); + } + ); + await Promise.all([ Data._options.KeyStorage.setItem(TOKEN_KEY, result.token).catch( - () => undefined + (error) => { + logKeyStorageError('setItem', TOKEN_KEY, error); + } ), - (result?.id !== null - ? Data._options.KeyStorage.setItem(USER_ID_KEY, String(result.id)) - : Data._options.KeyStorage.removeItem(USER_ID_KEY) - ).catch(() => undefined), - (normalizedExpiration - ? Data._options.KeyStorage.setItem( - TOKEN_EXPIRATION_KEY, - normalizedExpiration - ) - : Data._options.KeyStorage.removeItem(TOKEN_EXPIRATION_KEY) - ).catch(() => undefined), + userIdStoragePromise, + expirationStoragePromise, ]); (Data as any)._tokenIdSaved = result.token; User._tokenExpirationSaved = normalizedExpiration; From cd503c36182d43913b9f691a66b905d283cf4409 Mon Sep 17 00:00:00 2001 From: Fiodar Morau Date: Thu, 22 Jan 2026 20:27:39 +0300 Subject: [PATCH 5/5] release: v2.28.1 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5200802..78c5606 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@meteorrn/core", - "version": "2.28.0", + "version": "2.28.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@meteorrn/core", - "version": "2.28.0", + "version": "2.28.1", "license": "MIT", "dependencies": { "@meteorrn/minimongo": "1.0.1", diff --git a/package.json b/package.json index 30490db..82e5e74 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@meteorrn/core", - "version": "2.28.0", + "version": "2.28.1", "description": "Meteor Client for React Native", "type": "module", "main": "dist/src/index.js",