Files
readest/apps/readest-app/src/services/sync/replicaPullAndApply.ts
T
Huang Xin 5774e00c09 feat(sync): opt-in Credentials toggle + keyring v4 migration (#4111)
* feat(sync): add opt-in Credentials toggle to Manage Sync

Adds a new Credentials category (default OFF) that gates the encrypted
fields (OPDS / KOSync / Readwise / Hardcover usernames, passwords, and
tokens) at both the publish and pull pipelines. When off, sensitive
fields never leave the device, the proactive passphrase prompt never
fires, and the Sync passphrase panel is hidden entirely.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* security: bump keyring to version 4

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 19:09:57 +02:00

308 lines
13 KiB
TypeScript

import { isReplicaRowAlive } from '@/libs/replicaInterpret';
import type { ReplicaRow } from '@/types/replica';
import type { ReplicaTransferFile } from '@/store/transferStore';
import type { BaseDir } from '@/types/system';
import type { ReplicaAdapter } from './replicaRegistry';
import {
captureCipherTexts,
cipherTextsChanged,
collectDecryptSuccess,
decryptRowFields,
} from './replicaCryptoMiddleware';
import { ensurePassphraseUnlocked } from './passphraseGate';
import { isCredentialsSyncEnabled } from './syncCategories';
import { cryptoSession } from '@/libs/crypto/session';
export interface ReplicaLocalRecord {
/**
* Per-record on-disk directory under the kind's base. Required for
* sync-era records; legacy entries (created before replica sync) may
* have it unset and the orchestrator treats them as non-syncable.
*/
bundleDir?: string;
name: string;
deletedAt?: number;
/**
* Per-field cipher fingerprint of the last successfully-decrypted
* pull. Used to skip the passphrase prompt when nothing's changed
* since last apply. See replicaCryptoMiddleware.captureCipherTexts.
*/
lastSeenCipher?: Record<string, string>;
}
export interface PullAndApplyDeps<T extends ReplicaLocalRecord> {
/** Replica adapter for this kind. Provides unpackRow + binary base dir. */
adapter: ReplicaAdapter<T>;
/** Pulls rows for this kind. Boot caller passes since=null for full sync. */
pull(): Promise<ReplicaRow[]>;
/** Looks up an existing local record by its cross-device contentId. */
findByContentId(contentId: string): T | undefined;
/** Adds a remote-sourced record to the local store WITHOUT republishing. */
applyRemote(record: T): void;
/**
* Tombstones the local entry whose contentId matches. Implementer
* looks up by contentId and removes it from the local store, but
* skips re-publishing the tombstone — the row is already tombstoned
* server-side; we just observed that fact.
*/
softDeleteByContentId(contentId: string): void;
/**
* Mints a fresh local bundleDir, creates the directory on disk under
* the kind's base dir, returns the directory name (relative).
*/
createBundleDir(): Promise<string>;
/**
* Hands the manifest's binary files off to TransferManager for
* download. Returns the transfer id (or null if the queue isn't
* ready). Caller arguments mirror transferManager.queueReplicaDownload.
*/
queueReplicaDownload(
contentId: string,
displayTitle: string,
files: ReplicaTransferFile[],
bundleDir: string,
base: BaseDir,
): string | null;
/**
* Returns true iff EVERY filename exists on disk under
* `<bundleDir>/<filename>` in the kind's base dir. Lets the
* orchestrator skip the download queue when the binaries from a
* previous session are still around.
*/
filesExist(bundleDir: string, filenames: string[]): Promise<boolean>;
/**
* Hydrates the local store from disk before the orchestrator queries
* findByContentId. Without this, applyRemote's auto-persist round-
* trip overwrites persisted entries that hadn't yet been pulled into
* the in-memory store by a feature mount.
*/
hydrateLocalStore?(): Promise<void>;
/**
* Reconciliation hook for "server has the row but no manifest, and
* we're the device with the local binaries". The orchestrator
* invokes this when applyRow finds an alive row with empty
* `manifest_jsonb` AND a matching local record. Implementation
* should fan out to the binary-upload pipeline (typically
* `queueReplicaBinaryUpload(kind, record, appService)`), which in
* turn fires `replica-transfer-complete` and commits the manifest.
* Without this, transient upload failures or "TM not ready at
* import time" leave the server row stuck with manifest_jsonb=null
* forever — a refresh wouldn't recover it.
*/
queueLocalBinaryUpload?(record: T): Promise<void>;
/**
* Optional auth precheck. When provided and resolves to false, the
* orchestrator skips the entire pull (no network call, no warnings).
*/
isAuthenticated?(): Promise<boolean>;
/**
* When true, encrypted-field cipher payloads are decrypted
* best-effort but the orchestrator NEVER triggers the passphrase
* gate for this kind. Cipher fields silently drop when the session
* is locked, leaving the local copy intact. Use for kinds where
* spam-prompting on every pull would be jarring (e.g., the bundled
* `settings` row, which pulls on every library mount). The user
* unlocks via an explicit Settings → Sync action; the next pull
* cycle then decrypts cleanly.
*/
silentDecrypt?: boolean;
/**
* Called when one or more cipher fields failed to decrypt because
* the cipher's `saltId` no longer exists in `replica_keys` (orphan
* after an out-of-band server reset). Adapters that persist a
* "previously published" fingerprint per encrypted path should
* clear it so the next save re-encrypts the still-locally-held
* plaintext under the current salt — overwriting the orphan
* cipher on the server. No-op when the kind has no such fingerprint
* to invalidate.
*/
onSaltNotFound?(paths: readonly string[]): void;
}
const MANIFEST_FILE_TO_TRANSFER = (
filename: string,
byteSize: number,
bundleDir: string,
): ReplicaTransferFile => ({
logical: filename,
lfp: `${bundleDir}/${filename}`,
byteSize,
});
const applyRow = async <T extends ReplicaLocalRecord>(
row: ReplicaRow,
deps: PullAndApplyDeps<T>,
): Promise<void> => {
const local = deps.findByContentId(row.replica_id);
// Decrypt encrypted-field cipher payloads in place so unpackRow sees
// plaintext. The prompt-decision heuristic compares the row's
// incoming ciphers against the local record's last-seen-cipher
// fingerprint:
// * fingerprint matches → we already have the plaintext for this
// exact ciphertext; skip the prompt + decrypt entirely
// * fingerprint differs (or local has no fingerprint yet) AND
// session is locked → prompt the gate so we can decrypt the new
// value (covers fresh device + password rotation on Device A)
// * session already unlocked → just decrypt; no prompt
// Cancel / failure leaves the field absent; the store's applyRemote
// merge preserves any local plaintext copy.
const encryptedFields = deps.adapter.encryptedFields;
// Credentials meta-toggle (default OFF): when the user hasn't opted
// in, every cipher payload in this row's encrypted slots gets stripped
// before we even capture the cipher fingerprint. The decrypt loop
// sees nothing, the prompt never fires, and the adapter unpacks
// without the credential fields. The store's applyRemote merge keeps
// any local plaintext copy intact (same code path as the locked-
// session case).
if (encryptedFields && encryptedFields.length > 0 && !isCredentialsSyncEnabled()) {
for (const field of encryptedFields) {
delete row.fields_jsonb[field];
}
}
const beforeDecrypt = captureCipherTexts(row.fields_jsonb, encryptedFields);
const localLastSeen = local?.lastSeenCipher;
const needsPrompt =
!deps.silentDecrypt &&
!cryptoSession.isUnlocked() &&
Object.keys(beforeDecrypt).length > 0 &&
cipherTextsChanged(beforeDecrypt, localLastSeen);
const onLocked = needsPrompt ? () => ensurePassphraseUnlocked() : undefined;
const decryptResult = await decryptRowFields(
row.fields_jsonb,
encryptedFields,
undefined,
onLocked,
);
if (decryptResult.saltNotFound.length > 0 && deps.onSaltNotFound) {
deps.onSaltNotFound(decryptResult.saltNotFound);
}
// Build the lastSeenCipher fingerprint to attach to the unpacked
// record. Only fields whose decrypt succeeded contribute — a failed
// decrypt leaves the previous fingerprint in place so we'll re-try
// (and re-prompt) on the next pull.
const decryptedThisRound = collectDecryptSuccess(row.fields_jsonb, beforeDecrypt);
const mergedLastSeen =
Object.keys(decryptedThisRound).length > 0 || localLastSeen
? { ...(localLastSeen ?? {}), ...decryptedThisRound }
: undefined;
const alive = isReplicaRowAlive(row);
if (!alive) {
// Always invoke softDeleteByContentId on a tombstoned row, even
// when no local record matches. The dict store uses this hook to
// scrub companion state (dictionarySettings.providerOrder /
// providerEnabled) that may have been seeded by the settings
// replica without a matching local row — a fresh device often hits
// this path because the settings replica lands before the dict
// replica, and a contentId may be referenced in providerEnabled
// even though its dict row arrives tombstoned. Other kinds (font,
// texture, opds_catalog) self-no-op when no local exists, so the
// unconditional call is safe.
deps.softDeleteByContentId(row.replica_id);
return;
}
// Decide bundleDir + display name. If a local entry already maps this
// contentId, reuse its bundleDir so we don't orphan the previously
// downloaded binaries; otherwise mint a fresh dir and apply the remote
// record to the local store. Legacy binary-kind records (pre-replica-
// sync) may carry no bundleDir — skip them; they aren't sync-eligible.
// Metadata-only kinds (no `binary` capability, e.g. opds_catalog) have
// no on-disk anchor at all, so the bundleDir requirement is dropped.
const needsBundleDir = !!deps.adapter.binary;
let bundleDir: string;
let displayName: string;
if (local) {
if (needsBundleDir && !local.bundleDir) return;
bundleDir = local.bundleDir ?? '';
displayName = deps.adapter.getDisplayName?.(local) ?? local.name;
// For metadata-only kinds, always re-apply the unpacked row so
// per-field updates merge into the local copy: renames pushed
// from another device, newly-decrypted credentials that weren't
// available on the previous pull (session was locked then), etc.
// The store's applyRemote merge preserves identity-stable local
// state. Binary kinds keep the skip-rebuild semantic so we don't
// re-download files we already have.
if (!needsBundleDir) {
const record = deps.adapter.unpackRow(row, bundleDir);
if (record) {
if (mergedLastSeen) record.lastSeenCipher = mergedLastSeen;
deps.applyRemote(record);
}
}
} else {
bundleDir = needsBundleDir ? await deps.createBundleDir() : '';
const record = deps.adapter.unpackRow(row, bundleDir);
if (!record) return;
if (mergedLastSeen) record.lastSeenCipher = mergedLastSeen;
deps.applyRemote(record);
displayName = deps.adapter.getDisplayName?.(record) ?? record.name;
}
// Metadata-only kinds: nothing more to do. The orchestrator's manifest
// / binary-download path is a no-op for them.
if (!needsBundleDir) return;
if (!row.manifest_jsonb || row.manifest_jsonb.files.length === 0) {
// Server row has no manifest yet — typically the device that
// wrote the metadata never finished the binary upload (TM wasn't
// ready, transient failure, app close mid-upload). If we're the
// device with the local copy, push the binaries now so the
// manifest commits via replica-transfer-complete.
if (local && deps.queueLocalBinaryUpload) {
await deps.queueLocalBinaryUpload(local);
}
return;
}
if (!deps.adapter.binary) return;
// Skip the download queue if every manifest file is already on disk
// under the resolved bundle dir. Refresh-the-page is a no-op rather
// than a re-download; partial-download recovery still queues because
// some files would be missing.
const filenames = row.manifest_jsonb.files.map((f) => f.filename);
const allPresent = await deps.filesExist(bundleDir, filenames);
if (allPresent) return;
const files = row.manifest_jsonb.files.map((f) =>
MANIFEST_FILE_TO_TRANSFER(f.filename, f.byteSize, bundleDir),
);
deps.queueReplicaDownload(
row.replica_id,
displayName,
files,
bundleDir,
deps.adapter.binary.localBaseDir,
);
};
/**
* Generic pull-side dispatcher for any replica kind. Walks rows since
* the last cursor advance and applies each via applyRow. Errors per
* row are isolated — one bad row never blocks the others.
*
* The dictionary adapter and (future) font / texture adapters share
* this orchestrator; per-kind translation lives entirely in the
* adapter's unpackRow + binary capability.
*/
export const replicaPullAndApply = async <T extends ReplicaLocalRecord>(
deps: PullAndApplyDeps<T>,
): Promise<void> => {
if (deps.isAuthenticated && !(await deps.isAuthenticated())) return;
if (deps.hydrateLocalStore) {
await deps.hydrateLocalStore();
}
const rows = await deps.pull();
for (const row of rows) {
try {
await applyRow(row, deps);
} catch (err) {
console.warn('replica pull row apply failed', { replicaId: row.replica_id, err });
}
}
};