Commit graph

78 commits

Author SHA1 Message Date
Johannes Millan
0d5a562aba test(sync-server): freeze clock in 1ms-over-drift clamp test
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.
2026-04-21 15:03:41 +02:00
Johannes Millan
70414145a3
fix(sync): prevent magic link prefetch failures and improve auth resilience (#7175)
* 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>
2026-04-10 14:46:57 +02:00
Johannes Millan
d32f7037a3 fix(sync): preserve own vector clock counter across full-state op resets
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.
2026-04-01 15:41:13 +02:00
Johannes Millan
821a87ba71 chore(deps): upgrade prisma from 5.22.0 to 7.6.0
Migrate super-sync-server to Prisma v7:
- Switch generator from prisma-client-js to prisma-client with local output
- Add prisma.config.ts for datasource URL configuration
- Use PrismaPg driver adapter instead of built-in Rust engine
- Add @prisma/adapter-pg and pg dependencies
- Update all imports from @prisma/client to generated client path
- Fix Buffer→Uint8Array for Bytes fields (Prisma v6 breaking change)
- Update Dockerfile: prisma CLI version, copy prisma.config.ts
- Add DATABASE_URL validation in db.ts
- Add src/generated/ to .gitignore
2026-03-31 20:05:03 +02:00
Thorsten Klein
7fa8f12132
feat(sync): add Helm chart and WebSocket push for SuperSync (#6971)
* 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>
2026-03-30 21:34:30 +02:00
Johannes Millan
43e04bc407 fix(sync): fix supersync server unit test failures
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.
2026-03-22 10:10:19 +01:00
Johannes Millan
861425fd28 refactor(sync): improve vector clock implementation quality
- 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
2026-03-17 13:59:40 +01:00
Johannes Millan
b5536adfe5 feat(sync): add verification resend cap of 20 attempts
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.
2026-03-12 13:50:50 +01:00
Johannes Millan
3ba5841421 feat(sync): add magic link registration to SuperSync server
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
2026-03-12 13:36:05 +01:00
Johannes Millan
7070fde51e
Pr 6741 (#6743)
* 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
2026-03-05 21:17:59 +01:00
Johannes Millan
da8e509972 refactor(sync): unify JWT expiry to 365 days for all auth methods
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.
2026-02-16 18:44:27 +01:00
Johannes Millan
454fefcb04
test(sync): add vector clock pruning edge case tests (#6506)
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>
2026-02-13 12:42:36 +01:00
Johannes Millan
5b7c9bdfb7 test(sync): add comprehensive SuperSync test coverage
Add 7 E2E tests (lastSeq preservation, token expiry recovery, cascade
delete, compaction resilience, global config edge cases, provider switch,
multi-tab), 2 server unit tests (replaceToken expiry regression,
middleware auth), 2 client integration tests (IndexedDB error recovery,
schema version sync), and locale-independent sort edge cases for vector
clocks.
2026-02-12 16:27:56 +01:00
Johannes Millan
c70ced204e fix(sync): fix stale comments, doc refs, and preserve lastSeq on clean-slate
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.
2026-02-12 16:27:56 +01:00
Johannes Millan
ef8ad4ded7 refactor(sync): address code review findings across server and client
- 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)
2026-02-12 16:27:55 +01:00
Johannes Millan
29541951a3 refactor(sync): increase MAX_VECTOR_CLOCK_SIZE from 10 to 30 and remove defense layers
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)
2026-02-12 16:27:55 +01:00
Johannes Millan
fdc942babb
fix(sync): prevent infinite loop when concurrent modification resolution keeps failing (#6434)
* 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>
2026-02-09 12:01:43 +01:00
Johannes Millan
cd2ae61ae0 Revert "feat(sync): add rolling JWT token refresh on sync requests"
This reverts commit eca323505d1923fcfc470d78fc8984251e825382.
2026-02-02 17:15:13 +01:00
Johannes Millan
717e4ba84d feat(sync): add rolling JWT token refresh on sync requests
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).
2026-02-02 17:15:13 +01:00
Johannes Millan
c5409bbd25 fix(sync): add server pruning log, harden test coverage for vector clocks
- 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
2026-01-30 19:50:30 +01:00
Johannes Millan
c268b1a9a9 fix(sync): harden vector clock pruning and post-review sync fixes
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
2026-01-30 18:40:54 +01:00
Johannes Millan
361d0e605f refactor(sync): rename "stale" to "superseded" across sync/operation domain
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.
2026-01-30 16:59:40 +01:00
Johannes Millan
100b8af5f2 fix(sync): return existingClock in conflict rejections to fix infinite loop
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.
2026-01-25 18:10:08 +01:00
Johannes Millan
8ba3a07f70 test(sync): fix failing unit tests for recent server changes
- 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
2026-01-24 21:14:58 +01:00
Johannes Millan
737d11d8f3 feat(cors): normalize domain to lowercase and validate non-empty origins 2026-01-24 21:14:57 +01:00
Johannes Millan
3a6b69a1ba test(supersync): add integration tests for CORS wildcard patterns 2026-01-24 21:14:57 +01:00
Johannes Millan
ded17e96fc fix(supersync): parse wildcard patterns when universal wildcard present
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.
2026-01-24 21:14:57 +01:00
Johannes Millan
1da3d90c6b feat(supersync): parse wildcard patterns in CORS_ORIGINS env var 2026-01-24 21:14:57 +01:00
Johannes Millan
6a743181e5 fix(supersync): prevent domain confusion in parseCorsOrigin wildcard conversion 2026-01-24 21:14:57 +01:00
Johannes Millan
d990173b34 fix(supersync): prevent domain confusion in preview CORS pattern 2026-01-24 13:23:21 +01:00
Johannes Millan
be26d6de05 feat(supersync): add parseCorsOrigin helper with wildcard support 2026-01-24 12:57:30 +01:00
Johannes Millan
038a722ed8 style: apply prettier formatting 2026-01-10 17:09:14 +01:00
Johannes Millan
f0f536671b test(server): fix testing gaps and failing tests
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.
2026-01-05 17:39:17 +01:00
Johannes Millan
e641b89187 test(server): add unit tests for deleteAllUserData (Reset Account)
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.
2026-01-05 15:47:43 +01:00
Johannes Millan
f1eab1d8ca debug(sync-server): add detailed credential ID logging for passkey
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.
2026-01-03 13:01:34 +01:00
Johannes Millan
3a179df3cc fix(sync): prevent data loss when multiple clients join SuperSync
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
2026-01-02 17:51:12 +01:00
Johannes Millan
187cbdafe3 fix(sync-server): reject empty/whitespace-only entityId in validation
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)
2026-01-02 13:58:07 +01:00
Johannes Millan
c087ffee8a fix(sync): prevent infinite rejection loop from corrupt operations
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)
2025-12-31 17:29:25 +01:00
Johannes Millan
a86df2c356 fix(sync): prevent duplicate operation from aborting batch upload
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.
2025-12-31 16:39:55 +01:00
Johannes Millan
fd6499f138 fix(security): address critical and high severity vulnerabilities
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
2025-12-31 12:15:56 +01:00
Johannes Millan
1da70487f9 fix: address code review findings from Dec 27-29 changes
ESLint rule improvements:
- Fix require-hydration-guard to detect guards on combineLatest/forkJoin/zip
  results (eliminates 20+ false positive warnings)

Server improvements:
- Add OperationDownloadService unit tests (21 tests covering vector clock
  aggregation, gap detection, excludeClient parameter, snapshot optimization)
- Split ZIP bomb size limits: 10MB for /ops, 30MB for /snapshot
- Document storage quota update as intentionally non-atomic

E2E test improvements:
- Add waitForUISettle() helper using Angular stability instead of fixed timeouts
- Update supersync-cross-entity tests to use dynamic waits

Unit test improvements:
- Add HydrationStateService edge case tests (concurrent calls, cooldown cycles,
  timer cleanup, interleaved operations)
2025-12-29 10:37:12 +01:00
Johannes Millan
c29913aeac test(time-tracking): add comprehensive tests and documentation
Add extensive tests and documentation for TimeTrackingState sync:

Unit tests (~22 new):
- merge-time-tracking-states.spec.ts: partial data merging, null
  handling, merge priority, scale testing
- sort-data-to-flush.spec.ts: flush completeness, LWW behavior,
  date boundaries
- time-tracking.reducer.spec.ts: context preservation, immutability

Integration tests (9 new):
- time-tracking-sync.integration.spec.ts: operation creation,
  SYNC_IMPORT with timeTracking, multi-client sync, archive flush

Server tests (18 new):
- time-tracking-operations.spec.ts: operation upload, conflict
  detection, snapshot generation, LWW semantics

Documentation:
- JSDoc for merge-time-tracking-states.ts and sort-data-to-flush.ts
- Section E.6 "Time Tracking Sync Semantics" in architecture docs
2025-12-28 13:16:07 +01:00
Johannes Millan
ed061f90fb fix(sync): clamp future timestamps instead of rejecting
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
2025-12-28 12:23:36 +01:00
Johannes Millan
d3d5886b63 test(sync): add comprehensive tests for retention configuration
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
2025-12-28 12:13:45 +01:00
Johannes Millan
a52b7716aa refactor(sync): remove tombstone system
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.
2025-12-28 12:08:28 +01:00
Johannes Millan
805327731b refactor(sync): unify retention config to single value
- 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)
2025-12-28 12:05:44 +01:00
Johannes Millan
578faafb0b fix(sync): handle base64-encoded gzip from Android clients
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.
2025-12-28 10:37:45 +01:00
Johannes Millan
9ecd921500 test(sync): add tests for clearForUser and vector clock pruning
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
2025-12-28 09:52:02 +01:00
Johannes Millan
18f03c1ec7 refactor(sync): extract SnapshotService from SyncService
Extract snapshot-related functionality into dedicated SnapshotService:
- getCachedSnapshot, cacheSnapshot, generateSnapshot
- getRestorePoints, generateSnapshotAtSeq, replayOpsToState

Key changes:
- Preserve FIX 1.7 concurrent snapshot lock behavior
- Fix SYNC_IMPORT/BACKUP_IMPORT/REPAIR handling to run before
  entity type check (these are full-state operations)
- Add comprehensive unit tests for SnapshotService

SyncService reduced from ~1988 to 918 lines (54% reduction).
2025-12-27 20:59:31 +01:00
Johannes Millan
4f3f778da5 refactor(sync): extract StorageQuotaService from SyncService
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.
2025-12-27 20:48:08 +01:00