Files
NetMesh/infrastructure/services/cloudSync/authMethods.ts

765 lines
28 KiB
TypeScript
Raw Normal View History

/* eslint-disable @typescript-eslint/no-explicit-any */
import { EncryptionService } from '../EncryptionService';
import { createAdapter, type CloudAdapter } from '../adapters';
import type GitHubAdapter from '../adapters/GitHubAdapter';
import type GoogleDriveAdapter from '../adapters/GoogleDriveAdapter';
import type OneDriveAdapter from '../adapters/OneDriveAdapter';
import { createSyncedFileSignature as createSyncedFileSignatureCore } from '../syncSignature.js';
import { decideRemoteChanged } from '../syncAnchorDecision.js';
import type {
CloudProvider,
OAuthTokens,
ProviderAccount,
ProviderConnection,
S3Config,
SyncedFile,
SyncPayload,
WebDAVConfig,
} from '../../../domain/sync';
import { normalizeDurablePluginSyncCredentialRef } from '../../../domain/sync';
import { isPluginCloudProviderId } from '../../../domain/cloudProviderIds';
import type {
ProviderSyncAnchor,
StartProviderAuthResult,
} from '../CloudSyncManager';
import {
registerPluginProviderIdImpl,
unregisterPluginProviderIdImpl,
} from './stateAndSecurityMethods';
const SYNC_REMOTE_ANCHOR_STORAGE_KEY = 'netcatty_sync_remote_anchor_v1';
export function clearProviderMergeStateImpl(this: any, provider: CloudProvider): void {
this.removeFromStorage(this.syncBaseKey(provider));
this.removeFromStorage(this.convergentProviderBaselineKey(provider));
this.clearSyncAnchor(provider);
}
export async function startProviderAuthImpl(this: any,
provider: CloudProvider,
redirectUri?: string
): Promise<StartProviderAuthResult> {
if (provider === 'webdav' || provider === 's3') {
throw new Error('Provider requires manual configuration');
}
const authAttemptId = ++this.providerAuthAttemptSeq[provider];
this.providerAuthRestoreState[provider] = {
attemptId: authAttemptId,
connection: { ...this.state.providers[provider] },
adapter: this.adapters.get(provider) ?? null,
};
const adapter = await createAdapter(provider);
if (!this.isActiveAuthAttempt(provider, authAttemptId)) {
throw new Error(`${provider} auth superseded`);
}
this.adapters.set(provider, adapter);
this.updateProviderStatus(provider, 'connecting');
try {
if (provider === 'github') {
// GitHub uses Device Flow
const ghAdapter = adapter as GitHubAdapter;
const deviceFlow = await ghAdapter.startAuth();
return {
type: 'device_code',
data: { ...deviceFlow, authAttemptId },
};
} else {
// Google and OneDrive use PKCE with redirect
if (!redirectUri) {
throw new Error(
`startProviderAuth('${provider}') requires a redirectUri — ` +
'call prepareOAuthCallback on the bridge first and pass its redirectUri through.'
);
}
if (provider === 'google') {
const gdAdapter = adapter as GoogleDriveAdapter;
const url = await gdAdapter.startAuth(redirectUri);
return { type: 'url', data: { url, redirectUri, authAttemptId } };
} else {
const odAdapter = adapter as OneDriveAdapter;
const url = await odAdapter.startAuth(redirectUri);
return { type: 'url', data: { url, redirectUri, authAttemptId } };
}
}
} catch (error) {
if (!this.isActiveAuthAttempt(provider, authAttemptId)) {
throw error;
}
const errorMessage = error instanceof Error ? error.message : String(error);
console.error(`[CloudSync] ${provider} connect failed`, {
error: errorMessage,
});
this.updateProviderStatus(provider, 'error', errorMessage);
throw error;
}
}
export async function completeGitHubAuthImpl(this: any,
deviceCode: string,
interval: number,
expiresAt: number,
onPending?: () => void,
signal?: AbortSignal,
authAttemptId?: number
): Promise<void> {
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
throw new Error('github auth superseded');
}
const adapter = this.adapters.get('github');
if (!adapter) {
throw new Error('GitHub adapter not initialized');
}
const ghAdapter = adapter as GitHubAdapter;
try {
// Snapshot the prior account BEFORE we overwrite providers[provider].
// Used as a fallback for the same-account comparison when the persisted
// accountId key is absent (e.g., first re-auth after upgrading to this
// version, where the key didn't exist yet).
const previousAccount = this.state.providers.github?.account;
const tokens = await ghAdapter.completeAuth(deviceCode, interval, expiresAt, onPending, signal);
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
throw new Error('github auth superseded');
}
const resourceId = await ghAdapter.initializeSync(signal);
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
throw new Error('github auth superseded');
}
++this.providerDecryptSeq.github;
this.state.providers.github = {
...this.state.providers.github,
status: 'connected',
tokens,
account: ghAdapter.accountInfo || undefined,
};
if (resourceId) {
this.state.providers.github.resourceId = resourceId;
}
await this.saveProviderConnection('github', this.state.providers.github, authAttemptId);
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
throw new Error('github auth superseded');
}
// Only clear the merge base if the authenticated account identity differs
// from the previously-stored one. See notes in completePKCEAuth.
const newId = ghAdapter.accountInfo?.id ?? null;
const previousId = this.loadProviderAccountId('github') ?? previousAccount?.id ?? null;
const sameAccount = newId !== null && previousId !== null && newId === previousId;
if (!sameAccount) {
clearProviderMergeStateImpl.call(this, 'github');
}
if (newId) {
this.saveProviderAccountId('github', newId);
}
this.emit({
type: 'AUTH_COMPLETED',
provider: 'github',
account: ghAdapter.accountInfo!,
});
this.providerAuthRestoreState.github = null;
} catch (error) {
if (authAttemptId != null && !this.isActiveAuthAttempt('github', authAttemptId)) {
throw error;
}
if (error instanceof Error && error.message.includes('auth superseded')) {
throw error;
}
if (error instanceof Error && error.name === 'AbortError') {
this.resetProviderStatus('github', authAttemptId);
throw error;
}
this.resetProviderStatus('github', authAttemptId);
this.setProviderError('github', String(error));
throw error;
}
}
export async function completePKCEAuthImpl(this: any,
provider: 'google' | 'onedrive',
code: string,
redirectUri: string,
authAttemptId?: number
): Promise<void> {
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
throw new Error(`${provider} auth superseded`);
}
const adapter = this.adapters.get(provider);
if (!adapter) {
throw new Error(`${provider} adapter not initialized`);
}
try {
// Snapshot the prior account BEFORE we overwrite providers[provider].
// Used as a fallback for the same-account comparison when the persisted
// accountId key is absent (e.g., first re-auth after upgrading to this
// version, where the key didn't exist yet).
const previousAccount = this.state.providers[provider]?.account;
let tokens: OAuthTokens;
let account;
if (provider === 'google') {
const gdAdapter = adapter as GoogleDriveAdapter;
tokens = await gdAdapter.completeAuth(code, redirectUri);
account = gdAdapter.accountInfo;
} else {
const odAdapter = adapter as OneDriveAdapter;
tokens = await odAdapter.completeAuth(code, redirectUri);
account = odAdapter.accountInfo;
}
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
throw new Error(`${provider} auth superseded`);
}
const resourceId = await adapter.initializeSync();
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
throw new Error(`${provider} auth superseded`);
}
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
...this.state.providers[provider],
status: 'connected',
tokens,
account: account || undefined,
};
if (resourceId) {
this.state.providers[provider].resourceId = resourceId;
}
await this.saveProviderConnection(provider, this.state.providers[provider], authAttemptId);
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
throw new Error(`${provider} auth superseded`);
}
// Only clear the merge base if the authenticated account identity differs
// from the previously-stored one. Same-account re-auth preserves the base
// so the next sync computes correct local-deletions instead of treating
// it as "first sync" and resurrecting zombie entries via null-base union.
const newId = account?.id ?? null;
const previousId = this.loadProviderAccountId(provider) ?? previousAccount?.id ?? null;
const sameAccount = newId !== null && previousId !== null && newId === previousId;
if (!sameAccount) {
clearProviderMergeStateImpl.call(this, provider);
}
if (newId) {
this.saveProviderAccountId(provider, newId);
}
this.emit({
type: 'AUTH_COMPLETED',
provider,
account: account!,
});
this.providerAuthRestoreState[provider] = null;
} catch (error) {
if (authAttemptId != null && !this.isActiveAuthAttempt(provider, authAttemptId)) {
throw error;
}
if (error instanceof Error && error.message.includes('auth superseded')) {
throw error;
}
this.resetProviderStatus(provider, authAttemptId);
this.setProviderError(provider, String(error));
throw error;
}
}
export async function connectConfigProviderImpl(this: any,
provider: 'webdav' | 's3',
config: WebDAVConfig | S3Config
): Promise<void> {
const adapter = await createAdapter(provider, undefined, undefined, config);
this.adapters.set(provider, adapter);
this.updateProviderStatus(provider, 'connecting');
try {
const resourceId = await adapter.initializeSync();
const account = adapter.accountInfo || this.buildAccountFromConfig(provider, config);
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
provider,
status: 'connected',
config,
account,
resourceId: resourceId || undefined,
};
await this.saveProviderConnection(provider, this.state.providers[provider]);
// Clear all trusted merge state when changing endpoint or bucket.
clearProviderMergeStateImpl.call(this, provider);
this.emit({
type: 'AUTH_COMPLETED',
provider,
account,
});
} catch (error) {
this.updateProviderStatus(provider, 'error', String(error));
throw error;
}
}
/**
* Connect a namespaced plugin sync Provider. Configuration is opaque plugin-
* owned JSON; Netcatty still owns encryption and only forwards encrypted objects.
*/
export async function connectPluginProviderImpl(
this: any,
providerId: string,
configuration: unknown = {},
credential?: unknown,
): Promise<void> {
if (!isPluginCloudProviderId(providerId)) {
throw new Error(`Invalid plugin sync provider ID: ${providerId}`);
}
// Initialize sequence counters before the first status write so cross-window
// handlers never see NaN and discard every later update.
if (this.providerWriteSeq[providerId] == null || Number.isNaN(this.providerWriteSeq[providerId])) {
this.providerWriteSeq[providerId] = 0;
}
if (this.providerDecryptSeq[providerId] == null || Number.isNaN(this.providerDecryptSeq[providerId])) {
this.providerDecryptSeq[providerId] = 0;
}
if (this.providerDecrypted[providerId] == null) this.providerDecrypted[providerId] = true;
if (this.providerAuthAttemptSeq[providerId] == null || Number.isNaN(this.providerAuthAttemptSeq[providerId])) {
this.providerAuthAttemptSeq[providerId] = 0;
}
this.updateProviderStatus(providerId, 'connecting');
try {
const createPluginStorage = async (id: string) => {
if (typeof this.createPluginStorage === 'function') {
return this.createPluginStorage(id, {
provider: id,
status: 'connecting',
config: configuration as ProviderConnection['config'],
});
}
const { createPluginSyncIpcHost, isPluginSyncIpcAvailable } = await import(
'../adapters/pluginSyncIpcHost'
);
const { createPluginSyncObjectStorage } = await import('../adapters/pluginSyncObjectStorage');
if (!isPluginSyncIpcAvailable()) {
throw new Error(`Plugin sync provider ${id} is unavailable (plugin host not enabled)`);
}
return createPluginSyncObjectStorage({
providerId: id,
host: createPluginSyncIpcHost(),
configuration,
credential: credential as import('../adapters/pluginSyncObjectStorage').PluginSyncCredentialRef | undefined,
});
};
const adapter = await createAdapter(
providerId,
undefined,
undefined,
undefined,
{ createPluginStorage },
);
// Only cache after initializeSync succeeds so a rejected reconnect cannot
// leave a half-authenticated adapter pinned for later auto-sync.
let resourceId: string | null;
try {
resourceId = await adapter.initializeSync();
} catch (error) {
try { adapter.signOut(); } catch { /* ignore */ }
this.adapters.delete(providerId);
throw error;
}
this.adapters.set(providerId, adapter);
const account = adapter.accountInfo ?? { id: providerId };
if (this.providerDecryptSeq[providerId] == null) this.providerDecryptSeq[providerId] = 0;
if (this.providerWriteSeq[providerId] == null) this.providerWriteSeq[providerId] = 0;
if (this.providerDecrypted[providerId] == null) this.providerDecrypted[providerId] = true;
++this.providerDecryptSeq[providerId];
// Capture pre-connect identity before overwriting state.
const previous = this.state.providers[providerId];
const previousAccountId = previous?.account?.id ?? null;
const previousResource = previous?.resourceId ?? null;
const configFingerprint = (value: unknown): string => {
const normalize = (input: unknown): unknown => {
if (Array.isArray(input)) return input.map(normalize);
if (input && typeof input === 'object') {
return Object.fromEntries(
Object.keys(input as Record<string, unknown>)
.sort()
.map((key) => [key, normalize((input as Record<string, unknown>)[key])]),
);
}
return input;
};
try {
return JSON.stringify(normalize(value ?? null));
} catch {
return String(value);
}
};
const previousConfigFp = configFingerprint(previous?.config ?? null);
const nextAccountId = account?.id ?? null;
const nextResource = resourceId || null;
const nextConfigFp = configFingerprint(configuration ?? null);
this.state.providers[providerId] = {
provider: providerId,
status: 'connected',
config: configuration as ProviderConnection['config'],
...((() => {
const ref = normalizeDurablePluginSyncCredentialRef(credential);
return ref ? { credential: ref } : {};
})()),
account,
resourceId: resourceId || undefined,
};
registerPluginProviderIdImpl.call(this, providerId);
await this.saveProviderConnection(providerId, this.state.providers[providerId]);
// Preserve merge base / anchors when reconnecting the same account+resource
// and configuration; clear when backend identity or config changes.
const previousCredentialFp = `${previous?.credential?.kind ?? ''}\0${previous?.credential?.id ?? ''}\0${previous?.credential?.key ?? ''}`;
const nextCredentialFp = `${this.state.providers[providerId].credential?.kind ?? ''}\0${this.state.providers[providerId].credential?.id ?? ''}\0${this.state.providers[providerId].credential?.key ?? ''}`;
if (
previousAccountId !== nextAccountId
|| previousResource !== nextResource
|| previousConfigFp !== nextConfigFp
|| previousCredentialFp !== nextCredentialFp
) {
clearProviderMergeStateImpl.call(this, providerId);
}
this.emit({
type: 'AUTH_COMPLETED',
provider: providerId,
account,
});
this.notifyStateChange();
} catch (error) {
this.updateProviderStatus(providerId, 'error', String(error));
throw error;
}
}
export function resetProviderStatusImpl(this: any,provider: CloudProvider, authAttemptId?: number): void {
const restoreState = this.providerAuthRestoreState[provider];
if (
authAttemptId != null &&
restoreState &&
restoreState.attemptId !== authAttemptId
) {
return;
}
if (restoreState) {
this.state.providers[provider] = { ...restoreState.connection };
if (restoreState.adapter) {
this.adapters.set(provider, restoreState.adapter);
} else {
this.adapters.delete(provider);
}
this.notifyStateChange();
} else if (this.state.providers[provider]?.status === 'connecting') {
this.updateProviderStatus(provider, 'disconnected');
return;
}
if (!restoreState || authAttemptId == null || restoreState.attemptId === authAttemptId) {
this.providerAuthRestoreState[provider] = null;
}
}
export function setProviderErrorImpl(this: any,provider: CloudProvider, error: string): void {
this.updateProviderStatus(provider, 'error', error);
}
export function clearConnectingStatusImpl(this: any,provider: CloudProvider): void {
if (this.state.providers[provider]?.status !== 'connecting') {
return;
}
this.updateProviderStatus(provider, 'disconnected');
}
export function clearProviderErrorImpl(this: any,provider: CloudProvider): void {
const connection = this.state.providers[provider];
if (!connection?.error && connection?.status !== 'error') {
return;
}
this.state.providers[provider] = {
...connection,
status: connection.status === 'error' ? 'disconnected' : connection.status,
error: undefined,
};
this.notifyStateChange();
}
export function cancelProviderAuthAttemptImpl(this: any,provider: CloudProvider, authAttemptId?: number): void {
if (
authAttemptId != null &&
!this.isActiveAuthAttempt(provider, authAttemptId)
) {
return;
}
this.resetProviderStatus(provider, authAttemptId);
++this.providerAuthAttemptSeq[provider];
const restoreState = this.providerAuthRestoreState[provider];
if (!restoreState || authAttemptId == null || restoreState.attemptId === authAttemptId) {
this.providerAuthRestoreState[provider] = null;
}
}
export async function disconnectProviderImpl(this: any,provider: CloudProvider): Promise<void> {
this.cancelProviderAuthAttempt(provider);
const adapter = this.adapters.get(provider);
if (adapter) {
adapter.signOut();
this.adapters.delete(provider);
}
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
provider,
status: 'disconnected',
};
await this.saveProviderConnection(provider, this.state.providers[provider]);
// Explicit disconnect removes the dynamic provider from the restart registry.
// Missing plugins with preserved config remain registered and are not coerced away.
unregisterPluginProviderIdImpl.call(this, provider);
// Clear all trusted merge state so a later account/resource cannot reuse
// an unrelated snapshot or convergent baseline.
clearProviderMergeStateImpl.call(this, provider);
this.removeFromStorage(this.providerAccountIdKey(provider));
// Drop OS-backed sync secrets so disconnect does not leave reusable passwords.
if (isPluginCloudProviderId(provider)) {
try {
const { deletePluginSyncSecrets } = await import('../adapters/pluginSyncIpcHost');
await deletePluginSyncSecrets({ providerId: provider });
} catch {
/* best-effort; disconnect still succeeds */
}
}
// Reset BLOCKED state if it was present — disconnect implicitly resolves
// any pending shrink-block warning since there's no provider to push to.
this.exitBlockedState();
if (this.state.syncState === 'BLOCKED') {
this.state.syncState = 'IDLE';
}
this.notifyStateChange(); // Ensure UI updates immediately after disconnect
}
export function updateProviderStatusImpl(this: any,
provider: CloudProvider,
status: ProviderConnection['status'],
error?: string
): void {
// Bump sequence to invalidate any in-flight async decrypt for this provider
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
...this.state.providers[provider],
status,
error,
};
this.notifyStateChange(); // Notify UI of status change
}
export function isActiveAuthAttemptImpl(this: any,provider: CloudProvider, authAttemptId: number): boolean {
return this.providerAuthAttemptSeq[provider] === authAttemptId;
}
export function buildAccountFromConfigImpl(this: any,
provider: 'webdav' | 's3',
config: WebDAVConfig | S3Config
): ProviderAccount {
if (provider === 'webdav') {
const endpoint = (config as WebDAVConfig).endpoint;
return { id: endpoint, name: endpoint };
}
const s3 = config as S3Config;
return { id: `${s3.bucket}@${s3.endpoint}`, name: `${s3.bucket} (${s3.region})` };
}
export function syncAnchorKeyImpl(this: any,provider: CloudProvider): string {
return `${SYNC_REMOTE_ANCHOR_STORAGE_KEY}_${provider}`;
}
export function createSyncedFileSignatureImpl(this: any,syncedFile: SyncedFile | null): Promise<string | null> {
return createSyncedFileSignatureCore(syncedFile);
}
export function loadSyncAnchorImpl(this: any,provider: CloudProvider): ProviderSyncAnchor | null {
return this.loadFromStorage<ProviderSyncAnchor>(this.syncAnchorKey(provider));
}
export async function saveSyncAnchorImpl(this: any,
provider: CloudProvider,
syncedFile: SyncedFile | null,
resourceId?: string | null,
): Promise<void> {
this.saveToStorage(this.syncAnchorKey(provider), {
signature: await this.createSyncedFileSignature(syncedFile),
version: syncedFile?.meta.version ?? 0,
updatedAt: syncedFile?.meta.updatedAt ?? 0,
deviceId: syncedFile?.meta.deviceId,
resourceId: resourceId ?? this.state.providers[provider].resourceId ?? null,
observedAt: Date.now(),
} satisfies ProviderSyncAnchor);
}
export function clearSyncAnchorImpl(this: any,provider?: CloudProvider): void {
if (provider) {
this.removeFromStorage(this.syncAnchorKey(provider));
return;
}
const providers = new Set<CloudProvider>([
'github', 'google', 'onedrive', 'webdav', 's3',
]);
for (const id of Object.keys(this.state?.providers ?? {})) {
providers.add(id as CloudProvider);
}
if (typeof this.listRegisteredPluginProviderIds === 'function') {
for (const id of this.listRegisteredPluginProviderIds()) {
providers.add(id as CloudProvider);
}
}
for (const p of providers) {
this.removeFromStorage(this.syncAnchorKey(p));
}
}
export async function inspectProviderRemoteStateImpl(this: any,
provider: CloudProvider,
adapter: CloudAdapter,
): Promise<{
remoteChanged: boolean;
remoteFile: SyncedFile | null;
error?: string;
}> {
try {
const remoteFile = await adapter.download();
const currentSignature = await this.createSyncedFileSignature(remoteFile);
const anchor = this.loadSyncAnchor(provider);
const currentResourceId = adapter.resourceId || this.state.providers[provider].resourceId || null;
const decision = decideRemoteChanged({
currentSignature,
currentResourceId,
anchor,
hasRemoteFile: Boolean(remoteFile),
});
return {
remoteChanged: decision.remoteChanged,
remoteFile,
};
} catch (error) {
// A dead OneDrive refresh token surfaces here during sync preflight,
// syncAll preflight, and startup inspection. Clear the stale credentials
// so the provider drops to a reconnect state instead of being retried.
if (typeof this.handleProviderReauthRequired === 'function') {
this.handleProviderReauthRequired(provider, error);
}
return {
remoteChanged: false,
remoteFile: null,
error: String(error),
};
}
}
export async function checkProviderConflictImpl(this: any,
provider: CloudProvider,
adapter: CloudAdapter
): Promise<{
conflict: boolean;
remoteFile?: SyncedFile;
}> {
const inspection = await this.inspectProviderRemoteState(provider, adapter);
if (inspection.error) {
throw new Error(inspection.error);
}
return {
conflict: inspection.remoteChanged && Boolean(inspection.remoteFile),
remoteFile: inspection.remoteFile ?? undefined,
};
}
export async function inspectProviderRemoteImpl(this: any,provider: CloudProvider): Promise<{
remoteChanged: boolean;
remoteFile: SyncedFile | null;
payload: SyncPayload | null;
}> {
if (this.state.securityState !== 'UNLOCKED' || !this.masterPassword) {
throw new Error('Vault is locked');
}
const adapter = await this.getConnectedAdapter(provider);
const inspection = await this.inspectProviderRemoteState(provider, adapter);
if (inspection.error) {
throw new Error(inspection.error);
}
if (!inspection.remoteFile) {
return {
remoteChanged: inspection.remoteChanged,
remoteFile: null,
payload: null,
};
}
return {
remoteChanged: inspection.remoteChanged,
remoteFile: inspection.remoteFile,
payload: await EncryptionService.decryptPayload(inspection.remoteFile, this.masterPassword),
};
}
export async function commitRemoteInspectionImpl(this: any,
provider: CloudProvider,
remoteFile: SyncedFile,
payload: SyncPayload,
opts: { recordDownload?: boolean } = {},
): Promise<void> {
const adapter = await this.getConnectedAdapter(provider);
const resourceId = adapter.resourceId || this.state.providers[provider].resourceId || null;
if (resourceId && this.state.providers[provider].resourceId !== resourceId) {
++this.providerDecryptSeq[provider];
this.state.providers[provider] = {
...this.state.providers[provider],
resourceId,
};
}
this.state.localVersion = remoteFile.meta.version;
this.state.localUpdatedAt = remoteFile.meta.updatedAt;
this.state.remoteVersion = remoteFile.meta.version;
this.state.remoteUpdatedAt = remoteFile.meta.updatedAt;
this.state.providers[provider].lastSync = Date.now();
this.state.providers[provider].lastSyncVersion = remoteFile.meta.version;
await this.saveSyncBase(payload, provider);
this.saveSyncConfig();
await this.saveSyncAnchor(provider, remoteFile, resourceId);
await this.saveProviderConnection(provider, this.state.providers[provider]);
if (opts.recordDownload === true) {
this.addSyncHistoryEntry({
timestamp: Date.now(),
provider,
action: 'download',
success: true,
localVersion: remoteFile.meta.version,
remoteVersion: remoteFile.meta.version,
deviceName: remoteFile.meta.deviceName,
});
}
this.notifyStateChange();
}