[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
873
domain/sync.ts
Normal file
873
domain/sync.ts
Normal file
@@ -0,0 +1,873 @@
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user