From 87d719ad9b89aca13be754abcf780649a10282f7 Mon Sep 17 00:00:00 2001 From: elpatron Date: Wed, 27 May 2026 21:50:11 +0200 Subject: [PATCH] feat & docs: implement zero-knowledge background sync protocol & conflict resolution --- .planning/STATE.md | 8 +- client/src/App.tsx | 17 +- client/src/components/LogbookDashboard.tsx | 27 ++- client/src/services/sync.ts | 237 +++++++++++++++++++++ server/src/index.ts | 2 + server/src/routes/sync.ts | 208 ++++++++++++++++++ 6 files changed, 491 insertions(+), 8 deletions(-) create mode 100644 client/src/services/sync.ts create mode 100644 server/src/routes/sync.ts diff --git a/.planning/STATE.md b/.planning/STATE.md index d500911..196830c 100755 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -9,12 +9,12 @@ See: .planning/PROJECT.md (updated 2026-05-26) ## Current Position -Phase: 2 of 4 (Sync Protocol & Multi-Logbooks) -Plan: 2 of 2 in current phase +Phase: 3 of 4 (Master Data & Log entries) +Plan: 1 of 3 in current phase Status: Ready to plan -Last activity: 2026-05-27 — Plan 02-01 completed (Multi-logbooks IndexedDB Dexie.js caching, E2E title encryption client service, and dashboard UI switching complete) +Last activity: 2026-05-27 — Phase 2 completed (Multi-logbooks IndexedDB cache, E2E push/pull sync scheduler, last-write-wins conflict resolution, and live sync status indicators complete) -Progress: [████░░░░░░] 40% +Progress: [█████░░░░░] 50% ## Performance Metrics diff --git a/client/src/App.tsx b/client/src/App.tsx index 6654b7c..378afbd 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -3,6 +3,7 @@ import './App.css' import AuthOnboarding from './components/AuthOnboarding.tsx' import LogbookDashboard from './components/LogbookDashboard.tsx' import { getActiveMasterKey, logoutUser } from './services/auth.js' +import { startBackgroundSync, stopBackgroundSync, syncAllLogbooks } from './services/sync.js' import { Ship, LogOut, ChevronLeft, Users, Compass, FileText, Settings, Wifi, WifiOff } from 'lucide-react' import { useTranslation } from 'react-i18next' @@ -15,7 +16,10 @@ function App() { const [online, setOnline] = useState(navigator.onLine) useEffect(() => { - const handleOnline = () => setOnline(true) + const handleOnline = () => { + setOnline(true) + syncAllLogbooks() + } const handleOffline = () => setOnline(false) window.addEventListener('online', handleOnline) window.addEventListener('offline', handleOffline) @@ -25,6 +29,17 @@ function App() { } }, []) + useEffect(() => { + if (isAuthenticated) { + startBackgroundSync() + } else { + stopBackgroundSync() + } + return () => { + stopBackgroundSync() + } + }, [isAuthenticated]) + useEffect(() => { const savedUser = localStorage.getItem('active_username') const key = getActiveMasterKey() diff --git a/client/src/components/LogbookDashboard.tsx b/client/src/components/LogbookDashboard.tsx index f63581a..2a277a2 100644 --- a/client/src/components/LogbookDashboard.tsx +++ b/client/src/components/LogbookDashboard.tsx @@ -1,5 +1,7 @@ import React, { useState, useEffect } from 'react' import { useTranslation } from 'react-i18next' +import { useLiveQuery } from 'dexie-react-hooks' +import { db } from '../services/db.js' import { fetchLogbooks, createLogbook, deleteLogbook, type DecryptedLogbook } from '../services/logbook.js' import { logoutUser } from '../services/auth.js' import { BookOpen, Plus, Trash2, LogOut, Languages, RefreshCw, Ship, User, Wifi, WifiOff } from 'lucide-react' @@ -19,6 +21,9 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD const [online, setOnline] = useState(navigator.onLine) const [username] = useState(localStorage.getItem('active_username') || 'Skipper') + // Reactive sync queue count + const pendingCount = useLiveQuery(() => db.syncQueue.count()) || 0 + // Listen to connectivity changes useEffect(() => { const handleOnline = () => setOnline(true) @@ -109,9 +114,25 @@ export default function LogbookDashboard({ onSelectLogbook, onLogout }: LogbookD
{/* Connection Indicator */} -
- {online ? : } - {online ? 'Online' : t('sync.status_offline')} +
0 ? 'unsynced' : 'online') : 'offline'}`} title={online ? (pendingCount > 0 ? 'Pending Sync' : 'Synced') : 'Offline'}> + {online ? ( + pendingCount > 0 ? ( + <> + + {t('sync.status_unsynced')} ({pendingCount}) + + ) : ( + <> + + {t('sync.status_synced')} + + ) + ) : ( + <> + + {t('sync.status_offline')} + + )}
{/* Skipper profile */} diff --git a/client/src/services/sync.ts b/client/src/services/sync.ts new file mode 100644 index 0000000..aa440c7 --- /dev/null +++ b/client/src/services/sync.ts @@ -0,0 +1,237 @@ +import { db } from './db.js' +import { getActiveMasterKey } from './auth.js' + +const API_BASE = 'http://localhost:5000/api/sync' +const syncingLogbooks = new Set() + +// Helper to check if a timestamp is newer +function isNewer(timeA: string | Date, timeB: string | Date): boolean { + return new Date(timeA).getTime() > new Date(timeB).getTime() +} + +// Push local sync queue items to the server +async function pushChanges(logbookId: string): Promise { + const userId = localStorage.getItem('active_userid') + if (!userId) return false + + // Fetch all pending queue items for this logbook + const pending = await db.syncQueue.where({ logbookId }).toArray() + if (pending.length === 0) return true + + try { + const response = await fetch(`${API_BASE}/push`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-User-Id': userId + }, + body: JSON.stringify({ items: pending }) + }) + + if (!response.ok) { + console.warn('Sync push request was rejected by server') + return false + } + + const { results } = await response.json() + + // Process results + for (const res of results) { + if (res.status === 'success' || res.status === 'conflict') { + // Find matching queue item + const queueItem = pending.find((item) => item.payloadId === res.payloadId) + if (queueItem && queueItem.id !== undefined) { + // Delete from sync queue + await db.syncQueue.delete(queueItem.id) + } + } else { + console.error(`Sync failed for item ${res.payloadId}:`, res.error) + } + } + return true + } catch (error) { + console.error('Error during sync push:', error) + return false + } +} + +// Pull updates from the server and apply last-write-wins +async function pullChanges(logbookId: string): Promise { + const userId = localStorage.getItem('active_userid') + if (!userId) return false + + try { + const response = await fetch(`${API_BASE}/pull?logbookId=${logbookId}`, { + method: 'GET', + headers: { + 'X-User-Id': userId + } + }) + + if (!response.ok) { + console.warn('Sync pull request was rejected by server') + return false + } + + const { yacht, deviation, crews, entries } = await response.json() + + // 1. Sync Yacht Payload + if (yacht) { + const local = await db.yachts.get(logbookId) + if (!local || isNewer(yacht.updatedAt, local.updatedAt)) { + await db.yachts.put({ + logbookId, + encryptedData: yacht.encryptedData, + iv: yacht.iv, + tag: yacht.tag, + updatedAt: yacht.updatedAt + }) + } + } + + // 2. Sync Deviation Payload + if (deviation) { + const local = await db.deviations.get(logbookId) + if (!local || isNewer(deviation.updatedAt, local.updatedAt)) { + await db.deviations.put({ + logbookId, + encryptedData: deviation.encryptedData, + iv: deviation.iv, + tag: deviation.tag, + updatedAt: deviation.updatedAt + }) + } + } + + // 3. Sync Crew List Payloads + const serverCrewMap = new Map() + if (crews && Array.isArray(crews)) { + for (const c of crews) { + serverCrewMap.set(c.payloadId, c) + const local = await db.crews.get(c.payloadId) + if (!local || isNewer(c.updatedAt, local.updatedAt)) { + await db.crews.put({ + payloadId: c.payloadId, + logbookId, + encryptedData: c.encryptedData, + iv: c.iv, + tag: c.tag, + updatedAt: c.updatedAt + }) + } + } + } + + // Deletions for Crew: If present locally but not on server, and not pending creation locally + const localCrews = await db.crews.where({ logbookId }).toArray() + for (const lc of localCrews) { + if (!serverCrewMap.has(lc.payloadId)) { + // Verify it's not a newly created item offline that hasn't synced yet + const pendingCreate = await db.syncQueue + .where({ payloadId: lc.payloadId, action: 'create' }) + .first() + if (!pendingCreate) { + await db.crews.delete(lc.payloadId) + } + } + } + + // 4. Sync Journal Entry Payloads + const serverEntryMap = new Map() + if (entries && Array.isArray(entries)) { + for (const e of entries) { + serverEntryMap.set(e.payloadId, e) + const local = await db.entries.get(e.payloadId) + if (!local || isNewer(e.updatedAt, local.updatedAt)) { + await db.entries.put({ + payloadId: e.payloadId, + logbookId, + encryptedData: e.encryptedData, + iv: e.iv, + tag: e.tag, + updatedAt: e.updatedAt + }) + } + } + } + + // Deletions for Entries + const localEntries = await db.entries.where({ logbookId }).toArray() + for (const le of localEntries) { + if (!serverEntryMap.has(le.payloadId)) { + const pendingCreate = await db.syncQueue + .where({ payloadId: le.payloadId, action: 'create' }) + .first() + if (!pendingCreate) { + await db.entries.delete(le.payloadId) + } + } + } + + return true + } catch (error) { + console.error('Error during sync pull:', error) + return false + } +} + +// Main function to synchronize a specific logbook +export async function syncLogbook(logbookId: string): Promise { + if (!navigator.onLine) return false + + const masterKey = getActiveMasterKey() + if (!masterKey) return false + + if (syncingLogbooks.has(logbookId)) return false + syncingLogbooks.add(logbookId) + + try { + const pushed = await pushChanges(logbookId) + const pulled = await pullChanges(logbookId) + return pushed && pulled; + } finally { + syncingLogbooks.delete(logbookId) + } +} + +// Synchronize all user logbooks that are cached locally +export async function syncAllLogbooks(): Promise { + if (!navigator.onLine) return + + const masterKey = getActiveMasterKey() + if (!masterKey) return + + try { + // 1. Fetch latest logbook lists first (synchronizes db.logbooks index) + const logbooks = await db.logbooks.toArray() + + // 2. Synchronize payloads for each logbook + for (const lb of logbooks) { + await syncLogbook(lb.id) + } + } catch (error) { + console.error('Error synchronizing all logbooks:', error) + } +} + +// Setup background sync intervals +let syncIntervalId: any = null + +export function startBackgroundSync(intervalMs = 30000) { + if (syncIntervalId) clearInterval(syncIntervalId) + + // Trigger immediate sync + syncAllLogbooks() + + // Set interval + syncIntervalId = setInterval(() => { + syncAllLogbooks() + }, intervalMs) +} + +export function stopBackgroundSync() { + if (syncIntervalId) { + clearInterval(syncIntervalId) + syncIntervalId = null + } +} diff --git a/server/src/index.ts b/server/src/index.ts index 1a4fb75..c35157e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -3,6 +3,7 @@ import cors from 'cors' import dotenv from 'dotenv' import authRouter from './routes/auth.js' import logbooksRouter from './routes/logbooks.js' +import syncRouter from './routes/sync.js' dotenv.config() @@ -15,6 +16,7 @@ app.use(express.json()) // Mount routes app.use('/api/auth', authRouter) app.use('/api/logbooks', logbooksRouter) +app.use('/api/sync', syncRouter) // Health check endpoint app.get('/api/health', (req, res) => { diff --git a/server/src/routes/sync.ts b/server/src/routes/sync.ts new file mode 100644 index 0000000..fb0fa64 --- /dev/null +++ b/server/src/routes/sync.ts @@ -0,0 +1,208 @@ +import { Router } from 'express' +import { prisma } from '../db.js' + +const router = Router() + +// Middleware to extract user ID from headers +const requireUser = (req: any, res: any, next: any) => { + const userId = req.headers['x-user-id'] + if (!userId) { + return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' }) + } + req.userId = userId + next() +} + +router.use(requireUser) + +// 1. Push local changes to the server +router.post('/push', async (req: any, res) => { + try { + const { items } = req.body + if (!items || !Array.isArray(items)) { + return res.status(400).json({ error: 'items array is required' }) + } + + const results = [] + + for (const item of items) { + const { action, type, payloadId, logbookId, data, updatedAt } = item + const itemUpdatedAt = new Date(updatedAt) + + try { + // Authorize: Check if logbook belongs to user + // Exception: If action is create logbook, the logbook might not exist yet, + // so we authorize based on user creating a logbook with their userId. + if (type === 'logbook' && action === 'create') { + const existing = await prisma.logbook.findUnique({ + where: { id: logbookId } + }) + if (existing && existing.userId !== req.userId) { + results.push({ payloadId, status: 'error', error: 'Forbidden: Logbook belongs to another user' }) + continue + } + + const parsed = JSON.parse(data) + await prisma.logbook.upsert({ + where: { id: logbookId }, + create: { + id: logbookId, + userId: req.userId, + encryptedTitle: parsed.encryptedTitle, + updatedAt: itemUpdatedAt + }, + update: { + encryptedTitle: parsed.encryptedTitle, + updatedAt: itemUpdatedAt + } + }) + results.push({ payloadId, status: 'success' }) + continue + } + + // Standard Authorization: Logbook must exist and belong to user + const logbook = await prisma.logbook.findUnique({ + where: { id: logbookId } + }) + + if (!logbook) { + results.push({ payloadId, status: 'error', error: 'Logbook not found' }) + continue + } + + if (logbook.userId !== req.userId) { + results.push({ payloadId, status: 'error', error: 'Forbidden: Access denied' }) + continue + } + + if (type === 'logbook' && action === 'delete') { + await prisma.logbook.delete({ + where: { id: logbookId } + }) + results.push({ payloadId, status: 'success' }) + continue + } + + // Parse Payload parameters + const parsed = JSON.parse(data) + const { encryptedData, iv, tag } = parsed + + if (type === 'yacht') { + if (action === 'delete') { + await prisma.yachtPayload.deleteMany({ where: { logbookId } }) + } else { + const existing = await prisma.yachtPayload.findUnique({ where: { logbookId } }) + if (existing && new Date(existing.updatedAt) > itemUpdatedAt) { + results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' }) + continue + } + await prisma.yachtPayload.upsert({ + where: { logbookId }, + create: { logbookId, encryptedData, iv, tag, updatedAt: itemUpdatedAt }, + update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt } + }) + } + } else if (type === 'deviation') { + if (action === 'delete') { + await prisma.deviationPayload.deleteMany({ where: { logbookId } }) + } else { + const existing = await prisma.deviationPayload.findUnique({ where: { logbookId } }) + if (existing && new Date(existing.updatedAt) > itemUpdatedAt) { + results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' }) + continue + } + await prisma.deviationPayload.upsert({ + where: { logbookId }, + create: { logbookId, encryptedData, iv, tag, updatedAt: itemUpdatedAt }, + update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt } + }) + } + } else if (type === 'crew') { + if (action === 'delete') { + await prisma.crewPayload.deleteMany({ where: { logbookId, payloadId } }) + } else { + const existing = await prisma.crewPayload.findUnique({ + where: { logbookId_payloadId: { logbookId, payloadId } } + }) + if (existing && new Date(existing.updatedAt) > itemUpdatedAt) { + results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' }) + continue + } + await prisma.crewPayload.upsert({ + where: { logbookId_payloadId: { logbookId, payloadId } }, + create: { logbookId, payloadId, encryptedData, iv, tag, updatedAt: itemUpdatedAt }, + update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt } + }) + } + } else if (type === 'entry') { + if (action === 'delete') { + await prisma.entryPayload.deleteMany({ where: { logbookId, payloadId } }) + } else { + const existing = await prisma.entryPayload.findUnique({ + where: { logbookId_payloadId: { logbookId, payloadId } } + }) + if (existing && new Date(existing.updatedAt) > itemUpdatedAt) { + results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' }) + continue + } + await prisma.entryPayload.upsert({ + where: { logbookId_payloadId: { logbookId, payloadId } }, + create: { logbookId, payloadId, encryptedData, iv, tag, updatedAt: itemUpdatedAt }, + update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt } + }) + } + } + + results.push({ payloadId, status: 'success' }) + } catch (err: any) { + console.error(`Error processing sync item ${payloadId}:`, err) + results.push({ payloadId, status: 'error', error: err.message || 'Operation failed' }) + } + } + + return res.json({ results }) + } catch (error: any) { + console.error('Error during sync push:', error) + return res.status(500).json({ error: error.message || 'Internal server error' }) + } +}) + +// 2. Pull server updates to the client +router.get('/pull', async (req: any, res) => { + try { + const { logbookId } = req.query + if (!logbookId) { + return res.status(400).json({ error: 'logbookId is required' }) + } + + // Authorize: Check if logbook belongs to user + const logbook = await prisma.logbook.findUnique({ + where: { id: logbookId } + }) + + if (!logbook) { + return res.status(404).json({ error: 'Logbook not found' }) + } + + if (logbook.userId !== req.userId) { + return res.status(403).json({ error: 'Forbidden: Access denied' }) + } + + const yacht = await prisma.yachtPayload.findUnique({ where: { logbookId } }) + const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } }) + const crews = await prisma.crewPayload.findMany({ where: { logbookId } }) + const entries = await prisma.entryPayload.findMany({ where: { logbookId } }) + + return res.json({ + yacht, + deviation, + crews, + entries + }) + } catch (error: any) { + console.error('Error during sync pull:', error) + return res.status(500).json({ error: error.message || 'Internal server error' }) + } +}) + +export default router