'use strict';
const crypto = require('crypto');
const nodeFs = require('fs');
const os = require('os');
/* Require Optional Dependencies */
var fs = nodeFs;
try {
fs = require('fs-extra');
} catch {
// Native PostgreSQL mode only needs the Node.js fs implementation.
}
try {
var unzipper = require('unzipper');
} catch {
// Validated by the legacy RemoteAuth constructor below.
}
let archiverAvailable = false;
try {
require.resolve('archiver');
archiverAvailable = true;
} catch {
// Validated by the legacy RemoteAuth constructor below.
}
let zipArchiveConstructorPromise;
const loadZipArchiveConstructor = async () => {
zipArchiveConstructorPromise ||= import('archiver').then(
({ ZipArchive }) => {
if (typeof ZipArchive !== 'function') {
throw new Error('wwebjs_archiver_zip_export_missing');
}
return ZipArchive;
},
);
return zipArchiveConstructorPromise;
};
const path = require('path');
const { Events } = require('./../util/Constants');
const { isBrowserConnected } = require('./../util/Puppeteer');
const {
BrowserSessionBridge,
compareCanonicalAppStateSyncKeys,
normalizeCanonicalAppStateForProviderHandoff,
normalizeCanonicalAppStateForPortablePersistence,
normalizeCanonicalAuthoritativeAppStateForPortablePersistence,
normalizeCanonicalAppStateForSnapshotResync,
normalizedSnapshotResyncCollections,
reconcileCanonicalAppStateForPersistence,
reconcileCanonicalAppStateSyncKeysForPersistence,
} = require('./../session/BrowserSessionBridge');
const {
CANONICAL_PQ_KEY_MODE_GENERATE_AFTER_RECONNECT,
CANONICAL_PRIVATE_MODULE_ABI,
assertCanonicalTargetProviderCompatibility,
canonicalBrowserProjectionToStore,
canonicalStoreProjectionToBrowser,
} = require('./../session/CanonicalSessionBridge');
const { SESSION_STORE_KIND } = require('./../session/PostgresSessionStore');
const {
WhatsAppSessionCompatibilityError,
} = require('./../session/SessionErrors');
const BaseAuthStrategy = require('./BaseAuthStrategy');
const CANONICAL_WEBSOCKET_GATE_PATH = path.join(
__dirname,
'..',
'session',
'canonical-websocket-gate',
);
const CANONICAL_WEBSOCKET_GATE_RULESET = 'canonical_websocket_gate';
const PROFILE_CHANGED_DURING_CHECKPOINT =
'whatsapp_artifact_profile_changed_during_checkpoint';
const CANONICAL_OFFLINE_CHECKPOINT_TRANSIENT_BLOCKERS = new Set([
'app_state_sync_keys.read_failed',
'app_state_versions.read_failed',
'app_state_versions.row_incompatible',
'app_state_mutation_macs.read_failed',
]);
const comparableJid = (jid) => {
const normalized = String(jid || '')
.trim()
.toLowerCase();
if (!normalized) return '';
const separator = normalized.lastIndexOf('@');
const rawUser =
separator === -1 ? normalized : normalized.slice(0, separator);
const rawServer =
separator === -1 ? 's.whatsapp.net' : normalized.slice(separator + 1);
const server = rawServer === 'c.us' ? 's.whatsapp.net' : rawServer;
return `${rawUser.split(':')[0]}@${server}`;
};
const canonicalDeviceJid = (jid) => {
const normalized = String(jid || '')
.trim()
.toLowerCase();
const separator = normalized.lastIndexOf('@');
if (separator <= 0 || separator === normalized.length - 1) return '';
const local = normalized.slice(0, separator);
const rawServer = normalized.slice(separator + 1);
const server = rawServer === 'c.us' ? 's.whatsapp.net' : rawServer;
return `${local}@${server}`;
};
const canonicalDeviceJidHash = (jid) => {
const canonical = canonicalDeviceJid(jid);
return canonical
? crypto.createHash('sha256').update(canonical).digest('hex')
: null;
};
const hasNonzeroDeviceId = (jid) => {
const canonical = canonicalDeviceJid(jid);
const separator = canonical.lastIndexOf('@');
const match = /:([0-9]+)$/u.exec(canonical.slice(0, separator));
return Boolean(match && Number(match[1]) > 0);
};
const safeFailureCode = (value, fallback) =>
typeof value === 'string' && /^[a-z0-9_.-]{1,100}$/iu.test(value)
? value
: fallback;
const stableNativeFailureCode = (error, fallback) => {
const explicit = safeFailureCode(error?.code, '');
if (explicit) return explicit;
const message = String(error?.message || '');
return /^wwebjs_[a-z0-9_]{1,93}$/u.test(message) ? message : fallback;
};
const CONNECTED_PROFILE_NAVIGATION_SUPERSEDED =
'wwebjs_connected_profile_navigation_superseded';
const TRANSIENT_NAVIGATION_ERROR_CODES = new Set([
CONNECTED_PROFILE_NAVIGATION_SUPERSEDED,
'wwebjs_legacy_volume_navigation_superseded',
]);
const isTransientNavigationError = (error) =>
TRANSIENT_NAVIGATION_ERROR_CODES.has(error?.code) ||
/Execution context was destroyed|Execution context is not available in detached frame|Cannot find context with specified id|Attempted to use detached Frame|Navigating frame was detached|frame was detached|net::ERR_ABORTED/iu.test(
String(error?.message || error),
);
const LEGACY_VOLUME_NAVIGATION_SETTLE_TIMEOUT_MS = 1000;
const LEGACY_VOLUME_NAVIGATION_SETTLE_POLL_MS = 20;
const LEGACY_VOLUME_RUNTIME_CAPABILITY_WAIT_MS = 15000;
const LEGACY_VOLUME_IDENTITY_READ_MAX_ATTEMPTS = 20;
const LEGACY_VOLUME_IDENTITY_READ_RETRY_MS = 250;
const isTransientCompanionIdentityReadError = (error) => {
const code = String(error?.code || error?.message || '');
return (
code === 'wwebjs_companion_identity_read_failed' ||
/^Invariant Violation: Minified invariant #[0-9]+(?:;.*)?$/iu.test(code)
);
};
const safeAggregateErrorDetails = (error) => {
const candidates = [
error?.details,
error?.cause?.details,
...(Array.isArray(error?.errors)
? error.errors.map((entry) => entry?.details)
: []),
];
const aggregate = {};
for (const details of candidates) {
if (!details || typeof details !== 'object' || Array.isArray(details)) {
continue;
}
for (const [key, value] of Object.entries(details)) {
if (
(key === 'status' || key === 'reason') &&
typeof value === 'string' &&
/^[a-z0-9_.-]{1,100}$/iu.test(value)
) {
aggregate[key] ??= value;
continue;
}
if (
/_count$/u.test(key) &&
Number.isSafeInteger(value) &&
value >= 0
) {
aggregate[key] ??= value;
}
}
}
return aggregate;
};
const CANONICAL_APP_STATE_TABLES = [
'whatsapp_app_state_sync_keys',
'whatsapp_app_state_version',
'whatsapp_app_state_mutation_macs',
];
const CANONICAL_APP_STATE_COLLECTIONS = Object.freeze([
'critical_block',
'critical_unblock_low',
'regular',
'regular_high',
'regular_low',
]);
const canonicalBytesEqual = (left, right) => {
const leftBytes = Buffer.from(left || []);
const rightBytes = Buffer.from(right || []);
return leftBytes.equals(rightBytes);
};
const CANONICAL_PROJECTION_TABLE_PRIMARY_KEYS = Object.freeze({
whatsapp_identity_keys: Object.freeze(['their_id']),
whatsapp_pre_keys: Object.freeze(['key_id']),
whatsapp_pq_pre_keys: Object.freeze(['key_id']),
whatsapp_pq_pre_key_state: Object.freeze([]),
whatsapp_signal_sessions: Object.freeze(['their_id', 'scope']),
whatsapp_sender_keys: Object.freeze(['chat_id', 'sender_id']),
whatsapp_app_state_sync_keys: Object.freeze(['key_id']),
whatsapp_app_state_version: Object.freeze(['name']),
whatsapp_app_state_mutation_macs: Object.freeze([
'name',
'index_mac',
'version',
]),
whatsapp_contacts: Object.freeze(['their_jid']),
whatsapp_chat_settings: Object.freeze(['chat_jid']),
whatsapp_message_secrets: Object.freeze([
'chat_jid',
'sender_jid',
'message_id',
]),
whatsapp_privacy_tokens: Object.freeze(['their_jid']),
whatsapp_nct_salt: Object.freeze([]),
whatsapp_lid_map: Object.freeze(['lid']),
whatsapp_event_buffer: Object.freeze(['ciphertext_hash']),
whatsapp_retry_buffer: Object.freeze(['chat_jid', 'message_id']),
});
const canonicalDigestByteView = (value) => {
if (Buffer.isBuffer(value)) return value;
if (value instanceof ArrayBuffer) return Buffer.from(value);
if (ArrayBuffer.isView(value)) {
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
}
return undefined;
};
const createCanonicalDigestWriter = (hash) => {
const lengthBytes = Buffer.allocUnsafe(8);
const numberBytes = Buffer.allocUnsafe(8);
const writeLength = (length) => {
lengthBytes.writeBigUInt64BE(BigInt(length));
hash.update(lengthBytes);
};
const writeTag = (tag) => hash.update(tag, 'ascii');
const writeStringPayload = (value) => {
writeLength(Buffer.byteLength(value, 'utf8'));
hash.update(value, 'utf8');
};
const writeBytesPayload = (value) => {
writeLength(value.byteLength);
hash.update(value);
};
const writeValue = (value, context) => {
const byteView = canonicalDigestByteView(value);
if (byteView) {
writeTag('y');
writeBytesPayload(byteView);
return;
}
if (value === null) {
writeTag('n');
return;
}
if (value === undefined) {
writeTag('u');
return;
}
if (typeof value === 'boolean') {
writeTag(value ? 't' : 'f');
return;
}
if (typeof value === 'number') {
writeTag('d');
numberBytes.writeDoubleBE(value);
hash.update(numberBytes);
return;
}
if (typeof value === 'bigint') {
writeTag('i');
writeStringPayload(value.toString());
return;
}
if (typeof value === 'string') {
writeTag('s');
writeStringPayload(value);
return;
}
if (value instanceof Date) {
writeTag('z');
writeStringPayload(value.toISOString());
return;
}
if (Array.isArray(value)) {
writeTag('a');
writeLength(value.length);
for (const item of value) writeValue(item);
return;
}
if (typeof value !== 'object') {
throw new TypeError('wwebjs_canonical_projection_digest_invalid');
}
const keys = Object.keys(value)
.filter((key) => value[key] !== undefined)
.sort();
writeTag('o');
writeLength(keys.length);
for (const key of keys) {
writeStringPayload(key);
if (context === 'tables') {
if (
!Object.hasOwn(
CANONICAL_PROJECTION_TABLE_PRIMARY_KEYS,
key,
) ||
!Array.isArray(value[key])
) {
throw new TypeError(
'wwebjs_canonical_projection_digest_table_invalid',
);
}
writeCanonicalTableRows(hash, key, value[key]);
continue;
}
writeValue(
value[key],
context === 'projection' &&
(key === 'tables' || key === 'passthrough_tables')
? 'tables'
: undefined,
);
}
};
return writeValue;
};
const canonicalDigestValue = (value) => {
const hash = crypto.createHash('sha256');
createCanonicalDigestWriter(hash)(value);
return hash.digest();
};
const writeCanonicalTableRows = (hash, table, rows) => {
const primaryKey = CANONICAL_PROJECTION_TABLE_PRIMARY_KEYS[table];
const orderedRows = rows
.map((row) => ({
row,
primaryKeyDigest: canonicalDigestValue(
primaryKey.map((column) => row?.[column]),
),
// Invalid duplicate PKs must still compare independently of their
// physical enumeration order. Normalization rejects them later;
// this tie-breaker only keeps the snapshot check deterministic.
rowDigest: canonicalDigestValue(row),
}))
.sort(
(left, right) =>
Buffer.compare(left.primaryKeyDigest, right.primaryKeyDigest) ||
Buffer.compare(left.rowDigest, right.rowDigest),
);
const writeValue = createCanonicalDigestWriter(hash);
hash.update('a', 'ascii');
const lengthBytes = Buffer.allocUnsafe(8);
lengthBytes.writeBigUInt64BE(BigInt(orderedRows.length));
hash.update(lengthBytes);
for (const { row } of orderedRows) writeValue(row);
};
const canonicalProjectionSnapshotDigest = (projection) => {
const hash = crypto.createHash('sha256');
createCanonicalDigestWriter(hash)(projection, 'projection');
return hash.digest();
};
const canonicalProjectionSnapshotsEqual = (left, right) =>
crypto.timingSafeEqual(
canonicalProjectionSnapshotDigest(left),
canonicalProjectionSnapshotDigest(right),
);
const profileProjectionChangedError = (before, after) => {
const syncKeyComparison = compareCanonicalAppStateSyncKeys(before, after);
const safeSyncKeyDetails = Object.fromEntries(
Object.entries(syncKeyComparison).filter(([key, value]) => {
return (
key !== 'status' &&
key !== 'reason' &&
(typeof value === 'number' || typeof value === 'boolean')
);
}),
);
const error = new Error(PROFILE_CHANGED_DURING_CHECKPOINT);
error.code = PROFILE_CHANGED_DURING_CHECKPOINT;
error.details = {
phase: 'canonical_projection_changed',
app_state_tables_changed: !canonicalAppStateTablesEqual(before, after),
...safeSyncKeyDetails,
};
return error;
};
const canonicalAppStateTablesEqual = (left, right) => {
const leftSyncKeys = left?.tables?.whatsapp_app_state_sync_keys;
const rightSyncKeys = right?.tables?.whatsapp_app_state_sync_keys;
const leftVersions = left?.tables?.whatsapp_app_state_version;
const rightVersions = right?.tables?.whatsapp_app_state_version;
const leftMacs = left?.tables?.whatsapp_app_state_mutation_macs;
const rightMacs = right?.tables?.whatsapp_app_state_mutation_macs;
if (
!Array.isArray(leftSyncKeys) ||
!Array.isArray(rightSyncKeys) ||
!Array.isArray(leftVersions) ||
!Array.isArray(rightVersions) ||
!Array.isArray(leftMacs) ||
!Array.isArray(rightMacs) ||
leftSyncKeys.length !== rightSyncKeys.length ||
leftVersions.length !== rightVersions.length ||
leftMacs.length !== rightMacs.length
) {
return false;
}
const syncKeyById = new Map(
leftSyncKeys.map((row) => [
Buffer.from(row.key_id || []).toString('base64'),
row,
]),
);
for (const row of rightSyncKeys) {
const previous = syncKeyById.get(
Buffer.from(row.key_id || []).toString('base64'),
);
if (
!previous ||
previous.timestamp !== row.timestamp ||
!canonicalBytesEqual(previous.key_data, row.key_data) ||
!canonicalBytesEqual(previous.fingerprint, row.fingerprint)
) {
return false;
}
}
const versionByName = new Map(leftVersions.map((row) => [row.name, row]));
for (const row of rightVersions) {
const previous = versionByName.get(row.name);
if (
!previous ||
previous.version !== row.version ||
!canonicalBytesEqual(previous.hash, row.hash)
) {
return false;
}
}
const macByKey = new Map(
leftMacs.map((row) => [
`${row.name}\u0000${row.version}\u0000${Buffer.from(
row.index_mac || [],
).toString('base64')}`,
row,
]),
);
for (const row of rightMacs) {
const key = `${row.name}\u0000${row.version}\u0000${Buffer.from(
row.index_mac || [],
).toString('base64')}`;
const previous = macByKey.get(key);
if (
!previous ||
!canonicalBytesEqual(previous.value_mac, row.value_mac)
) {
return false;
}
}
return true;
};
const expandCanonicalAppStateDirtyPlan = (
dirtyPlan,
{ baseline, browserProjection, persistenceProjection },
) => {
if (!dirtyPlan || dirtyPlan.fullReplace) return dirtyPlan;
const normalizationChangedAppState = !canonicalAppStateTablesEqual(
browserProjection,
persistenceProjection,
);
const persistedAppStateChanged = !canonicalAppStateTablesEqual(
baseline,
persistenceProjection,
);
if (!normalizationChangedAppState && !persistedAppStateChanged) {
return dirtyPlan;
}
if (normalizationChangedAppState) {
return {
...dirtyPlan,
fullReplace: true,
device: true,
tables: [],
};
}
return {
...dirtyPlan,
tables: [
...new Set([...dirtyPlan.tables, ...CANONICAL_APP_STATE_TABLES]),
],
};
};
/**
* Remote-based authentication
* @param {object} options - options
* @param {object} options.store - Remote database store instance
* @param {string} options.clientId - Client id to distinguish instances if you are using multiple, otherwise keep null if you are using only one instance
* @param {string} options.dataPath - Change the default path for saving session files, default is: "./.wwebjs_auth/"
* @param {number} options.backupSyncIntervalMs - Sets the time interval for periodic session backups. Accepts values starting from 60000ms {1 minute}
* @param {number} options.initialSyncDelayMs - Delay before the first checkpoint for a newly paired session
* @param {function} options.identityResolver - Trusted resolver for the companion fingerprint; required for verified native PostgreSQL mode
* @param {boolean} options.requireFingerprintVerification - Fail readiness unless the trusted fingerprint matches. Defaults to true in production.
* @param {boolean} options.allowNoiseMetadataBootstrap - Allow the official WWeb noise metadata regeneration path when importing a native-provider candidate.
* @param {number} options.canonicalFlushDebounceMs - Debounce for critical canonical-only flushes. Defaults to 1000ms.
* @param {number} options.canonicalAppStateCatchUpTimeoutMs - Maximum handoff app-state catch-up time. Defaults to 120000ms.
* @param {number} options.canonicalAppStatePollIntervalMs - App-state watermark polling interval. Defaults to 2000ms.
* @param {number} options.rmMaxRetries - Sets the maximum number of retries for removing the session directory
*/
class RemoteAuth extends BaseAuthStrategy {
constructor({
clientId,
dataPath,
store,
backupSyncIntervalMs,
initialSyncDelayMs = 60000,
identityResolver,
requireFingerprintVerification = process.env.NODE_ENV === 'production',
allowNoiseMetadataBootstrap = false,
canonicalFlushDebounceMs = 1000,
canonicalAppStateCatchUpTimeoutMs = 120000,
canonicalAppStatePollIntervalMs = 2000,
rmMaxRetries,
} = {}) {
const nativePostgres = store?.kind === SESSION_STORE_KIND;
if (
!nativePostgres &&
(!fs.emptyDir || !unzipper || !archiverAvailable)
)
throw new Error(
'Optional Dependencies [fs-extra, unzipper, archiver] are required to use RemoteAuth. Make sure to run npm install correctly and remove the --no-optional flag',
);
super();
const idRegex = /^[-_\w]+$/i;
if (clientId && !idRegex.test(clientId)) {
throw new Error(
'Invalid clientId. Only alphanumeric characters, underscores and hyphens are allowed.',
);
}
if (!backupSyncIntervalMs || backupSyncIntervalMs < 60000) {
throw new Error(
'Invalid backupSyncIntervalMs. Accepts values starting from 60000ms {1 minute}.',
);
}
if (!Number.isFinite(initialSyncDelayMs) || initialSyncDelayMs < 0) {
throw new Error('Invalid initialSyncDelayMs.');
}
if (
!Number.isFinite(canonicalFlushDebounceMs) ||
canonicalFlushDebounceMs < 100 ||
canonicalFlushDebounceMs > 30000
) {
throw new Error('Invalid canonicalFlushDebounceMs.');
}
if (
!Number.isSafeInteger(canonicalAppStateCatchUpTimeoutMs) ||
canonicalAppStateCatchUpTimeoutMs < 1000 ||
canonicalAppStateCatchUpTimeoutMs > 600000
) {
throw new Error('Invalid canonicalAppStateCatchUpTimeoutMs.');
}
if (
!Number.isSafeInteger(canonicalAppStatePollIntervalMs) ||
canonicalAppStatePollIntervalMs < 100 ||
canonicalAppStatePollIntervalMs > 10000 ||
canonicalAppStatePollIntervalMs > canonicalAppStateCatchUpTimeoutMs
) {
throw new Error('Invalid canonicalAppStatePollIntervalMs.');
}
if (!store) throw new Error('Remote database store is required.');
if (
identityResolver !== undefined &&
typeof identityResolver !== 'function'
) {
throw new Error('wwebjs_companion_identity_resolver_invalid');
}
if (
nativePostgres &&
requireFingerprintVerification &&
typeof identityResolver !== 'function'
) {
throw new Error('wwebjs_companion_identity_resolver_required');
}
this.store = store;
this.nativePostgres = nativePostgres;
this.clientId = clientId;
this.backupSyncIntervalMs = backupSyncIntervalMs;
this.initialSyncDelayMs = initialSyncDelayMs;
this.identityResolver = identityResolver;
this.requireFingerprintVerification = requireFingerprintVerification;
this.allowNoiseMetadataBootstrap = allowNoiseMetadataBootstrap === true;
this.canonicalFlushDebounceMs = canonicalFlushDebounceMs;
this.canonicalAppStateCatchUpTimeoutMs =
canonicalAppStateCatchUpTimeoutMs;
this.canonicalAppStatePollIntervalMs = canonicalAppStatePollIntervalMs;
this.providerHandoffCheckpointDrainTimeoutMs = Math.min(
90000,
Math.max(30000, canonicalAppStateCatchUpTimeoutMs),
);
this.dataPath = path.resolve(dataPath || './.wwebjs_auth/');
try {
fs.accessSync(process.cwd(), fs.constants.W_OK);
this.zipDir = process.cwd();
} catch {
this.zipDir = this.dataPath;
}
this.tempDir = `${this.dataPath}/wwebjs_temp_session_${this.clientId}`;
this.requiredDirs = [
'Default',
'IndexedDB',
'Local Storage',
]; /* => Required Files & Dirs in WWebJS to restore session */
this.rmMaxRetries = rmMaxRetries ?? 4;
this.authReady = false;
this.nativeInitialPairingReadyRuntimeEpoch = undefined;
this.nativeReadyCheckpointRuntimeEpoch = undefined;
this.shutdownRequested = false;
this.shutdownPromise = undefined;
this.shutdownProjectionCaptureFailed = false;
this.checkpointTail = Promise.resolve();
this.periodicCheckpoint = undefined;
this.profileDirty = true;
this.criticalCheckpointTimer = undefined;
this.canonicalActivationCheckpointInProgress = false;
this.canonicalActivationCheckpointGeneration = 0;
this.projectionHydrated = false;
this.providerHandoffKey = undefined;
this.providerHandoffPromise = undefined;
this.providerHandoffCheckpointAdmissionClosed = false;
this.preparedProviderHandoff = undefined;
this.effectiveAllowNoiseMetadataBootstrap = false;
this.pendingPostQuantumBootstrap = false;
this.pendingPostQuantumRollbackRecovery = undefined;
this.pendingCanonicalAppStateCatchUp = false;
this.preserveExistingProfileArtifactDuringCanonicalCatchUp = false;
this.pendingCanonicalAppStateSyncKeyCount = 0;
this.pendingCanonicalAppStateProjection = undefined;
this.canonicalActivationReadyProjection = undefined;
this.canonicalActivationUsesPromotedProfileArtifact = false;
this.lastCanonicalProjection = undefined;
this.lastPersistedCanonicalProjection = undefined;
this.initialPairingAuthorized = false;
this.handoffSourceCanonicalProjection = undefined;
this.canonicalReconnectProjection = undefined;
this.canonicalReconnectExpectedRegistrationState = undefined;
this.canonicalSealedReloadRegistrationSummary = undefined;
this.canonicalActivationResumeProjection = undefined;
this.canonicalActiveRestartReplay = false;
this.canonicalActiveRestartAppStateOverlayRequired = false;
this.canonicalActiveRestartCanonicalGeneration = undefined;
this.canonicalActiveRestartAttestedProjection = undefined;
this.canonicalReusableHandoffNativeAppState = false;
this.canonicalNativeAppStateAuthoritative = false;
this.canonicalDeferredOnlineRegisteredLaunch = false;
this.canonicalCandidatePromoted = false;
this.canonicalActivationMarkerPresent = false;
this.canonicalWebSocketGateRequired = false;
this.canonicalWebSocketGateExtensionId = undefined;
this.canonicalWebSocketGateServiceWorker = undefined;
this.canonicalWebSocketGateStagingDir = undefined;
this.canonicalWebSocketGateMonitor = undefined;
this.canonicalWebSocketGateViolation = false;
this.nativeFailurePostBrowserCleanupPromise = undefined;
this.nativeFailureStopPromise = undefined;
this.nativeFailureFinalizationPromise = undefined;
this.nativeFailureBrowserTerminationConfirmed = false;
this.nativeFailureRecoveryProjection = undefined;
}
validateClientOptions(options) {
if (!this.nativePostgres) return;
if (this.store.webVersionIntegrityPinned) {
if (options.webVersion !== this.store.webVersion) {
throw new Error('wwebjs_web_version_incompatible');
}
if (
options.webVersionCache?.type === 'none' ||
options.webVersionCache?.strict !== true
) {
throw new Error('wwebjs_strict_web_cache_required');
}
return;
}
// An unpinned PostgreSQL session always follows the document served by
// WhatsApp. Do not let Client's bundled-cache default silently turn
// that mode back into a fixed build. The bridge binds the actual
// window.Debug.VERSION after its private-module ABI preflight.
options.webVersionCache = { type: 'none' };
}
/**
* Keep the live/latest store binding aligned with the document that
* produced the trusted companion identity. A controlled native-stall
* recovery may reload WhatsApp Web after the earlier ABI preflight and
* the live endpoint can legitimately serve a newer (or adjacent rollout)
* build. getCompanionIdentity() attests the module ABI, web version and
* companion fingerprint from that same document, so an unpinned store may
* safely rebind here. Integrity-pinned stores remain strictly fail-closed.
*/
reconcileResolvedIdentityWebVersion(resolvedIdentity) {
if (!this.requireFingerprintVerification) return;
const actualVersion = resolvedIdentity?.webVersion;
const expectedVersion = this.store.webVersion;
if (actualVersion === expectedVersion) return;
if (this.store.webVersionIntegrityPinned === true) {
const error = new Error(
'wwebjs_companion_identity_web_version_mismatch',
);
error.expected = expectedVersion;
error.actual = actualVersion;
throw error;
}
if (typeof this.store.bindRuntimeWebVersion !== 'function') {
throw new Error('wwebjs_runtime_web_version_binding_unavailable');
}
this.store.bindRuntimeWebVersion(
actualVersion,
resolvedIdentity?.moduleAbi,
);
if (this.store.webVersion !== actualVersion) {
throw new Error('wwebjs_runtime_web_version_binding_failed');
}
this.logNative(
'runtime.identity_web_version_rebound',
{
previous_web_version: expectedVersion,
web_version: actualVersion,
module_abi: resolvedIdentity?.moduleAbi,
integrity_pinned: false,
reason: 'trusted_identity_after_controlled_navigation',
},
{ force: true },
);
}
/**
* A cross-provider candidate must load WhatsApp's runtime before its
* canonical credentials can be written. A dedicated MV3 extension blocks
* every WebSocket at Chromium's request boundary during that first load,
* so the empty bootstrap profile cannot request a pairing ref or race the
* source companion. The extension is removed only while the page is
* offline, after the imported tuple passed its readback gate.
*/
async installCanonicalWebSocketGate() {
if (!this.canonicalWebSocketGateRequired) return false;
const browser = this.client?.pupBrowser;
const page = this.client?.pupPage;
if (
!browser?.process?.() ||
typeof browser.installExtension !== 'function' ||
typeof browser.uninstallExtension !== 'function' ||
typeof page?.createCDPSession !== 'function'
) {
throw new Error(
'wwebjs_canonical_websocket_gate_runtime_unavailable',
);
}
const stagingDir = await nodeFs.promises.mkdtemp(
path.join(os.tmpdir(), 'wwebjs-canonical-ws-gate-'),
);
await nodeFs.promises.chmod(stagingDir, 0o700);
this.canonicalWebSocketGateStagingDir = stagingDir;
try {
for (const filename of [
'manifest.json',
'background.js',
'rules.json',
]) {
const source = path.join(
CANONICAL_WEBSOCKET_GATE_PATH,
filename,
);
nodeFs.accessSync(source, nodeFs.constants.R_OK);
const target = path.join(stagingDir, filename);
await nodeFs.promises.copyFile(source, target);
await nodeFs.promises.chmod(target, 0o600);
}
} catch (error) {
await this.removeCanonicalWebSocketGateStagingDir();
throw error;
}
const monitor = await page.createCDPSession();
await monitor.send('Network.enable');
const state = { created: 0, handshakes: 0 };
const onCreated = () => {
state.created += 1;
};
const onHandshake = () => {
state.handshakes += 1;
this.canonicalWebSocketGateViolation = true;
void page.setOfflineMode(true).catch(() => {});
};
monitor.on('Network.webSocketCreated', onCreated);
monitor.on('Network.webSocketWillSendHandshakeRequest', onHandshake);
monitor.on('Network.webSocketHandshakeResponseReceived', onHandshake);
this.canonicalWebSocketGateMonitor = {
session: monitor,
state,
onCreated,
onHandshake,
};
let extensionId;
try {
extensionId = await browser.installExtension(stagingDir);
} catch (error) {
await this.disposeCanonicalWebSocketGateMonitor();
await this.removeCanonicalWebSocketGateStagingDir();
throw error;
}
if (
typeof extensionId !== 'string' ||
!/^[a-p]{32}$/u.test(extensionId)
) {
await this.disposeCanonicalWebSocketGateMonitor();
await this.removeCanonicalWebSocketGateStagingDir();
throw new Error('wwebjs_canonical_websocket_gate_install_invalid');
}
let serviceWorker;
try {
const serviceWorkerTarget = await browser.waitForTarget(
(target) =>
target.type() === 'service_worker' &&
target.url() ===
`chrome-extension://${extensionId}/background.js`,
{ timeout: 5000 },
);
serviceWorker = await serviceWorkerTarget.worker();
const enabledRulesets = await serviceWorker?.evaluate(() =>
globalThis.chrome.declarativeNetRequest.getEnabledRulesets(),
);
if (
!Array.isArray(enabledRulesets) ||
enabledRulesets.length !== 1 ||
enabledRulesets[0] !== CANONICAL_WEBSOCKET_GATE_RULESET
) {
throw new Error(
'wwebjs_canonical_websocket_gate_ruleset_not_ready',
);
}
} catch (error) {
await browser.uninstallExtension(extensionId).catch(() => {});
await this.disposeCanonicalWebSocketGateMonitor();
await this.removeCanonicalWebSocketGateStagingDir();
throw error;
}
this.canonicalWebSocketGateExtensionId = extensionId;
this.canonicalWebSocketGateServiceWorker = serviceWorker;
this.logNative(
'handoff.initial_websocket_gate_installed',
{ dedicated_browser: true },
{ force: true },
);
return true;
}
assertCanonicalWebSocketGateHeld() {
if (!this.canonicalWebSocketGateRequired) return;
if (
!this.canonicalWebSocketGateExtensionId ||
!this.canonicalWebSocketGateMonitor ||
this.canonicalWebSocketGateViolation
) {
const error = new Error(
'wwebjs_canonical_initial_websocket_gate_violated',
);
error.code = error.message;
throw error;
}
}
async disposeCanonicalWebSocketGateMonitor() {
const monitor = this.canonicalWebSocketGateMonitor;
this.canonicalWebSocketGateMonitor = undefined;
if (!monitor) return;
monitor.session.off?.('Network.webSocketCreated', monitor.onCreated);
monitor.session.off?.(
'Network.webSocketWillSendHandshakeRequest',
monitor.onHandshake,
);
monitor.session.off?.(
'Network.webSocketHandshakeResponseReceived',
monitor.onHandshake,
);
await monitor.session.detach?.().catch(() => {});
}
async removeCanonicalWebSocketGateStagingDir() {
const stagingDir = this.canonicalWebSocketGateStagingDir;
this.canonicalWebSocketGateStagingDir = undefined;
if (!stagingDir) return;
const expectedParent = path.resolve(os.tmpdir());
const resolved = path.resolve(stagingDir);
if (
path.dirname(resolved) !== expectedParent ||
!path.basename(resolved).startsWith('wwebjs-canonical-ws-gate-')
) {
throw new Error(
'wwebjs_canonical_websocket_gate_cleanup_path_invalid',
);
}
await nodeFs.promises.rm(resolved, {
recursive: true,
force: true,
maxRetries: this.rmMaxRetries,
});
}
async releaseCanonicalWebSocketGateOffline() {
if (!this.canonicalWebSocketGateRequired) return false;
if (
!this.canonicalCandidatePromoted ||
this.store.revisionStatus !== 'active'
) {
throw new Error(
'wwebjs_canonical_websocket_gate_release_before_promotion',
);
}
this.assertCanonicalWebSocketGateHeld();
this.browserBridge?.assertNoCanonicalCredentialViolation?.();
// A temporary CDP lifecycle session used by the profile snapshot can
// restore Chromium's physical online signal when it detaches. The MV3
// request gate is still held here, so reassert the target-wide
// Puppeteer override and prove the page is offline before removing
// that final request-boundary protection.
await this.client.pupPage.setOfflineMode(true);
this.assertCanonicalWebSocketGateHeld();
this.browserBridge?.assertNoCanonicalCredentialViolation?.();
const pageOffline =
(await this.client.pupPage.evaluate(
() => navigator.onLine === false,
)) === true;
if (!pageOffline) {
throw new Error(
'wwebjs_canonical_websocket_gate_release_requires_offline',
);
}
const serviceWorker = this.canonicalWebSocketGateServiceWorker;
if (typeof serviceWorker?.evaluate !== 'function') {
throw new Error(
'wwebjs_canonical_websocket_gate_worker_unavailable',
);
}
const blockedSocketAttempts =
this.canonicalWebSocketGateMonitor?.state?.created || 0;
const enabledRulesets = await serviceWorker.evaluate(
async (rulesetId) => {
await globalThis.chrome.declarativeNetRequest.updateEnabledRulesets(
{ disableRulesetIds: [rulesetId] },
);
return globalThis.chrome.declarativeNetRequest.getEnabledRulesets();
},
CANONICAL_WEBSOCKET_GATE_RULESET,
);
if (
!Array.isArray(enabledRulesets) ||
enabledRulesets.includes(CANONICAL_WEBSOCKET_GATE_RULESET)
) {
throw new Error(
'wwebjs_canonical_websocket_gate_disable_not_acknowledged',
);
}
// Extensions.uninstall can wait indefinitely for Chromium's MV3
// service-worker teardown while the browser is running. Disabling the
// only static ruleset is the request-boundary release itself and is
// acknowledged above. Keep the now-inert extension directory until
// Chromium has stopped so cleanup cannot race a live extension.
await this.client.pupPage.setOfflineMode(true);
const remainedOffline =
(await this.client.pupPage.evaluate(
() => navigator.onLine === false,
)) === true;
if (!remainedOffline || this.canonicalWebSocketGateViolation) {
throw new Error(
'wwebjs_canonical_websocket_gate_release_not_quiesced',
);
}
this.browserBridge?.assertNoCanonicalCredentialViolation?.();
this.canonicalWebSocketGateExtensionId = undefined;
this.canonicalWebSocketGateServiceWorker = undefined;
await this.disposeCanonicalWebSocketGateMonitor();
this.logNative(
'handoff.initial_websocket_gate_released',
{
offline: true,
blocked_socket_attempts: blockedSocketAttempts,
},
{ force: true },
);
return true;
}
async beforeBrowserInitialized() {
this.shutdownRequested = false;
this.shutdownPromise = undefined;
this.shutdownProjectionCaptureFailed = false;
this.nativeFailureStopPromise = undefined;
this.nativeFailureFinalizationPromise = undefined;
this.nativeFailurePostBrowserCleanupPromise = undefined;
this.nativeFailureBrowserTerminationConfirmed = false;
this.nativeFailureRecoveryProjection = undefined;
this.nativeInitialPairingReadyRuntimeEpoch = undefined;
this.nativeReadyCheckpointRuntimeEpoch = undefined;
this.projectionHydrated = false;
this.shutdownProjection = undefined;
this.effectiveAllowNoiseMetadataBootstrap = false;
this.pendingPostQuantumBootstrap = false;
this.pendingPostQuantumRollbackRecovery = undefined;
this.pendingCanonicalAppStateCatchUp = false;
this.preserveExistingProfileArtifactDuringCanonicalCatchUp = false;
this.pendingCanonicalAppStateSyncKeyCount = 0;
this.pendingCanonicalAppStateProjection = undefined;
this.canonicalActivationReadyProjection = undefined;
this.canonicalActivationUsesPromotedProfileArtifact = false;
this.lastCanonicalProjection = undefined;
this.lastPersistedCanonicalProjection = undefined;
this.initialPairingAuthorized = false;
this.handoffSourceCanonicalProjection = undefined;
this.canonicalReconnectProjection = undefined;
this.canonicalReconnectExpectedRegistrationState = undefined;
this.canonicalSealedReloadRegistrationSummary = undefined;
this.canonicalActivationResumeProjection = undefined;
this.canonicalActiveRestartReplay = false;
this.canonicalActiveRestartAppStateOverlayRequired = false;
this.canonicalActiveRestartCanonicalGeneration = undefined;
this.canonicalActiveRestartAttestedProjection = undefined;
this.canonicalReusableHandoffNativeAppState = false;
this.canonicalNativeAppStateAuthoritative = false;
this.canonicalCandidatePromoted = false;
this.canonicalActivationMarkerPresent = false;
this.canonicalWebSocketGateRequired = false;
this.canonicalWebSocketGateExtensionId = undefined;
this.canonicalWebSocketGateServiceWorker = undefined;
this.canonicalWebSocketGateMonitor = undefined;
this.canonicalWebSocketGateViolation = false;
await this.browserBridge?.close();
await this.removeCanonicalWebSocketGateStagingDir();
this.browserBridge = undefined;
const puppeteerOpts = this.client.options.puppeteer;
const sessionDirName = this.clientId
? `RemoteAuth-${this.clientId}`
: 'RemoteAuth';
const dirPath = path.join(this.dataPath, sessionDirName);
if (
puppeteerOpts.userDataDir &&
puppeteerOpts.userDataDir !== dirPath
) {
throw new Error(
'RemoteAuth is not compatible with a user-supplied userDataDir.',
);
}
this.userDataDir = dirPath;
this.sessionName = sessionDirName;
if (this.nativePostgres) {
await this.store.open({
onLeaseLost: (error) => this.handleLeaseLoss(error),
});
try {
let activeActivationNoiseMetadataBootstrapRequired = false;
await this.extractNativeSession();
this.canonicalWebSocketGateRequired =
this.store.isHandoffRevision?.() === true;
if (!this.canonicalWebSocketGateRequired) {
const pendingActivation =
await this.store.loadPendingCanonicalActivation?.();
if (pendingActivation) {
const pendingProjection = pendingActivation.projection;
if (!pendingProjection) {
throw new Error(
'wwebjs_canonical_activation_projection_missing',
);
}
this.handoffSourceCanonicalProjection =
pendingProjection;
this.canonicalActivationResumeProjection =
pendingProjection;
this.pendingCanonicalAppStateCatchUp = Boolean(
pendingActivation.appStateHydrationRequired,
);
this.canonicalActivationMarkerPresent = true;
this.pendingCanonicalAppStateSyncKeyCount =
pendingProjection.tables
?.whatsapp_app_state_sync_keys?.length || 0;
this.pendingPostQuantumBootstrap = Boolean(
pendingActivation.pqBootstrapRequired,
);
this.canonicalCandidatePromoted = true;
activeActivationNoiseMetadataBootstrapRequired =
pendingActivation.noiseMetadataBootstrapRequired ===
true;
// Active activation recovery must replay the latest
// canonical DB projection before its first WebSocket.
this.canonicalWebSocketGateRequired = true;
this.logNative(
'canonical_activation.resumed',
{
pending_sync_key_count:
this.pendingCanonicalAppStateSyncKeyCount,
pq_bootstrap_required:
this.pendingPostQuantumBootstrap,
},
{ force: true },
);
} else if (this.store.revisionStatus === 'active') {
if (
typeof this.store.loadCanonicalRestartAuthority !==
'function'
) {
throw new Error(
'wwebjs_profile_anchor_restart_authority_unavailable',
);
}
let restartAuthority =
await this.store.loadCanonicalRestartAuthority();
let activeProjection = restartAuthority?.projection;
if (!activeProjection?.device?.jid) {
throw new Error(
'wwebjs_active_canonical_projection_missing',
);
}
const portableRestartProjection =
normalizeCanonicalAppStateForPortablePersistence(
activeProjection,
);
if (
portableRestartProjection.snapshot_resync_collection_count >
0
) {
const repaired =
await this.store.saveCanonicalProjection({
projection:
portableRestartProjection.projection,
source: 'restart_app_state_snapshot_resync_repair',
});
activeProjection =
portableRestartProjection.projection;
restartAuthority = {
...restartAuthority,
projection: activeProjection,
appStateOverlayRequired: true,
canonicalGeneration:
repaired.canonicalGeneration ??
restartAuthority.canonicalGeneration,
};
this.logNative(
'canonical_restart.app_state_source_repaired',
{
snapshot_resync_collection_count:
portableRestartProjection.snapshot_resync_collection_count,
removed_version_row_count:
portableRestartProjection.removed_version_row_count,
removed_mutation_mac_count:
portableRestartProjection.removed_mutation_mac_count,
collection_ids:
portableRestartProjection.collection_ids,
canonical_generation:
restartAuthority.canonicalGeneration,
},
{ force: true },
);
}
this.handoffSourceCanonicalProjection =
activeProjection;
this.canonicalActivationResumeProjection =
activeProjection;
this.pendingPostQuantumBootstrap =
activeProjection.capabilities
?.pq_server_rollback_acknowledged === true;
this.canonicalActiveRestartReplay = true;
this.canonicalActiveRestartAppStateOverlayRequired =
restartAuthority.appStateOverlayRequired === true;
this.canonicalActiveRestartCanonicalGeneration =
restartAuthority.canonicalGeneration;
// Canonical-only critical checkpoints can be newer
// than the last profile artifact. Replay the database
// projection under the first-WebSocket gate on every
// active PostgreSQL restart.
this.canonicalWebSocketGateRequired = true;
this.logNative(
'canonical_restart.replay_required',
{
canonical_record_count:
activeProjection.record_count,
profile_artifact_id:
restartAuthority.profileArtifactId,
canonical_generation:
restartAuthority.canonicalGeneration,
legacy_authority:
restartAuthority.legacy === true,
app_state_overlay_required:
this
.canonicalActiveRestartAppStateOverlayRequired,
pq_bootstrap_required:
this.pendingPostQuantumBootstrap,
},
{ force: true },
);
}
}
this.effectiveAllowNoiseMetadataBootstrap = Boolean(
this.allowNoiseMetadataBootstrap &&
(this.canonicalActivationMarkerPresent === true
? activeActivationNoiseMetadataBootstrapRequired
: this.store.requiresNoiseMetadataBootstrap()),
);
this.logNative('handoff.noise_bootstrap_policy_resolved', {
requested: this.allowNoiseMetadataBootstrap,
effective: this.effectiveAllowNoiseMetadataBootstrap,
source_provider: this.store.sourceProvider,
revision_source: this.store.revisionSource,
active_activation_resume:
this.canonicalActivationMarkerPresent === true,
});
const pendingPqRollback =
await this.store.loadPendingPostQuantumServerRollback?.();
if (pendingPqRollback) {
this.pendingPostQuantumRollbackRecovery = pendingPqRollback;
this.pendingPostQuantumBootstrap = true;
// Keep the first WebSocket behind the canonical gate. An
// intent means the delete RPC may have reached WhatsApp,
// even when no ACK was durably recorded before a crash.
this.canonicalWebSocketGateRequired = true;
this.logNative(
'handoff.pq_source_recovery_resumed',
{
marker_state: pendingPqRollback.state,
handoff_id: pendingPqRollback.handoff_id,
},
{ force: true },
);
}
if (
this.canonicalWebSocketGateRequired ||
this.canonicalActivationMarkerPresent
) {
if (
puppeteerOpts.browser ||
puppeteerOpts.page ||
puppeteerOpts.browserWSEndpoint ||
puppeteerOpts.browserURL
) {
throw new Error(
'wwebjs_canonical_activation_dedicated_browser_required',
);
}
if (
puppeteerOpts.protocol &&
puppeteerOpts.protocol !== 'cdp'
) {
throw new Error(
'wwebjs_canonical_websocket_gate_cdp_required',
);
}
if (
(puppeteerOpts.args || []).some((argument) =>
String(argument).startsWith('--disable-extensions'),
)
) {
throw new Error(
'wwebjs_canonical_websocket_gate_extensions_disabled',
);
}
}
} catch (error) {
throw await this.finalizeNativeFailure(
error,
'handoff_profile_restore_failed',
);
}
} else {
await this.extractRemoteSession();
}
this.client.options.puppeteer = {
...puppeteerOpts,
userDataDir: dirPath,
...(this.canonicalWebSocketGateRequired
? {
pipe: true,
enableExtensions: true,
protocol: 'cdp',
}
: {}),
};
}
async afterBrowserInitialized() {
if (!this.nativePostgres) return;
try {
await this.installCanonicalWebSocketGate();
} catch (error) {
throw await this.finalizeNativeFailure(
error,
'handoff_initial_websocket_gate_failed',
);
}
this.browserBridge = new BrowserSessionBridge({
page: this.client.pupPage,
expectedVersion: this.store.webVersion,
enforceExpectedVersion: this.store.webVersionIntegrityPinned,
logger: this.store.logger,
onCriticalMutation: () => this.scheduleCriticalCheckpoint(),
onCanonicalProviderState: async (providerState) => {
this.pendingCanonicalProviderState = providerState;
},
allowNoiseMetadataBootstrap:
this.effectiveAllowNoiseMetadataBootstrap,
});
const inheritedPageNetworkFence =
this.browserBridge.networkQuiesced === true;
try {
await this.browserBridge.install();
if (this.canonicalWebSocketGateRequired) {
// Install the navigation-scoped guard on about:blank, before
// Client performs the first WhatsApp navigation. This closes
// the bootstrap window where an empty profile can rotate or
// clear credentials before the canonical database projection
// is hydrated. The MV3 gate independently blocks all WSS.
await this.browserBridge.armCanonicalCredentialGuard({
ttlMs: 300000,
navigationScoped: true,
});
} else if (inheritedPageNetworkFence) {
// BrowserSessionBridge.close() fences the Page/CDP target,
// while a server-side logout deliberately reuses that Page.
// Release only a fence inherited from the previous bridge;
// canonical handoff/replay startup must remain offline.
await this.browserBridge.resumeNetwork();
this.logNative(
'browser_bridge.reused_page_network_resumed',
{
revision_status: this.store.revisionStatus,
revision_source: this.store.revisionSource,
},
{ force: true },
);
}
} catch (error) {
throw await this.finalizeNativeFailure(
error,
'handoff_browser_bridge_install_failed',
);
}
}
async beforeClientInjected() {
if (!this.nativePostgres || this.projectionHydrated) return;
try {
const stagedProviderHandoff =
this.store.isHandoffRevision?.() === true;
// The PostgreSQL revision is the authoritative handoff signal.
// Do not let a caller's ordinary authentication deadline shrink
// the guarded offline import/reload path back to 30 seconds.
const canonicalRuntimeTimeoutMs = stagedProviderHandoff
? 120000
: Math.min(this.client.options.authTimeoutMs || 30000, 120000);
const canonicalModuleTimeoutMs = stagedProviderHandoff
? 60000
: Math.min(this.client.options.authTimeoutMs || 30000, 60000);
const canonicalRegistrationRepairTimeoutMs = stagedProviderHandoff
? 30000
: Math.min(this.client.options.authTimeoutMs || 30000, 30000);
this.assertCanonicalWebSocketGateHeld();
await this.browserBridge.waitForRuntimeStable({
timeoutMs: canonicalRuntimeTimeoutMs,
});
const compatibility = await this.browserBridge.assertCompatible({
waitForModulesMs: canonicalModuleTimeoutMs,
capabilityStage: 'initial_runtime',
});
this.store.bindRuntimeWebVersion(
compatibility.actualVersion,
compatibility.moduleAbi,
);
const activationResumeProjection =
this.canonicalActivationResumeProjection;
const resumingCanonicalActivation =
this.canonicalActivationMarkerPresent;
// A durable activation marker means commitActivation() already
// promoted the target-local profile in an earlier runtime. Keep
// that artifact through final readiness; its canonical overlay
// is sufficient to replay the live browser delta, while rotating
// the artifact again would evict the handoff-protected recovery
// anchor before finalizeActivation() can close the fence.
this.canonicalActivationUsesPromotedProfileArtifact =
resumingCanonicalActivation;
const replayingActiveRevision = this.canonicalActiveRestartReplay;
const allowEstablishedOfflineState =
resumingCanonicalActivation || replayingActiveRevision;
const offlineSocketStates = allowEstablishedOfflineState
? ['OPENING', 'TIMEOUT', 'UNLAUNCHED']
: ['OPENING', 'UNLAUNCHED'];
let canonicalProjection =
activationResumeProjection ||
(await this.store.consumePendingCanonicalProjection());
if (
!canonicalProjection &&
this.canonicalWebSocketGateRequired &&
this.store.revisionSource === 'secure_import'
) {
const providerProjection =
await this.store.consumePendingProjection();
if (!providerProjection) {
throw new Error(
'wwebjs_secure_import_provider_projection_missing',
);
}
if (
providerProjection.web_version !==
compatibility.actualVersion
) {
throw new Error(
'wwebjs_secure_import_web_version_mismatch',
);
}
// The provider projection came from an authenticated Chrome
// tab, not from a trusted canonical store. Restore it while
// both network layers are fenced, reload with only HTTP
// bootstrap traffic enabled, and derive the canonical form
// before the existing activation path is allowed to proceed.
await this.browserBridge.quiesceNetwork();
await this.browserBridge.armCanonicalCredentialGuard({
ttlMs: 300000,
navigationScoped: true,
});
await this.browserBridge.importProjection(providerProjection);
this.projectionHydrated = true;
this.assertCanonicalWebSocketGateHeld();
const bootstrapReload =
await this.browserBridge.resumeNetworkAndReload({
timeoutMs: canonicalRuntimeTimeoutMs,
});
this.assertCanonicalWebSocketGateHeld();
await this.browserBridge.waitForRuntimeStable({
timeoutMs: canonicalRuntimeTimeoutMs,
});
const bootstrapCompatibility =
await this.browserBridge.assertCompatible({
waitForModulesMs: canonicalModuleTimeoutMs,
capabilityStage: 'secure_import_browser_bootstrap',
});
if (
bootstrapCompatibility.actualVersion !==
providerProjection.web_version
) {
throw new Error(
'wwebjs_secure_import_web_version_mismatch',
);
}
this.store.bindRuntimeWebVersion(
bootstrapCompatibility.actualVersion,
bootstrapCompatibility.moduleAbi,
);
await this.browserBridge.quiesceNetwork();
await this.browserBridge.armCanonicalCredentialGuard({
ttlMs: 300000,
navigationScoped: true,
});
const exportedCanonical =
await this.browserBridge.exportCanonicalProjection({
allowIncompleteAppStateOverlay: true,
});
const persisted =
await this.store.persistExternalBrowserBootstrapCanonicalProjection(
exportedCanonical,
);
canonicalProjection = persisted.projection;
this.logNative(
'secure_import.browser_projection_canonicalized',
{
web_version: bootstrapCompatibility.actualVersion,
record_count: canonicalProjection.record_count,
size_bytes: canonicalProjection.size_bytes,
navigation_source: bootstrapReload.navigationSource,
explicit_reload_count:
bootstrapReload.explicitReloadCount,
},
{ force: true },
);
}
if (canonicalProjection) {
this.markCanonicalProjectionPersisted(canonicalProjection);
this.handoffSourceCanonicalProjection = canonicalProjection;
await this.browserBridge.quiesceNetwork();
// BrowserSessionBridge proves a full Baileys ML-KEM inventory
// in the Node realm before this page task starts. Keep one
// bounded import deadline for every staged projection; the
// renderer no longer receives a four-minute crypto allowance.
const canonicalImportTaskTimeoutMs = 120000;
const credentialGuardTtlMs = Math.min(
300000,
Math.max(
240000,
canonicalImportTaskTimeoutMs + 60000,
(this.client.options.authTimeoutMs || 30000) * 4,
),
);
await this.browserBridge.armCanonicalCredentialGuard({
ttlMs: credentialGuardTtlMs,
navigationScoped: true,
});
const preserveExistingAppState =
replayingActiveRevision ||
this.store.canPreserveReusableHandoffProfileAppState?.(
canonicalProjection,
) === true;
this.canonicalReusableHandoffNativeAppState = Boolean(
preserveExistingAppState && !replayingActiveRevision,
);
let preserveExistingSignalTables = false;
let preserveExistingSignalComponents;
if (this.canonicalReusableHandoffNativeAppState) {
const signalTablePreservation =
await this.resolveReusableHandoffSignalTablePreservation(
canonicalProjection,
);
if (signalTablePreservation === true) {
preserveExistingSignalTables = true;
} else if (
signalTablePreservation &&
typeof signalTablePreservation === 'object'
) {
preserveExistingSignalComponents =
signalTablePreservation;
}
}
const imported =
await this.browserBridge.importCanonicalProjection(
canonicalProjection,
{
allowNoiseMetadataBootstrap:
this.effectiveAllowNoiseMetadataBootstrap,
requireRoutingInfo:
this.effectiveAllowNoiseMetadataBootstrap,
preserveExistingAppState,
preserveExistingSignalTables,
...(preserveExistingSignalComponents
? { preserveExistingSignalComponents }
: {}),
// A staged cross-provider handoff may spend longer
// than Puppeteer's single Runtime.callFunctionOn
// deadline while IndexedDB initializes. Run that
// import as a monitored page task; ordinary active
// revision replay keeps the direct fast path.
...(stagedProviderHandoff
? {
backgroundTask: true,
backgroundTaskTimeoutMs:
canonicalImportTaskTimeoutMs,
}
: {}),
},
);
if (stagedProviderHandoff) {
// The background task owns most of its TTL at high PQ
// cardinality. Renew immediately after the successful
// proof so registration inspection and the controlled
// reload never inherit only the timeout margin.
await this.browserBridge.armCanonicalCredentialGuard({
ttlMs: credentialGuardTtlMs,
navigationScoped: true,
});
}
if (this.pendingCanonicalProviderState) {
await this.store.saveCanonicalProviderState(
this.pendingCanonicalProviderState,
);
this.pendingCanonicalProviderState = undefined;
}
if (
imported.reload_required !== false ||
imported.reconnect_required !== true
) {
throw new Error(
'wwebjs_canonical_import_reconnect_contract_invalid',
);
}
this.pendingPostQuantumBootstrap =
imported.pq_key_mode ===
CANONICAL_PQ_KEY_MODE_GENERATE_AFTER_RECONNECT ||
Boolean(this.pendingPostQuantumRollbackRecovery);
this.pendingCanonicalAppStateCatchUp =
imported.app_state_import_mode ===
'deferred_authenticated_full_sync' &&
(imported.pending_app_state_sync_keys > 0 ||
canonicalProjection.tables.whatsapp_app_state_version
.length > 0 ||
canonicalProjection.capabilities
?.app_state_snapshot_resync_required === true);
this.pendingCanonicalAppStateSyncKeyCount = Number(
imported.pending_app_state_sync_keys || 0,
);
if (
this.pendingCanonicalAppStateCatchUp &&
this.pendingCanonicalAppStateSyncKeyCount <= 0
) {
throw new Error(
'wwebjs_canonical_app_state_sync_keys_missing',
);
}
const preReconnectRegistration =
await this.browserBridge.inspectCanonicalRegistrationState();
const preReconnectSummary =
this.summarizeCanonicalRegistrationState(
preReconnectRegistration,
canonicalProjection,
imported.expected_registration_state,
);
this.logNative(
'handoff.registration_state.pre_reconnect',
preReconnectSummary,
{ force: true },
);
this.assertCanonicalRegistrationState(preReconnectSummary);
this.assertCanonicalWebSocketGateHeld();
const canonicalReload =
await this.browserBridge.reloadCanonicalImportedRuntime({
timeoutMs: canonicalRuntimeTimeoutMs,
waitForModulesMs: canonicalModuleTimeoutMs,
credentialGuardTtlMs,
// A current WWeb bootstrap may clear its registered
// tuple while the MV3 gate deliberately blocks WSS.
// This only permits the reload to return offline; the
// source projection is fully resealed and strictly
// validated below before the gate can be released.
allowRegistrationRepair: true,
});
if (stagedProviderHandoff) {
// The reload bridge rearms before its ABI and online/offline
// proofs, which may consume up to 90 seconds. Reset the
// full TTL here so the second 120-second import/reseal can
// never inherit that spent window.
await this.browserBridge.armCanonicalCredentialGuard({
ttlMs: credentialGuardTtlMs,
navigationScoped: true,
});
}
this.assertCanonicalWebSocketGateHeld();
const websocketHandshakeCount =
this.canonicalWebSocketGateMonitor?.state?.handshakes || 0;
if (websocketHandshakeCount !== 0) {
throw new Error(
'wwebjs_canonical_reload_websocket_handshake_detected',
);
}
if (canonicalReload.explicitReloadCount !== 1) {
throw new Error('wwebjs_canonical_reload_count_invalid');
}
this.store.bindRuntimeWebVersion(
canonicalReload.actualVersion,
canonicalReload.moduleAbi,
);
const sealedReloadContinuity =
this.canUseCanonicalSealedReloadContinuity({
stagedProviderHandoff,
resumingCanonicalActivation,
replayingActiveRevision,
preserveExistingAppState,
imported,
canonicalReload,
websocketHandshakeCount,
});
let resealed = imported;
let postReloadSummary;
if (sealedReloadContinuity) {
const continuityState =
await this.browserBridge.inspectCanonicalReloadContinuityState(
{ requireAcknowledged: false },
);
this.canonicalSealedReloadRegistrationSummary =
Object.freeze({ ...preReconnectSummary });
postReloadSummary =
this.mergeCanonicalSealedReloadRegistrationSummary(
continuityState,
);
this.logNative(
'handoff.reseal_skipped_by_reload_continuity',
{
proof_source:
'pre_reload_cryptographic_seal_and_navigation_guard',
signal_storage_initialization_mode:
imported.signal_storage_initialization_mode,
document_epoch_changed: true,
explicit_reload_count: 1,
websocket_handshake_count: 0,
},
{ force: true },
);
} else {
resealed =
await this.browserBridge.importCanonicalProjection(
canonicalProjection,
{
allowNoiseMetadataBootstrap:
this.effectiveAllowNoiseMetadataBootstrap,
requireRoutingInfo:
this.effectiveAllowNoiseMetadataBootstrap,
// When the first import had to construct WWeb-only
// noise metadata, reuse that exact recovery token.
// Generating a second one would make the DB/source
// projection differ from the final document.
noiseBootstrap:
imported.generated_provider_state,
preserveExistingAppState,
preserveExistingSignalTables,
...(preserveExistingSignalComponents
? { preserveExistingSignalComponents }
: {}),
...(stagedProviderHandoff
? {
backgroundTask: true,
backgroundTaskTimeoutMs:
canonicalImportTaskTimeoutMs,
}
: {}),
},
);
this.assertCanonicalResealContract(imported, resealed);
if (stagedProviderHandoff) {
// Reset the full post-reseal window before any provider
// state write, optional registration repair, artifact
// upload, activation CAS or reconnect preparation.
await this.browserBridge.armCanonicalCredentialGuard({
ttlMs: credentialGuardTtlMs,
navigationScoped: true,
});
}
if (this.pendingCanonicalProviderState) {
await this.store.saveCanonicalProviderState(
this.pendingCanonicalProviderState,
);
this.pendingCanonicalProviderState = undefined;
}
const postReloadRegistration =
await this.browserBridge.inspectCanonicalRegistrationState();
postReloadSummary =
this.summarizeCanonicalRegistrationState(
postReloadRegistration,
canonicalProjection,
resealed.expected_registration_state,
);
}
this.logNative(
'handoff.registration_state.post_reload',
{
...postReloadSummary,
proof_source: sealedReloadContinuity
? 'sealed_reload_continuity'
: 'fresh_signal_read',
document_epoch_changed: true,
explicit_reload_count: 1,
websocket_handshake_count: websocketHandshakeCount,
},
{ force: true },
);
this.assertCanonicalRegistrationState(postReloadSummary);
if (
canonicalReload.registrationRepairRequired === true ||
canonicalReload.runtimeBootstrapRequired === true ||
replayingActiveRevision
) {
if (
replayingActiveRevision &&
canonicalReload.registrationRepairRequired !== true
) {
// A restored active profile already owns a valid
// registration tuple. Even when the reload initially
// reports WAComms + OPENING, that offline bootstrap can
// collapse immediately after network release into a
// model-only CONNECTED state with no live comms
// stream. Stop that provisional runtime while WSS is
// still fenced and dispatch one official high-level
// launch only after the network is live.
await this.browserBridge.deferCanonicalRegisteredLaunchOffline();
this.canonicalDeferredOnlineRegisteredLaunch = true;
} else {
await this.browserBridge.repairCanonicalRegistrationOffline(
{
timeoutMs: canonicalRegistrationRepairTimeoutMs,
},
);
}
const repairedRegistration =
await this.browserBridge.inspectCanonicalRegistrationState();
const repairedSummary =
this.summarizeCanonicalRegistrationState(
repairedRegistration,
canonicalProjection,
resealed.expected_registration_state,
);
this.assertCanonicalRegistrationState(repairedSummary);
this.assertCanonicalReconnectSafetyState(repairedSummary, {
requireConnected: false,
allowedSocketStates: ['OPENING', 'UNLAUNCHED'],
});
this.logNative(
'handoff.registration_state.post_reload_repaired',
repairedSummary,
{ force: true },
);
}
await this.browserBridge.acknowledgeCanonicalCredentialProtection();
this.assertCanonicalWebSocketGateHeld();
this.projectionHydrated = true;
this.canonicalReconnectProjection = canonicalProjection;
this.canonicalReconnectExpectedRegistrationState =
resealed.expected_registration_state;
this.canonicalActivationResumeProjection = undefined;
await this.browserBridge.prepareCanonicalCandidateOffline({
allowEstablished: allowEstablishedOfflineState,
});
for (let probe = 1; probe <= 3; probe += 1) {
await this.delay(250);
await this.assertCanonicalCandidateReady(
`offline_stability_${probe}`,
{
requireConnected: false,
allowedSocketStates: offlineSocketStates,
allowSealedReloadContinuityProof:
sealedReloadContinuity,
},
);
}
this.preserveExistingProfileArtifactDuringCanonicalCatchUp =
replayingActiveRevision || resumingCanonicalActivation;
if (replayingActiveRevision) {
if (
resealed.app_state_import_mode !==
'preserved_native_profile' ||
resealed.pending_app_state_sync_keys !== 0
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_active_restart_native_app_state_invalid',
);
}
if (this.canonicalActiveRestartAppStateOverlayRequired) {
this.pendingCanonicalAppStateSyncKeyCount =
canonicalProjection.tables
?.whatsapp_app_state_sync_keys?.length || 0;
if (this.pendingCanonicalAppStateSyncKeyCount <= 0) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_app_state_sync_keys_missing',
);
}
this.pendingCanonicalAppStateCatchUp = true;
this.canonicalNativeAppStateAuthoritative = false;
this.logNative(
'canonical_restart.app_state_overlay_required',
{
canonical_generation:
this
.canonicalActiveRestartCanonicalGeneration,
sync_key_count:
this.pendingCanonicalAppStateSyncKeyCount,
app_state_import_mode:
resealed.app_state_import_mode,
},
{ force: true },
);
} else {
// The typed authority proves the profile's app-state
// baseline and current canonical generation agree.
// Only that case may keep the restored native tables
// authoritative without an authenticated overlay.
this.canonicalNativeAppStateAuthoritative = true;
this.logNative(
'canonical_restart.profile_artifact_preserved',
{
reason: 'authority_app_state_coherent',
canonical_generation:
this
.canonicalActiveRestartCanonicalGeneration,
app_state_import_mode:
resealed.app_state_import_mode,
},
{ force: true },
);
}
} else if (this.canonicalReusableHandoffNativeAppState) {
await this.checkpointReusableCanonicalHandoffOffline({
resealed,
preserveExistingProfileArtifact:
resumingCanonicalActivation,
...(sealedReloadContinuity
? {
trustedCanonicalProjection:
canonicalProjection,
}
: {}),
});
} else if (!resumingCanonicalActivation) {
await this.checkpointCanonicalCandidateOffline({
source: this.store.revisionSource || 'handoff',
preserveExistingProfileArtifact: false,
});
} else {
// commit_whatsapp_session_activation() promotes the
// staging artifact before the authenticated app-state
// overlay starts. A post-PONR retry must keep that exact
// last-known-good profile: taking another snapshot from
// the partially materialized live profile both races the
// journal and changes the anchor that recovery is meant
// to attest.
this.logNative(
'checkpoint.offline_candidate_profile_preserved',
{
reason: 'activation_resume_app_state_pending',
},
{ force: true },
);
}
// A staging/handoff revision needs this first complete
// candidate artifact. From this point onward it is also the
// last-known-good rollback source and must not be replaced by
// the partially materialized live Chromium profile.
if (this.pendingCanonicalAppStateCatchUp) {
this.preserveExistingProfileArtifactDuringCanonicalCatchUp = true;
}
await this.assertCanonicalCandidateReady(
'offline_after_artifact_upload',
{
requireConnected: false,
allowedSocketStates: offlineSocketStates,
allowSealedReloadContinuityProof:
sealedReloadContinuity,
},
);
if (!replayingActiveRevision) {
await this.store.markCanonicalActivationPending({
appStateHydrationRequired:
this.pendingCanonicalAppStateCatchUp,
pqBootstrapRequired: this.pendingPostQuantumBootstrap,
});
this.canonicalActivationMarkerPresent = true;
}
await this.store.renewLease();
this.store.assertLocalLease();
await this.assertCanonicalCandidateReady(
'offline_before_promotion',
{
requirePublicEventGate: true,
requireConnected: false,
allowedSocketStates: offlineSocketStates,
allowSealedReloadContinuityProof:
sealedReloadContinuity,
},
);
this.assertCanonicalWebSocketGateHeld();
if (!replayingActiveRevision && !resumingCanonicalActivation) {
await this.store.commitActivation();
}
if (this.store.revisionStatus !== 'active') {
throw new Error(
'wwebjs_canonical_offline_promotion_not_active',
);
}
this.canonicalCandidatePromoted = true;
await this.releaseCanonicalWebSocketGateOffline();
// Extension removal is a Chromium-level transition. Re-run
// the bridge quiescence barrier so its page-level state and
// journal are synchronized before the in-page reconnect ABI
// checks navigator.onLine.
await this.browserBridge.quiesceNetwork();
const reconnect =
await this.browserBridge.rearmCanonicalImportedSocket({
timeoutMs: Math.min(
this.client.options.authTimeoutMs || 30000,
30000,
),
allowEstablished: allowEstablishedOfflineState,
});
await this.browserBridge.releaseCanonicalImportedNetwork();
this.canonicalDeferredOnlineRegisteredLaunch = false;
const appStateCatchUpRequired =
this.pendingCanonicalAppStateCatchUp;
await this.browserBridge.waitForCanonicalConnected({
timeoutMs: Math.min(
180000,
Math.max(
60000,
(this.client.options.authTimeoutMs || 30000) * 4,
),
),
// Imported app-state keys cannot be restored until the
// authenticated transport exists. Requiring hasSynced at
// this point creates a circular gate: WhatsApp waits for
// those keys while the bridge waits for WhatsApp.
requireSynced: !appStateCatchUpRequired,
});
await this.completePendingCanonicalAppStateBeforeReadiness();
// ONLINE and public READY remain fail-closed: after restoring
// app-state, require a second fresh proof with hasSynced=true.
if (appStateCatchUpRequired) {
await this.browserBridge.waitForCanonicalConnected({
timeoutMs: Math.min(
30000,
this.canonicalAppStateCatchUpTimeoutMs,
),
requireSynced: true,
});
}
await this.assertCanonicalCandidateReady('promoted_connected');
this.canonicalSealedReloadRegistrationSummary = undefined;
// Keep both credential and pairing guards armed through the
// final offline activation checkpoint. They are released by
// checkpointCanonicalActivationReady only after the durable
// artifact, activation CAS, reconnect, and final identity
// proof have all succeeded.
this.logNative('handoff.browser_reconnect_resumed', {
projection_kind: 'canonical',
previous_socket_state: reconnect.previous_socket_state,
socket_state: reconnect.socket_state,
promoted_offline: true,
bootstrapped_by_reload:
reconnect.bootstrapped_by_reload === true,
explicit_reload_count: 1,
});
return;
}
if (this.canonicalWebSocketGateRequired) {
throw new Error('wwebjs_canonical_handoff_projection_missing');
}
const projection = await this.store.consumePendingProjection();
if (!projection) return;
await this.browserBridge.quiesceNetwork();
await this.browserBridge.importProjection(projection);
this.projectionHydrated = true;
this.logNative('handoff.browser_reload_started');
const reload = await this.browserBridge.resumeNetworkAndReload({
timeoutMs: canonicalRuntimeTimeoutMs,
});
const reloadedCompatibility =
await this.browserBridge.assertCompatible({
waitForModulesMs: canonicalModuleTimeoutMs,
capabilityStage: 'legacy_reload',
});
this.store.bindRuntimeWebVersion(
reloadedCompatibility.actualVersion,
reloadedCompatibility.moduleAbi,
);
this.logNative('handoff.browser_reload_completed', {
navigation_source: reload.navigationSource,
explicit_reload_count: reload.explicitReloadCount,
});
} catch (error) {
throw await this.finalizeNativeFailure(
error,
stableNativeFailureCode(
error,
'handoff_browser_hydration_failed',
),
);
}
}
/**
* Stop every local writer before a rollback or lease release. Concurrent
* failure paths share one promise so Chromium and the bridge are closed at
* most once during an initialization lifecycle.
*/
isCanonicalActivationRecoveryRequired() {
return Boolean(
this.nativePostgres &&
!this.authReady &&
// Journal callbacks remain active while the canonical projection
// is imported and while its one controlled reload is settling.
// Never persist that transient browser state: an export can become
// structurally complete before every source record has been
// restored. The dirty journal is deliberately retained and is
// covered by the explicit post-hydration checkpoint instead.
this.projectionHydrated &&
this.store.revisionStatus === 'active' &&
(this.canonicalActivationMarkerPresent ||
this.canonicalCandidatePromoted),
);
}
isCanonicalActiveRestartAttestationPending() {
return Boolean(
this.nativePostgres &&
!this.authReady &&
this.canonicalActiveRestartReplay,
);
}
/**
* Returns only in-memory evidence backed by the durable active-revision
* activation marker. The Client uses this during failure cleanup to
* advertise forward recovery without ever claiming ONLINE or exposing a
* replacement QR after the point of no return.
*/
isPostPointOfNoReturnRecoveryPending() {
return Boolean(
this.nativePostgres &&
!this.authReady &&
this.store.revisionStatus === 'active' &&
(this.canonicalActivationMarkerPresent ||
this.canonicalCandidatePromoted),
);
}
async captureCanonicalActivationRecoveryProjection() {
if (!this.isCanonicalActivationRecoveryRequired()) return undefined;
this.store.assertLocalLease();
// While app-state is catching up, the browser can legitimately expose
// half-applied collection rows. The last offline checkpoint is already
// fenced, identity-validated and contains the complete source state,
// so it is the only safe canonical recovery projection. Chromium is
// still captured after its process is stopped below.
if (
this.pendingCanonicalAppStateCatchUp &&
this.lastCanonicalProjection
) {
await this.browserBridge.flush();
return {
projection: this.lastCanonicalProjection,
dirtyPlan: this.browserBridge.getCanonicalDirtyPlan?.(),
};
}
let lastError;
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
await this.browserBridge.flush();
const dirtyPlan = this.browserBridge.getCanonicalDirtyPlan?.();
const browserProjection =
await this.browserBridge.exportCanonicalProjection();
const persistenceCandidate = this
.pendingCanonicalAppStateCatchUp
? this.buildCanonicalOfflineCheckpointProjection(
browserProjection,
)
: browserProjection;
const projection =
this.reconcileCanonicalProjectionForPersistence(
persistenceCandidate,
{
baseline:
this.canonicalActivationReadyProjection ||
this.getPersistedCanonicalProjection(),
preserveNativeAppState: Boolean(
this.canonicalActivationReadyProjection,
),
},
);
const tail = await this.browserBridge.flush();
const finalDirtyPlan =
this.browserBridge.getCanonicalDirtyPlan?.();
const canonicalTailChanged =
tail?.critical === true ||
(dirtyPlan &&
finalDirtyPlan &&
finalDirtyPlan.generation !== dirtyPlan.generation);
if (canonicalTailChanged) {
throw new Error(
'wwebjs_canonical_activation_recovery_tail_unstable',
);
}
if (tail?.dirty) {
this.logNative(
'checkpoint.activation_recovery_noncritical_tail',
{
event_count: tail.events?.length || 0,
dropped_count: tail.dropped || 0,
local_storage_event_count:
tail.events?.filter(
(event) => event?.area === 'local_storage',
).length || 0,
indexeddb_event_count:
tail.events?.filter(
(event) => event?.area === 'indexeddb',
).length || 0,
canonical_table_count:
finalDirtyPlan?.tables?.length || 0,
canonical_tables: finalDirtyPlan?.tables || [],
},
{ force: true },
);
}
return { projection, dirtyPlan };
} catch (error) {
lastError = error;
this.logNative(
'checkpoint.activation_recovery_capture_retry',
{
attempt,
error_code: safeFailureCode(
error?.code || error?.message,
'wwebjs_canonical_activation_recovery_capture_retry',
),
},
{ force: true },
);
if (attempt < 3) await this.delay(25);
}
}
throw new Error('wwebjs_canonical_activation_recovery_capture_failed', {
cause: lastError,
});
}
async terminateNativeBrowser(browser) {
if (!browser) return true;
const child = browser.process?.();
const childTrackable = Boolean(
child &&
('exitCode' in child ||
'signalCode' in child ||
typeof child.kill === 'function'),
);
const childExited = () =>
childTrackable &&
(child.exitCode !== null && child.exitCode !== undefined
? true
: child.signalCode !== null && child.signalCode !== undefined);
let closeError;
try {
if (isBrowserConnected(browser)) {
if (typeof browser.close !== 'function') {
throw new Error('wwebjs_browser_close_unavailable');
}
await browser.close();
}
} catch (error) {
closeError = error;
}
if (childTrackable && !childExited()) {
try {
if (typeof child.kill !== 'function') {
throw new Error('wwebjs_browser_process_kill_unavailable');
}
child.kill('SIGKILL');
if (!childExited() && typeof child.once === 'function') {
await new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
clearTimeout(timeout);
resolve();
};
const timeout = setTimeout(finish, 5000);
child.once('exit', finish);
child.once('close', finish);
});
}
} catch (error) {
closeError = closeError
? new AggregateError(
[closeError, error],
'wwebjs_browser_termination_failed',
)
: error;
}
}
const confirmed = childTrackable
? childExited()
: !isBrowserConnected(browser);
if (!confirmed) {
throw new Error('wwebjs_browser_termination_unconfirmed', {
cause: closeError,
});
}
if (closeError) {
this.logNativeFailure('client.failure_browser_close_recovered', {
error: closeError,
});
}
return true;
}
async stopNativeFailureWriters() {
if (!this.nativePostgres) return [];
this.shutdownRequested = true;
this.authReady = false;
this.stopBackupSync();
if (this.nativeFailureStopPromise) {
return this.nativeFailureStopPromise;
}
const operation = (async () => {
const failures = [];
const page = this.client?.pupPage;
let offline = false;
let recovery = this.nativeFailureRecoveryProjection;
let networkCutTimeout;
try {
if (typeof page?.setOfflineMode !== 'function') {
throw new Error(
'wwebjs_failure_network_quiescence_unavailable',
);
}
await Promise.race([
page.setOfflineMode(true),
new Promise((_, reject) => {
networkCutTimeout = setTimeout(
() =>
reject(
new Error(
'wwebjs_failure_network_quiescence_timeout',
),
),
2000,
);
}),
]);
offline = true;
} catch (error) {
this.logNativeFailure('client.failure_network_cut_failed', {
error,
});
} finally {
clearTimeout(networkCutTimeout);
}
if (
!recovery &&
offline &&
this.isCanonicalActivationRecoveryRequired()
) {
if (
this.pendingCanonicalAppStateCatchUp ||
this
.preserveExistingProfileArtifactDuringCanonicalCatchUp ||
this.canonicalActivationUsesPromotedProfileArtifact
) {
// An active-revision replay deliberately keeps the last
// known-good profile artifact untouched until its live
// canonical projection has passed the stability and
// identity gates. Capturing the half-settled browser here
// cannot improve recovery and, if export itself fails,
// must not hide the primary initialization failure.
this.logNative(
'checkpoint.activation_recovery_profile_preserved',
{
reason: 'active_restart_live_attestation_failed',
},
{ force: true },
);
} else {
try {
recovery =
await this.captureCanonicalActivationRecoveryProjection();
this.nativeFailureRecoveryProjection = recovery;
} catch (error) {
failures.push(error);
this.logNativeFailure(
'checkpoint.activation_recovery_capture_failed',
{ error },
);
}
}
}
const browser = this.client?.pupBrowser;
let browserStopped = false;
try {
browserStopped = await this.terminateNativeBrowser(browser);
this.nativeFailureBrowserTerminationConfirmed = browserStopped;
} catch (error) {
failures.push(error);
this.logNativeFailure('client.failure_browser_close_failed', {
error,
offline,
});
this.nativeFailureBrowserTerminationConfirmed = false;
}
// Browser termination is deliberately before bridge.close(). A
// blocked journal flush can delay cleanup, but can no longer keep
// a stale fenced writer online.
if (browserStopped) {
try {
await this.browserBridge?.close?.();
} catch (error) {
failures.push(error);
this.logNativeFailure(
'client.failure_bridge_close_failed',
{ error },
);
}
}
if (recovery && browserStopped) {
if (
this.pendingCanonicalAppStateCatchUp ||
this
.preserveExistingProfileArtifactDuringCanonicalCatchUp ||
this.canonicalActivationUsesPromotedProfileArtifact
) {
this.nativeFailureRecoveryProjection = undefined;
this.logNative(
'checkpoint.activation_recovery_profile_preserved',
{
reason: 'active_restart_app_state_pending',
},
{ force: true },
);
} else {
try {
const checkpoint = await this.store.checkpointProfile({
profilePath: this.userDataDir,
canonicalProjection: recovery.projection,
source: 'checkpoint',
promote: false,
});
this.browserBridge?.acknowledgeCanonicalDirtyPlan?.(
recovery.dirtyPlan,
);
this.markCanonicalProjectionPersisted(
recovery.projection,
);
this.nativeFailureRecoveryProjection = undefined;
this.profileDirty = false;
this.logNative(
'checkpoint.activation_recovery_completed',
{
artifact_id: checkpoint.artifactId,
canonical_record_count:
recovery.projection.record_count,
},
{ force: true },
);
} catch (error) {
failures.push(error);
this.logNativeFailure(
'checkpoint.activation_recovery_persist_failed',
{ error },
);
}
}
} else if (recovery && !browserStopped) {
const error = new Error(
'wwebjs_canonical_activation_recovery_browser_not_stopped',
);
failures.push(error);
this.logNativeFailure(
'checkpoint.activation_recovery_persist_failed',
{ error },
);
}
if (browserStopped) {
await this.disposeCanonicalWebSocketGateMonitor().catch(
(error) => {
failures.push(error);
},
);
await this.removeCanonicalWebSocketGateStagingDir().catch(
(error) => {
failures.push(error);
},
);
this.canonicalWebSocketGateExtensionId = undefined;
this.canonicalWebSocketGateServiceWorker = undefined;
}
return failures;
})();
this.nativeFailureStopPromise = operation;
void operation
.finally(() => {
if (
!this.nativeFailureBrowserTerminationConfirmed &&
this.nativeFailureStopPromise === operation
) {
this.nativeFailureStopPromise = undefined;
}
})
.catch(() => {});
return operation;
}
/**
* Fail closed while the fencing lease is still owned: stop browser writes,
* attempt the handoff rollback, and release the lease only after Chromium
* termination is proven. Cleanup failures are aggregated with the primary
* error using fixed messages so credentials or key material never become
* log/event labels.
*/
async finalizeNativeFailure(primaryError, errorCode) {
if (!this.nativePostgres) return primaryError;
if (!this.nativeFailureFinalizationPromise) {
const rollbackCode = safeFailureCode(
errorCode,
'wwebjs_native_session_failure',
);
const primaryErrorNames = new Set([
'AggregateError',
'Error',
'TypeError',
'WhatsAppSessionCompatibilityError',
'WhatsAppSessionError',
'WhatsAppSessionLeaseLostError',
]);
this.logNativeFailure('client.failure_primary', {
error_code: stableNativeFailureCode(
primaryError,
'wwebjs_native_session_primary_failure',
),
error_name: primaryErrorNames.has(primaryError?.name)
? primaryError.name
: 'Error',
cleanup_code: rollbackCode,
});
const operation = (async () => {
const failures = [...(await this.stopNativeFailureWriters())];
try {
if (
this.nativeFailureBrowserTerminationConfirmed &&
!this.canonicalCandidatePromoted &&
this.store.isHandoffRevision()
) {
await this.rollbackHandoff(rollbackCode);
}
} catch (error) {
failures.push(error);
} finally {
if (!this.nativeFailureBrowserTerminationConfirmed) {
if (
!failures.some(
(error) =>
error?.message ===
'wwebjs_browser_termination_unconfirmed',
)
) {
failures.push(
new Error(
'wwebjs_browser_termination_unconfirmed',
),
);
}
this.logNativeFailure(
'client.failure_lease_release_blocked',
{
error: new Error(
'wwebjs_browser_termination_unconfirmed',
),
},
);
} else {
try {
const leaseWasHeld = Boolean(
this.store.fencingToken,
);
const released = await this.store.releaseLease();
if (leaseWasHeld && released !== true) {
const error = new Error(
'whatsapp_session_lease_release_rejected',
);
failures.push(error);
this.logNativeFailure(
'client.failure_lease_release_failed',
{ error },
);
}
} catch (error) {
failures.push(error);
this.logNativeFailure(
'client.failure_lease_release_failed',
{ error },
);
}
}
}
if (this.nativeFailureBrowserTerminationConfirmed) {
this.clearCanonicalCandidateReferences();
}
return failures;
})();
this.nativeFailureFinalizationPromise = operation;
}
const cleanupFailures = await this.nativeFailureFinalizationPromise;
if (!this.nativeFailureBrowserTerminationConfirmed) {
this.nativeFailureFinalizationPromise = undefined;
}
if (cleanupFailures.length === 0) return primaryError;
return new AggregateError(
[primaryError, ...cleanupFailures],
'wwebjs_native_session_failure_cleanup_failed',
{ cause: primaryError },
);
}
logNative(event, fields, options) {
try {
this.store.logger?.log?.(event, fields, options);
} catch {
// Diagnostic output must never change session state.
}
}
logNativeFailure(event, fields) {
this.logNative(event, fields, { force: true });
}
/**
* Collapse concurrent failure paths onto one fenced rollback. A transient
* database failure is retried while the lease is still owned; callers must
* not release the lease until this method resolves.
*/
async rollbackHandoff(errorCode) {
if (!this.nativePostgres || !this.store.isHandoffRevision()) {
return false;
}
if (this.handoffRollbackPromise) return this.handoffRollbackPromise;
const operation = (async () => {
const failures = [];
for (let attempt = 1; attempt <= 3; attempt += 1) {
try {
await this.store.rollback(errorCode);
return true;
} catch (error) {
failures.push(error);
this.logNativeFailure('handoff.rollback_attempt_failed', {
attempt,
error_code: safeFailureCode(
error?.code,
'wwebjs_handoff_rollback_attempt_failed',
),
});
if (!this.store.isHandoffRevision()) return true;
if (attempt < 3) {
await this.delay(attempt === 1 ? 100 : 250);
}
}
}
throw new AggregateError(
failures,
'wwebjs_handoff_rollback_failed',
);
})();
this.handoffRollbackPromise = operation;
try {
return await operation;
} finally {
this.handoffRollbackPromise = undefined;
}
}
summarizeCanonicalRegistrationState(
state,
projection,
expectedRegistrationState,
) {
const expectedFingerprint =
expectedRegistrationState?.companion_fingerprint;
const expectedPnHash = canonicalDeviceJidHash(projection?.device?.jid);
const expectedLidHash = projection?.device?.lid
? canonicalDeviceJidHash(projection.device.lid)
: null;
const hashMatches = (actual, expected) =>
/^[0-9a-f]{64}$/u.test(expected || '') && actual === expected;
return {
is_registered: state?.is_registered === true,
has_noise_info: state?.has_noise_info === true,
noise_shape_valid: state?.noise_shape_valid === true,
has_me_device_pn: state?.has_me_device_pn === true,
me_device_pn_device_id_present:
state?.me_device_pn_device_id_present === true,
me_device_pn_device_id_nonzero:
state?.me_device_pn_device_id_nonzero === true,
me_device_pn_hash_match:
Boolean(expectedPnHash) &&
state?.me_device_pn_hash === expectedPnHash,
has_me_device_lid: state?.has_me_device_lid === true,
me_device_lid_device_id_present:
state?.me_device_lid_device_id_present === true,
me_device_lid_device_id_nonzero:
state?.me_device_lid_device_id_nonzero === true,
me_device_lid_hash_match:
expectedLidHash === null ||
state?.me_device_lid_hash === expectedLidHash,
has_registration_info: state?.has_registration_info === true,
registration_id_match:
Number.isSafeInteger(
expectedRegistrationState?.registration_id,
) &&
state?.registration_id ===
expectedRegistrationState.registration_id,
registration_shape_valid: state?.registration_shape_valid === true,
noise_public_hash_match: hashMatches(
state?.noise_public_hash,
expectedRegistrationState?.noise_public_hash,
),
noise_keypair_math_valid:
expectedRegistrationState?.noise_keypair_math_valid === true &&
state?.noise_keypair_math_valid === true,
identity_public_hash_match: hashMatches(
state?.identity_public_hash,
expectedRegistrationState?.identity_public_hash,
),
identity_keypair_math_valid:
expectedRegistrationState?.identity_keypair_math_valid ===
true && state?.identity_keypair_math_valid === true,
has_signed_identity: state?.has_signed_identity === true,
signed_identity_shape_valid:
state?.signed_identity_shape_valid === true,
signed_identity_hash_match: hashMatches(
state?.signed_identity_hash,
expectedRegistrationState?.signed_identity_hash,
),
has_signed_pre_key: state?.has_signed_pre_key === true,
signed_pre_key_shape_valid:
state?.signed_pre_key_shape_valid === true,
signed_pre_key_id_match:
Number.isSafeInteger(
expectedRegistrationState?.signed_pre_key_id,
) &&
state?.signed_pre_key_id ===
expectedRegistrationState.signed_pre_key_id,
signed_pre_key_keypair_math_valid:
expectedRegistrationState?.signed_pre_key_keypair_math_valid ===
true && state?.signed_pre_key_keypair_math_valid === true,
signed_pre_key_signature_valid:
expectedRegistrationState?.signed_pre_key_signature_valid ===
true && state?.signed_pre_key_signature_valid === true,
companion_fingerprint_match: hashMatches(
state?.companion_fingerprint,
expectedFingerprint,
),
bootstrap_session_record_present:
state?.bootstrap_session_record_present === true,
bootstrap_md_session_id_present:
state?.bootstrap_md_session_id_present === true,
bootstrap_mms4_record_present:
state?.bootstrap_mms4_record_present === true,
bootstrap_mms4_shape_valid:
state?.bootstrap_mms4_shape_valid === true,
bootstrap_routing_info_present:
state?.bootstrap_routing_info_present === true,
routing_info_hash_match:
expectedRegistrationState?.routing_info_hash == null ||
state?.routing_info_hash ===
expectedRegistrationState.routing_info_hash,
bootstrap_noise_record_present:
state?.bootstrap_noise_record_present === true,
bootstrap_noise_iv_present:
state?.bootstrap_noise_iv_present === true,
bootstrap_encryption_salt_present:
state?.bootstrap_encryption_salt_present === true,
bootstrap_signal_meta_row_count: Number.isSafeInteger(
state?.bootstrap_signal_meta_row_count,
)
? state.bootstrap_signal_meta_row_count
: 0,
bootstrap_signal_base_key_row_count: Number.isSafeInteger(
state?.bootstrap_signal_base_key_row_count,
)
? state.bootstrap_signal_base_key_row_count
: 0,
bootstrap_registration_meta_present:
state?.bootstrap_registration_meta_present === true,
bootstrap_adv_identity_meta_present:
state?.bootstrap_adv_identity_meta_present === true,
canonical_credential_guard_active:
state?.canonical_credential_guard_active === true,
canonical_pairing_watchdog_active:
state?.canonical_pairing_watchdog_active === true,
canonical_pairing_detected:
state?.canonical_pairing_detected === true,
canonical_pairing_ref_present:
state?.canonical_pairing_ref_present === true,
canonical_runtime_launch_state: [
'starting',
'dispatched',
'completed',
'failed',
'none',
].includes(state?.canonical_runtime_launch_state)
? state.canonical_runtime_launch_state
: 'none',
has_synced: state?.has_synced === true,
socket_state: state?.socket_state || 'unknown',
};
}
assertCanonicalRegistrationState(state) {
const requiredChecks = [
['registration_flag', 'is_registered'],
['noise_present', 'has_noise_info'],
['noise_shape', 'noise_shape_valid'],
['device_pn_present', 'has_me_device_pn'],
['device_pn_id_present', 'me_device_pn_device_id_present'],
['device_pn_id_nonzero', 'me_device_pn_device_id_nonzero'],
['device_pn_match', 'me_device_pn_hash_match'],
['device_lid_id_present', 'me_device_lid_device_id_present'],
['device_lid_id_nonzero', 'me_device_lid_device_id_nonzero'],
['device_lid_match', 'me_device_lid_hash_match'],
['registration_present', 'has_registration_info'],
['registration_id_match', 'registration_id_match'],
['registration_shape', 'registration_shape_valid'],
['noise_public_match', 'noise_public_hash_match'],
['noise_keypair_math', 'noise_keypair_math_valid'],
['identity_public_match', 'identity_public_hash_match'],
['identity_keypair_math', 'identity_keypair_math_valid'],
['signed_identity_present', 'has_signed_identity'],
['signed_identity_shape', 'signed_identity_shape_valid'],
['signed_identity_match', 'signed_identity_hash_match'],
['signed_pre_key_present', 'has_signed_pre_key'],
['signed_pre_key_shape', 'signed_pre_key_shape_valid'],
['signed_pre_key_id_match', 'signed_pre_key_id_match'],
[
'signed_pre_key_keypair_math',
'signed_pre_key_keypair_math_valid',
],
['signed_pre_key_signature', 'signed_pre_key_signature_valid'],
['companion_fingerprint_match', 'companion_fingerprint_match'],
['routing_info_match', 'routing_info_hash_match'],
];
const reasonCategories = requiredChecks
.filter(([, field]) => state?.[field] !== true)
.map(([category]) => category);
if (reasonCategories.length > 0) {
this.logNative(
'handoff.registration_gate_rejected',
{
reason_categories: reasonCategories,
failure_count: reasonCategories.length,
},
{ force: true },
);
const error = new Error(
'wwebjs_canonical_registration_state_invalid',
);
error.code = 'wwebjs_canonical_registration_state_invalid';
throw error;
}
}
assertCanonicalResealContract(initialImport, resealedImport) {
const expectedFields = [
'registration_id',
'noise_public_hash',
'identity_public_hash',
'signed_identity_hash',
'companion_fingerprint',
'signed_pre_key_id',
'routing_info_hash',
'noise_keypair_math_valid',
'identity_keypair_math_valid',
'signed_pre_key_keypair_math_valid',
'signed_pre_key_signature_valid',
];
const invariantFields = [
'imported_records',
'imported_signal_sessions',
'imported_sender_keys',
'imported_pq_pre_key_count',
'imported_pq_last_resort_key_count',
'imported_pq_migrated',
'pending_app_state_sync_keys',
'app_state_import_mode',
'pq_key_mode',
'pq_storage_mode',
'routing_info_imported',
'signal_session_resync_required',
];
const initialExpected = initialImport?.expected_registration_state;
const resealedExpected = resealedImport?.expected_registration_state;
const expectedMismatches = expectedFields.filter(
(field) =>
Object.prototype.hasOwnProperty.call(
initialExpected || {},
field,
) === false ||
Object.prototype.hasOwnProperty.call(
resealedExpected || {},
field,
) === false ||
initialExpected[field] !== resealedExpected[field],
);
const invariantMismatches = invariantFields.filter(
(field) =>
initialImport?.[field] !== undefined &&
(Object.prototype.hasOwnProperty.call(
resealedImport || {},
field,
) === false ||
initialImport[field] !== resealedImport[field]),
);
if (
resealedImport?.reload_required !== false ||
resealedImport?.reconnect_required !== true ||
expectedMismatches.length > 0 ||
invariantMismatches.length > 0 ||
(initialImport?.generated_provider_state &&
(resealedImport?.generated_provider_state ||
resealedImport?.noise_metadata_mode !== 'preserved'))
) {
this.logNative(
'handoff.reseal_contract_rejected',
{
reload_contract_valid:
resealedImport?.reload_required === false,
reconnect_contract_valid:
resealedImport?.reconnect_required === true,
expected_mismatches: expectedMismatches,
invariant_mismatches: invariantMismatches,
generated_provider_state_reused:
!initialImport?.generated_provider_state ||
(!resealedImport?.generated_provider_state &&
resealedImport?.noise_metadata_mode ===
'preserved'),
},
{ force: true },
);
const error = new Error(
'wwebjs_canonical_post_reload_reseal_mismatch',
);
error.code = error.message;
throw error;
}
}
canUseCanonicalSealedReloadContinuity({
stagedProviderHandoff,
resumingCanonicalActivation,
replayingActiveRevision,
preserveExistingAppState,
imported,
canonicalReload,
websocketHandshakeCount,
}) {
return Boolean(
stagedProviderHandoff === true &&
resumingCanonicalActivation !== true &&
replayingActiveRevision !== true &&
preserveExistingAppState === true &&
this.canonicalReusableHandoffNativeAppState === true &&
imported?.app_state_import_mode === 'preserved_native_profile' &&
imported?.pending_app_state_sync_keys === 0 &&
imported?.signal_storage_initialization_mode ===
'preinitialized_bootstrap_proof' &&
canonicalReload?.explicitReloadCount === 1 &&
canonicalReload?.registrationRepairRequired === false &&
// A freshly reloaded registered document may intentionally stop at
// UNLAUNCHED while WSS is fenced. The official offline launch below
// can initialize that runtime without reopening Signal storage.
// Any other bootstrap-required shape still takes the conservative
// full reseal path.
(canonicalReload?.runtimeBootstrapRequired === false ||
(canonicalReload?.runtimeBootstrapRequired === true &&
canonicalReload?.socketState === 'UNLAUNCHED')) &&
websocketHandshakeCount === 0,
);
}
async resolveReusableHandoffSignalTablePreservation(canonicalProjection) {
if (!this.canonicalReusableHandoffNativeAppState) return false;
try {
const restoredNativeProjection =
await this.browserBridge.exportCanonicalProjection({
// App-state equivalence was already proven against the
// reusable-profile authority immediately before this
// readback. A transient private SyncDB read failure must
// not hide an otherwise complete Signal projection and
// force an expensive bulk-table replacement. The bridge
// still rejects every non-app-state blocker, so any
// uncertainty in device/identity/pre-key/PQ/session/
// sender-key material keeps the conservative full-replace
// path below.
allowIncompleteAppStateOverlay: true,
});
if (
typeof this.store
.getReusableHandoffProfileSignalTableEquivalence ===
'function'
) {
const proof =
this.store.getReusableHandoffProfileSignalTableEquivalence(
canonicalProjection,
restoredNativeProjection,
);
const matches = proof?.componentMatches;
const requiredMatchNames = [
'device',
'identity_keys',
'pre_keys',
'pq_pre_keys',
'pq_pre_key_state',
'signal_sessions',
'sender_keys',
];
if (
!proof ||
proof.appStateAuthorityMatched !== true ||
!matches ||
requiredMatchNames.some(
(name) => typeof matches[name] !== 'boolean',
) ||
matches.device !== true
) {
return false;
}
const components = Object.freeze({
identity_keys: matches.identity_keys,
pre_keys: matches.pre_keys,
// The PQ inventory and its counters are one atomic native
// component. Preserving only one side would make upload
// boundaries ambiguous after reconnect.
pq_pre_keys:
matches.pq_pre_keys && matches.pq_pre_key_state,
signal_sessions: matches.signal_sessions,
sender_keys: matches.sender_keys,
});
const values = Object.values(components);
if (values.every(Boolean)) return true;
if (!values.some(Boolean)) return false;
this.logNative(
'handoff.reusable_profile_signal_component_selection',
{
preserved_components: Object.entries(components)
.filter(([, matched]) => matched)
.map(([name]) => name),
replaced_components: Object.entries(components)
.filter(([, matched]) => !matched)
.map(([name]) => name),
},
{ force: true },
);
return components;
}
return (
this.store.canPreserveReusableHandoffProfileSignalTables?.(
canonicalProjection,
restoredNativeProjection,
) === true
);
} catch {
this.logNative(
'handoff.reusable_profile_signal_table_equivalence_unavailable',
{
error_code:
'wwebjs_reusable_profile_signal_projection_unavailable',
},
{ force: true },
);
return false;
}
}
mergeCanonicalSealedReloadRegistrationSummary(runtimeState) {
if (!this.canonicalSealedReloadRegistrationSummary) {
throw new Error('wwebjs_canonical_reload_continuity_proof_missing');
}
return {
...this.canonicalSealedReloadRegistrationSummary,
is_registered: runtimeState?.is_registered === true,
canonical_credential_guard_active:
runtimeState?.canonical_credential_guard_active === true,
canonical_pairing_watchdog_active:
runtimeState?.canonical_pairing_watchdog_active === true,
canonical_pairing_detected:
runtimeState?.canonical_pairing_detected === true,
canonical_pairing_ref_present:
runtimeState?.canonical_pairing_ref_present === true,
canonical_runtime_launch_state:
runtimeState?.canonical_runtime_launch_state || 'none',
has_synced: runtimeState?.has_synced === true,
socket_state: runtimeState?.socket_state || 'unknown',
};
}
assertCanonicalReconnectSafetyState(
state,
{
requireConnected = false,
requireGuard = true,
requireWatchdog = true,
allowedSocketStates = ['OPENING', 'CONNECTED'],
} = {},
) {
const socketStateValid = requireConnected
? state?.socket_state === 'CONNECTED' && state?.has_synced === true
: allowedSocketStates.includes(state?.socket_state);
const deferredRegisteredLaunchValid =
state?.socket_state !== 'UNLAUNCHED' ||
['dispatched', 'completed'].includes(
state?.canonical_runtime_launch_state,
) ||
(this.canonicalDeferredOnlineRegisteredLaunch === true &&
state?.canonical_runtime_launch_state === 'none');
if (
(requireGuard &&
state?.canonical_credential_guard_active !== true) ||
(requireWatchdog &&
state?.canonical_pairing_watchdog_active !== true) ||
state?.canonical_pairing_detected === true ||
state?.canonical_pairing_ref_present === true ||
state?.canonical_runtime_launch_state === 'failed' ||
deferredRegisteredLaunchValid !== true ||
!socketStateValid
) {
const error = new Error(
requireConnected
? 'wwebjs_canonical_ready_state_invalid'
: 'wwebjs_canonical_reconnect_stability_invalid',
);
error.code = error.message;
throw error;
}
}
async assertCanonicalCandidateReady(
stage,
{
requirePublicEventGate = false,
requireConnected = true,
requireGuard = true,
requireWatchdog = true,
allowedSocketStates,
allowSealedReloadContinuityProof = false,
} = {},
) {
if (
!this.canonicalReconnectProjection ||
!this.canonicalReconnectExpectedRegistrationState
) {
return undefined;
}
if (allowSealedReloadContinuityProof && requireConnected) {
throw new Error(
'wwebjs_canonical_reload_continuity_online_forbidden',
);
}
this.browserBridge.assertNoCanonicalCredentialViolation?.();
const summary = allowSealedReloadContinuityProof
? this.mergeCanonicalSealedReloadRegistrationSummary(
await this.browserBridge.inspectCanonicalReloadContinuityState(
{ requireAcknowledged: true },
),
)
: this.summarizeCanonicalRegistrationState(
await this.browserBridge.inspectCanonicalRegistrationState(),
this.canonicalReconnectProjection,
this.canonicalReconnectExpectedRegistrationState,
);
this.assertCanonicalRegistrationState(summary);
this.assertCanonicalReconnectSafetyState(summary, {
requireConnected,
requireGuard,
requireWatchdog,
...(allowedSocketStates ? { allowedSocketStates } : {}),
});
if (requirePublicEventGate) {
if (
typeof this.client?.assertPublicEventGateHealthy !== 'function'
) {
throw new Error('wwebjs_public_event_gate_unavailable');
}
this.client.assertPublicEventGateHealthy();
}
this.logNative(
'handoff.registration_state.ready_gate',
{ ...summary, stage },
{ force: true },
);
return summary;
}
clearCanonicalCandidateReferences() {
this.handoffSourceCanonicalProjection = undefined;
this.canonicalReconnectProjection = undefined;
this.canonicalReconnectExpectedRegistrationState = undefined;
this.canonicalSealedReloadRegistrationSummary = undefined;
this.canonicalActivationResumeProjection = undefined;
this.canonicalActiveRestartReplay = false;
this.canonicalActiveRestartAppStateOverlayRequired = false;
this.canonicalActiveRestartCanonicalGeneration = undefined;
this.canonicalActiveRestartAttestedProjection = undefined;
this.canonicalReusableHandoffNativeAppState = false;
this.canonicalActivationReadyProjection = undefined;
this.canonicalActivationUsesPromotedProfileArtifact = false;
this.pendingCanonicalProviderState = undefined;
this.pendingCanonicalAppStateProjection = undefined;
}
getPersistedCanonicalProjection() {
return (
this.lastPersistedCanonicalProjection ||
this.lastCanonicalProjection
);
}
markCanonicalProjectionPersisted(projection) {
if (!projection?.tables) {
throw new Error('wwebjs_canonical_projection_missing');
}
this.lastPersistedCanonicalProjection = projection;
this.lastCanonicalProjection = projection;
return projection;
}
reconcileCanonicalProjectionForPersistence(
browserProjection,
{
baseline = this.getPersistedCanonicalProjection(),
clearSnapshotResyncMarker = false,
preserveNativeAppState = this
.canonicalNativeAppStateAuthoritative === true,
} = {},
) {
const baselineSnapshotResyncCollections =
normalizedSnapshotResyncCollections(baseline?.capabilities, {
optional: true,
});
const browserSnapshotResyncCollections =
normalizedSnapshotResyncCollections(
browserProjection?.capabilities,
{ optional: true },
);
if (
baselineSnapshotResyncCollections.length > 0 &&
browserSnapshotResyncCollections.length > 0 &&
JSON.stringify(baselineSnapshotResyncCollections) !==
JSON.stringify(browserSnapshotResyncCollections)
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_app_state_snapshot_resync_capability_invalid',
);
}
const snapshotResyncCollections =
baselineSnapshotResyncCollections.length > 0
? baselineSnapshotResyncCollections
: browserSnapshotResyncCollections;
if (preserveNativeAppState) {
let persistenceProjection = browserProjection;
const syncKeys =
browserProjection?.tables?.whatsapp_app_state_sync_keys;
const versions =
browserProjection?.tables?.whatsapp_app_state_version;
const mutationMacs =
browserProjection?.tables?.whatsapp_app_state_mutation_macs;
if (
!Array.isArray(syncKeys) ||
!Array.isArray(versions) ||
!Array.isArray(mutationMacs)
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_app_state_projection_invalid',
);
}
const materializedCollections = new Set(
versions.map((row) => row?.name),
);
const missingMaterializedCollections =
CANONICAL_APP_STATE_COLLECTIONS.filter(
(name) => !materializedCollections.has(name),
);
if (missingMaterializedCollections.length > 0) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_app_state_checkpoint_pending',
{
reason: 'native_profile_materialization_incomplete',
materialized_collection_count:
CANONICAL_APP_STATE_COLLECTIONS.length -
missingMaterializedCollections.length,
missing_materialized_collection_count:
missingMaterializedCollections.length,
required_materialized_collection_count:
CANONICAL_APP_STATE_COLLECTIONS.length,
},
);
}
if (baseline?.tables) {
const syncKeyComparison = compareCanonicalAppStateSyncKeys(
baseline,
browserProjection,
);
if (syncKeyComparison.status !== 'ready') {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_app_state_diverged',
syncKeyComparison,
);
}
persistenceProjection =
reconcileCanonicalAppStateSyncKeysForPersistence(
baseline,
browserProjection,
).projection;
}
const committedVersionByName = new Map(
versions.map((row) => [row?.name, row?.version]),
);
const hasFutureOrInvalidMutationMac = mutationMacs.some((row) => {
const committedVersion = committedVersionByName.get(row?.name);
return (
!Number.isSafeInteger(committedVersion) ||
!Number.isSafeInteger(row?.version) ||
row.version < 0 ||
row.version > committedVersion
);
});
const requiresPortableNormalization =
snapshotResyncCollections.length > 0 ||
hasFutureOrInvalidMutationMac;
if (!requiresPortableNormalization) {
return persistenceProjection;
}
if (syncKeys.length === 0) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_app_state_snapshot_resync_sync_key_missing',
);
}
// Always cross the authoritative native projection through the
// portable normalizer. Comparing a projection with itself cannot
// reveal rows above its own committed collection watermark, so
// an early self-comparison would accidentally retain WWeb-only
// future MACs in the cross-provider canonical checkpoint.
const portable =
normalizeCanonicalAuthoritativeAppStateForPortablePersistence(
persistenceProjection,
);
if (portable.removed_future_mutation_mac_count > 0) {
this.logNative(
'canonical_checkpoint.app_state_native_baseline_normalized',
{
removed_future_mutation_mac_count:
portable.removed_future_mutation_mac_count,
materialized_collection_count:
portable.materialized_collection_count,
},
{ force: true },
);
}
return portable.projection;
}
const snapshotResyncCollectionSet = new Set(snapshotResyncCollections);
const baselineAllowsSnapshotResync =
baselineSnapshotResyncCollections.length > 0;
let snapshotResync = {
projection: browserProjection,
snapshot_resync_collection_count: 0,
removed_version_row_count: 0,
removed_mutation_mac_count: 0,
collection_ids: [],
};
if (baselineAllowsSnapshotResync) {
snapshotResync = normalizeCanonicalAppStateForSnapshotResync(
browserProjection,
{
eligibleCollectionNames: snapshotResyncCollectionSet,
},
);
}
const persistenceProjection = snapshotResync.projection;
const trustedBaseline = baseline || persistenceProjection;
const reconciled = reconcileCanonicalAppStateForPersistence(
trustedBaseline,
persistenceProjection,
);
if (snapshotResync.snapshot_resync_collection_count > 0) {
this.logNative(
'canonical_checkpoint.app_state_snapshot_resync',
{
snapshot_resync_collection_count:
snapshotResync.snapshot_resync_collection_count,
removed_version_row_count:
snapshotResync.removed_version_row_count,
removed_mutation_mac_count:
snapshotResync.removed_mutation_mac_count,
collection_ids: snapshotResync.collection_ids,
},
{ force: true },
);
}
if (reconciled.comparison.normalized_future_substitution_count > 0) {
this.logNative(
'canonical_checkpoint.app_state_reconciled',
{
normalized_future_substitution_count:
reconciled.comparison
.normalized_future_substitution_count,
persisted_future_target_mac_count:
reconciled.comparison.persisted_future_target_mac_count,
},
{ force: true },
);
}
const materializedCollectionNames = new Set(
reconciled.projection.tables.whatsapp_app_state_version.map(
(row) => row.name,
),
);
const missingMaterializedCollectionCount =
snapshotResyncCollections.reduce(
(count, name) =>
count + (materializedCollectionNames.has(name) ? 0 : 1),
0,
);
const materializedCollectionCount =
snapshotResyncCollections.length -
missingMaterializedCollectionCount;
if (
clearSnapshotResyncMarker &&
snapshotResyncCollections.length > 0 &&
(snapshotResync.snapshot_resync_collection_count > 0 ||
missingMaterializedCollectionCount > 0)
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_app_state_checkpoint_pending',
{
reason: 'snapshot_resync_not_materialized',
snapshot_resync_collection_count:
snapshotResync.snapshot_resync_collection_count,
materialized_collection_count: materializedCollectionCount,
missing_materialized_collection_count:
missingMaterializedCollectionCount,
required_materialized_collection_count:
snapshotResyncCollections.length,
},
);
}
const preserveSnapshotResyncMarker =
snapshotResyncCollections.length > 0 && !clearSnapshotResyncMarker;
return {
...reconciled.projection,
capabilities: {
...reconciled.projection.capabilities,
app_state_snapshot_resync_required:
preserveSnapshotResyncMarker,
app_state_snapshot_resync_collections:
preserveSnapshotResyncMarker
? [...snapshotResyncCollections]
: [],
},
};
}
buildCanonicalOfflineCheckpointProjection(browserProjection) {
const source = this.canonicalReconnectProjection;
if (!source?.tables || !browserProjection?.tables) {
throw new Error(
'wwebjs_canonical_offline_checkpoint_projection_missing',
);
}
const sourceSnapshotResyncCollections =
normalizedSnapshotResyncCollections(source.capabilities, {
optional: true,
});
const merged = {
...browserProjection,
complete: true,
blockers: [],
capabilities: {
...browserProjection.capabilities,
app_state_snapshot_resync_required:
sourceSnapshotResyncCollections.length > 0,
app_state_snapshot_resync_collections: [
...sourceSnapshotResyncCollections,
],
},
provider_state:
browserProjection.provider_state || source.provider_state,
transport_state:
browserProjection.transport_state || source.transport_state,
tables: {
...browserProjection.tables,
whatsapp_app_state_sync_keys:
source.tables.whatsapp_app_state_sync_keys,
whatsapp_app_state_version:
source.tables.whatsapp_app_state_version,
whatsapp_app_state_mutation_macs:
source.tables.whatsapp_app_state_mutation_macs,
},
};
const normalized = canonicalBrowserProjectionToStore(
canonicalStoreProjectionToBrowser(merged, {
webVersion: browserProjection.web_version,
}),
);
if (source.passthrough_tables) {
normalized.passthrough_tables = source.passthrough_tables;
}
return normalized;
}
async validateCanonicalCandidateIdentityOffline(
projection,
{ persistProjection = true } = {},
) {
const jid = projection?.device?.jid;
const fingerprint = projection?.device?.device_fingerprint;
if (!jid || !fingerprint) {
throw new Error('wwebjs_canonical_offline_identity_missing');
}
const identityPreflight = await this.store.preflightReadyIdentity({
jid,
companionFingerprint: fingerprint,
canonicalProjection: projection,
});
if (!persistProjection) {
return identityPreflight.canonicalProjection;
}
await this.store.saveCanonicalProjection({
projection: identityPreflight.canonicalProjection,
source: 'offline_handoff_preflight',
});
const validation = await this.store.validateReadyIdentity({
jid,
companionFingerprint: fingerprint,
});
if (
!validation.fingerprintVerified &&
!validation.fingerprintRegistered
) {
throw new Error('wwebjs_companion_fingerprint_not_verified');
}
this.markCanonicalProjectionPersisted(
identityPreflight.canonicalProjection,
);
return identityPreflight.canonicalProjection;
}
async checkpointCanonicalCandidateOffline({
source,
preserveExistingProfileArtifact = false,
allowIncompleteAppStateOverlay = false,
trustedCanonicalProjection,
} = {}) {
if (
trustedCanonicalProjection !== undefined &&
!this.canonicalSealedReloadRegistrationSummary
) {
throw new Error(
'wwebjs_canonical_reload_continuity_checkpoint_forbidden',
);
}
this.logNative('checkpoint.offline_candidate_started');
const maxAttempts = 6;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
await this.browserBridge.flush();
const initialDirtyPlan =
this.browserBridge.getCanonicalDirtyPlan?.();
let browserProjection;
if (trustedCanonicalProjection !== undefined) {
await this.browserBridge.inspectCanonicalReloadContinuityState({
requireAcknowledged: true,
});
browserProjection = trustedCanonicalProjection;
this.logNative(
'checkpoint.offline_candidate_sealed_projection_reused',
{
proof_source:
'pre_reload_cryptographic_seal_and_reload_guard',
},
{ force: true },
);
} else {
try {
browserProjection =
await this.browserBridge.exportCanonicalProjection({
allowIncompleteAppStateOverlay,
});
} catch (error) {
const blockers = Array.isArray(error?.details?.blockers)
? [...new Set(error.details.blockers)].filter(
(entry) => typeof entry === 'string',
)
: [];
const transientAppStateRead =
error?.code ===
'wwebjs_canonical_projection_incomplete' &&
blockers.length > 0 &&
blockers.every((entry) =>
CANONICAL_OFFLINE_CHECKPOINT_TRANSIENT_BLOCKERS.has(
entry,
),
);
if (!transientAppStateRead || attempt >= maxAttempts) {
throw error;
}
this.logNative(
'checkpoint.offline_candidate_retry',
{
attempt,
reason: 'app_state_projection_transient',
blockers: blockers.sort(),
},
{ force: true },
);
await this.delay(Math.min(attempt * 250, 1000));
continue;
}
}
const canonicalProjection =
this.buildCanonicalOfflineCheckpointProjection(
browserProjection,
);
await this.validateCanonicalCandidateIdentityOffline(
canonicalProjection,
{
persistProjection: !preserveExistingProfileArtifact,
},
);
if (!preserveExistingProfileArtifact) {
try {
await this.withCanonicalProfileSnapshotFreeze(() =>
this.store.checkpointProfile({
profilePath: this.userDataDir,
canonicalProjection,
source:
source ||
this.store.revisionSource ||
'handoff',
promote: false,
}),
);
} catch (error) {
const errorCode = String(
error?.code || error?.message || '',
);
if (
errorCode !==
'whatsapp_artifact_profile_changed_during_checkpoint'
) {
throw error;
}
this.logNative(
'checkpoint.offline_candidate_retry',
{
attempt,
tail_dirty: true,
reason: 'profile_changed_during_checkpoint',
},
{ force: true },
);
if (attempt < maxAttempts) await this.delay(attempt * 100);
continue;
}
}
const tail = await this.browserBridge.flush();
const finalDirtyPlan = this.browserBridge.getCanonicalDirtyPlan?.();
const stable =
!tail?.dirty &&
(!initialDirtyPlan ||
!finalDirtyPlan ||
finalDirtyPlan.generation === initialDirtyPlan.generation);
if (stable) {
if (!preserveExistingProfileArtifact) {
this.browserBridge.acknowledgeCanonicalDirtyPlan?.(
initialDirtyPlan,
);
await this.browserBridge.markClean();
this.profileDirty = false;
this.markCanonicalProjectionPersisted(canonicalProjection);
}
this.logNative(
preserveExistingProfileArtifact
? 'checkpoint.offline_candidate_profile_preserved'
: 'checkpoint.offline_candidate_completed',
{
attempt_count: attempt,
...(preserveExistingProfileArtifact
? {
reason: 'active_restart_app_state_pending',
}
: {}),
},
{ force: true },
);
return true;
}
this.logNative(
'checkpoint.offline_candidate_retry',
{ attempt, tail_dirty: tail?.dirty === true },
{ force: true },
);
if (attempt < maxAttempts) await this.delay(attempt * 100);
}
throw new Error(
'wwebjs_canonical_offline_checkpoint_mutated_during_upload',
);
}
async checkpointReusableCanonicalHandoffOffline({
resealed,
preserveExistingProfileArtifact = false,
trustedCanonicalProjection,
} = {}) {
if (
resealed?.app_state_import_mode !== 'preserved_native_profile' ||
resealed?.pending_app_state_sync_keys !== 0
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_reusable_handoff_native_app_state_invalid',
);
}
// A reusable profile belongs to an older WWebJS revision. Its native
// app-state may be reused only after the checksum gate, but the first
// activation CAS deliberately requires an offline artifact owned by
// the target revision. A post-PONR retry is the inverse case: that
// target-local artifact is already promoted and its predecessor is
// retained as the handoff rollback proof, so rotating it again must
// remain forbidden until the handoff completes.
await this.checkpointCanonicalCandidateOffline({
source: this.store.revisionSource || 'handoff',
preserveExistingProfileArtifact,
allowIncompleteAppStateOverlay: true,
...(trustedCanonicalProjection
? { trustedCanonicalProjection }
: {}),
});
this.canonicalNativeAppStateAuthoritative = true;
this.preserveExistingProfileArtifactDuringCanonicalCatchUp = true;
this.logNative(
'handoff.reusable_profile_app_state_authoritative',
{
app_state_import_mode: resealed.app_state_import_mode,
pending_app_state_sync_keys: 0,
target_profile_checkpointed: !preserveExistingProfileArtifact,
target_profile_preserved: preserveExistingProfileArtifact,
},
{ force: true },
);
return true;
}
async withCanonicalProfileSnapshotFreeze(operation) {
await this.browserBridge.freezeCanonicalProfileSnapshot();
let result;
let hasPrimaryError = false;
let primaryError;
try {
result = await operation();
} catch (error) {
hasPrimaryError = true;
primaryError = error;
}
try {
await this.browserBridge.resumeCanonicalProfileSnapshot();
} catch (resumeError) {
if (hasPrimaryError) {
throw new AggregateError(
[primaryError, resumeError],
'wwebjs_canonical_profile_checkpoint_resume_failed',
{ cause: primaryError },
);
}
throw resumeError;
}
if (hasPrimaryError) throw primaryError;
return result;
}
async checkpointCanonicalActivationReady() {
return this.checkpointCanonicalGuardedReady({
finalizeActivation: true,
});
}
async checkpointCanonicalActiveRestartReady() {
return this.checkpointCanonicalGuardedReady({
finalizeActivation: false,
});
}
getCanonicalActivationCheckpointState() {
const profileSnapshotFrozen =
this.browserBridge?.canonicalProfileSnapshotFreeze !== undefined;
const activationRecoveryRequired =
this.isCanonicalActivationRecoveryRequired();
const activeRestartAttestationPending =
this.isCanonicalActiveRestartAttestationPending();
return {
inProgress:
this.canonicalActivationCheckpointInProgress === true ||
profileSnapshotFrozen ||
activationRecoveryRequired ||
activeRestartAttestationPending,
generation: this.canonicalActivationCheckpointGeneration,
profileSnapshotFrozen,
activationRecoveryRequired,
activeRestartAttestationPending,
};
}
async checkpointCanonicalGuardedReady({ finalizeActivation }) {
if (this.canonicalActivationCheckpointInProgress) {
throw new Error(
'wwebjs_canonical_activation_checkpoint_already_running',
);
}
if (
this.canonicalActivationCheckpointGeneration >=
Number.MAX_SAFE_INTEGER
) {
throw new Error(
'wwebjs_canonical_activation_checkpoint_generation_exhausted',
);
}
this.canonicalActivationCheckpointGeneration += 1;
this.canonicalActivationCheckpointInProgress = true;
clearTimeout(this.criticalCheckpointTimer);
this.criticalCheckpointTimer = undefined;
try {
// A journal notification may already have queued a canonical-only
// checkpoint. Drain it before taking the one activation artifact
// so database and profile writers cannot race each other.
await this.checkpointTail;
return await this.performCanonicalActivationReadyCheckpoint({
finalizeActivation,
});
} finally {
this.canonicalActivationCheckpointInProgress = false;
}
}
async performCanonicalActivationReadyCheckpoint({
finalizeActivation = true,
} = {}) {
if (
this.pendingCanonicalAppStateCatchUp ||
this.pendingPostQuantumBootstrap
) {
throw new Error('wwebjs_canonical_activation_work_incomplete');
}
const checkpointStage = finalizeActivation
? 'activation_ready'
: 'active_restart_ready';
this.logNative(`checkpoint.${checkpointStage}_started`, undefined, {
force: true,
});
await this.browserBridge.quiesceNetwork();
// The first canonical reconnect deliberately moves destructive
// credential protection from `sealed` to `live`. A later readiness
// snapshot must establish a fresh offline suppression boundary before
// freezing Chromium; otherwise reconnect proof would either accept a
// live credential writer or fail because the runtime is not sealed.
await this.browserBridge.armCanonicalCredentialGuard({
ttlMs: Math.min(
300000,
Math.max(
240000,
(this.client.options.authTimeoutMs || 30000) * 4,
),
),
navigationScoped: true,
});
await this.browserBridge.acknowledgeCanonicalCredentialProtection();
let checkpoint;
let canonicalProjection;
let retainedAttestedProjection;
const resumedPromotedProfile = Boolean(
finalizeActivation &&
this.canonicalActivationUsesPromotedProfileArtifact,
);
// A resumed activation can reuse its current target-local artifact
// only when it is already a distinct, full checkpoint written after
// the point of no return. The original promoted artifact is the
// rollback proof and must never be presented as the post-activation
// readiness proof. This read is only an optimization decision; the
// database finalization CAS still validates the complete contract.
const retainPromotedProfileArtifact = Boolean(
resumedPromotedProfile &&
typeof this.store.canReusePromotedProfileForActivationCheckpoint ===
'function' &&
(await this.store.canReusePromotedProfileForActivationCheckpoint()),
);
if (resumedPromotedProfile) {
this.logNative(
'checkpoint.activation_ready_profile_strategy',
{
reuse_post_activation_artifact:
retainPromotedProfileArtifact,
create_fresh_post_activation_artifact:
!retainPromotedProfileArtifact,
},
{ force: true },
);
}
for (let attempt = 1; attempt <= 4; attempt += 1) {
await this.browserBridge.flush();
const initialDirtyPlan =
this.browserBridge.getCanonicalDirtyPlan?.();
const browserProjection =
await this.browserBridge.exportCanonicalProjection();
retainedAttestedProjection = false;
try {
canonicalProjection =
this.reconcileCanonicalProjectionForPersistence(
browserProjection,
{
baseline:
finalizeActivation &&
this.canonicalActivationReadyProjection
? this.canonicalActivationReadyProjection
: this.getPersistedCanonicalProjection(),
preserveNativeAppState: Boolean(
finalizeActivation &&
this.canonicalActivationReadyProjection,
),
},
);
} catch (error) {
if (
finalizeActivation ||
error?.code !==
'wwebjs_canonical_app_state_checkpoint_pending' ||
!this.canonicalActiveRestartAttestedProjection?.tables
) {
throw error;
}
// The authenticated two-sample identity gate immediately
// before this checkpoint already proved a complete portable
// projection. Taking WWeb offline can briefly expose an
// incomplete IndexedDB app-state view while the native store
// closes its live sync transaction. Persist the attested
// snapshot into the existing profile anchor and retain the
// dirty journal so a post-READY critical checkpoint captures
// every later mutation.
canonicalProjection =
this.canonicalActiveRestartAttestedProjection;
retainedAttestedProjection = true;
this.logNative(
'checkpoint.active_restart_attested_projection_retained',
{
reason:
error.details?.reason ||
'offline_app_state_materialization_pending',
attempt,
pending_tail_retained: true,
},
{ force: true },
);
}
if (
!finalizeActivation &&
this.canonicalNativeAppStateAuthoritative &&
!canonicalAppStateTablesEqual(
this.canonicalActiveRestartAttestedProjection ||
this.getPersistedCanonicalProjection(),
canonicalProjection,
)
) {
// App-state remains live between the authenticated two-sample
// gate and this final offline checkpoint (PQ recovery is one
// legitimate writer in that interval). The browser profile is
// already the typed authority, and the reconciler above has
// revalidated its sync-key lineage, complete collection set,
// row shapes and portable watermarks against the durable
// baseline. Re-attest the now-quiesced projection instead of
// turning safe forward progress into an endless restart loop.
this.logNative(
'checkpoint.active_restart_app_state_reattested',
{
reason: 'authoritative_native_progress',
attempt,
},
{ force: true },
);
this.canonicalActiveRestartAttestedProjection =
canonicalProjection;
}
try {
checkpoint = finalizeActivation
? retainPromotedProfileArtifact
? await this.store.saveCanonicalProjection({
projection: canonicalProjection,
source: 'activation_ready_promoted_profile',
dirtyPlan: initialDirtyPlan,
})
: await this.withCanonicalProfileSnapshotFreeze(() =>
this.store.checkpointProfile({
profilePath: this.userDataDir,
canonicalProjection,
source: 'checkpoint',
promote: false,
}),
)
: this.canonicalActiveRestartAppStateOverlayRequired
? await this.withCanonicalProfileSnapshotFreeze(() =>
this.store.checkpointProfile({
profilePath: this.userDataDir,
canonicalProjection,
source: 'active_restart_overlay_rebased',
promote: true,
}),
)
: await this.store.saveCanonicalProjection({
projection: canonicalProjection,
source: 'active_restart_ready',
dirtyPlan: initialDirtyPlan,
});
} catch (error) {
const errorCode = String(error?.code || error?.message || '');
if (
errorCode !==
'whatsapp_artifact_profile_changed_during_checkpoint'
) {
throw error;
}
checkpoint = undefined;
}
const tail = await this.browserBridge.flush();
const finalDirtyPlan = this.browserBridge.getCanonicalDirtyPlan?.();
const canonicalTailChanged =
tail?.critical === true ||
(initialDirtyPlan &&
finalDirtyPlan &&
finalDirtyPlan.generation !== initialDirtyPlan.generation);
if (
!checkpoint ||
(canonicalTailChanged && !retainedAttestedProjection)
) {
this.logNative(
`checkpoint.${checkpointStage}_retry`,
{
attempt,
profile_changed_during_checkpoint: !checkpoint,
tail_dirty: tail?.dirty === true,
tail_critical: tail?.critical === true,
event_count: tail?.events?.length || 0,
dropped_count: tail?.dropped || 0,
local_storage_event_count:
tail?.events?.filter(
(event) => event?.area === 'local_storage',
).length || 0,
indexeddb_event_count:
tail?.events?.filter(
(event) => event?.area === 'indexeddb',
).length || 0,
initial_generation: initialDirtyPlan?.generation || 0,
final_generation: finalDirtyPlan?.generation || 0,
canonical_full_replace:
finalDirtyPlan?.fullReplace === true,
canonical_device_changed:
finalDirtyPlan?.device === true,
canonical_table_count:
finalDirtyPlan?.tables?.length || 0,
canonical_tables: finalDirtyPlan?.tables || [],
},
{ force: true },
);
checkpoint = undefined;
if (attempt < 4) {
await this.delay(attempt * 100);
continue;
}
break;
}
if (tail?.dirty) {
this.logNative(
`checkpoint.${checkpointStage}_noncritical_tail`,
{
attempt,
event_count: tail.events?.length || 0,
dropped_count: tail.dropped || 0,
local_storage_event_count:
tail.events?.filter(
(event) => event?.area === 'local_storage',
).length || 0,
indexeddb_event_count:
tail.events?.filter(
(event) => event?.area === 'indexeddb',
).length || 0,
canonical_table_count:
finalDirtyPlan?.tables?.length || 0,
canonical_tables: finalDirtyPlan?.tables || [],
},
{ force: true },
);
}
if (retainedAttestedProjection || retainPromotedProfileArtifact) {
this.profileDirty = true;
if (retainPromotedProfileArtifact) {
this.logNative(
'checkpoint.activation_ready_promoted_profile_retained',
{
reason: 'post_ponr_handoff_retry',
attempt,
pending_tail_retained: true,
},
{ force: true },
);
}
} else {
this.browserBridge.acknowledgeCanonicalDirtyPlan?.(
initialDirtyPlan,
);
await this.browserBridge.markClean();
this.profileDirty = false;
}
this.markCanonicalProjectionPersisted(canonicalProjection);
break;
}
if (!checkpoint) {
throw new Error(
'wwebjs_canonical_activation_checkpoint_mutated_during_upload',
);
}
if (
!checkpoint?.artifactId ||
!/^[0-9a-f-]{36}$/i.test(checkpoint.artifactId) ||
!/^[0-9a-f]{64}$/.test(checkpoint.checksumSha256 || '')
) {
throw new Error(
'wwebjs_canonical_activation_checkpoint_proof_invalid',
);
}
// This update deliberately happens only after the offline artifact
// and canonical tail are durable and stable. No network is released
// between this proof and the database finalization CAS.
if (finalizeActivation) {
// The gate reads canonical tables from PostgreSQL. Checkpoint it
// only after the frozen profile transaction replaced those rows,
// so recovery can never attest the pre-sync candidate artifact.
const appStateResyncGate =
await this.store.checkpointAppStateSnapshotResyncGate?.();
if (appStateResyncGate && !appStateResyncGate.ready) {
throw new Error(
'whatsapp_session_app_state_snapshot_resync_pending',
);
}
await this.store.markCanonicalActivationPending({
appStateHydrationRequired: false,
pqBootstrapRequired: false,
readyCheckpointArtifactId: checkpoint.artifactId,
readyCheckpointChecksumSha256: checkpoint.checksumSha256,
});
}
await this.store.renewLease();
this.store.assertLocalLease();
if (typeof this.client?.assertPublicEventGateHealthy !== 'function') {
throw new Error('wwebjs_public_event_gate_unavailable');
}
this.client.assertPublicEventGateHealthy();
if (finalizeActivation) {
await this.store.finalizeActivation();
this.canonicalActivationMarkerPresent = false;
} else if (this.store.revisionStatus !== 'active') {
throw new Error(
'wwebjs_canonical_active_restart_revision_not_active',
);
}
const reconnect = await this.browserBridge.rearmCanonicalImportedSocket(
{
timeoutMs: Math.min(
this.client.options.authTimeoutMs || 30000,
30000,
),
allowEstablished: true,
},
);
await this.browserBridge.releaseCanonicalImportedNetwork();
await this.browserBridge.waitForCanonicalConnected({
timeoutMs: Math.min(
180000,
Math.max(
60000,
(this.client.options.authTimeoutMs || 30000) * 4,
),
),
});
await this.assertCanonicalCandidateReady(
finalizeActivation
? 'activation_checkpoint_reconnected'
: 'active_restart_checkpoint_reconnected',
{ requirePublicEventGate: true },
);
this.client.assertPublicEventGateHealthy();
await this.browserBridge.disarmCanonicalCredentialGuard({
strict: true,
});
await this.browserBridge.disarmCanonicalPairingWatchdog();
this.logNative(
`checkpoint.${checkpointStage}_completed`,
{
socket_state: 'CONNECTED',
has_synced: true,
previous_socket_state: reconnect.previous_socket_state,
},
{ force: true },
);
return true;
}
shouldDelayPublicEventsUntilReady() {
return Boolean(
this.nativePostgres &&
!this.authReady &&
(this.store.isHandoffRevision?.() === true ||
this.canonicalReconnectProjection ||
this.canonicalActivationMarkerPresent ||
this.canonicalWebSocketGateRequired),
);
}
async onInitializationFailure(error) {
if (!this.nativePostgres) return;
this.logNativeFailure('client.initialization_failed', { error });
const failureCode = stableNativeFailureCode(
error,
'handoff_client_initialization_failed',
);
const failure = await this.finalizeNativeFailure(error, failureCode);
if (failure !== error) throw failure;
}
/**
* Client invokes this only after Chromium has been closed. Keeping local
* profile deletion and owned-pool shutdown on this side of that boundary
* prevents Chrome from recreating files while cleanup runs.
*/
async afterInitializationFailureBrowserClosed(initializationError) {
if (!this.nativePostgres) return;
if (!this.nativeFailurePostBrowserCleanupPromise) {
this.nativeFailurePostBrowserCleanupPromise = (async () => {
const failures = [];
if (!this.nativeFailureBrowserTerminationConfirmed) {
// Client may have completed browser.close() after the
// strategy's first kill attempt failed. Retry the fenced
// finalization now: a captured post-CAS projection must be
// checkpointed before any rollback or lease release.
this.nativeFailureStopPromise = undefined;
this.nativeFailureFinalizationPromise = undefined;
const retryPrimary =
initializationError instanceof Error
? initializationError
: new Error('wwebjs_client_initialization_failed');
const retried = await this.finalizeNativeFailure(
retryPrimary,
retryPrimary.code ||
'handoff_client_post_browser_close',
);
if (retried !== retryPrimary) failures.push(retried);
if (!this.nativeFailureBrowserTerminationConfirmed) {
const error = new Error(
'wwebjs_browser_termination_unconfirmed',
);
failures.push(error);
throw new AggregateError(
failures,
'wwebjs_native_post_browser_cleanup_failed',
{ cause: error },
);
}
}
try {
await this.removeLocalSession();
} catch (error) {
failures.push(error);
this.logNativeFailure(
'client.failure_local_profile_remove_failed',
{ error },
);
}
try {
await this.store.close();
} catch (error) {
failures.push(error);
this.logNativeFailure('client.failure_store_close_failed', {
error,
});
}
this.clearCanonicalCandidateReferences();
if (failures.length > 0) {
throw new AggregateError(
failures,
'wwebjs_native_post_browser_cleanup_failed',
);
}
})();
}
return this.nativeFailurePostBrowserCleanupPromise;
}
async logout({ preserveInitialPairing = false } = {}) {
const keepInitialPairingRevision =
preserveInitialPairing === true && this.isInitialPairingPending();
this.shutdownRequested = true;
this.stopBackupSync();
await this.checkpointTail.catch(() => {});
this.authReady = false;
if (this.nativePostgres) {
await this.browserBridge?.close();
if (keepInitialPairingRevision) {
this.logNative(
'pairing.restart_session_preserved',
{
revision_status: this.store.revisionStatus,
revision_source: this.store.revisionSource,
},
{ force: true },
);
} else {
await this.store.delete();
}
// A server-side/mobile logout navigates the still-open page back
// to pairing and Client immediately initializes this strategy
// again. Keep the pool reusable in that path; an explicit
// Client.logout() has already closed Chromium and can close it.
if (isBrowserConnected(this.client.pupBrowser)) {
await this.store.releaseLease();
} else {
await this.store.close();
}
} else {
await this.deleteRemoteSession();
}
this.canonicalNativeAppStateAuthoritative = false;
await this.removeLocalSession();
}
isInitialPairingPending() {
return Boolean(
this.nativePostgres &&
this.authReady !== true &&
this.store.revisionStatus === 'staging' &&
this.store.revisionSource === 'pairing',
);
}
async destroy() {
await this.shutdown();
}
async disconnect() {
this.shutdownRequested = true;
this.stopBackupSync();
// Client calls destroy() immediately after this hook. The final
// checkpoint belongs to shutdown(); saving here as well would rotate
// the active/previous revisions twice with the same Chromium profile.
await this.checkpointTail;
}
async beforeBrowserDestroyed() {
if (
!this.nativePostgres ||
(!this.authReady && !this.isCanonicalActivationRecoveryRequired())
) {
return;
}
if (this.isCanonicalActiveRestartAttestationPending()) {
this.logNative(
'checkpoint.shutdown_projection_skipped',
{ reason: 'active_restart_attestation_pending' },
{ force: true },
);
return;
}
this.stopBackupSync();
await this.checkpointTail;
if (!this.browserBridge) return;
if (this.shutdownProjection) return;
const browserConnected = isBrowserConnected(this.client?.pupBrowser);
const pageClosed = this.client?.pupPage?.isClosed?.() === true;
if (
this.nativeFailureBrowserTerminationConfirmed ||
!browserConnected ||
pageClosed
) {
// Failure cleanup may have already stopped Chromium, captured the
// recovery projection, checkpointed it, and released the lease.
// A dead Page is itself the definitive writer fence; asking the
// bridge for a second in-page quiescence proof can only manufacture
// a ProtocolError and turn a successful cleanup into a false
// shutdown failure.
this.logNative(
'checkpoint.shutdown_projection_skipped',
{
reason: 'runtime_already_stopped',
browser_connected: browserConnected,
page_closed: pageClosed,
failure_browser_stopped:
this.nativeFailureBrowserTerminationConfirmed === true,
},
{ force: true },
);
return;
}
let canonicalProjection;
try {
await this.browserBridge.quiesceNetwork();
await this.browserBridge.flush();
const browserProjection =
await this.browserBridge.exportCanonicalProjection();
// A promoted handoff can be shutting down while the browser is
// still catching up the source app-state. Persisting the
// browser-only view here would replace durable source keys/MACs
// with an incomplete projection and make the next restart
// unrecoverable.
const persistenceCandidate = this.pendingCanonicalAppStateCatchUp
? this.buildCanonicalOfflineCheckpointProjection(
browserProjection,
)
: browserProjection;
canonicalProjection =
this.reconcileCanonicalProjectionForPersistence(
persistenceCandidate,
);
this.shutdownProjection = { canonicalProjection };
this.shutdownProjectionCaptureFailed = false;
} catch (error) {
// Chromium can still mutate its LevelDB profile until the network
// fence and the matching canonical export have both succeeded.
// Client.destroy() deliberately closes Chromium even when this
// hook fails. Remember that missing proof so the post-close phase
// cannot combine the newer profile with an older, previously
// persisted projection and replace the last coherent artifact.
this.shutdownProjectionCaptureFailed = true;
const contextUnavailableAfterOffline = Boolean(
error?.code === 'wwebjs_network_quiescence_failed' &&
error?.details?.offline_command_applied === true &&
error?.details?.context_unavailable_after_offline === true,
);
this.logNativeFailure('checkpoint.shutdown_projection_failed', {
existing_profile_artifact_preserved: true,
context_unavailable_after_offline:
contextUnavailableAfterOffline,
error_code: safeFailureCode(
error?.code || error?.message,
'wwebjs_shutdown_projection_capture_failed',
),
});
if (contextUnavailableAfterOffline) {
// CDP accepted the target-wide offline fence, but WhatsApp's
// offline navigation replaced the document for the entire
// bounded proof window. There is no safe page projection to
// pair with the newer profile. Preserve the last coherent
// artifact and allow Client.destroy() to close Chromium; all
// other quiescence failures still propagate fail-closed.
this.logNative(
'checkpoint.shutdown_projection_context_preserved',
{
existing_profile_artifact_preserved: true,
transient_navigation_retries:
error.details.transient_navigation_retries,
},
{ force: true },
);
return;
}
throw error;
}
await this.browserBridge.close();
this.logNative('checkpoint.shutdown_projection_captured', {
canonical_record_count: canonicalProjection.record_count,
canonical_size_bytes: canonicalProjection.size_bytes,
});
}
/**
* Persists a final checkpoint without deleting the remote session, then
* removes the ephemeral Chromium profile.
*/
async shutdown() {
if (!this.shutdownPromise) {
this.shutdownPromise = this.performShutdown();
}
return this.shutdownPromise;
}
async performShutdown() {
this.shutdownRequested = true;
this.stopBackupSync();
let requireLeaseRelease = false;
await this.checkpointTail;
if (this.nativeFailureFinalizationPromise) {
await this.nativeFailureFinalizationPromise;
} else if (this.nativeFailureStopPromise) {
await this.nativeFailureStopPromise;
}
const failureBrowserAlreadyStopped =
this.nativeFailureBrowserTerminationConfirmed === true;
const activationRecoveryRequired =
this.isCanonicalActivationRecoveryRequired();
const activeRestartAttestationPending =
this.isCanonicalActiveRestartAttestationPending();
if (
!failureBrowserAlreadyStopped &&
!activeRestartAttestationPending &&
(this.authReady || activationRecoveryRequired)
) {
if (this.nativePostgres) {
if (
!this.shutdownProjection &&
isBrowserConnected(this.client.pupBrowser)
) {
await this.beforeBrowserDestroyed();
}
const canonicalProjection =
this.shutdownProjection?.canonicalProjection;
if (canonicalProjection) {
if (this.store.revisionStatus === 'active') {
await this.store.saveCanonicalProjection({
projection: canonicalProjection,
source: 'shutdown',
});
requireLeaseRelease = true;
this.logNative(
'checkpoint.shutdown_profile_anchor_completed',
{
profile_checkpoint_mode:
'immutable_profile_anchor_plus_fresh_canonical_v1',
canonical_record_count:
canonicalProjection.record_count,
canonical_size_bytes:
canonicalProjection.size_bytes,
},
{ force: true },
);
} else {
await this.checkpointStableNativeProfile(
{
profilePath: this.userDataDir,
canonicalProjection,
source:
this.store.revisionStatus === 'staging'
? this.store.revisionSource || 'pairing'
: 'checkpoint',
},
{ reason: 'shutdown' },
);
}
this.markCanonicalProjectionPersisted(canonicalProjection);
} else {
// A post-close profile is checkpointable only when its
// exact canonical projection was captured after the
// pre-close writer fence. Falling back to an older
// projection creates an internally inconsistent artifact
// while retiring the last known-good one.
this.logNative(
'checkpoint.shutdown_profile_preserved',
{
reason: this.shutdownProjectionCaptureFailed
? 'projection_capture_failed'
: 'projection_capture_unavailable',
existing_profile_artifact_preserved: true,
},
{ force: true },
);
}
} else {
await this.checkpoint({ force: true, reason: 'shutdown' });
}
}
this.authReady = false;
await this.browserBridge?.close();
if (this.nativePostgres) {
await this.store.close(
requireLeaseRelease ? { requireLeaseRelease: true } : undefined,
);
// Client.destroy()/logout() invokes this strategy only after the
// dedicated Chromium process has stopped. The released extension
// was deliberately left inert until this point.
if (!isBrowserConnected(this.client.pupBrowser)) {
this.canonicalWebSocketGateExtensionId = undefined;
this.canonicalWebSocketGateServiceWorker = undefined;
await this.disposeCanonicalWebSocketGateMonitor();
await this.removeCanonicalWebSocketGateStagingDir();
}
}
this.canonicalNativeAppStateAuthoritative = false;
await this.removeLocalSession();
}
async onAuthenticationNeeded() {
if (this.nativePostgres) {
const handoff = this.store.isHandoffRevision();
const pairingAllowed = await this.store.isPairingAllowed();
this.initialPairingAuthorized = pairingAllowed && !handoff;
if (!pairingAllowed) {
if (handoff) {
await this.rollbackHandoff('handoff_requested_qr');
}
return {
failed: true,
restart: false,
failureEventPayload: handoff
? 'wwebjs_handoff_requested_qr'
: 'wwebjs_postgres_session_requested_qr',
};
}
}
return {
failed: false,
restart: false,
failureEventPayload: undefined,
};
}
async restoreCanonicalAppStateAfterNativeIdle(sourceProjection) {
const maxAttempts = 3;
const startedAt = Date.now();
const deadlineAt = startedAt + this.canonicalAppStateCatchUpTimeoutMs;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const remainingForStage = () => {
const remainingMs = deadlineAt - Date.now();
if (remainingMs >= 1000) return remainingMs;
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_app_state_restore_window_unstable',
{ attempt_count: attempt - 1 },
);
};
const restoreWindowProof =
await this.browserBridge.waitForCanonicalAppStateRestoreIdle({
timeoutMs: Math.min(60000, remainingForStage()),
pollIntervalMs: Math.max(
100,
Math.min(1000, this.canonicalAppStatePollIntervalMs),
),
requiredStableSamples: 2,
});
await this.browserBridge.quiesceNetwork({
timeoutMs: Math.min(15000, remainingForStage()),
});
try {
return await this.browserBridge.restoreCanonicalAppState(
sourceProjection,
{
timeoutMs: Math.min(30000, remainingForStage()),
syncJobTtlMs: this.canonicalAppStateCatchUpTimeoutMs,
requireOffline: true,
passiveBlockedCollections: Array.isArray(
restoreWindowProof?.passive_blocked_collections,
)
? restoreWindowProof.passive_blocked_collections
: [],
passiveRetryCollections: Array.isArray(
restoreWindowProof?.passive_retry_collections,
)
? restoreWindowProof.passive_retry_collections
: [],
},
);
} catch (error) {
const errorCode = String(error?.code || error?.message || '');
if (
errorCode !==
'wwebjs_canonical_app_state_restore_window_changed'
) {
throw error;
}
this.logNative(
'canonical_activation.app_state_restore_window_retry',
{
attempt,
max_attempts: maxAttempts,
destructive_mutation_started: false,
duration_ms: Date.now() - startedAt,
},
{ force: true },
);
if (attempt >= maxAttempts) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_app_state_restore_window_unstable',
{
attempt_count: attempt,
destructive_mutation_started: false,
},
{ cause: error },
);
}
await this.browserBridge.resumeNetwork();
const reconnectRemainingMs = remainingForStage();
await this.browserBridge.waitForCanonicalConnected({
timeoutMs: Math.min(30000, reconnectRemainingMs),
requireSynced: true,
});
}
}
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_app_state_restore_window_unstable',
{ attempt_count: maxAttempts },
);
}
async completePendingCanonicalAppStateBeforeReadiness() {
if (!this.pendingCanonicalAppStateCatchUp) return false;
if (
this.pendingCanonicalAppStateSyncKeyCount <= 0 ||
!this.handoffSourceCanonicalProjection
) {
throw new Error('wwebjs_canonical_app_state_sync_keys_missing');
}
const sourceProjection = this.handoffSourceCanonicalProjection;
const authoritativeNativeReset = Boolean(
this.canonicalActiveRestartReplay &&
this.canonicalActiveRestartAppStateOverlayRequired,
);
// The authenticated transport keeps its final database encryption
// material and stream behind WhatsApp's official critical-sync
// acknowledgement. The canonical source snapshot is already fully
// validated and public events remain fenced, so persist that durable
// acknowledgement before using the product API to restore encrypted
// sync keys and run the real full collection sync.
await this.browserBridge.finalizeCanonicalAppStateReadiness({
timeoutMs: Math.min(30000, this.canonicalAppStateCatchUpTimeoutMs),
});
// The authenticated transport may already have started WhatsApp's
// native collection scheduler. Let it reach a proven idle boundary
// while online, then fence the network and replace encrypted material.
// If work appears in the small barrier-to-fence window, retry only
// before any clear/write has started.
const restoreResult =
await this.restoreCanonicalAppStateAfterNativeIdle(
sourceProjection,
);
if (
!Number.isSafeInteger(restoreResult?.sync_job_generation) ||
restoreResult.sync_job_generation <= 0
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_app_state_sync_generation_invalid',
);
}
await this.browserBridge.resumeNetwork();
await this.browserBridge.startCanonicalAppStateSync({
expectedGeneration: restoreResult.sync_job_generation,
syncJobTtlMs: this.canonicalAppStateCatchUpTimeoutMs,
});
const caughtUp =
await this.browserBridge.waitForCanonicalAppStateCatchUp(
sourceProjection,
{
timeoutMs: this.canonicalAppStateCatchUpTimeoutMs,
pollIntervalMs: this.canonicalAppStatePollIntervalMs,
expectedSyncGeneration: restoreResult.sync_job_generation,
allowPreservedNativeAppStateBaseline:
authoritativeNativeReset,
},
);
// A completed official sync can leave WWeb's native store with
// browser-only future mutation rows. The bridge then returns a
// portable projection that deliberately preserves the snapshot-resync
// marker. Keep that marker durable: the active WWeb profile is usable,
// while a later Baileys/WhatsMeow handoff must still request an
// authenticated server snapshot instead of importing those rows.
const snapshotResyncPreserved =
caughtUp.snapshot_resync_preserved === true;
this.pendingCanonicalAppStateProjection =
this.reconcileCanonicalProjectionForPersistence(
caughtUp.projection,
{
baseline: sourceProjection,
clearSnapshotResyncMarker: !snapshotResyncPreserved,
preserveNativeAppState: authoritativeNativeReset,
},
);
if (authoritativeNativeReset) {
// The official generation plus the two-sample native attestation
// has superseded the stale canonical overlay. Keep the live
// profile authoritative through identity validation; the final
// active-restart checkpoint will rotate profile and canonical
// together while the persistent overlay flag is still present.
this.canonicalNativeAppStateAuthoritative = true;
}
this.pendingCanonicalAppStateCatchUp = false;
this.pendingCanonicalAppStateSyncKeyCount = 0;
this.preserveExistingProfileArtifactDuringCanonicalCatchUp = false;
this.logNative(
'canonical_activation.pre_ready_app_state_completed',
{
collection_count: caughtUp.collection_count,
duration_ms: caughtUp.duration_ms,
sync_job_generation: restoreResult.sync_job_generation,
authoritative_native_reset: authoritativeNativeReset,
},
{ force: true },
);
return true;
}
/**
* The initial PostgreSQL pairing checkpoint races with the last app-state
* IndexedDB transaction even after the readiness sample was stable. The
* live Chromium profile can also change while its artifact is streamed.
* Retry only those two explicit transient conditions and keep every other
* checkpoint failure fatal.
*/
async checkpointInitialNativePairingReady() {
const startedAt = Date.now();
const deadlineAtMs = startedAt + this.canonicalAppStateCatchUpTimeoutMs;
let attempt = 0;
let lastPendingDetails;
let lastTransient = 'app_state';
while (Date.now() < deadlineAtMs) {
attempt += 1;
try {
return await this.checkpoint({
emit: true,
force: true,
reason: 'ready',
connectedProfileSnapshot: true,
});
} catch (error) {
const errorCode = String(error?.code || error?.message || '');
const profileChanged =
errorCode === PROFILE_CHANGED_DURING_CHECKPOINT;
const appStatePending =
errorCode ===
'wwebjs_canonical_app_state_checkpoint_pending';
if (!profileChanged && !appStatePending) {
throw error;
}
lastPendingDetails = error.details;
lastTransient = profileChanged ? 'profile' : 'app_state';
const elapsedMs = Date.now() - startedAt;
const remainingMs = deadlineAtMs - Date.now();
this.logNative(
profileChanged
? 'checkpoint.initial_pairing_profile_unstable'
: 'checkpoint.initial_pairing_app_state_pending',
{
attempt,
duration_ms: elapsedMs,
remaining_ms: Math.max(0, remainingMs),
...(profileChanged && error.details?.path
? { changed_path: error.details.path }
: {}),
},
{ force: true },
);
if (profileChanged) {
if (remainingMs <= 0) break;
const retryDelayMs = Math.min(
remainingMs,
Math.min(2000, Math.max(250, attempt * 250)),
);
await this.delay(retryDelayMs);
continue;
}
if (
remainingMs <= 0 ||
!this.lastCanonicalProjection ||
typeof this.browserBridge
.waitForCanonicalAppStateCatchUp !== 'function'
) {
break;
}
try {
await this.browserBridge.waitForCanonicalAppStateCatchUp(
this.lastCanonicalProjection,
{
timeoutMs: this.canonicalAppStateCatchUpTimeoutMs,
pollIntervalMs:
this.canonicalAppStatePollIntervalMs,
deadlineAtMs,
},
);
} catch (caughtError) {
if (
caughtError?.code !==
'wwebjs_canonical_app_state_catch_up_timeout'
) {
throw caughtError;
}
lastPendingDetails =
caughtError.details || lastPendingDetails;
break;
}
}
}
throw new WhatsAppSessionCompatibilityError(
lastTransient === 'profile'
? 'wwebjs_canonical_initial_profile_stability_timeout'
: 'wwebjs_canonical_initial_app_state_stability_timeout',
{
phase: 'ready_checkpoint',
attempt_count: attempt,
duration_ms: Date.now() - startedAt,
transient: lastTransient,
...(lastPendingDetails
? { last_pending: lastPendingDetails }
: {}),
},
);
}
async afterAuthReady() {
const readinessDocumentEpoch = this.client?._connectionDocumentEpoch;
const readinessNavigationSequence =
this.client?._mainFrameNavigationSequence;
const readinessRuntimeEpoch = this.client?._connectionRuntimeEpoch;
const readinessPage = this.client?.pupPage;
const durableReadyNavigationSuccessor = Boolean(
this.nativePostgres &&
this.authReady === true &&
this.store.revisionStatus === 'active' &&
Number.isSafeInteger(readinessRuntimeEpoch) &&
this.nativeReadyCheckpointRuntimeEpoch === readinessRuntimeEpoch &&
!this.canonicalActiveRestartReplay &&
!this.canonicalActivationMarkerPresent &&
!this.canonicalCandidatePromoted &&
!this.providerHandoffCheckpointAdmissionClosed &&
!this.shutdownRequested,
);
const initialPairingReadyNavigationSuccessor =
this.isInitialPairingReadyNavigationSuccessorAllowed({
runtimeEpoch: readinessRuntimeEpoch,
documentEpoch: readinessDocumentEpoch,
navigationSequence: readinessNavigationSequence,
});
const readinessWasSuperseded = () =>
readinessDocumentEpoch !== this.client?._connectionDocumentEpoch ||
readinessNavigationSequence !==
this.client?._mainFrameNavigationSequence;
const navigationSupersededReadiness = async (error) => {
const errorCode = String(error?.code || error?.message || '');
const replacementDocumentProjectionPending =
errorCode === 'wwebjs_canonical_projection_incomplete';
const replacementDocumentIdentityPending =
isTransientCompanionIdentityReadError(error);
if (
!isTransientNavigationError(error) &&
!replacementDocumentProjectionPending &&
!replacementDocumentIdentityPending
) {
return false;
}
if (readinessWasSuperseded()) return true;
// Puppeteer can reject Runtime.callFunctionOn a few milliseconds
// before emitting the matching main-frame navigation event. Give
// only the fenced legacy-volume bootstrap a short observation
// window before destructive finalization. The same Page and
// runtime must remain authoritative; browser/page replacement is
// not inferred as a recoverable navigation.
const deadline =
Date.now() + LEGACY_VOLUME_NAVIGATION_SETTLE_TIMEOUT_MS;
while (Date.now() < deadline) {
if (
this.client?._connectionRuntimeEpoch !==
readinessRuntimeEpoch ||
(readinessPage !== undefined &&
this.client?.pupPage !== readinessPage) ||
readinessPage?.isClosed?.() === true
) {
return false;
}
await new Promise((resolve) =>
setTimeout(
resolve,
LEGACY_VOLUME_NAVIGATION_SETTLE_POLL_MS,
),
);
if (readinessWasSuperseded()) return true;
}
return false;
};
const sessionExists = this.nativePostgres
? this.store.revisionStatus === 'active' ||
this.store.isHandoffRevision()
: await this.store.sessionExists({
session: this.sessionName,
});
const legacyVolumeMigrationBootstrap = Boolean(
this.nativePostgres &&
this.store.revisionSource === 'legacy_volume_migration' &&
['staging', 'validating'].includes(this.store.revisionStatus),
);
const initialNativePairing = Boolean(
this.nativePostgres &&
this.initialPairingAuthorized &&
!sessionExists &&
!this.lastCanonicalProjection,
);
// The database-backed empty-pairing proof is a one-shot capability.
// A second readiness attempt must obtain a fresh proof instead of
// reusing authorization from a failed or superseded QR lifecycle.
this.initialPairingAuthorized = false;
this.authReady = false;
if (
!sessionExists &&
!legacyVolumeMigrationBootstrap &&
!initialPairingReadyNavigationSuccessor &&
this.initialSyncDelayMs > 0
) {
await this.delay(this.initialSyncDelayMs);
}
this.assertNotShuttingDown();
// READY must only be published after at least one durable checkpoint.
if (this.nativePostgres) {
try {
if (this.pendingCanonicalAppStateCatchUp) {
throw new Error(
'wwebjs_canonical_app_state_pre_ready_incomplete',
);
}
let readinessBrowserProjection;
const completesSnapshotResync = Boolean(
this.pendingCanonicalAppStateProjection &&
normalizedSnapshotResyncCollections(
this.pendingCanonicalAppStateProjection.capabilities,
{ optional: true },
).length === 0,
);
if (this.pendingCanonicalAppStateProjection) {
readinessBrowserProjection =
this.pendingCanonicalAppStateProjection;
} else if (
initialNativePairing ||
initialPairingReadyNavigationSuccessor ||
legacyVolumeMigrationBootstrap
) {
if (
typeof this.browserBridge
.waitForInitialCanonicalAppStateStability !==
'function'
) {
throw new WhatsAppSessionCompatibilityError(
legacyVolumeMigrationBootstrap
? 'wwebjs_canonical_legacy_volume_app_state_gate_unavailable'
: 'wwebjs_canonical_initial_app_state_gate_unavailable',
);
}
if (legacyVolumeMigrationBootstrap) {
if (
typeof this.browserBridge.assertCompatible !==
'function'
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_legacy_volume_runtime_gate_unavailable',
);
}
// The official app-state synchronizer can replace the
// main document while a LocalAuth profile is being
// promoted. A successor document may report
// authenticated before its private module registry is
// fully rehydrated. Require the complete canonical ABI
// for a bounded interval before exporting. This never
// accepts an incomplete projection: a permanently
// incompatible runtime still fails closed.
await this.browserBridge.assertCompatible({
waitForModulesMs:
LEGACY_VOLUME_RUNTIME_CAPABILITY_WAIT_MS,
pollIntervalMs: 250,
capabilityStage: 'runtime',
});
if (readinessWasSuperseded()) {
const superseded = new Error(
'wwebjs_legacy_volume_navigation_superseded',
);
superseded.code = superseded.message;
throw superseded;
}
}
// A legacy LocalAuth profile is already authenticated, but
// it has no canonical PostgreSQL baseline yet. Treat its
// first browser projection like the first durable pairing
// snapshot: wait for two compatible, complete samples and
// let the official app-state synchronizer resolve only the
// bounded transient cases. A one-shot export here races
// IndexedDB materialization and can reject a valid profile.
const stability =
await this.browserBridge.waitForInitialCanonicalAppStateStability(
{
timeoutMs:
this.canonicalAppStateCatchUpTimeoutMs,
pollIntervalMs:
this.canonicalAppStatePollIntervalMs,
allowLegacyProfileTransientProjection:
legacyVolumeMigrationBootstrap,
},
);
readinessBrowserProjection = stability.projection;
} else if (
this.canonicalActiveRestartReplay ||
this.canonicalReusableHandoffNativeAppState
) {
if (
!this.canonicalReconnectProjection ||
typeof this.browserBridge
.waitForCanonicalAppStateCatchUp !== 'function'
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_canonical_active_restart_stability_gate_unavailable',
);
}
// The restored native profile owns app-state on an active
// same-provider restart. Canonical replay may update
// Signal/device material, but must not clear or force a
// full app-state sync. Require two mutually compatible
// live exports before identity validation instead. The
// bridge treats only the documented partial collection
// row as transient during this bounded wait.
const stability =
await this.browserBridge.waitForCanonicalAppStateCatchUp(
this.canonicalReconnectProjection,
{
timeoutMs:
this.canonicalAppStateCatchUpTimeoutMs,
pollIntervalMs:
this.canonicalAppStatePollIntervalMs,
allowPreservedNativeAppStateBaseline:
this
.preserveExistingProfileArtifactDuringCanonicalCatchUp ||
this.canonicalReusableHandoffNativeAppState,
},
);
readinessBrowserProjection = stability.projection;
} else {
readinessBrowserProjection =
await this.browserBridge.exportCanonicalProjection();
}
const readinessCanonicalProjection =
this.reconcileCanonicalProjectionForPersistence(
readinessBrowserProjection,
{
clearSnapshotResyncMarker: completesSnapshotResync,
preserveNativeAppState:
// The frozen restart checkpoint clears replay
// references before Client can publish READY.
// A same-runtime successor must retain the
// authority already attested by that checkpoint,
// just like subsequent critical checkpoints.
// Otherwise legitimate browser-private future
// MACs are incorrectly treated as a pending
// cross-provider import and stop a valid session.
(durableReadyNavigationSuccessor &&
this
.canonicalNativeAppStateAuthoritative ===
true) ||
this.canonicalReusableHandoffNativeAppState ===
true ||
(this.canonicalActiveRestartReplay === true &&
(this
.canonicalNativeAppStateAuthoritative ===
true ||
!this
.canonicalActiveRestartAppStateOverlayRequired)),
},
);
let resolvedIdentity = {};
if (this.identityResolver) {
let lastIdentityReadError;
const maxIdentityReadAttempts =
legacyVolumeMigrationBootstrap
? LEGACY_VOLUME_IDENTITY_READ_MAX_ATTEMPTS
: 1;
for (
let attempt = 1;
attempt <= maxIdentityReadAttempts;
attempt += 1
) {
try {
resolvedIdentity = await this.identityResolver(
this.client,
);
lastIdentityReadError = undefined;
break;
} catch (error) {
lastIdentityReadError = error;
if (
!legacyVolumeMigrationBootstrap ||
!isTransientCompanionIdentityReadError(error) ||
attempt >= maxIdentityReadAttempts
) {
throw error;
}
// The projection has not been persisted yet. A
// transient lazy-store getter failure can be
// retried safely in the same document; a document
// replacement is handed to Client's serialized
// successor flight instead.
if (readinessWasSuperseded()) throw error;
this.logNative(
'session.identity_validation_read_retry',
{
attempt,
max_attempts: maxIdentityReadAttempts,
retry_delay_ms:
LEGACY_VOLUME_IDENTITY_READ_RETRY_MS,
},
{ force: true },
);
await this.delay(
LEGACY_VOLUME_IDENTITY_READ_RETRY_MS,
);
if (readinessWasSuperseded()) throw error;
}
}
if (lastIdentityReadError) throw lastIdentityReadError;
}
const trustedFingerprint =
resolvedIdentity?.companionFingerprint ||
resolvedIdentity?.deviceFingerprint;
if (
this.requireFingerprintVerification &&
resolvedIdentity?.moduleAbi !== CANONICAL_PRIVATE_MODULE_ABI
) {
throw new Error('wwebjs_companion_identity_abi_mismatch');
}
this.reconcileResolvedIdentityWebVersion(resolvedIdentity);
if (
this.requireFingerprintVerification &&
!trustedFingerprint
) {
throw new Error(
'wwebjs_companion_fingerprint_not_verified',
);
}
const browserJid =
this.client.info?.wid?._serialized ||
this.client.info?.wid?.user;
if (
resolvedIdentity?.jid &&
comparableJid(resolvedIdentity.jid) !==
comparableJid(browserJid)
) {
throw new Error('wwebjs_identity_resolver_jid_mismatch');
}
const identityPreflight =
await this.store.preflightReadyIdentity({
jid: browserJid,
companionFingerprint: trustedFingerprint,
canonicalProjection: readinessCanonicalProjection,
});
const activationCheckpointOwnsProjection =
this.canonicalActivationMarkerPresent === true;
if (this.canonicalActiveRestartReplay) {
// The active revision's last profile artifact and its
// canonical projection are one crash-consistency unit.
// Keep the live-attested projection in memory until the
// final profile checkpoint persists both together.
this.canonicalActiveRestartAttestedProjection =
identityPreflight.canonicalProjection;
} else if (activationCheckpointOwnsProjection) {
// The official app-state sync can legitimately leave
// browser-only future MAC rows in Chromium. Retain the
// exact portable projection that passed identity
// preflight so the final frozen profile checkpoint
// reconciles live browser progress against this new
// authority, rather than against the retired provider's
// source snapshot.
this.canonicalActivationReadyProjection =
identityPreflight.canonicalProjection;
} else if (!activationCheckpointOwnsProjection) {
await this.store.saveCanonicalProjection({
projection: identityPreflight.canonicalProjection,
source: 'ready_preflight',
});
}
// A post-PONR target remains `preparing` until the final
// activation CAS. Its old candidate artifact is intentionally
// not eligible for ordinary active-anchor adoption. Let the
// frozen activation checkpoint publish the live profile and
// this preflighted canonical authority as one new unit, then
// evaluate the resync gate from those durable bytes.
const appStateResyncGate = activationCheckpointOwnsProjection
? undefined
: await this.store.checkpointAppStateSnapshotResyncGate?.();
if (appStateResyncGate && !appStateResyncGate.ready) {
throw new Error(
'whatsapp_session_app_state_snapshot_resync_pending',
);
}
if (
!this.canonicalActiveRestartReplay &&
!activationCheckpointOwnsProjection
) {
this.markCanonicalProjectionPersisted(
identityPreflight.canonicalProjection,
);
}
this.pendingCanonicalAppStateProjection = undefined;
const validation = await this.store.validateReadyIdentity({
jid: browserJid,
companionFingerprint: trustedFingerprint,
});
if (
this.requireFingerprintVerification &&
(!trustedFingerprint ||
(!validation.fingerprintVerified &&
!validation.fingerprintRegistered))
) {
throw new Error(
'wwebjs_companion_fingerprint_not_verified',
);
}
if (this.pendingPostQuantumBootstrap) {
try {
if (this.pendingPostQuantumRollbackRecovery) {
// An intent is sufficient: the delete RPC may
// have reached the server before the source
// crashed. Recreate and upload a fresh bundle,
// persist the canonical result under the current
// fence, and only then remove the recovery marker.
const pqRecovery =
await this.browserBridge.recoverPostQuantumAfterClassicalHandoffAbort();
const recoveredBrowserProjection =
await this.browserBridge.exportCanonicalProjection();
const recoveredProjection =
this.reconcileCanonicalProjectionForPersistence(
recoveredBrowserProjection,
{
preserveNativeAppState:
this
.canonicalActiveRestartReplay ===
true &&
(this
.canonicalNativeAppStateAuthoritative ===
true ||
!this
.canonicalActiveRestartAppStateOverlayRequired),
},
);
await this.store.saveCanonicalProjection({
projection: recoveredProjection,
source: 'startup_pq_source_recovery',
});
await this.store.completePostQuantumServerRollbackRecovery(
this.pendingPostQuantumRollbackRecovery,
pqRecovery,
);
this.markCanonicalProjectionPersisted(
recoveredProjection,
);
this.pendingPostQuantumRollbackRecovery = undefined;
} else {
await this.browserBridge.ensurePostQuantumReady();
}
this.pendingPostQuantumBootstrap = false;
} catch (error) {
let persistenceError;
try {
await this.checkpoint({
force: true,
reason: 'post_quantum_failure_state',
});
} catch (caughtError) {
persistenceError = caughtError;
}
if (persistenceError) {
throw new AggregateError(
[error, persistenceError],
'wwebjs_post_quantum_state_persistence_failed',
{ cause: error },
);
}
throw error;
}
}
} catch (error) {
this.authReady = false;
if (
(legacyVolumeMigrationBootstrap ||
initialPairingReadyNavigationSuccessor ||
durableReadyNavigationSuccessor) &&
(await navigationSupersededReadiness(error))
) {
// The replacement document owns the serialized successor
// readiness flight in Client. This pre-checkpoint attempt
// has no authority to stop Chromium, roll back the
// candidate or release the lease. The successor reruns
// every identity and generation proof on the fresh realm.
// This exception is intentionally limited to a fenced
// same-runtime successor. A fresh pairing only enters
// this path after identity preflight established durable
// canonical authority for the staging revision; an
// unproven QR lifecycle still needs a new one-shot proof.
this.logNative(
'session.identity_validation_navigation_superseded',
{
error_code: stableNativeFailureCode(
error,
'wwebjs_navigation_context_replaced',
),
navigation_sequence: readinessNavigationSequence,
current_navigation_sequence:
this.client?._mainFrameNavigationSequence,
document_epoch: readinessDocumentEpoch,
current_document_epoch:
this.client?._connectionDocumentEpoch,
},
{ force: true },
);
if (isTransientNavigationError(error)) throw error;
const superseded = new Error(
'wwebjs_legacy_volume_navigation_superseded',
{ cause: error },
);
superseded.code = superseded.message;
throw superseded;
}
let errorCode = 'handoff_identity_validation_failed';
if (this.store.isHandoffRevision()) {
errorCode =
error.code ||
(/^wwebjs_[a-z0-9_]+$/u.test(error.message || '')
? error.message
: 'handoff_identity_validation_failed');
}
this.logNativeFailure('session.identity_validation_failed', {
...safeAggregateErrorDetails(error),
error,
});
throw await this.finalizeNativeFailure(error, errorCode);
}
}
if (this.nativePostgres && this.canonicalActiveRestartReplay) {
// Arm the persistent mode only after the two-sample browser gate,
// companion identity preflight and durable canonical validation
// all succeeded. It intentionally survives clearing the transient
// replay references so every later checkpoint keeps the live
// native profile's app-state bytes and the original marker.
this.canonicalNativeAppStateAuthoritative = true;
}
let persisted;
let canonicalReadyCheckpoint = false;
try {
if (this.nativePostgres && this.canonicalActivationMarkerPresent) {
persisted = await this.checkpointCanonicalActivationReady();
canonicalReadyCheckpoint = true;
} else if (
this.nativePostgres &&
this.canonicalActiveRestartReplay
) {
persisted = await this.checkpointCanonicalActiveRestartReady();
canonicalReadyCheckpoint = true;
} else if (durableReadyNavigationSuccessor) {
persisted = await this.checkpointReadyNavigationSuccessor({
runtimeEpoch: readinessRuntimeEpoch,
documentEpoch: readinessDocumentEpoch,
navigationSequence: readinessNavigationSequence,
});
} else if (
initialNativePairing ||
initialPairingReadyNavigationSuccessor ||
legacyVolumeMigrationBootstrap
) {
if (
initialNativePairing &&
Number.isSafeInteger(readinessRuntimeEpoch)
) {
// Identity preflight and the canonical ready projection
// are durable before the live Chromium artifact starts.
// Preserve only this same-runtime provenance so a normal
// WhatsApp main-frame replacement during the frozen
// profile checkpoint can continue on its successor
// document without accepting a new or unrelated QR flow.
this.nativeInitialPairingReadyRuntimeEpoch =
readinessRuntimeEpoch;
}
persisted = await this.checkpointInitialNativePairingReady();
} else {
persisted = await this.checkpoint({
emit: true,
force: true,
reason: 'ready',
});
}
} catch (error) {
this.authReady = false;
if (
(legacyVolumeMigrationBootstrap ||
initialNativePairing ||
initialPairingReadyNavigationSuccessor ||
durableReadyNavigationSuccessor) &&
(await navigationSupersededReadiness(error))
) {
throw error;
}
if (this.nativePostgres) {
this.nativeInitialPairingReadyRuntimeEpoch = undefined;
throw await this.finalizeNativeFailure(
error,
error.code || 'handoff_checkpoint_failed',
);
}
throw error;
}
if (!persisted) {
if (durableReadyNavigationSuccessor && readinessWasSuperseded()) {
const superseded = new Error(
'wwebjs_legacy_volume_navigation_superseded',
);
superseded.code = superseded.message;
throw superseded;
}
const error = new Error(
'Unable to persist the initial RemoteAuth checkpoint.',
);
if (this.nativePostgres) {
this.nativeInitialPairingReadyRuntimeEpoch = undefined;
throw await this.finalizeNativeFailure(
error,
'handoff_checkpoint_not_persisted',
);
}
throw error;
}
if (
this.nativePostgres &&
this.store.revisionStatus === 'active' &&
Number.isSafeInteger(readinessRuntimeEpoch)
) {
// A same-runtime main-frame replacement can occur after the
// initial profile promotion but before Client publishes READY.
// Retain narrowly scoped durable provenance so the successor
// document can repeat identity validation and append a canonical
// checkpoint without treating the already-promoted revision as a
// fresh active-process restart.
this.nativeReadyCheckpointRuntimeEpoch = readinessRuntimeEpoch;
}
this.nativeInitialPairingReadyRuntimeEpoch = undefined;
if (this.nativePostgres) {
if (this.canonicalActivationMarkerPresent) {
throw new Error(
'wwebjs_canonical_activation_finalize_incomplete',
);
}
if (canonicalReadyCheckpoint) {
// Once the frozen ready checkpoint has committed, Chromium's
// active WWeb profile is the app-state authority. Official
// sync can keep browser-only mutation MAC rows above the
// portable collection watermark; every later periodic or
// critical checkpoint must therefore pass through the native
// portable normalizer instead of comparing those rows
// strictly against the just-committed activation baseline.
// The activation checkpoint itself already used the exact
// identity-preflighted baseline, so arm this mode only after
// that crash-consistent profile+canonical commit succeeds.
this.canonicalNativeAppStateAuthoritative = true;
}
if (canonicalReadyCheckpoint || durableReadyNavigationSuccessor) {
// Navigation may discard the first document's gated signal.
// The successor has just committed its own fenced checkpoint;
// notify the application before Client publishes READY again.
this.client.emit(Events.REMOTE_SESSION_SAVED);
}
if (initialNativePairing || legacyVolumeMigrationBootstrap) {
const activePairingSnapshotResyncCollections =
this.store.revisionStatus === 'active'
? normalizedSnapshotResyncCollections(
this.getPersistedCanonicalProjection()
?.capabilities,
{ optional: true },
)
: [];
// A fresh pairing normally stays on the strict canonical
// reconciler. The sole exception is the explicit
// snapshot-resync fallback: Chromium's complete native
// profile remains authoritative while the active projection
// carries that marker. Handoff targets never enter here.
this.canonicalNativeAppStateAuthoritative =
activePairingSnapshotResyncCollections.length > 0;
}
this.preserveExistingProfileArtifactDuringCanonicalCatchUp = false;
this.clearCanonicalCandidateReferences();
this.canonicalCandidatePromoted = false;
}
this.assertNotShuttingDown();
this.authReady = true;
this.initialPairingAuthorized = false;
if (
this.nativePostgres &&
(this.profileDirty || this.browserBridge?.dirty)
) {
this.scheduleCriticalCheckpoint();
}
this.startBackupSync();
}
startBackupSync() {
if (this.backupSync) return;
this.backupSync = setInterval(() => {
void this.checkpoint({
periodic: true,
reason: 'periodic',
}).catch(() => {});
}, this.backupSyncIntervalMs);
}
/**
* Queues a serialized durable checkpoint. Periodic calls are coalesced so
* a slow store can never build an unbounded interval backlog.
*/
checkpoint({
emit = false,
force = false,
periodic = false,
reason = 'manual',
connectedProfileSnapshot = false,
} = {}) {
if (this.providerHandoffCheckpointAdmissionClosed) {
return Promise.resolve(false);
}
if (this.isCanonicalActiveRestartAttestationPending()) {
return Promise.resolve(false);
}
if (!force && !this.authReady) return Promise.resolve(false);
if (
this.nativePostgres &&
periodic &&
!this.profileDirty &&
!this.browserBridge?.dirty
) {
return Promise.resolve(false);
}
if (periodic && this.periodicCheckpoint) {
return this.periodicCheckpoint;
}
const queued = this.checkpointTail.then(() =>
this.performCheckpoint({
emit,
force,
reason,
connectedProfileSnapshot,
}),
);
this.checkpointTail = queued.catch(() => {});
if (periodic) {
this.periodicCheckpoint = queued;
void queued
.finally(() => {
if (this.periodicCheckpoint === queued) {
this.periodicCheckpoint = undefined;
}
})
.catch(() => {});
}
return queued;
}
isReadyNavigationSuccessorCheckpointAllowed({
runtimeEpoch,
documentEpoch,
navigationSequence,
}) {
return Boolean(
this.nativePostgres &&
this.store.revisionStatus === 'active' &&
Number.isSafeInteger(runtimeEpoch) &&
this.nativeReadyCheckpointRuntimeEpoch === runtimeEpoch &&
this.client?._connectionRuntimeEpoch === runtimeEpoch &&
this.client?._connectionDocumentEpoch === documentEpoch &&
this.client?._mainFrameNavigationSequence === navigationSequence &&
!this.canonicalActiveRestartReplay &&
!this.canonicalActivationMarkerPresent &&
!this.canonicalCandidatePromoted &&
!this.providerHandoffCheckpointAdmissionClosed &&
!this.shutdownRequested,
);
}
isInitialPairingReadyNavigationSuccessorAllowed({
runtimeEpoch,
documentEpoch,
navigationSequence,
}) {
return Boolean(
this.nativePostgres &&
this.authReady !== true &&
this.store.revisionStatus === 'staging' &&
this.store.revisionSource === 'pairing' &&
Number.isSafeInteger(runtimeEpoch) &&
this.nativeInitialPairingReadyRuntimeEpoch === runtimeEpoch &&
this.client?._connectionRuntimeEpoch === runtimeEpoch &&
this.client?._connectionDocumentEpoch === documentEpoch &&
this.client?._mainFrameNavigationSequence === navigationSequence &&
this.lastPersistedCanonicalProjection?.tables &&
!this.canonicalActiveRestartReplay &&
!this.canonicalActivationMarkerPresent &&
!this.canonicalCandidatePromoted &&
!this.providerHandoffCheckpointAdmissionClosed &&
!this.shutdownRequested,
);
}
checkpointReadyNavigationSuccessor(expected) {
if (!this.isReadyNavigationSuccessorCheckpointAllowed(expected)) {
return Promise.resolve(false);
}
const queued = this.checkpointTail.then(() => {
if (!this.isReadyNavigationSuccessorCheckpointAllowed(expected)) {
return false;
}
return this.performCanonicalCheckpoint(
'ready_navigation_successor',
{ allowPreReadyNavigationSuccessor: expected },
);
});
this.checkpointTail = queued.catch(() => {});
return queued;
}
async storeRemoteSession(options = {}) {
return this.checkpoint({ emit: options.emit, force: true });
}
async checkpointStableNativeProfile(
options,
{ reason = 'checkpoint', maxAttempts = 4 } = {},
) {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return await this.store.checkpointProfile(options);
} catch (error) {
const errorCode = String(error?.code || error?.message || '');
if (
errorCode !== PROFILE_CHANGED_DURING_CHECKPOINT ||
attempt >= maxAttempts
) {
throw error;
}
this.profileDirty = true;
this.logNative(
'checkpoint.profile_snapshot_retry',
{
reason,
attempt,
max_attempts: maxAttempts,
mutation_phase: safeFailureCode(
error?.details?.phase,
'profile_changed',
),
},
{ force: true },
);
await this.delay(attempt * 100);
}
}
throw new Error('wwebjs_profile_checkpoint_retry_exhausted');
}
canonicalProjectionSnapshotValidator(canonicalProjection) {
return async () => {
// buildProfileArtifact proves that the selected LevelDB/profile
// inventory stayed stable while it was streamed. Close the
// remaining temporal gap before persistRevision: a canonical
// writer may have advanced after the first export but before the
// artifact metadata transaction.
await this.browserBridge.flush();
const validationBrowserProjection =
await this.browserBridge.exportCanonicalProjection();
const validationCanonicalProjection =
this.reconcileCanonicalProjectionForPersistence(
validationBrowserProjection,
);
if (
!canonicalProjectionSnapshotsEqual(
canonicalProjection,
validationCanonicalProjection,
)
) {
throw profileProjectionChangedError(
canonicalProjection,
validationCanonicalProjection,
);
}
};
}
async performCheckpoint({
emit,
force,
reason,
connectedProfileSnapshot = false,
}) {
if (this.isCanonicalActiveRestartAttestationPending()) return false;
const pathExists = await this.isValidPath(this.userDataDir);
if (!pathExists) return false;
if (this.nativePostgres) {
if (!this.browserBridge) {
throw new Error('wwebjs_browser_bridge_unavailable');
}
if (
this.store.revisionStatus === 'active' &&
reason !== 'post_quantum_failure_state'
) {
const persisted = await this.performCanonicalCheckpoint(reason);
if (persisted && emit) {
this.client.emit(Events.REMOTE_SESSION_SAVED);
}
return persisted;
}
this.logNative('checkpoint.started', { reason });
const maxAttempts = 4;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const journal = await this.browserBridge.flush();
if (
attempt === 1 &&
!force &&
!this.profileDirty &&
!journal?.dirty
) {
return false;
}
const canonicalDirtyPlan =
this.browserBridge.getCanonicalDirtyPlan?.();
const browserProjection =
await this.browserBridge.exportCanonicalProjection();
const canonicalProjection =
this.reconcileCanonicalProjectionForPersistence(
browserProjection,
);
const source =
this.store.revisionStatus === 'staging'
? this.store.revisionSource || 'pairing'
: 'checkpoint';
let connectedSnapshotFrozen = false;
let connectedSnapshotResumed = false;
try {
if (connectedProfileSnapshot) {
if (
reason !== 'ready' ||
this.store.revisionStatus === 'active' ||
!['pairing', 'legacy_volume_migration'].includes(
this.store.revisionSource,
) ||
this.store.isHandoffRevision?.() === true ||
typeof this.browserBridge
.freezeConnectedProfileSnapshot !==
'function' ||
typeof this.browserBridge
.resumeConnectedProfileSnapshot !== 'function'
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_connected_profile_checkpoint_scope_invalid',
);
}
await this.browserBridge.freezeConnectedProfileSnapshot();
connectedSnapshotFrozen = true;
}
await this.store.checkpointProfile({
profilePath: this.userDataDir,
canonicalProjection,
source,
promote: !(
reason === 'ready' &&
this.canonicalReconnectProjection &&
this.store.isHandoffRevision?.() === true
),
validateSnapshot: async () => {
if (connectedSnapshotFrozen) {
connectedSnapshotFrozen = false;
await this.browserBridge.resumeConnectedProfileSnapshot();
connectedSnapshotResumed = true;
}
await this.canonicalProjectionSnapshotValidator(
canonicalProjection,
)();
},
});
} catch (error) {
if (connectedSnapshotFrozen) {
connectedSnapshotFrozen = false;
try {
await this.browserBridge.resumeConnectedProfileSnapshot();
connectedSnapshotResumed = true;
} catch (resumeError) {
if (
resumeError?.code ===
CONNECTED_PROFILE_NAVIGATION_SUPERSEDED
) {
throw resumeError;
}
throw new AggregateError(
[error, resumeError],
'wwebjs_connected_profile_checkpoint_resume_failed',
{ cause: error },
);
}
}
const errorCode = String(
error?.code || error?.message || '',
);
if (
errorCode !== PROFILE_CHANGED_DURING_CHECKPOINT ||
attempt >= maxAttempts
) {
throw error;
}
// ProfileArtifact re-lists every selected root before it
// commits its frozen inventory. A LevelDB rotation causes
// a full retry from a fresh journal/projection instead of
// persisting the previous partial filesystem view.
this.profileDirty = true;
this.logNative(
'checkpoint.profile_snapshot_retry',
{
reason,
attempt,
max_attempts: maxAttempts,
mutation_phase: safeFailureCode(
error?.details?.phase,
'profile_changed',
),
},
{ force: true },
);
await this.delay(attempt * 100);
continue;
}
if (connectedProfileSnapshot && !connectedSnapshotResumed) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_connected_profile_checkpoint_resume_missing',
);
}
this.markCanonicalProjectionPersisted(canonicalProjection);
this.browserBridge.acknowledgeCanonicalDirtyPlan?.(
canonicalDirtyPlan,
);
this.pendingCanonicalProviderState = undefined;
const postCheckpointJournal = await this.browserBridge.flush();
if (postCheckpointJournal?.dirty) {
this.profileDirty = true;
if (postCheckpointJournal.critical) {
this.scheduleCriticalCheckpoint();
}
} else {
await this.browserBridge.markClean();
this.profileDirty = false;
}
if (emit) this.client.emit(Events.REMOTE_SESSION_SAVED);
return true;
}
throw new Error('wwebjs_profile_checkpoint_retry_exhausted');
}
let compressedSessionPath;
try {
compressedSessionPath = await this.compressSession();
await this.store.save({
session: this.sessionName,
path: compressedSessionPath,
});
if (emit) this.client.emit(Events.REMOTE_SESSION_SAVED);
return true;
} finally {
const paths = [
this.tempDir,
...(compressedSessionPath ? [compressedSessionPath] : []),
];
await Promise.allSettled(
paths.map((p) =>
fs.promises.rm(p, {
recursive: true,
force: true,
maxRetries: this.rmMaxRetries,
}),
),
);
}
}
async extractNativeSession() {
await this.removeLocalSession();
try {
const restored = await this.store.restoreProfile({
profilePath: this.userDataDir,
selector: 'active',
});
if (!restored) {
if (this.store.revisionStatus === 'active') {
throw new Error('wwebjs_active_profile_artifact_missing');
}
await fs.promises.mkdir(this.userDataDir, {
recursive: true,
});
}
} catch (error) {
await this.removeLocalSession();
// Once a WWeb revision is active it is the point of no return:
// the server may already have advanced ratchets/PQ state. Never
// reactivate the retired native provider merely because the
// active artifact is unavailable. Staging failures are rolled
// back by finalizeNativeFailure before any WebSocket is released.
throw error;
}
}
scheduleCriticalCheckpoint({
delayMs = this.canonicalFlushDebounceMs,
} = {}) {
this.profileDirty = true;
if (this.providerHandoffCheckpointAdmissionClosed) return;
if (this.canonicalActivationCheckpointInProgress) return;
if (this.isCanonicalActiveRestartAttestationPending()) return;
if (!this.authReady && this.pendingCanonicalAppStateCatchUp) {
return;
}
if (
(!this.authReady &&
!this.isCanonicalActivationRecoveryRequired()) ||
this.shutdownRequested
) {
return;
}
if (this.criticalCheckpointTimer) return;
this.criticalCheckpointTimer = setTimeout(() => {
this.criticalCheckpointTimer = undefined;
if (this.providerHandoffCheckpointAdmissionClosed) return;
const queued = this.checkpointTail.then(() =>
this.performCanonicalCheckpoint('critical_mutation'),
);
this.checkpointTail = queued.catch(() => {});
void queued.catch((error) => {
this.logNative(
'canonical_checkpoint.critical_failed',
{
error,
error_code: safeFailureCode(
error?.code || error?.message,
'wwebjs_canonical_checkpoint_critical_failed',
),
...safeAggregateErrorDetails(error),
},
{ force: true },
);
if (
error?.code ===
'wwebjs_canonical_app_state_checkpoint_pending' &&
!this.shutdownRequested
) {
this.scheduleCriticalCheckpoint({
delayMs: Math.max(
this.canonicalFlushDebounceMs,
this.canonicalAppStatePollIntervalMs,
),
});
}
});
}, delayMs);
}
async performCanonicalCheckpoint(
reason,
{ allowPreReadyNavigationSuccessor } = {},
) {
if (!this.browserBridge || this.shutdownRequested) return false;
if (this.isCanonicalActiveRestartAttestationPending()) return false;
const preReadyNavigationSuccessor = Boolean(
allowPreReadyNavigationSuccessor &&
this.isReadyNavigationSuccessorCheckpointAllowed(
allowPreReadyNavigationSuccessor,
),
);
// Recheck the lifecycle boundary at execution time. A debounced
// checkpoint may have been queued before a canonical import/reload
// started, so the scheduling-time check alone cannot protect the
// active database revision from a transient or partial projection.
if (
!this.authReady &&
!this.isCanonicalActivationRecoveryRequired() &&
!preReadyNavigationSuccessor
) {
return false;
}
const startedAt = Date.now();
await this.browserBridge.flush();
const journalPlan = this.browserBridge.getCanonicalDirtyPlan?.();
const journalPersistencePlan =
journalPlan &&
typeof journalPlan.fullReplace === 'boolean' &&
typeof journalPlan.device === 'boolean' &&
Array.isArray(journalPlan.tables)
? journalPlan
: {
fullReplace: true,
device: true,
tables: [],
};
const browserProjection =
await this.browserBridge.exportCanonicalProjection();
const persistenceCandidate =
!this.authReady && this.pendingCanonicalAppStateCatchUp
? this.buildCanonicalOfflineCheckpointProjection(
browserProjection,
)
: browserProjection;
const projection =
this.reconcileCanonicalProjectionForPersistence(
persistenceCandidate,
);
const dirtyPlan = expandCanonicalAppStateDirtyPlan(
journalPersistencePlan,
{
baseline: this.getPersistedCanonicalProjection(),
browserProjection: persistenceCandidate,
persistenceProjection: projection,
},
);
const preReadyNavigationSuccessorAtPersistence = Boolean(
preReadyNavigationSuccessor &&
this.isReadyNavigationSuccessorCheckpointAllowed(
allowPreReadyNavigationSuccessor,
),
);
if (
!this.authReady &&
!this.isCanonicalActivationRecoveryRequired() &&
!preReadyNavigationSuccessorAtPersistence
) {
return false;
}
if (this.isCanonicalActiveRestartAttestationPending()) return false;
await this.store.saveCanonicalProjection({
projection,
source: reason,
dirtyPlan,
});
this.browserBridge.acknowledgeCanonicalDirtyPlan?.(journalPlan);
this.markCanonicalProjectionPersisted(projection);
this.pendingCanonicalProviderState = undefined;
const postCheckpointJournal = await this.browserBridge.flush();
if (postCheckpointJournal?.dirty) {
this.profileDirty = true;
if (postCheckpointJournal.critical) {
this.scheduleCriticalCheckpoint();
}
} else {
await this.browserBridge.markClean?.();
this.profileDirty = false;
}
this.logNative('canonical_checkpoint.completed', {
reason,
record_count: projection.record_count,
size_bytes: projection.size_bytes,
duration_ms: Date.now() - startedAt,
profile_snapshot: false,
full_replace: dirtyPlan.fullReplace,
device_replaced: dirtyPlan.device,
table_count: dirtyPlan.tables.length,
tail_dirty: postCheckpointJournal?.dirty === true,
});
return true;
}
async handleLeaseLoss(error) {
const failures = [];
try {
this.client._emitConnectionLifecycleEvent(
Events.DISCONNECTED,
error.code,
);
} catch (emitError) {
failures.push(emitError);
}
failures.push(...(await this.stopNativeFailureWriters()));
if (failures.length > 0) {
throw new AggregateError(
[error, ...failures],
'wwebjs_lease_loss_cleanup_failed',
{ cause: error },
);
}
}
async exportProjection() {
if (!this.nativePostgres) {
throw new Error('wwebjs_native_postgres_store_required');
}
if (this.isCanonicalActiveRestartAttestationPending()) {
throw new Error(
'wwebjs_canonical_active_restart_attestation_pending',
);
}
if (!this.browserBridge) {
throw new Error('wwebjs_browser_bridge_unavailable');
}
// The expensive generic IndexedDB projection is intentionally
// opt-in. Normal READY/checkpoint/shutdown/handoff paths persist the
// incremental profile artifact plus the canonical crypto projection.
const maxAttempts = 4;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
await this.browserBridge.flush();
const providerProjection =
await this.browserBridge.exportProjection();
const browserProjection =
await this.browserBridge.exportCanonicalProjection();
const canonicalProjection =
this.reconcileCanonicalProjectionForPersistence(
browserProjection,
);
try {
await this.store.checkpointProfile({
profilePath: this.userDataDir,
projection: providerProjection,
canonicalProjection,
source: 'checkpoint',
validateSnapshot:
this.canonicalProjectionSnapshotValidator(
canonicalProjection,
),
});
} catch (error) {
const errorCode = String(error?.code || error?.message || '');
if (
errorCode !== PROFILE_CHANGED_DURING_CHECKPOINT ||
attempt >= maxAttempts
) {
throw error;
}
this.profileDirty = true;
this.logNative(
'checkpoint.profile_snapshot_retry',
{
reason: 'export_projection',
attempt,
max_attempts: maxAttempts,
mutation_phase: safeFailureCode(
error?.details?.phase,
'profile_changed',
),
},
{ force: true },
);
await this.delay(attempt * 100);
continue;
}
this.markCanonicalProjectionPersisted(canonicalProjection);
return this.store.exportProjection();
}
throw new Error('wwebjs_profile_checkpoint_retry_exhausted');
}
async importProjection(options) {
if (!this.nativePostgres) {
throw new Error('wwebjs_native_postgres_store_required');
}
return this.store.importProjection(options);
}
async drainProviderHandoffCheckpointTail() {
const checkpointTail = this.checkpointTail;
let settled = false;
void checkpointTail.then(
() => {
settled = true;
},
() => {
settled = true;
},
);
await Promise.resolve();
if (settled) return false;
let timeout;
try {
await Promise.race([
checkpointTail,
new Promise((_, reject) => {
timeout = setTimeout(() => {
const error = new Error(
'wwebjs_provider_handoff_checkpoint_drain_timeout',
);
error.code = error.message;
reject(error);
}, this.providerHandoffCheckpointDrainTimeoutMs);
}),
]);
} finally {
clearTimeout(timeout);
}
return true;
}
prepareHandoff(targetProvider, expected = {}) {
let handoffKey;
try {
if (!this.nativePostgres) {
throw new Error('wwebjs_native_postgres_store_required');
}
if (this.isCanonicalActiveRestartAttestationPending()) {
throw new Error(
'wwebjs_canonical_active_restart_attestation_pending',
);
}
handoffKey = [
targetProvider,
expected.handoffId,
expected.lifecycleOperationId,
expected.sourceRevisionId,
].join(':');
if (
this.providerHandoffKey &&
this.providerHandoffKey !== handoffKey
) {
throw new Error('wwebjs_provider_handoff_replay_mismatch');
}
} catch (error) {
// Preserve the rejected-Promise contract of the former async
// method while allowing same-key callers to receive the exact
// shared operation Promise below.
return Promise.reject(error);
}
if (this.providerHandoffPromise) {
return this.providerHandoffPromise;
}
this.providerHandoffKey = handoffKey;
// Install the shared promise before authorization can execute. This
// also closes a synchronous re-entry window in custom store adapters:
// every same-key caller observes this exact operation, never a second
// authorization/drain pipeline.
const operation = Promise.resolve()
.then(() =>
this.performProviderHandoff(
targetProvider,
expected,
handoffKey,
),
)
.finally(() => {
if (this.providerHandoffPromise === operation) {
if (
!this.providerHandoffCheckpointAdmissionClosed &&
this.providerHandoffKey === handoffKey
) {
this.providerHandoffKey = undefined;
}
this.providerHandoffPromise = undefined;
}
});
this.providerHandoffPromise = operation;
return operation;
}
async performProviderHandoff(targetProvider, expected, handoffKey) {
const checkpointAdmissionWasClosed =
this.providerHandoffCheckpointAdmissionClosed;
if (this.preparedProviderHandoff) {
const leaseReleased = await this.store.close({
requireLeaseRelease: true,
});
return { ...this.preparedProviderHandoff, leaseReleased };
}
let authorizedHandoff;
try {
authorizedHandoff = await this.store.assertAuthorizedHandoff(
targetProvider,
expected,
);
} catch (error) {
// Authorization happens before any drain/checkpoint side effect.
// Do not let a rejected request poison a later legitimate target.
// A retry after a drain timeout inherits a fail-closed admission
// fence. A subsequently stale authorization must not reopen it
// while the original checkpoint writer can still be running.
if (
!checkpointAdmissionWasClosed &&
this.providerHandoffKey === handoffKey
) {
this.providerHandoffCheckpointAdmissionClosed = false;
}
throw error;
}
// Close checkpoint admission synchronously after authorization and
// before reading checkpointTail. checkpoint() itself has no await
// before it appends to the tail, so every writer is now either already
// represented by the promise we drain or rejected by this fence.
this.providerHandoffCheckpointAdmissionClosed = true;
this.shutdownRequested = true;
this.stopBackupSync();
this.authReady = false;
this.client._markConnectionHandoff?.('handoff_preparing');
// A periodic/profile checkpoint may already be running even though
// its interval and critical debounce have just been stopped. Its
// blob upload is deliberately split from the artifact transaction;
// starting the handoff snapshot concurrently would let either
// checkpoint prune the other's not-yet-mapped blobs. Drain the
// serialized writer before quiescing or terminating Chromium so the
// handoff owns the only remaining profile snapshot.
const checkpointDrainWaited =
await this.drainProviderHandoffCheckpointTail();
if (checkpointDrainWaited) {
const refreshedAuthorization =
await this.store.assertAuthorizedHandoff(
targetProvider,
expected,
);
const stableFields = [
'handoffId',
'sourceRevisionId',
'targetRevisionId',
'lifecycleOperationId',
'targetProvider',
];
if (
stableFields.some(
(field) =>
String(refreshedAuthorization?.[field] ?? '') !==
String(authorizedHandoff?.[field] ?? ''),
)
) {
throw new WhatsAppSessionCompatibilityError(
'wwebjs_provider_handoff_authorization_changed_during_checkpoint_drain',
);
}
authorizedHandoff = refreshedAuthorization;
}
let canonicalProjection;
let pqServerRollback;
let pqServerRollbackIntent;
let pqServerRollbackRecoveryRequired = false;
let pqServerRollbackAcknowledgementUnknown = false;
try {
if (targetProvider === 'whatsmeow') {
pqServerRollbackIntent =
await this.store.beginPostQuantumServerRollbackIntent(
authorizedHandoff,
);
try {
const rollback =
await this.browserBridge?.rollbackPostQuantumForClassicalHandoff?.();
if (!rollback) {
throw new Error(
'wwebjs_pq_rollback_runtime_unavailable',
);
}
pqServerRollbackRecoveryRequired = true;
// Persist the acknowledgement immediately. Nothing that
// follows (drain, export, profile checkpoint) is allowed
// to create a crash window where the server deletion is
// known only to Chromium memory.
pqServerRollback =
await this.store.acknowledgePostQuantumServerRollback(
pqServerRollbackIntent,
rollback,
);
} catch (error) {
pqServerRollbackRecoveryRequired =
Boolean(pqServerRollbackIntent) ||
error?.serverAcknowledged === true ||
error?.acknowledgementUnknown === true;
pqServerRollbackAcknowledgementUnknown =
error?.acknowledgementUnknown === true;
throw error;
}
}
await this.browserBridge?.quiesceNetwork();
const browserProjection =
await this.browserBridge?.exportCanonicalProjection({
pqServerRollback,
});
canonicalProjection =
this.reconcileCanonicalProjectionForPersistence(
browserProjection,
);
const handoffAppState =
normalizeCanonicalAppStateForProviderHandoff(
canonicalProjection,
);
canonicalProjection = handoffAppState.projection;
if (handoffAppState.snapshot_resync_collection_count > 0) {
this.logNative(
'handoff.app_state_snapshot_resync_normalized',
{
snapshot_resync_collection_count:
handoffAppState.snapshot_resync_collection_count,
removed_version_row_count:
handoffAppState.removed_version_row_count,
removed_mutation_mac_count:
handoffAppState.removed_mutation_mac_count,
collection_ids: handoffAppState.collection_ids,
},
{ force: true },
);
}
if (!hasNonzeroDeviceId(canonicalProjection?.device?.jid)) {
throw new Error('wwebjs_canonical_device_wid_required');
}
assertCanonicalTargetProviderCompatibility(
canonicalProjection,
targetProvider,
);
if (pqServerRollback) {
// This full canonical replace is the durable, fenced proof of
// the server ACK. It also removes status/PQ Signal scopes from
// the future Meow candidate before Chromium is closed.
await this.store.saveCanonicalProjection({
projection: canonicalProjection,
source: 'handoff_pq_server_rollback',
});
}
} catch (error) {
this.logNativeFailure('handoff.preflight_rejected', {
target_provider: targetProvider,
error_code: safeFailureCode(
error?.code || error?.message,
'wwebjs_handoff_preflight_failed',
),
});
let recoveryError;
if (pqServerRollbackAcknowledgementUnknown) {
const failClosedErrors = [error];
// A timed-out/context-lost delete RPC may still be executing.
// Never reuse that page for recovery: terminate Chromium and
// leave the durable intent for conservative startup recovery.
try {
await this.terminateNativeBrowser(this.client.pupBrowser);
} catch (caughtError) {
failClosedErrors.push(caughtError);
}
if (failClosedErrors.length === 1) {
try {
await this.browserBridge?.close?.();
} catch (caughtError) {
failClosedErrors.push(caughtError);
}
}
this.client._markConnectionHandoff?.(
'handoff_acknowledgement_unknown',
);
throw new AggregateError(
failClosedErrors,
'wwebjs_pq_rollback_server_ack_unknown',
{ cause: error },
);
}
if (pqServerRollbackRecoveryRequired) {
try {
// Public readiness remains fenced while the source
// transport is briefly restored for the official upload.
// Close and drain the final PQ upload sinks before making
// the network available; recovery itself keeps that fence
// closed until its one exclusive upload is validated.
await this.browserBridge?.beginPostQuantumRollbackUploadFence?.();
await this.browserBridge?.resumeNetwork?.();
const pqRecovery =
await this.browserBridge?.recoverPostQuantumAfterClassicalHandoffAbort?.();
const recoveredBrowserProjection =
await this.browserBridge?.exportCanonicalProjection();
const recoveredProjection =
this.reconcileCanonicalProjectionForPersistence(
recoveredBrowserProjection,
);
await this.store.saveCanonicalProjection({
projection: recoveredProjection,
source: 'handoff_pq_source_recovery',
});
await this.store.completePostQuantumServerRollbackRecovery(
pqServerRollbackIntent,
pqRecovery,
);
} catch (caughtError) {
recoveryError = caughtError;
}
} else {
await this.browserBridge?.resumeNetwork?.().catch(() => {});
}
if (recoveryError) {
const failClosedErrors = [error, recoveryError];
// The recovery transport was resumed. If re-upload or its
// fenced checkpoint fails, force Chromium offline and closed
// before surfacing the error. Keep the DB lease owned so no
// second writer can start from an indeterminate source state.
try {
await this.terminateNativeBrowser(this.client.pupBrowser);
} catch (caughtError) {
failClosedErrors.push(caughtError);
}
if (failClosedErrors.length === 2) {
try {
await this.browserBridge?.close?.();
} catch (caughtError) {
failClosedErrors.push(caughtError);
}
}
this.client._markConnectionHandoff?.('handoff_recovery_failed');
throw new AggregateError(
failClosedErrors,
'wwebjs_pq_rollback_source_recovery_failed',
{ cause: error },
);
}
this.shutdownRequested = false;
this.authReady = true;
if (this.providerHandoffKey === handoffKey) {
this.providerHandoffCheckpointAdmissionClosed = false;
}
this.startBackupSync();
await this.client
._publishOnlineConnectionStatus?.('handoff_cancelled')
.catch(() => false);
throw error;
}
const browser = this.client.pupBrowser;
await this.terminateNativeBrowser(browser);
await this.browserBridge?.close();
try {
const activeSource = this.store.revisionStatus === 'active';
const stableProfileOptions = activeSource
? {
...expected,
profilePath: this.userDataDir,
canonicalProjection,
}
: expected;
let handoff;
try {
// Chromium is fully stopped at this point. Capture its stable
// native profile together with the exact canonical projection
// exported behind the network fence. This keeps the rollback
// profile and portable Signal tables on the same temporal
// boundary without ever streaming a live LevelDB.
handoff = await this.store.prepareHandoff(
targetProvider,
stableProfileOptions,
);
} catch (error) {
const errorCode = String(error?.code || error?.message || '');
const observedSize = error?.details?.size_bytes;
const maxSize = error?.details?.max_bytes;
const validOversizeFallback = Boolean(
activeSource &&
errorCode === 'whatsapp_artifact_profile_too_large' &&
Number.isSafeInteger(observedSize) &&
Number.isSafeInteger(maxSize) &&
observedSize > maxSize &&
maxSize > 0,
);
if (!validOversizeFallback) throw error;
// Oversized profiles already have a separately verified
// last-good anchor. Preserve that bounded compatibility path
// instead of making a safe handoff impossible, but expose the
// fallback explicitly so performance evidence cannot confuse
// it with a coherent full-profile checkpoint.
this.logNative(
'handoff.stable_profile_checkpoint_oversize_fallback',
{
target_provider: targetProvider,
rejected_profile_size_bytes: observedSize,
profile_max_bytes: maxSize,
},
{ force: true },
);
handoff = await this.store.prepareHandoff(targetProvider, {
...expected,
canonicalProjection,
lastGoodProfileFallback: {
version: 1,
error_code: errorCode,
size_bytes: observedSize,
max_bytes: maxSize,
},
});
}
this.preparedProviderHandoff = handoff;
const leaseReleased = await this.store.close({
requireLeaseRelease: true,
});
await this.removeLocalSession();
return { ...handoff, leaseReleased };
} catch (error) {
if (!this.preparedProviderHandoff) {
await this.store.close().catch(() => {});
}
throw error;
}
}
async promote(revisionId) {
if (!this.nativePostgres) {
throw new Error('wwebjs_native_postgres_store_required');
}
return this.store.promote(revisionId);
}
async rollback(errorCode) {
if (!this.nativePostgres) {
throw new Error('wwebjs_native_postgres_store_required');
}
return this.store.rollback(errorCode);
}
async extractRemoteSession() {
const compressedSessionPath = path.join(
this.zipDir,
`${this.sessionName}.zip`,
);
const sessionExists = await this.store.sessionExists({
session: this.sessionName,
});
// Remote storage is the source of truth. Local profiles are ephemeral
// and must never silently override the selected remote revision.
await this.removeLocalSession();
if (sessionExists) {
try {
await this.extractRevision(
this.store.extract.bind(this.store),
compressedSessionPath,
);
} catch (activeRevisionError) {
await this.removeLocalSession();
await fs.promises.rm(compressedSessionPath, { force: true });
if (typeof this.store.extractPrevious !== 'function') {
throw activeRevisionError;
}
try {
await this.extractRevision(
this.store.extractPrevious.bind(this.store),
compressedSessionPath,
);
} catch (previousRevisionError) {
throw new AggregateError(
[activeRevisionError, previousRevisionError],
'Unable to restore active or previous remote session revision.',
);
}
}
} else {
await fs.promises.mkdir(this.userDataDir, { recursive: true });
}
}
async extractRevision(extract, compressedSessionPath) {
await extract({
session: this.sessionName,
path: compressedSessionPath,
});
await this.unCompressSession(compressedSessionPath);
}
async deleteRemoteSession() {
const sessionExists = await this.store.sessionExists({
session: this.sessionName,
});
if (sessionExists)
await this.store.delete({ session: this.sessionName });
}
stopBackupSync() {
clearInterval(this.backupSync);
this.backupSync = undefined;
clearTimeout(this.criticalCheckpointTimer);
this.criticalCheckpointTimer = undefined;
}
assertNotShuttingDown() {
if (this.shutdownRequested) {
throw new Error(
'RemoteAuth shutdown started before authentication readiness completed.',
);
}
}
async removeLocalSession() {
if (!this.userDataDir) return;
await fs.promises.rm(this.userDataDir, {
recursive: true,
force: true,
maxRetries: this.rmMaxRetries,
});
}
async compressSession() {
const stageDefaultPath = path.join(this.tempDir, 'Default');
const userDataDefaultPath = path.join(this.userDataDir, 'Default');
await fs.emptyDir(stageDefaultPath);
await this.copyByRequiredDirs(userDataDefaultPath, stageDefaultPath);
const ZipArchive = await loadZipArchiveConstructor();
const archive = new ZipArchive();
const outPath = path.join(this.zipDir, `${this.sessionName}.zip`);
const out = fs.createWriteStream(outPath);
await new Promise((resolve, reject) => {
out.once('close', resolve);
out.once('error', reject);
archive.once('error', reject);
archive.pipe(out);
archive.directory(this.tempDir, false);
archive.finalize();
});
return outPath;
}
async unCompressSession(compressedSessionPath) {
const stream = fs.createReadStream(compressedSessionPath);
const extractor = unzipper.Extract({
path: this.userDataDir,
concurrency: 10,
});
await new Promise((resolve, reject) => {
stream.once('error', reject);
extractor.once('error', reject);
// unzipper's writable `finish` only means that it consumed the
// archive stream. `close` is the completion boundary for the
// asynchronous filesystem writes performed by Extract.
extractor.once('close', resolve);
stream.pipe(extractor);
});
await fs.promises.unlink(compressedSessionPath);
}
async copyByRequiredDirs(from, to) {
for (const d of this.requiredDirs) {
const src = path.join(from, d);
if (await this.isValidPath(src)) {
const dest = path.join(to, path.basename(src));
await fs.promises.cp(src, dest, {
recursive: true,
force: true,
errorOnExist: false,
});
}
}
}
async isValidPath(path) {
try {
await fs.promises.access(path);
return true;
} catch {
return false;
}
}
async delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
module.exports = RemoteAuth;