The "just 1ms over max clock drift boundary" test captured Date.now()
once in the test and again inside uploadOps. If the second sample
advanced by >=1ms, the op was within the drift window from the
service's perspective, clamping never fired, and toBeLessThan failed
with equal values.
Freeze time with vi.useFakeTimers/setSystemTime so both samples
match, and tighten the assertion to the exact clamped value.
* fix(sync): prevent magic link prefetch failures and improve auth resilience
- Make /magic-login prefetch-immune: GET now renders a confirmation page
instead of consuming the single-use token. A "Log In" button triggers
POST /api/login/magic-link/verify which does the actual verification.
This prevents email client link prefetchers (Outlook SafeLinks, Gmail)
from consuming magic link tokens before users click them.
- Increase rate limits on auth endpoints for shared-IP scenarios (e.g.
university NAT). Global: 100→500, register: 10→50, login: 5→30,
verify: 10→50, magic-login page: 10→50.
- Add trustProxy to Fastify so req.ip uses X-Forwarded-For behind
reverse proxies instead of collapsing all clients to one rate bucket.
- Clear stale SuperSync auth tokens after 3 consecutive AuthFailSPErrors
instead of never clearing them. Prevents persistent failure loops where
an invalid token keeps retrying indefinitely until app reinstall.
- Improve registration-to-login UX messaging to emphasize email
verification is required before login links will work.
https://claude.ai/code/session_01WkbLzvhguQwRDtk7ht38aY
* chore: update package-lock.json from npm install
https://claude.ai/code/session_01WkbLzvhguQwRDtk7ht38aY
* fix(sync): address review feedback for auth resilience
- Remove conflicting style.css link from magic-login page
- Change trustProxy from true to 1 (trust single hop only)
- Increase /login/magic-link rate limit from 30 to 50 per 15min
- Delete dead magic-login-redirect.js (replaced by magic-login-confirm.js)
- Reset auth failure counter on non-auth errors (truly consecutive)
- Add test for counter reset on non-auth error between auth errors
https://claude.ai/code/session_01WkbLzvhguQwRDtk7ht38aY
* fix(sync): increase passkey rate limits and fix CSP inline script violations
- Increase passkey endpoint rate limits to match magic-link equivalents (50/15min)
- Extract inline scripts from /reset-password and /recover-passkey pages
into external JS files to comply with CSP scriptSrc: ["'self'"]
- Remove dead safeJsonForScript helper (no longer needed)
- Increase /recover-passkey page rate limit to 50/15min for consistency
https://claude.ai/code/session_01WkbLzvhguQwRDtk7ht38aY
* fix(sync): update security tests for escapeHtml and fix recover/passkey rate limit
- Update server-security.spec.ts: tests now check for escapeHtml output
(<, ") instead of safeJsonForScript output (\u003c), matching
the new data-token attribute approach
- Increase /recover/passkey rate limit from 30 to 50 per 15min to match
/login/magic-link (both send email with a secret link)
https://claude.ai/code/session_01WkbLzvhguQwRDtk7ht38aY
* fix(sync): reset auth failure counter after successful download
Move the counter reset from the final InSync return to right after
downloadRemoteOps() succeeds. This ensures early returns (cancelled,
LWW pending, payload rejected) also break the consecutive-failure
chain, since a successful download confirms auth is working.
https://claude.ai/code/session_01WkbLzvhguQwRDtk7ht38aY
---------
Co-authored-by: Claude <noreply@anthropic.com>
When a full-state op (SYNC_IMPORT/BACKUP_IMPORT) arrived from another
client, mergeRemoteOpClocks reset the local clock to a "minimal" form
but only preserved the current client's counter from the incoming op's
clock. If the current client had issued ops (e.g. GLOBAL_CONFIG) not
reflected in the incoming full-state op's clock, its counter was dropped,
causing subsequent ops to reuse the same counter value. Downstream clients
then saw these ops as EQUAL (duplicate) and skipped them silently.
Fix: take max(mergedClock[clientId], currentClock[clientId]) when
rebuilding the clock after a full-state op reset.
Also add __SP_E2E_BLOCK_WS_DOWNLOAD flag to WsTriggeredDownloadService
to allow E2E tests to block automatic WS-triggered downloads during
concurrent conflict scenarios.
Fix archive conflict test by blocking WS downloads on Client A during
the concurrent edit phase so it doesn't auto-receive B's rename via
WebSocket before archiving (restoring the intended conflict scenario).
Fix LWW singleton test to assert convergence rather than specific winner.
Fix renameTask helper to avoid Playwright/Angular re-render races.
Fix shepherd.js import paths that broke the Angular dev server build.
* feat(sync): add Helm chart for SuperSync Kubernetes deployment
Includes Deployment, StatefulSet (PostgreSQL), Service, Ingress,
ConfigMap, Secret, HPA, PDB, NetworkPolicy, and test templates.
Supports both bundled PostgreSQL and external database configurations.
* feat(sync): add WebSocket push notifications for near-realtime sync
Server: Fastify WebSocket plugin with connection manager, app-level
heartbeat (30s), debounced notifications, and per-user routing.
Client: WebSocket service with exponential backoff reconnection,
WS-triggered download service, and reduced polling when connected.
* fix(sync): improve WebSocket error handling and reactivity
Make syncInterval$ reactive to WS connection state, fix Set/Map
mutation during heartbeat iteration, add error handling to WS route
handler, separate JSON parsing from message handling, detect auth
failures in WS-triggered downloads, and add logging to all empty
catch blocks.
* fix(sync): address PR review findings for WebSocket and Helm
Fix race condition in WS-triggered download pipeline by moving
isSyncInProgress filter after debounce and adding guard in
_downloadOps. Add logging to remaining empty catch blocks, fix
missing $NODE_IP in Helm NOTES.txt, and correct inaccurate
comments in values.yaml and ws-triggered-download.service.ts.
* fix(sync): add rate limiting to WebSocket upgrade endpoint
Limit WS connection attempts to 10 per minute per IP to prevent
connection flooding, matching the rate-limit pattern used by other
sync endpoints.
* fix(sync): extract shared constants, fix debounce correctness, replace deprecated toPromise
Extract CLIENT_ID_REGEX and MAX_CLIENT_ID_LENGTH into sync.const.ts
to prevent drift between sync.routes.ts and websocket.routes.ts.
Fix debounce in notifyNewOps to accumulate excluded client IDs across
rapid calls from different clients, preventing self-notifications.
Replace deprecated toPromise() with firstValueFrom in connectWebSocket
and _sync methods. Add .catch() to reconnect path and logging to
silent early returns in _downloadOps.
* fix(build): restore correct package-lock.json and fix upstream lint errors
* revert: restore upstream HTML formatting to match CI prettier config
* fix(sync): use DownloadOutcome discriminated union in WsTriggeredDownloadService
* fix(sync): narrow TokenVerificationResult before accessing userId
* fix(sync): await async getProviderById in connectWebSocket
Missing await caused the Promise object to be cast to
SuperSyncProvider, making getWebSocketParams undefined.
* feat(sync): add SEED_USERS env var to create verified users on startup
For self-hosted single-user setups: set SEED_USERS=email1,email2 to
create verified users on boot and log their access tokens. Skips
existing users. Removes need for SMTP/magic link registration.
Also fix Dockerfile to use npm install instead of npm ci for lockfile
compatibility.
* fix(sync): address PR review feedback for Helm chart and server
Security:
- Remove seed-users.ts (logged full JWT tokens to stdout)
- Add fail assertions for missing jwtSecret and postgresql.password
- Add smtp.user/smtp.password values fields
- Add from: selector to NetworkPolicy ingress
- Add egress rule for external database when postgresql.enabled=false
Correctness:
- Fix PostgreSQL StatefulSet indentation for non-persistent mode
- Fix NOTES.txt panic on empty tls list (use len instead of index)
- Restore npm ci in Dockerfile by using node:24-alpine (ships npm 11)
- Add Recreate deployment strategy when using RWO PVC
Operational:
- Add fail guard preventing HPA maxReplicas > 1 (in-memory WS state)
- Fix PDB to use maxUnavailable instead of minAvailable
- Add WebSocket ingress timeout annotation examples
- Add Prisma db push init container for schema migrations
- Default serviceAccount.automount to false
- Add Chart.yaml maintainers, home, sources metadata
* feat(sync): add ALLOWED_EMAILS env var to restrict registration
Supports fully qualified emails (user@example.com) and domain
wildcards (*@example.com). When unset, all emails are allowed.
Applied to all three registration endpoints (passkey options,
passkey verify, magic link).
* fix(sync): exempt health endpoint from rate limiting
Kubernetes liveness/readiness probes hit /health every 5-15s,
exhausting the global rate limit (100 req/15min) and causing
429 responses that trigger container restarts.
* fix(sync): harden WebSocket, Helm chart, and sync services from multi-agent review
- Pin prisma@5.22.0 in init container and use migrate deploy instead of db push
- Add per-user WebSocket connection limit (max 10) to prevent resource exhaustion
- Add replicaCount > 1 fail guard in deployment template (in-memory WS state)
- Set maxPayload: 1024 on WebSocket plugin (only pong messages expected)
- Remove premature IN_SYNC status from download-only WS-triggered sync
- Fix double removeConnection on error+close events (let close handle cleanup)
- DRY debounce logic in notifyNewOps and store latestSeq on pending entry
- Remove dead fromClientId from NewOpsNotification and WsMessage interfaces
- Add takeUntilDestroyed to _wsProviderCleanup subscription
- Simplify email-allowlist.ts to eager init (eliminate mutable state)
- Guard connectWebSocket at call site for non-SuperSync providers
- Add distinctUntilChanged to syncInterval$ to prevent unnecessary resets
- Add container-level securityContext to PostgreSQL StatefulSet
- Fix HPA maxReplicas default to 1 (matches single-replica constraint)
* fix(sync): restore migration in Dockerfile CMD for non-Helm Docker users
Helm users get migration via init container (runs first, CMD becomes no-op).
Docker-compose/plain Docker users get automatic migration back on startup.
* fix(boards): add missing drag delay for touch and extract shared signal
Add cdkDragStartDelay to board-panel drag items, which was missing
entirely. Extract the repeated `isTouchActive() ? DRAG_DELAY_FOR_TOUCH : 0`
expression into a shared `dragDelayForTouch` computed signal and refactor
all 8 components to use it.
* test(sync): add comprehensive WebSocket test coverage
Add unit tests for the new WebSocket real-time sync notification pipeline:
- WebSocketConnectionService (server): connection lifecycle, max-per-user
limits, notification debouncing, heartbeat, graceful shutdown (13 tests)
- WebSocket routes validation: token/clientId validation, close codes,
error handling, validation ordering (18 tests)
- SuperSyncWebSocketService (frontend): reconnection with exponential
backoff, heartbeat, disconnect cleanup, URL conversion (6 tests)
- WsTriggeredDownloadService: auth error handling, pipeline resilience,
start() idempotency (3 tests)
- SyncWrapperService: connectWebSocket guards for non-SuperSync, null
params, and already-connected cases (3 tests)
- E2E realtime push: verifies WS-triggered download between two clients
Quality fixes:
- Replace waitForTimeout with syncAndWait in E2E
- Add explanatory comment for microtask flushing in sync-wrapper spec
- Use getter for isSyncInProgress mock in ws-triggered-download spec
---------
Co-authored-by: Johannes Millan <johannes.millan@gmail.com>
Add pretest script to run prisma generate before tests, ensuring the
Prisma client is always generated in any environment. Fix verifyToken
mocks across all test files to include valid:true matching the
TokenVerificationResult discriminated union. Expand jsonwebtoken mock
to include JsonWebTokenError/TokenExpiredError stubs needed by auth.ts.
Add $queryRaw to transaction mock in sync-fixes tests.
- Replace O(N) Prisma findMany with PostgreSQL SQL aggregate
(jsonb_each_text + GROUP BY + MAX) for snapshot vector clock
computation, avoiding loading all ops' clocks into Node.js memory
- Add defensive regex filter (^[0-9]+$) for non-numeric JSONB values
- Fix || to ?? for nullish coalescing in vector clock value lookups
- Remove unreachable CONCURRENT check in compareVectorClocks
- Remove unused VectorClockMetrics interface and measureVectorClock
- Add 7 new unit tests for SQL aggregate path verification
- Add 10 integration tests running actual SQL against PostgreSQL
Prevents email bombing by capping verificationResendCount at 20 for
both magic link and passkey registration flows. Returns a safe error
message when the cap is reached.
Add email-only registration flow (no passkey required) with verification
email. For existing unverified users, email is sent before DB token
update to preserve the old verification link on failure.
- POST /api/register/magic-link endpoint with Zod validation and rate limiting
- registerWithMagicLink function in auth.ts with P2002 race condition handling
- Frontend "Register with Email" button and JS handler
- Export shared VERIFICATION_TOKEN_EXPIRY_MS constant (eliminates duplication)
- 8 unit tests covering all paths including email failure cleanup
* fix(build): fix tag resolution and add error handling in bump-android-version
The previous two-tag approach (`currentTag` → `prevTag`) would resolve the
wrong changelog range during `npm version` since the new tag doesn't exist
yet. Simplified to a single `lastTag...HEAD` range which correctly captures
all commits since the last release.
Added try-catch with fallback to last 20 commits when no tags exist, and
a fallback message for empty changelogs.
* fix(sync): differentiate auth error messages and clarify token revocation
- Rename server dashboard "Refresh Token" to "Revoke & Replace Token"
with explicit warning that ALL devices will be disconnected
- Return rejection reason in 401 responses (revoked, expired, etc.)
via discriminated union TokenVerificationResult type
- Show different client error messages for server-side token rejection
vs missing local credentials to aid user diagnosis
- Extract server error reason from JSON response body in AuthFailSPError
Closes#6597
* refactor(sync): use generic auth reason and improve test readability
- Replace 'User not found' and 'Account not verified' with generic
'Account unavailable' to avoid leaking account state in API responses
- Extract long reason strings to constants in middleware.spec.ts
Replace separate JWT_EXPIRY_MAGIC_LINK (365d) and JWT_EXPIRY_PASSKEY (7d)
constants with a single JWT_EXPIRY (365d). The auth method only matters
during login — once a JWT is issued, it represents a verified session
regardless of how the user authenticated.
Add tests for previously untested vector clock pruning scenarios:
- Multi-preserve-ID pruning (two low-counter IDs both preserved)
- Overlapping/duplicate preserve IDs (Set deduplication)
- Preserve IDs missing from clock (silent skip, no crash)
- Snapshot vector clock aggregation + pruning integration
- Post-snapshot comparison correctness after pruning
- Server sanitizeVectorClock counter cap at 100M
- DoS cap at 2.5x MAX (reject, not prune)
- Invalid inputs (null, arrays, empty keys, long keys, negative/float values)
https://claude.ai/code/session_01GdsbKoo8eax394j2UyWUu1
Co-authored-by: Claude <noreply@anthropic.com>
Remove references to deleted docs/ai/ files, update stale comments about
protectedClientIds and pruning-aware comparison to reflect current REPLACE
semantics, fix MAX=30→20 heading, client_0..29→19 comment, use ?? over ||,
preserve lastSeq in server clean-slate path to prevent sequence reuse, and
add clarifying comments for mock limitations and validation guards.
- Consolidate getJwtSecret() and JWT_EXPIRY constants into auth.ts,
eliminating 3x duplication with inconsistent validation (C1+C2)
- Add isVerified defense-in-depth check to verifyToken() (C3)
- Add Content-Transfer-Encoding to CORS allowedHeaders for
Android/Capacitor clients (C4)
- Fix 9 stale comments referencing MAX_VECTOR_CLOCK_SIZE as 30
when actual value is 20 (I1)
- Remove dead _syncVectorClockFromPfapi method and update
referencing comments (S8)
- Replace in-memory payload size calculation with SQL aggregate
in deleteOldestRestorePointAndOps to avoid loading large
payloads into Node memory (I7)
At MAX=10, pruning triggered frequently enough (11+ unique client IDs from
reinstalls/new browsers) to require 4 defense layers compensating for
information loss: pruning-aware comparison, protected client IDs with
migration, isLikelyPruningArtifact heuristic, and same-client check.
At MAX=30, pruning almost never triggers (needs 31+ unique client IDs).
A 30-entry clock is ~500 bytes — negligible bandwidth. This allows removing
most defense layers while keeping two cheap backward-compat checks for old
10-entry pruned data still on servers.
Removed:
- Pruning-aware mode in compareVectorClocks (standard comparison now)
- Protected client IDs mechanism (storage, migration, preservation)
- selectProtectedClientIds function
- Clock normalization in SyncImportFilterService
Kept temporarily (backward compat with old 10-entry data):
- isLikelyPruningArtifact with LEGACY_MAX=10
- Same-client check (always mathematically correct)
* fix(sync): prevent infinite loop when concurrent modification resolution keeps failing
When vector clock pruning makes it impossible to create a dominating clock
(e.g., entity clock has MAX entries and client ID isn't among them), the cycle
"upload → CONFLICT_CONCURRENT → merge clocks → upload → reject again" repeats
endlessly. This adds a per-entity retry counter (MAX_CONCURRENT_RESOLUTION_ATTEMPTS=3)
that permanently rejects ops after exceeding the limit, breaking the sync loop.
The counter resets when a sync cycle completes with no rejections (healthy state).
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
* fix(sync): move vector clock pruning after conflict detection to fix root cause
The infinite sync loop happens because it's mathematically impossible to build
a dominating clock with MAX_VECTOR_CLOCK_SIZE entries when the entity's clock
already has MAX entries and the client's ID isn't among them. The merged clock
needs MAX+1 entries (all entity clock IDs + client ID), but client-side pruning
drops one entity clock ID. The server's pruning-aware comparison then sees the
dropped key as non-shared and returns CONCURRENT instead of GREATER_THAN.
Fix: Move limitVectorClockSize from validation (before comparison) to
processOperation (after comparison, before storage). The full unpruned clock
is now used for conflict detection — all entity clock IDs are present so
bOnlyCount=0 → GREATER_THAN. Storage still gets the pruned clock.
Client-side: Stop pruning in SupersededOperationResolverService. The server
handles pruning after conflict detection.
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
* fix(sync): tighten vector clock sanitize limit from 100 to 3x MAX_VECTOR_CLOCK_SIZE
The old sanitize cap of 100 entries was unnecessarily wide. Since conflict
resolution clocks are at most ~12-15 entries (entity clock MAX=10 + client ID
+ a few merged), cap at 3x MAX (30) for DoS protection while leaving ample
room for legitimate clocks.
Also update server-side pruning tests to use realistic clock sizes (20 entries
instead of 50) to stay within the new sanitize limit.
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
* docs(sync): document vector clock pruning invariant and infinite loop fix
- Add "Pruning and the Pruning-Aware Comparison" section to vector-clocks.md
explaining the critical invariant: server prunes AFTER comparison, not before
- Add rule 13 to CLAUDE.md to prevent future regressions
- Update conflict resolution key files table with rejected-ops-handler and
superseded-operation-resolver services
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
* docs(sync): update last-updated date in conflict resolution docs
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
* test(sync): update pruning tests to reflect server-side pruning design
Client no longer prunes vector clocks during conflict resolution — the
server handles pruning after conflict detection. Tests now verify the
merged clock is sent unpruned with all keys preserved.
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
* test(sync): fix client-side pruning tests and add server-side regression test
- Update 7 SupersededOperationResolverService tests to verify client
does NOT prune (server handles pruning after conflict detection)
- Remove redundant `newClock` alias in superseded-operation-resolver
- Add regression test: MAX+1 entry clock accepted as GREATER_THAN
when it dominates a MAX entry entity clock (the core infinite loop fix)
https://claude.ai/code/session_016RAxms5dTouU98wFNQaAcv
---------
Co-authored-by: Claude <noreply@anthropic.com>
On each successful sync request, the server now returns a fresh 14-day
JWT via X-Refreshed-Token response header. The client reads and stores
it automatically, preventing token expiry during active usage.
Server: export JWT_EXPIRY/getJwtSecret from auth, add createRefreshedToken(),
add onSend hook scoped to sync routes, expose header via CORS.
Client: store refreshed token in all 4 fetch methods, fix _getServerSeqKey
to hash only baseUrl (prevents full re-download on token change).
- Add Logger.warn() in ValidationService when oversized vector clocks
are pruned server-side, improving observability for buggy clients
- Add tests for hasVectorClockChanges (previously zero coverage)
- Add symmetric LESS_THAN test for pruning-aware comparison mode
- Export MAX_LWW_REUPLOAD_RETRIES constant to eliminate magic number
coupling between sync-wrapper service and its test
- Add test verifying uploading client ID is preserved during server-side
clock pruning
- Add test for limitVectorClockSize when preserveClientIds exceeds MAX
Address issues found by code review agents after the LWW loop fix
(cb36c09538):
- Enforce MAX_VECTOR_CLOCK_SIZE on server upload to prevent oversized
clocks from buggy/adversarial clients
- Limit aggregated snapshotVectorClock in download service to prevent
unbounded growth with many clients
- Set UNKNOWN_OR_CHANGED status when LWW retry cap is exhausted instead
of misleadingly reporting IN_SYNC
- Wrap startPostSyncCooldown() in try-catch so endApplyingRemoteOps()
always runs even on failure
- Cap limitVectorClockSize output at MAX even when preserved IDs exceed
the limit
- Update stale doc comments referencing MAX_VECTOR_CLOCK_SIZE=8 to 10
Includes backward compatibility: client accepts both CONFLICT_SUPERSEDED
and CONFLICT_STALE from the server, and the server keeps a deprecated
CONFLICT_STALE alias. Remove after all deployments are updated.
When encryption is enabled, clients could get stuck in infinite conflict
resolution loops because they couldn't create vector clocks that dominate
the server's state. The server already computed the existingClock during
conflict detection but didn't return it to the client.
This fix:
- Adds existingClock to UploadResult type (server)
- Returns conflict.existingClock in CONFLICT_CONCURRENT/CONFLICT_STALE rejections
- Passes existingClock through client upload service to rejected ops handler
- Merges entity clocks into extraClocks for stale operation resolution
Includes E2E tests for encryption + conflict resolution scenarios.
- passkey.spec.ts: accept both email verification and auto-verify messages
- security-fixes.spec.ts: update expected CORS origins to include preview pattern
- sync-operations.spec.ts: use CURRENT_SCHEMA_VERSION instead of hardcoded value
Fixed logic bug where wildcard subdomain patterns (e.g., https://*.example.com)
were not being parsed to RegExp when the universal wildcard (*) was present in
CORS_ORIGINS. All origins were incorrectly stored as strings.
Before: CORS_ORIGINS="https://*.example.com,*,https://app.com"
Result: ['https://*.example.com', '*', 'https://app.com'] (all strings)
After:
Result: [RegExp, '*', 'https://app.com'] (wildcard pattern → RegExp)
Changes:
- Modified loadConfigFromEnv() to map over origins when * is present
- Keep * as-is, but parse other origins through parseCorsOrigin()
- Added test case verifying mixed origins with universal wildcard
All existing tests pass. New test validates RegExp matching works correctly.
1. Add DELETE /api/sync/data route tests to sync.routes.spec.ts:
- Test successful deletion returns { success: true }
- Test 401 without authorization
- Test uploading new data works after reset
2. Fix passkey.spec.ts failures (4 tests):
- Add missing passkey.findUnique mock for credential lookup
- Update test expectations for discoverable credentials
(no allowCredentials - implementation changed)
3. Fix password-reset-api.spec.ts failures (12 tests):
- Exclude from vitest - tests routes that don't exist
- Server uses passkey/magic link auth, not password auth
All 412 tests now pass.
Add comprehensive unit tests for the reset account functionality:
- Test that deleteAllUserData removes all operations for a user
- Test that new operations can be uploaded after reset
- Test that resetting one user doesn't affect other users' data
Also adds missing userSyncState.deleteMany mock to the test setup.
Log the full credential ID (both hex and base64url) during:
- Registration: to see exactly what's being stored
- Login: to see exactly what's being retrieved from DB
This will help debug why passkeys aren't being found during login.
Fixes critical bug where multiple clients with existing data could each
create a SYNC_IMPORT when enabling SuperSync, causing data loss.
Root cause: When clients had local data (from legacy migration), each
would detect "server needs migration" and create competing SYNC_IMPORTs.
The last one uploaded would invalidate all prior data.
Fixes:
- Server rejects duplicate SYNC_IMPORT with SYNC_IMPORT_EXISTS (409)
- Client double-checks server is empty before creating SYNC_IMPORT
- Client merges all local op clocks into SYNC_IMPORT's vector clock
- hasSyncedOps() excludes MIGRATION/RECOVERY ops from check
- Upload service gracefully handles SYNC_IMPORT_EXISTS rejection
Tests added:
- ServerMigrationService: double-check and clock merging (5 tests)
- OperationLogStoreService: hasSyncedOps() MIGRATION exclusion (7 tests)
- OperationLogUploadService: SYNC_IMPORT_EXISTS handling (5 tests)
- E2E: Multiple clients with existing data merge correctly
The server was accepting empty string entityId values because the validation
only checked type and length, not content. This could allow corrupt operations
to be synced to other clients.
Changes:
- Add check for empty/whitespace-only entityId (returns INVALID_ENTITY_ID)
- Add 3 unit tests for empty, whitespace-only, and tab/newline entityId
- Update existing empty string test to expect INVALID_ENTITY_ID (format error)
instead of MISSING_ENTITY_ID (null/undefined check)
Root cause: Operations with `entityId: undefined` (corrupt ops) caused
an infinite rejection loop during sync:
1. Upload ops → Rejected with CONFLICT_CONCURRENT
2. Rejection handler creates merged ops (still with invalid entityId)
3. Upload merged ops → Rejected again
4. Loop continues forever
This prevented the sync status double checkmark from ever appearing
because hasPendingOps never became false.
Three-layer defense implemented:
1. Client: Guard time tracking dispatch (task.service.ts)
- Changed `if (currentTask &&` to `if (currentTask?.id &&`
- Prevents time tracking dispatch with undefined taskId
2. Client: Strengthen entityId validation (operation-log.effects.ts)
- Check that entityId is truthy AND a string type
- Log error with actual entityId value for debugging
3. Client: Cleanup corrupt ops on startup (operation-log-recovery.service.ts)
- New cleanupCorruptOps() method marks corrupt ops as rejected
- Runs during hydration to break existing infinite loops
- Excludes bulk 'ALL' operations which legitimately lack entityId
4. Server: Validate entityId for all regular operations (validation.service.ts)
- Reject ops with missing entityId (except full-state and bulk ops)
- Full-state: SYNC_IMPORT, BACKUP_IMPORT, REPAIR
- Bulk entity types: ALL, RECOVERY
- Returns MISSING_ENTITY_ID error code (permanent rejection)
When a duplicate operation was uploaded, PostgreSQL's unique constraint
violation (P2002) would abort the entire transaction, causing all
subsequent operations in the batch to fail with INTERNAL_ERROR.
Server-side fix:
- Add pre-check via findUnique before attempting insert
- Return DUPLICATE_OPERATION error code without aborting transaction
- Other ops in batch continue processing normally
Client-side fix:
- Handle DUPLICATE_OPERATION error code in rejected-ops-handler
- Mark duplicate as synced (not rejected) since server already has it
- Prevents infinite retry loop for legitimately uploaded ops
Adds tests for both server and client handling.
Security fixes implemented:
1. CRITICAL: Fix missing await in email verification (pages.ts:139)
- verifyEmail() was called without await, causing race condition
- Response was sent before verification completed
2. CRITICAL: Fix XSS vulnerability in password reset page (pages.ts)
- Added safeJsonForScript() to properly escape tokens in JS context
- JSON.stringify alone doesn't escape </script> sequences
- Now escapes <, >, & as unicode (\u003c, \u003e, \u0026)
3. CRITICAL: Fix XSS in privacy HTML template (server.ts)
- Added escapeHtml() function for all template interpolations
- Prevents XSS if environment variables contain malicious content
4. HIGH: Enable Content Security Policy (server.ts)
- CSP was disabled (contentSecurityPolicy: false)
- Now enabled with strict directives:
- default-src 'self', object-src 'none', frame-ancestors 'none'
5. HIGH: Block wildcard CORS in production (config.ts)
- CORS_ORIGINS=* with credentials is a security vulnerability
- Now throws error in production, warns in development
6. HIGH: Add password reset flow (auth.ts, api.ts, email.ts, schema.prisma)
- Secure token generation with crypto.randomBytes(32)
- 1-hour expiry, one-time use tokens
- Revokes all sessions on password reset
- Prevents email enumeration (same response for all cases)
- Rate limited: 5 requests/15min for forgot-password
- Rate limited: 10 requests/15min for reset-password
Test coverage:
- 45 new tests across 4 test files
- Tests for XSS prevention, CORS blocking, CSP headers
- Tests for password reset flow (API and unit level)
- Fixed pre-existing flaky boundary test in retention-config.spec.ts
Operations with timestamps beyond maxClockDriftMs (60s) were being
permanently rejected, causing silent data loss when a client's clock
was slightly ahead.
Now these timestamps are clamped to now + maxClockDriftMs and accepted.
A TIMESTAMP_CLAMPED audit log is recorded for debugging.
- Add clamping logic in SyncService.processOperation()
- Remove future timestamp rejection from ValidationService
- Add comprehensive tests verifying clamped values and boundaries
- Update integration test to expect acceptance
Add two new test files:
retention-config.spec.ts (15 tests):
- Validates RETENTION_DAYS and RETENTION_MS constants
- Confirms retentionMs is used in DEFAULT_SYNC_CONFIG
- Tests operation age validation at boundary conditions
- Verifies deprecated properties are removed
- Tests custom retention configuration
cleanup.spec.ts (7 tests):
- Tests initial cleanup runs after 10 seconds
- Verifies retentionMs is used for cutoff calculation
- Confirms cleanup runs daily
- Tests storage usage update for affected users
- Tests error isolation between cleanup tasks
Tombstones were used for tracking deleted entities but are no longer
needed with the operation log architecture. This removes:
- Tombstone table from Prisma schema
- Tombstone-related methods from SyncService
- Tombstone mocks and tests from all test files
- Database migration to drop tombstones table
The operation log now handles deletions through DEL operations,
making the separate tombstone tracking redundant.
- Replace opRetentionMs, maxOpAgeMs, and STALE_DEVICE_THRESHOLD_MS
with single retentionMs (45 days)
- Consolidate 5 cleanup jobs into 1 daily job
- Remove tombstone-related code and tests (tombstones were removed
in a previous migration)
- Simplifies configuration and removes contradictory values
(maxOpAgeMs was 30 days while opRetentionMs was 45 days)
Android WebView's fetch() corrupts binary Uint8Array bodies, causing
"incorrect header check" errors when decompressing gzip data on the
server. This fix uses CapacitorHttp with base64-encoded gzip on Android.
Client changes:
- Add isAndroidWebView getter for testability
- Add _fetchApiCompressedAndroid() method using CapacitorHttp
- Modify uploadOps() and uploadSnapshot() to use Android path
- Send Content-Transfer-Encoding: base64 header to signal encoding
Server changes:
- Add decompressBody() helper to handle base64+gzip
- Check Content-Transfer-Encoding header in /ops and /snapshot routes
- Decode base64 before gunzip when header is present
Net bandwidth savings ~50% (gzip saves 70%, base64 adds 33% overhead).
Includes comprehensive tests for both client and server paths.
Add unit tests for identified testing gaps:
SnapshotService.clearForUser:
- Test that clearing removes pending lock for a user
- Test that clearing doesn't affect other users' locks
Vector clock pruning edge cases:
- Test pruned client reappearing after merge with remote
- Test comparison still works correctly after pruning
Total: 4 new test cases
Extract storage quota calculation and checking methods:
- calculateStorageUsage(): Calculate total storage from ops + snapshot
- checkStorageQuota(): Check if upload is within quota
- updateStorageUsage(): Update cached storage counter
- getStorageInfo(): Get storage stats for status endpoint
Complex cleanup operations (freeStorageForUpload, deleteOldestRestorePointAndOps)
remain in SyncService as they orchestrate multiple operations.
All 246 tests passing.