Sync System¶
The sync system enables cross-device synchronization of profiles, diffs, and stars while maintaining end-to-end encryption. See API Endpoints for request/response details.
Overview¶
Sync is optional - the app works fully offline. When enabled, sync provides:
- Share profiles across devices using a password
- Sync diffs and starred items automatically
- Conflict resolution via "last write wins" with full content upload
Key Concepts¶
Two Types of Salt¶
The system uses two different salts:
| Salt | Purpose | Stored |
|---|---|---|
| Password Salt | Hashing password for authentication | In password_hash field (format: salt:hash) |
| Encryption Salt | AES-GCM encryption of content | In salt field |
Important
These are different values. The password salt is for auth verification, the encryption salt is for encrypting API keys and content.
Password Hashing¶
Passwords are hashed client-side before transmission:
async function hashPasswordForTransport(password: string, salt?: string): Promise<string> {
const saltValue = salt || generateRandomSalt(); // (1)!
const data = encoder.encode(saltValue + password);
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
return saltValue + ':' + base64(hashBuffer);
}
- If no salt provided, generates random 16-byte salt. For auth verification, must use the existing salt from the server.
Sync Flow¶
Initial Share (Primary Device)¶
sequenceDiagram
participant C as Client
participant S as Server
participant DB as D1 Database
C->>C: Generate password hash (new salt)
C->>C: Encrypt API key with AES-GCM
C->>S: POST /api/profile/create
S->>DB: Insert profile
S-->>C: Success + profile ID
C->>C: Store passwordSalt locally
C->>C: Mark all diffs/stars as modified
C->>S: POST /api/profile/{id}/sync
Note over C,S: Upload all encrypted content
S->>DB: Insert diffs and stars
S-->>C: Success + hashes
Import (Secondary Device)¶
sequenceDiagram
participant C as Client
participant S as Server
participant DB as D1 Database
C->>S: GET /api/share/{id}
S-->>C: Profile metadata + password_salt
C->>C: Hash password with server's salt
C->>S: GET /api/profile/{id}?password_hash=...
S->>DB: Verify password hash
S-->>C: Encrypted profile data
C->>C: Decrypt API key
C->>C: Store profile + passwordSalt
C->>S: POST /api/profile/{id}/content
S-->>C: Encrypted diffs and stars
C->>C: Decrypt and merge content
Ongoing Sync¶
sequenceDiagram
participant C as Client
participant S as Server
Note over C: On page load or change
C->>S: GET /api/profile/{id}/status
S-->>C: diffs_hash, stars_hash
C->>C: Compare with local hashes
alt Hashes differ & password cached
C->>S: POST /api/profile/{id}/content
S-->>C: Server content
C->>C: Download: merge server → local
C->>S: POST /api/profile/{id}/sync
Note over C,S: Upload: ALL local content
S-->>C: Updated hashes
end
Change Tracking¶
Changes are tracked in pendingSync (persisted to localStorage):
interface PendingSync {
modifiedDiffs: string[]; // IDs of new/changed diffs
modifiedStars: string[]; // IDs of new/changed stars
deletedDiffs: string[]; // IDs of deleted diffs
deletedStars: string[]; // IDs of deleted stars
profileModified: boolean; // Profile metadata changed
}
Auto-Sync¶
When the password is cached (in sessionStorage), sync triggers automatically:
| Trigger | Behavior |
|---|---|
| Local change | Sync after 2 second debounce |
| Tab refocus | Sync if last sync was over 1 hour ago |
| Page load | Sync if hashes differ from server |
_scheduleAutoSync() {
if (this._autoSyncTimeout) clearTimeout(this._autoSyncTimeout);
this._autoSyncTimeout = setTimeout(() => this.autoSync(), 2000);
}
_syncIfStale() {
const lastSync = new Date(this.profile.syncedAt).getTime();
const oneHour = 60 * 60 * 1000;
if (Date.now() - lastSync > oneHour) {
this.autoSync();
}
}
async autoSync() {
// Clear any pending scheduled sync
if (this._autoSyncTimeout) clearTimeout(this._autoSyncTimeout);
// Download first (merges server content into local)
await this.downloadContent(password);
// Then upload (sends all local content + deletions)
if (this.hasPendingChanges()) {
await this.uploadContent(password);
}
}
Deletion Preservation
The download phase preserves deletedDiffs and deletedStars so they're available when upload runs. Without this, deletions would be lost between download and upload.
Password Validation During Auto-Sync¶
If auto-sync fails with a 401 "Invalid password" error (e.g., password was changed on another device), the cached password is automatically cleared:
- Session storage password is cleared
- Any remembered password for this profile is cleared
- The sync state changes to "pending" (shows diamond icon)
- User is prompted to enter the correct password
This prevents the app from repeatedly retrying with an invalid password and triggering rate limits.
Upload Logic¶
Upload is skipped entirely if there are no pending changes:
Selective Upload¶
Before uploading, the client checks if the server state matches our last-synced state:
// Fetch current server status to compare hashes
const status = await fetch(`/api/profile/${profileId}/status`);
const serverDiffsMatch = status.diffs_hash === profile.diffsHash;
const serverStarsMatch = status.stars_hash === profile.starsHash;
useSelectiveUpload = serverDiffsMatch && serverStarsMatch;
If hashes match (server unchanged since our last sync):
- Only upload items in
modifiedDiffsandmodifiedStars - Deletions are always sent (just IDs, not content)
- Significantly reduces bandwidth when making small changes
If hashes don't match (another device synced):
- Fall back to full upload to ensure consistency
- Server uses
INSERT OR REPLACEso duplicates are idempotent
Deletions¶
Deleted items are tracked separately and sent to the server:
body: JSON.stringify({
diffs: diffsToUpload,
stars: starsToUpload,
deleted_diff_ids: pending?.deletedDiffs || [],
deleted_star_ids: pending?.deletedStars || [],
})
Cascade Deletes
When a diff is deleted, all stars referencing it are automatically deleted too. This happens both for manual deletions and when regenerating a same-day diff (which replaces the previous one). The old diff is tracked as deleted so it gets removed from the server on sync.
Download Logic¶
Selective Download¶
The client passes local content hashes to the server to skip unchanged collections:
const data = await postJson(`/api/profile/${profileId}/content`, {
password_hash: passwordHash,
diffs_hash: localDiffsHash, // Server skips diffs if hash matches
stars_hash: localStarsHash // Server skips stars if hash matches
});
The server compares hashes and returns diffs_skipped: true or stars_skipped: true when collections are unchanged, avoiding unnecessary data transfer.
Merge Logic¶
When content is fetched, it's merged into local state:
// Add items from server that don't exist locally
for (const encryptedDiff of data.diffs) {
const diff = await decryptData(encryptedDiff.encrypted_data, password, salt);
// Skip if already local OR pending deletion
if (existingIdx === -1 && !pendingDeletedDiffs.has(encryptedDiff.id)) {
this.history = [...this.history, diff];
}
}
// Remove local items not on server (deleted by another device)
// Keep items that are: on server, pending local deletion, or pending local upload
this.history = this.history.filter(d =>
serverDiffIds.has(d.id) || pendingDeletedDiffs.has(d.id) || pendingModifiedDiffs.has(d.id)
);
Skipped Collections
When a collection is skipped (hash matched), the merge and filter logic is also skipped for that collection. This prevents an empty server response from being interpreted as "delete all local items."
Profile Metadata Sync¶
Profile metadata (name, languages, frameworks, etc.) is also synced:
// Apply server profile metadata (skip if local changes pending)
if (data.profile && !hasLocalProfileChanges) {
this.profiles[id] = {
...current,
name: data.profile.name,
languages: data.profile.languages,
frameworks: data.profile.frameworks,
// ...
};
}
Conflict Resolution
If the local client has pending profile changes (profileModified: true), server profile data is skipped. The local changes will be uploaded next, overwriting the server.
Deletion Propagation¶
When Client A deletes an item:
- Client A: Item removed locally, ID added to
pendingDeletedDiffs - Client A syncs:
- Download skips re-adding (it's in pending deletions)
- Upload sends remaining items + deletion ID
- Server: Removes the item from database
- Client B syncs:
- Download sees item missing from server
- Removes it from local storage
Hash Comparison¶
Content hashes enable efficient sync by avoiding unnecessary data transfer:
// Compute deterministic hash over plaintext content
const sorted = [...items].sort((a, b) => a.id.localeCompare(b.id));
const serialized = sorted.map(item => JSON.stringify(item, Object.keys(item).sort()));
const diffsHash = await sha256(serialized.join('|'));
Hashes are computed over plaintext (not encrypted data) for deterministic comparison, since encryption produces different ciphertext each time.
Hash Usage¶
| Optimization | How Hashes Are Used |
|---|---|
| Skip sync entirely | Compare local hashes to server hashes - if both match, no sync needed |
| Selective download | Pass local hashes to server - skip fetching collections where hash matches |
| Selective upload | Compare server hash to profile's stored hash - if unchanged, only upload modified items |
The hash is computed over encrypted data, so it can be stored on the server without revealing content.
Password Caching¶
The sync password is cached in sessionStorage for the browser session:
- Cached only after successful sync (not before attempting)
- Enables auto-sync without re-entering password
- Cleared when switching profiles (password is profile-specific)
- Cleared on 401 "Invalid password" error (prevents retry loops)
- Lost when browser tab closes
Remember Password (Opt-in)¶
Users can opt to remember their password across browser sessions by checking "Remember password" when syncing. This stores the password in localStorage (plaintext) and should only be used on trusted devices.
Auto-Clear on Invalid
If a remembered password becomes invalid (e.g., password changed on another device), it is automatically cleared when the next sync attempt fails with 401. See Password Validation for details.
Profile Sync State¶
The syncedAt property indicates sync status:
| Value | Meaning | UI Display |
|---|---|---|
undefined |
Never synced | "local" + upload button |
null |
Was synced, not on server | "not on server" warning |
"2026-01-26T..." |
Synced and on server | "synced [date]" |
Security Considerations¶
End-to-End Encryption
All content (diffs, stars, API keys) is encrypted client-side before upload. The server only stores encrypted blobs.
Password Salt Exposure
The password salt is exposed via GET /api/share/{id} to enable import. This is safe because:
- Salts are not secrets (stored alongside hashes in standard password systems)
- The hash itself is never exposed
- Rate limiting prevents brute-force attacks (5 attempts → 15 min lockout)
Rate Limiting¶
Failed password attempts are tracked per-profile: