Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
874 lines
28 KiB
TypeScript
874 lines
28 KiB
TypeScript
/**
|
|
* Cloud Sync Domain Types & Interfaces
|
|
*
|
|
* Zero-Knowledge Encrypted Multi-Cloud Sync System
|
|
* Supports: GitHub Gist, Google Drive, Microsoft OneDrive, WebDAV, S3 Compatible
|
|
*/
|
|
|
|
import type { ShrinkFinding } from './syncGuards';
|
|
import type {
|
|
ConvergentFieldConflict,
|
|
ConvergentSyncEnvelopeV2,
|
|
ConvergentSyncStateV2,
|
|
} from './convergentSync';
|
|
import {
|
|
BUILTIN_CLOUD_PROVIDERS,
|
|
isBuiltinCloudProvider,
|
|
providerConnectionStorageKey,
|
|
type BuiltinCloudProvider,
|
|
type CloudProviderId,
|
|
} from './cloudProviderIds';
|
|
|
|
export type {
|
|
ConvergentFieldConflict,
|
|
ConvergentSyncEnvelopeV2,
|
|
ConvergentSyncStateV2,
|
|
} from './convergentSync';
|
|
|
|
export {
|
|
BUILTIN_CLOUD_PROVIDERS,
|
|
isBuiltinCloudProvider,
|
|
providerConnectionStorageKey,
|
|
type BuiltinCloudProvider,
|
|
};
|
|
|
|
/** Built-in short IDs or namespaced plugin contribution IDs. */
|
|
export type CloudProvider = CloudProviderId;
|
|
|
|
// ============================================================================
|
|
// Security State Machine
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Global Security State Machine
|
|
* Controls access to sync operations based on master key status
|
|
*/
|
|
export type SecurityState =
|
|
| 'NO_KEY' // User has not set up a master key - block all sync
|
|
| 'LOCKED' // Master key exists but not in memory - show unlock screen
|
|
| 'UNLOCKED'; // Master key in memory - sync operations allowed
|
|
|
|
/**
|
|
* Sync Operation State Machine
|
|
* Tracks the current sync operation status
|
|
*/
|
|
export type SyncState =
|
|
| 'IDLE' // Waiting for sync trigger
|
|
| 'SYNCING' // Active sync operation in progress
|
|
| 'CONFLICT' // Version conflict detected - needs resolution
|
|
| 'BLOCKED' // Outgoing payload would delete too much — user must choose restore or force-push
|
|
| 'ERROR'; // Operation failed - needs attention
|
|
|
|
/**
|
|
* Conflict Resolution Strategy
|
|
*/
|
|
export type ConflictResolution =
|
|
| 'USE_REMOTE' // Download cloud data, overwrite local
|
|
| 'USE_LOCAL' // Upload local data, overwrite cloud
|
|
| 'AUTO_MERGED'; // Three-way merge was applied automatically
|
|
|
|
// ============================================================================
|
|
// Cloud Provider Types
|
|
// ============================================================================
|
|
|
|
export type WebDAVAuthType = 'basic' | 'digest' | 'token';
|
|
|
|
export interface WebDAVConfig {
|
|
endpoint: string;
|
|
authType: WebDAVAuthType;
|
|
username?: string;
|
|
password?: string;
|
|
token?: string;
|
|
allowInsecure?: boolean;
|
|
}
|
|
|
|
export interface S3Config {
|
|
endpoint: string;
|
|
region: string;
|
|
bucket: string;
|
|
accessKeyId: string;
|
|
secretAccessKey: string;
|
|
sessionToken?: string;
|
|
prefix?: string;
|
|
forcePathStyle?: boolean;
|
|
allowInsecure?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Provider-specific connection status
|
|
*/
|
|
type ProviderConnectionStatus =
|
|
| 'disconnected'
|
|
| 'connecting'
|
|
| 'connected'
|
|
| 'syncing'
|
|
| 'error';
|
|
|
|
/**
|
|
* OAuth token storage structure
|
|
*/
|
|
export interface OAuthTokens {
|
|
accessToken: string;
|
|
refreshToken?: string;
|
|
expiresAt?: number; // Unix timestamp
|
|
tokenType: string;
|
|
scope?: string;
|
|
}
|
|
|
|
/**
|
|
* Marker prefixed onto OneDrive refresh errors when Microsoft reports the
|
|
* refresh token can no longer be used (expired / revoked / consent withdrawn).
|
|
* Only an error's `message` survives the Electron IPC boundary, so the marker is
|
|
* the stable signal that the OneDrive session must be re-authorized. It is added
|
|
* in the bridge (electron/bridges/onedriveAuthBridge.cjs) and detected/cleaned
|
|
* here so the same logic is shared by infrastructure and UI layers.
|
|
*/
|
|
export const ONEDRIVE_REAUTH_REQUIRED_MARKER = 'ONEDRIVE_REAUTH_REQUIRED';
|
|
|
|
/**
|
|
* True when an error indicates the OneDrive refresh token is dead and the user
|
|
* must reconnect. Robust to the error being re-wrapped (e.g. `new
|
|
* Error(String(err))`) as it bubbles through the provider-agnostic pipeline.
|
|
*/
|
|
export const isOneDriveReauthRequiredMessage = (message: string): boolean =>
|
|
message.includes(ONEDRIVE_REAUTH_REQUIRED_MARKER);
|
|
|
|
/**
|
|
* Produce a clean, user-facing message from a (possibly multiply-wrapped) error
|
|
* string by dropping everything up to and including the internal reauth marker,
|
|
* e.g. "Error: OneDriveReauthRequiredError: ONEDRIVE_REAUTH_REQUIRED: OneDrive
|
|
* session expired..." -> "OneDrive session expired...". Returns the original
|
|
* string unchanged when the marker is absent.
|
|
*/
|
|
export const cleanOneDriveErrorMessage = (message: string): string => {
|
|
const token = `${ONEDRIVE_REAUTH_REQUIRED_MARKER}:`;
|
|
const markerIndex = message.lastIndexOf(token);
|
|
if (markerIndex === -1) return message;
|
|
return message.slice(markerIndex + token.length).trim();
|
|
};
|
|
|
|
/**
|
|
* Provider account information
|
|
*/
|
|
export interface ProviderAccount {
|
|
id: string;
|
|
email?: string;
|
|
name?: string;
|
|
avatarUrl?: string;
|
|
}
|
|
|
|
/**
|
|
* Cloud provider connection state
|
|
*/
|
|
/**
|
|
* Opaque host-owned sync credential reference (SecretRef / CredentialRef shape).
|
|
* Never stores plaintext secrets — only the reference the plugin connect path needs.
|
|
* Secret leases are one-shot and must not be persisted for reconnect.
|
|
*/
|
|
export interface PluginSyncCredentialRef {
|
|
kind: 'secret' | 'credential';
|
|
id: string;
|
|
key?: string;
|
|
}
|
|
|
|
/** Reasonable upper bounds for durable opaque ref strings persisted at rest. */
|
|
const MAX_PLUGIN_SYNC_CREDENTIAL_ID_CHARS = 512;
|
|
const MAX_PLUGIN_SYNC_CREDENTIAL_KEY_CHARS = 256;
|
|
|
|
/**
|
|
* Normalize a value into a durable PluginSyncCredentialRef for reconnect
|
|
* persistence. Rejects arrays, leases, and oversized / malformed shapes.
|
|
*/
|
|
export function normalizeDurablePluginSyncCredentialRef(
|
|
value: unknown,
|
|
): PluginSyncCredentialRef | undefined {
|
|
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
|
|
return undefined;
|
|
}
|
|
const record = value as Record<string, unknown>;
|
|
const kind = record.kind;
|
|
const id = record.id;
|
|
if (kind !== 'secret' && kind !== 'credential') return undefined;
|
|
if (typeof id !== 'string' || id.length < 1 || id.length > MAX_PLUGIN_SYNC_CREDENTIAL_ID_CHARS) {
|
|
return undefined;
|
|
}
|
|
if (!Object.prototype.hasOwnProperty.call(record, 'key') || record.key === undefined) {
|
|
return { kind, id };
|
|
}
|
|
const key = record.key;
|
|
if (typeof key !== 'string' || key.length < 1 || key.length > MAX_PLUGIN_SYNC_CREDENTIAL_KEY_CHARS) {
|
|
return undefined;
|
|
}
|
|
return { kind, id, key };
|
|
}
|
|
|
|
export interface ProviderConnection {
|
|
provider: CloudProvider;
|
|
status: ProviderConnectionStatus;
|
|
account?: ProviderAccount;
|
|
tokens?: OAuthTokens;
|
|
config?: WebDAVConfig | S3Config;
|
|
/** Plugin sync providers: persisted SyncConnectPayload.credential for reconnect. */
|
|
credential?: PluginSyncCredentialRef;
|
|
lastSync?: number; // Unix timestamp
|
|
lastSyncVersion?: number;
|
|
resourceId?: string; // gistId / fileId / itemId
|
|
error?: string;
|
|
}
|
|
|
|
/**
|
|
* Whether a connection still has usable credentials/config to retry.
|
|
* Plugin configs may be valid falsy JSON scalars (`false`, `0`, `""`) or even
|
|
* JSON `null` when a schema uses `type: "null"`. Presence is property
|
|
* existence for `config`; do not use truthiness (`||` / `Boolean`).
|
|
*/
|
|
export const hasProviderConnectionData = (
|
|
connection: Pick<ProviderConnection, 'tokens' | 'config' | 'credential'>,
|
|
): boolean =>
|
|
connection.tokens != null
|
|
|| Object.prototype.hasOwnProperty.call(connection, 'config')
|
|
|| connection.credential != null;
|
|
|
|
export const isProviderReadyForSync = (
|
|
connection: Pick<ProviderConnection, 'status' | 'tokens' | 'config'>,
|
|
): boolean =>
|
|
connection.status === 'connected'
|
|
|| connection.status === 'syncing'
|
|
|| (connection.status === 'error' && hasProviderConnectionData(connection));
|
|
|
|
// ============================================================================
|
|
// Encrypted Sync File Schema
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Sync file metadata (stored in plaintext for version control)
|
|
*/
|
|
export interface SyncFileMeta {
|
|
version: number; // Incremental version number
|
|
updatedAt: number; // Unix timestamp (ms)
|
|
deviceId: string; // UUID identifying the device
|
|
deviceName?: string; // Human-readable device name
|
|
appVersion: string; // App version that created this sync
|
|
iv: string; // AES-GCM initialization vector (Base64)
|
|
salt: string; // KDF salt for key derivation (Base64)
|
|
algorithm: 'AES-256-GCM'; // Encryption algorithm identifier
|
|
kdf: 'PBKDF2' | 'Argon2id'; // Key derivation function
|
|
kdfIterations?: number; // PBKDF2 iterations (if applicable)
|
|
/** Present only for convergent-sync payloads. Unknown future versions fail closed. */
|
|
syncSchemaVersion?: 2;
|
|
}
|
|
|
|
/**
|
|
* Complete synced file structure
|
|
* The payload contains all encrypted user data
|
|
*/
|
|
export interface SyncedFile {
|
|
meta: SyncFileMeta;
|
|
payload: string; // Base64 encrypted ciphertext
|
|
}
|
|
|
|
/**
|
|
* Decrypted payload structure - contains all syncable data
|
|
*/
|
|
export interface SyncPayload {
|
|
// Core vault data
|
|
hosts: import('./models').Host[];
|
|
keys: import('./models').SSHKey[];
|
|
identities?: import('./models').Identity[];
|
|
proxyProfiles?: import('./models').ProxyProfile[];
|
|
snippets: import('./models').Snippet[];
|
|
customGroups: string[];
|
|
snippetPackages?: string[];
|
|
notes?: import('./models').VaultNote[];
|
|
noteGroups?: string[];
|
|
|
|
// Group configs (connection defaults per host group)
|
|
groupConfigs?: import('./models').GroupConfig[];
|
|
|
|
// Port forwarding rules
|
|
portForwardingRules?: import('./models').PortForwardingRule[];
|
|
|
|
// Known hosts
|
|
knownHosts?: import('./models').KnownHost[];
|
|
|
|
// Settings
|
|
settings?: {
|
|
// Theme & Appearance
|
|
theme?: 'light' | 'dark' | 'system';
|
|
lightUiThemeId?: string;
|
|
darkUiThemeId?: string;
|
|
accentMode?: 'theme' | 'custom';
|
|
customAccent?: string;
|
|
uiFontFamilyId?: string;
|
|
uiLanguage?: string;
|
|
customCSS?: string;
|
|
noteFontFamily?: string;
|
|
noteFontSize?: number;
|
|
noteCodeFontSize?: number;
|
|
// Terminal
|
|
terminalTheme?: string;
|
|
followAppTerminalTheme?: boolean;
|
|
terminalThemeDark?: string;
|
|
terminalThemeLight?: string;
|
|
terminalFontFamily?: string;
|
|
terminalFontSize?: number;
|
|
terminalSettings?: Record<string, unknown>;
|
|
terminalSidePanelAutoOpen?: boolean;
|
|
terminalSidePanelAutoOpenTab?: import('./terminalSidePanelAutoOpen').TerminalSidePanelAutoOpenTab;
|
|
customTerminalThemes?: Array<{ id: string; name: string; colors: Record<string, string> }>;
|
|
// Keyboard
|
|
customKeyBindings?: Record<string, { mac?: string; pc?: string }>;
|
|
// Editor
|
|
editorWordWrap?: boolean;
|
|
// SFTP
|
|
sftpDoubleClickBehavior?: 'open' | 'transfer';
|
|
sftpAutoSync?: boolean;
|
|
sftpShowHiddenFiles?: boolean;
|
|
sftpUseCompressedUpload?: boolean;
|
|
sftpSkipUnchanged?: boolean;
|
|
sftpAutoOpenSidebar?: boolean;
|
|
sftpFollowTerminalCwd?: boolean;
|
|
sftpDefaultViewMode?: 'list' | 'tree';
|
|
sftpGlobalBookmarks?: import('./models').SftpBookmark[];
|
|
// Vault: show recently connected hosts
|
|
showRecentHosts?: boolean;
|
|
// Vault: host click activates immediately, or select-then-click-again
|
|
hostClickBehavior?: 'connect' | 'select';
|
|
// Vault: root list shows only ungrouped hosts
|
|
showOnlyUngroupedHostsInRoot?: boolean;
|
|
// Top tabs: show standalone SFTP view tab
|
|
showSftpTab?: boolean;
|
|
// Shortcuts: Cmd/Ctrl+[1...9] and Ctrl+Tab skip pinned Vault/SFTP tabs
|
|
shellOnlyTabNumberShortcuts?: boolean;
|
|
// Shortcuts: show 1...9 badges on tabs matching number switch shortcuts
|
|
showTabNumberBadges?: boolean;
|
|
// Shortcuts: disable terminal font zoom shortcuts
|
|
disableTerminalFontZoom?: boolean;
|
|
// Terminal/editor tabs: show left host list sidebar
|
|
showHostTreeSidebar?: boolean;
|
|
// Workspace focus indicator style
|
|
workspaceFocusStyle?: 'dim' | 'border';
|
|
// AI configuration
|
|
ai?: {
|
|
providers?: Array<Record<string, unknown>>;
|
|
activeProviderId?: string;
|
|
activeModelId?: string;
|
|
globalPermissionMode?: 'observer' | 'confirm' | 'auto';
|
|
toolIntegrationMode?: 'mcp' | 'skills';
|
|
hostPermissions?: Array<Record<string, unknown>>;
|
|
// externalAgents intentionally omitted: command/args/env are device-local
|
|
// (binary paths, OS-specific values) and don't survive cross-device sync.
|
|
defaultAgentId?: string;
|
|
commandBlocklist?: string[];
|
|
commandTimeout?: number;
|
|
responseIdleTimeout?: number;
|
|
maxIterations?: number;
|
|
agentModelMap?: Record<string, string>;
|
|
agentProviderMap?: Record<string, string>;
|
|
agentThinkingMap?: Record<string, string>;
|
|
webSearchConfig?: Record<string, unknown> | null;
|
|
quickMessages?: Array<Record<string, unknown>>;
|
|
showTerminalSelectionAction?: boolean;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Encrypted-sidecar envelope for plugin user data that must survive missing
|
|
* plugins (sync:true settings, account/CRDT baselines). Secrets never appear here.
|
|
*/
|
|
pluginSidecars?: import('./pluginSyncSidecar').PluginSyncSidecarBundle;
|
|
|
|
// Sync metadata
|
|
syncedAt: number; // When this payload was created
|
|
|
|
// Reliability metadata used to make sync decisions auditable across devices.
|
|
syncMeta?: SyncReliabilityMeta;
|
|
|
|
/** Encrypted convergent-sync metadata. The adjacent fields remain a complete v1 snapshot. */
|
|
convergentSync?: ConvergentSyncEnvelopeV2;
|
|
}
|
|
|
|
/**
|
|
* Strip device-local connection telemetry from hosts before any sync
|
|
* comparison or upload (#2629). `lastConnectedAt` updates on every successful
|
|
* SSH connect and must not dirty auto-sync hashes, three-way merges, or
|
|
* inflate cloud versions. Legacy stored bases / remote payloads still carry
|
|
* the field, so all three merge sides are normalized with this helper.
|
|
*/
|
|
export function sanitizeHostsForSync(
|
|
hosts: import('./models').Host[] | undefined,
|
|
): import('./models').Host[] | undefined {
|
|
if (!hosts) return hosts;
|
|
return hosts.map((host) => ({ ...host, lastConnectedAt: undefined }));
|
|
}
|
|
|
|
/** Copy a payload with device-local host telemetry stripped for compare/convert. */
|
|
export function withHostsSanitizedForSync(payload: SyncPayload): SyncPayload {
|
|
return {
|
|
...payload,
|
|
hosts: sanitizeHostsForSync(payload.hosts) ?? payload.hosts,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Re-attach this device's Recently Connected timestamps when a cloud payload
|
|
* omits `lastConnectedAt`. Incoming timestamps (local backups, legacy cloud
|
|
* snapshots) still win when present.
|
|
*/
|
|
export function retainLocalHostLastConnectedAt(
|
|
incoming: import('./models').Host[] | undefined,
|
|
local: import('./models').Host[] | undefined,
|
|
): import('./models').Host[] | undefined {
|
|
if (!incoming) return incoming;
|
|
if (!local?.length) return incoming;
|
|
const localById = new Map(local.map((host) => [host.id, host.lastConnectedAt]));
|
|
return incoming.map((host) => {
|
|
if (host.lastConnectedAt != null) return host;
|
|
const localTs = localById.get(host.id);
|
|
if (localTs == null) return host;
|
|
return { ...host, lastConnectedAt: localTs };
|
|
});
|
|
}
|
|
|
|
export const SYNC_PAYLOAD_ENTITY_KEYS = [
|
|
'hosts',
|
|
'keys',
|
|
'identities',
|
|
'proxyProfiles',
|
|
'snippets',
|
|
'customGroups',
|
|
'snippetPackages',
|
|
'notes',
|
|
'noteGroups',
|
|
'portForwardingRules',
|
|
'knownHosts',
|
|
'groupConfigs',
|
|
] as const;
|
|
|
|
export const CLOUD_SYNC_PAYLOAD_ENTITY_KEYS = [
|
|
'hosts',
|
|
'keys',
|
|
'identities',
|
|
'proxyProfiles',
|
|
'snippets',
|
|
'customGroups',
|
|
'snippetPackages',
|
|
'notes',
|
|
'noteGroups',
|
|
'portForwardingRules',
|
|
'groupConfigs',
|
|
] as const;
|
|
|
|
export type SyncPayloadEntityKey = typeof SYNC_PAYLOAD_ENTITY_KEYS[number];
|
|
export type CloudSyncPayloadEntityKey = typeof CLOUD_SYNC_PAYLOAD_ENTITY_KEYS[number];
|
|
export type SyncChangeEntityKey = CloudSyncPayloadEntityKey | 'settings';
|
|
|
|
export interface SyncEntityChangeCounts {
|
|
added: { local: number; remote: number };
|
|
modified: { local: number; remote: number };
|
|
deleted: { local: number; remote: number };
|
|
}
|
|
|
|
export interface SyncConflictDetail {
|
|
entityType: SyncChangeEntityKey;
|
|
id?: string;
|
|
kind:
|
|
| 'both-added'
|
|
| 'both-modified'
|
|
| 'local-deleted-remote-modified'
|
|
| 'remote-deleted-local-modified';
|
|
}
|
|
|
|
export interface SyncChangeSummary {
|
|
hasLocalChanges: boolean;
|
|
hasRemoteChanges: boolean;
|
|
hasConflicts: boolean;
|
|
byEntity: Partial<Record<SyncChangeEntityKey, SyncEntityChangeCounts>>;
|
|
conflicts: SyncConflictDetail[];
|
|
}
|
|
|
|
export interface SyncDeletionRecord {
|
|
entityType: CloudSyncPayloadEntityKey;
|
|
id: string;
|
|
deletedAt: number;
|
|
deviceId?: string;
|
|
}
|
|
|
|
export interface SyncReliabilityMeta {
|
|
schemaVersion: 1;
|
|
generatedAt: number;
|
|
deviceId?: string;
|
|
baseSyncedAt?: number;
|
|
localChanged: boolean;
|
|
deletions: SyncDeletionRecord[];
|
|
changeSummary: SyncChangeSummary;
|
|
}
|
|
|
|
export interface SyncSnapshotEntry {
|
|
id: string;
|
|
timestamp: number;
|
|
provider?: CloudProvider;
|
|
payload: SyncPayload;
|
|
}
|
|
|
|
export interface ConvergentProviderMigrationStatus {
|
|
provider: CloudProvider;
|
|
status: 'ready' | 'empty' | 'unavailable' | 'blocked';
|
|
schemaVersion: 1 | 2 | 'future' | 'invalid';
|
|
entityCount: number;
|
|
hasTrustedBaseline: boolean;
|
|
message?: string;
|
|
}
|
|
|
|
export interface ConvergentMigrationPreview {
|
|
schemaVersion: 2;
|
|
canInitialize: boolean;
|
|
entityCounts: Partial<Record<CloudSyncPayloadEntityKey, number>>;
|
|
settingsLeafCount: number;
|
|
conflictCount: number;
|
|
conflicts: ConvergentFieldConflict[];
|
|
shrinkFindings: Array<{ provider: CloudProvider; finding: Extract<ShrinkFinding, { suspicious: true }> }>;
|
|
providers: ConvergentProviderMigrationStatus[];
|
|
oldClientCompatibility: 'materialized-v1-snapshot';
|
|
blockedReasons: string[];
|
|
}
|
|
|
|
export interface ConvergentReplicaRecordV2 {
|
|
schemaVersion: 2;
|
|
state: ConvergentSyncStateV2;
|
|
updatedAt: number;
|
|
}
|
|
|
|
export interface ConvergentProviderBaselineV2 {
|
|
schemaVersion: 2;
|
|
provider: CloudProvider;
|
|
remoteVersion: number;
|
|
remoteUpdatedAt: number;
|
|
remoteDeviceId: string;
|
|
materializedPayload: SyncPayload;
|
|
state: ConvergentSyncStateV2;
|
|
}
|
|
|
|
export function hasSyncPayloadEntityData(
|
|
payload: SyncPayload,
|
|
keys: readonly SyncPayloadEntityKey[] = SYNC_PAYLOAD_ENTITY_KEYS,
|
|
): boolean {
|
|
return keys.some((key) => {
|
|
const value = payload[key];
|
|
return Array.isArray(value) && value.length > 0;
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Encryption Types
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Encryption result
|
|
*/
|
|
export interface EncryptionResult {
|
|
ciphertext: Uint8Array;
|
|
iv: Uint8Array;
|
|
salt: Uint8Array;
|
|
algorithm: 'AES-256-GCM';
|
|
kdf: 'PBKDF2' | 'Argon2id';
|
|
kdfIterations?: number;
|
|
}
|
|
|
|
/**
|
|
* Decryption input
|
|
*/
|
|
export interface DecryptionInput {
|
|
ciphertext: Uint8Array;
|
|
iv: Uint8Array;
|
|
salt: Uint8Array;
|
|
kdf: 'PBKDF2' | 'Argon2id';
|
|
kdfIterations?: number;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Master Key Types
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Master key configuration stored in safeStorage
|
|
*/
|
|
export interface MasterKeyConfig {
|
|
// Verification hash to confirm correct password
|
|
verificationHash: string; // Base64 of hash(derived_key)
|
|
salt: string; // Base64 KDF salt
|
|
kdf: 'PBKDF2' | 'Argon2id';
|
|
kdfIterations?: number;
|
|
createdAt: number;
|
|
}
|
|
|
|
/**
|
|
* Unlocked master key state (in memory only)
|
|
*/
|
|
export interface UnlockedMasterKey {
|
|
derivedKey: CryptoKey; // AES-256-GCM key
|
|
salt: Uint8Array;
|
|
unlockedAt: number;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Sync Manager Types
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Sync operation result
|
|
*/
|
|
export interface SyncResult {
|
|
success: boolean;
|
|
provider: CloudProvider;
|
|
action: 'upload' | 'download' | 'merge' | 'none';
|
|
version?: number;
|
|
error?: string;
|
|
conflictDetected?: boolean;
|
|
/** Present when sync produced or selected a payload that caller should apply locally */
|
|
mergedPayload?: import('./sync').SyncPayload;
|
|
/** True when convergent sync already applied mergedPayload and committed its replica atomically. */
|
|
mergedPayloadApplied?: boolean;
|
|
/** Present with a downloaded payload so callers can commit the remote anchor after local apply succeeds. */
|
|
remoteFile?: SyncedFile;
|
|
/** True when a shrink-detection guard blocked the upload */
|
|
shrinkBlocked?: boolean;
|
|
/** The finding that triggered the shrink block or force-push */
|
|
finding?: ShrinkFinding;
|
|
/** Field-level conflicts retained by convergent sync v2. */
|
|
convergentConflicts?: ConvergentFieldConflict[];
|
|
/** Number of retained v2 conflicts; duplicated for lightweight status views. */
|
|
convergentConflictCount?: number;
|
|
}
|
|
|
|
export interface RemoteSyncPayload {
|
|
provider: CloudProvider;
|
|
payload: SyncPayload;
|
|
remoteFile: SyncedFile;
|
|
}
|
|
|
|
/**
|
|
* Conflict information for UI
|
|
*/
|
|
export interface ConflictInfo {
|
|
provider: CloudProvider;
|
|
localVersion: number;
|
|
localUpdatedAt: number;
|
|
localDeviceName?: string;
|
|
remoteVersion: number;
|
|
remoteUpdatedAt: number;
|
|
remoteDeviceName?: string;
|
|
changeSummary?: SyncChangeSummary;
|
|
}
|
|
|
|
/**
|
|
* Sync history record entry
|
|
*/
|
|
export interface SyncHistoryEntry {
|
|
id: string;
|
|
timestamp: number;
|
|
provider: CloudProvider;
|
|
action: 'upload' | 'download' | 'merge' | 'conflict_resolved';
|
|
success: boolean;
|
|
localVersion: number;
|
|
remoteVersion?: number;
|
|
deviceName?: string;
|
|
error?: string;
|
|
}
|
|
|
|
// ============================================================================
|
|
// OAuth Flow Types
|
|
// ============================================================================
|
|
|
|
/**
|
|
* GitHub Device Flow response
|
|
*/
|
|
export interface GitHubDeviceCodeResponse {
|
|
device_code: string;
|
|
user_code: string;
|
|
verification_uri: string;
|
|
expires_in: number;
|
|
interval: number;
|
|
}
|
|
|
|
/**
|
|
* OAuth PKCE challenge
|
|
*/
|
|
export interface PKCEChallenge {
|
|
codeVerifier: string;
|
|
codeChallenge: string;
|
|
state: string;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Event Types
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Sync event for UI updates
|
|
*/
|
|
export type SyncEvent =
|
|
| { type: 'SYNC_STARTED'; provider: CloudProvider }
|
|
| { type: 'SYNC_PROGRESS'; provider: CloudProvider; progress: number; message: string }
|
|
| { type: 'SYNC_COMPLETED'; provider: CloudProvider; result: SyncResult }
|
|
| { type: 'SYNC_ERROR'; provider: CloudProvider; error: string }
|
|
| { type: 'CONFLICT_DETECTED'; conflict: ConflictInfo }
|
|
| { type: 'SYNC_BLOCKED_SHRINK'; provider: CloudProvider; finding: ShrinkFinding }
|
|
| { type: 'SYNC_FORCED'; provider: CloudProvider; finding: ShrinkFinding }
|
|
| { type: 'CONFLICT_RESOLVED'; resolution: ConflictResolution }
|
|
| { type: 'AUTH_REQUIRED'; provider: CloudProvider }
|
|
| { type: 'AUTH_COMPLETED'; provider: CloudProvider; account: ProviderAccount }
|
|
| { type: 'SECURITY_STATE_CHANGED'; state: SecurityState }
|
|
| { type: 'SYNC_BLOCKED_CLEARED' }
|
|
| {
|
|
type: 'PROVIDERS_DIVERGED';
|
|
summaries: Array<{
|
|
provider: CloudProvider;
|
|
hosts: number;
|
|
keys: number;
|
|
snippets: number;
|
|
}>;
|
|
};
|
|
|
|
// ============================================================================
|
|
// Storage Keys
|
|
// ============================================================================
|
|
|
|
export const SYNC_STORAGE_KEYS = {
|
|
MASTER_KEY_CONFIG: 'netcatty_master_key_config_v1',
|
|
DEVICE_ID: 'netcatty_device_id_v1',
|
|
DEVICE_NAME: 'netcatty_device_name_v1',
|
|
SYNC_CONFIG: 'netcatty_sync_config_v2',
|
|
/** Auto-sync prefs (autoSync / interval / syncStrategy); kept separate from version stamps. */
|
|
SYNC_PREFERENCES: 'netcatty_sync_preferences_v1',
|
|
PROVIDER_GITHUB: 'netcatty_provider_github_v1',
|
|
PROVIDER_GOOGLE: 'netcatty_provider_google_v1',
|
|
PROVIDER_ONEDRIVE: 'netcatty_provider_onedrive_v1',
|
|
PROVIDER_WEBDAV: 'netcatty_provider_webdav_v1',
|
|
PROVIDER_S3: 'netcatty_provider_s3_v1',
|
|
PROVIDER_SMB: 'netcatty_provider_smb_v1',
|
|
/** Registry of connected namespaced plugin sync provider IDs. */
|
|
PLUGIN_CLOUD_PROVIDERS: 'netcatty_plugin_cloud_providers_v1',
|
|
/** Contribution-available plugin sync provider IDs (live catalog membership). */
|
|
AVAILABLE_PLUGIN_SYNC_PROVIDERS: 'netcatty_available_plugin_sync_providers_v1',
|
|
/** Last successful sidecar collect (upload fallback when host is offline). */
|
|
PLUGIN_SIDECARS_LAST_KNOWN: 'netcatty_plugin_sidecars_last_known_v1',
|
|
/** Remote sidecar apply queued while the plugin host was unavailable. */
|
|
PLUGIN_SIDECARS_PENDING_REMOTE: 'netcatty_plugin_sidecars_pending_remote_v1',
|
|
LOCAL_SYNC_META: 'netcatty_local_sync_meta_v1',
|
|
SYNC_BASE_PAYLOAD: 'netcatty_sync_base_payload_v1',
|
|
CONVERGENT_REPLICA: 'netcatty_convergent_sync_replica_v2',
|
|
CONVERGENT_PROVIDER_BASELINE: 'netcatty_convergent_sync_provider_baseline_v2',
|
|
} as const;
|
|
|
|
// ============================================================================
|
|
// Constants
|
|
// ============================================================================
|
|
|
|
const readBuildEnv = (key: string): string | undefined => {
|
|
const env = (import.meta as { env?: Record<string, string | undefined> }).env;
|
|
const value = env?.[key];
|
|
return value && value.trim().length ? value : undefined;
|
|
};
|
|
|
|
export const SYNC_CONSTANTS = {
|
|
// Encryption
|
|
AES_KEY_LENGTH: 256,
|
|
GCM_IV_LENGTH: 12, // bytes
|
|
GCM_TAG_LENGTH: 128, // bits
|
|
SALT_LENGTH: 32, // bytes
|
|
|
|
// PBKDF2
|
|
PBKDF2_ITERATIONS: 600000, // OWASP recommended minimum
|
|
PBKDF2_HASH: 'SHA-256',
|
|
|
|
// Sync
|
|
SYNC_FILE_NAME: 'netcatty-vault.json',
|
|
GIST_DESCRIPTION: 'Netcatty Encrypted Vault (DO NOT EDIT MANUALLY)',
|
|
|
|
// Auto-sync
|
|
DEFAULT_AUTO_SYNC_INTERVAL: 5, // minutes
|
|
MIN_SYNC_INTERVAL: 1, // minutes
|
|
MAX_SYNC_INTERVAL: 60, // minutes
|
|
|
|
// OAuth
|
|
GITHUB_CLIENT_ID: readBuildEnv('VITE_SYNC_GITHUB_CLIENT_ID') || '', // Public client ID for Device Flow
|
|
GOOGLE_CLIENT_ID: readBuildEnv('VITE_SYNC_GOOGLE_CLIENT_ID') || '',
|
|
GOOGLE_CLIENT_SECRET: readBuildEnv('VITE_SYNC_GOOGLE_CLIENT_SECRET') || '',
|
|
ONEDRIVE_CLIENT_ID: readBuildEnv('VITE_SYNC_ONEDRIVE_CLIENT_ID') || '',
|
|
|
|
// API endpoints
|
|
GITHUB_DEVICE_CODE_URL: 'https://github.com/login/device/code',
|
|
GITHUB_ACCESS_TOKEN_URL: 'https://github.com/login/oauth/access_token',
|
|
GITHUB_API_BASE: 'https://api.github.com',
|
|
|
|
GOOGLE_AUTH_URL: 'https://accounts.google.com/o/oauth2/v2/auth',
|
|
GOOGLE_TOKEN_URL: 'https://oauth2.googleapis.com/token',
|
|
GOOGLE_DRIVE_API: 'https://www.googleapis.com/drive/v3',
|
|
|
|
ONEDRIVE_AUTH_URL: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/authorize',
|
|
ONEDRIVE_TOKEN_URL: 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token',
|
|
ONEDRIVE_GRAPH_API: 'https://graph.microsoft.com/v1.0',
|
|
} as const;
|
|
|
|
// ============================================================================
|
|
// Helper Functions
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Generate a unique device ID
|
|
*/
|
|
export const generateDeviceId = (): string => {
|
|
return crypto.randomUUID();
|
|
};
|
|
|
|
/**
|
|
* Get default device name based on OS
|
|
*/
|
|
export const getDefaultDeviceName = (): string => {
|
|
const platform = navigator.platform || 'Unknown';
|
|
const hostname = 'Netcatty';
|
|
return `${hostname} (${platform})`;
|
|
};
|
|
|
|
/**
|
|
* Format a sync timestamp as `yyyymmdd hhmm` (e.g. `20250628 1430`).
|
|
*/
|
|
export const formatSyncDateTime = (timestamp: number): string => {
|
|
const date = new Date(timestamp);
|
|
const year = date.getFullYear();
|
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
|
const day = String(date.getDate()).padStart(2, '0');
|
|
const hours = String(date.getHours()).padStart(2, '0');
|
|
const minutes = String(date.getMinutes()).padStart(2, '0');
|
|
return `${year}${month}${day} ${hours}${minutes}`;
|
|
};
|
|
|
|
/**
|
|
* Format last sync time for display
|
|
*/
|
|
export const formatLastSync = (timestamp?: number): string => {
|
|
if (!timestamp) return 'Never synced';
|
|
|
|
const now = Date.now();
|
|
const diff = now - timestamp;
|
|
|
|
if (diff < 60000) return 'Just now';
|
|
if (diff < 3600000) return `${Math.floor(diff / 60000)} min ago`;
|
|
|
|
return formatSyncDateTime(timestamp);
|
|
};
|
|
|
|
/**
|
|
* Get status dot color class
|
|
*/
|
|
export const getSyncDotColor = (status: ProviderConnectionStatus): string => {
|
|
switch (status) {
|
|
case 'connected': return 'bg-green-500';
|
|
case 'syncing': return 'bg-blue-500';
|
|
case 'error': return 'bg-red-500';
|
|
case 'connecting': return 'bg-yellow-500';
|
|
default: return 'bg-muted-foreground';
|
|
}
|
|
};
|