feat & docs: implement zero-knowledge background sync protocol & conflict resolution

This commit is contained in:
2026-05-27 21:50:11 +02:00
parent 55cbe71520
commit 87d719ad9b
6 changed files with 491 additions and 8 deletions
+4 -4
View File
@@ -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
+16 -1
View File
@@ -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()
+24 -3
View File
@@ -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
<div className="header-actions">
{/* Connection Indicator */}
<div className={`conn-status ${online ? 'online' : 'offline'}`} title={online ? 'Online' : 'Offline'}>
{online ? <Wifi size={18} /> : <WifiOff size={18} />}
<span>{online ? 'Online' : t('sync.status_offline')}</span>
<div className={`conn-status ${online ? (pendingCount > 0 ? 'unsynced' : 'online') : 'offline'}`} title={online ? (pendingCount > 0 ? 'Pending Sync' : 'Synced') : 'Offline'}>
{online ? (
pendingCount > 0 ? (
<>
<RefreshCw size={18} className="spin" />
<span>{t('sync.status_unsynced')} ({pendingCount})</span>
</>
) : (
<>
<Wifi size={18} />
<span>{t('sync.status_synced')}</span>
</>
)
) : (
<>
<WifiOff size={18} />
<span>{t('sync.status_offline')}</span>
</>
)}
</div>
{/* Skipper profile */}
+237
View File
@@ -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<string>()
// 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<boolean> {
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<boolean> {
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<string, any>()
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<string, any>()
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<boolean> {
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<void> {
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
}
}
+2
View File
@@ -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) => {
+208
View File
@@ -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