Skip to content

fix(sync): server-authoritative device initialized state (stop already-synced devices re-syncing)#13

Merged
khokonm merged 1 commit into
mainfrom
fix/sync-server-authoritative-initialized
Jun 26, 2026
Merged

fix(sync): server-authoritative device initialized state (stop already-synced devices re-syncing)#13
khokonm merged 1 commit into
mainfrom
fix/sync-server-authoritative-initialized

Conversation

@khokonm

@khokonm khokonm commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Regression

Two already-synced devices started asking each other to "allow sync," and approving did nothing (the prompt hung).

Why it kept regressing — the real root cause

"Does this device need an initial transfer?" was being decided on the client — across the last several fixes it was inferred from a localStorage flag, note count, or the server cursor. All of those are unreliable:

  • The migrateInitFlagIfNeeded backfill (PR fix(sync): the requesting device must never show an approval prompt (+ backfill legacy initialized flag) #12) read callbacks.getNotes() during startSync, but App calls initSync() immediately after await loadFromVault()before React re-renders — so notesRef.current was still the stale empty array. Every already-synced device therefore read "0 notes," was marked "needs transfer," and the marker made it permanent. Hence established devices demanded a re-sync, prompted each other, and the bogus transfer hung.

Client state races with note-loading, gets wiped on logout, and differs across app versions. It is the wrong place for this decision.

The fix — make it server-authoritative

The server already persists a devices row per device. Add an initialized column that is the single source of truth:

  • Schema: initialized INTEGER NOT NULL DEFAULT 1, plus a guarded ALTER TABLE so existing rows default to 1. Every already-paired device is therefore instantly correct and can never be asked to re-bootstrap — this fixes the live regression on deploy with zero user action.
  • On join: a brand-new registration is a founder (initialized=1) only when no other device exists yet; a device joining an existing chain registers initialized=0 and must receive a transfer. An existing row keeps its earned state on reconnect.
  • isNewDevice = !device.initialized && otherDevices.length > 0 — computed from the DB, not a client hint.
  • On transfer-complete: the server marks the requester initialized=1, so it rejoins as a full member and never asks again.

The client now simply trusts the server's welcome.needsTransfer. All the client-side flag machinery (SYNC_INIT_ROOM_KEY, the migration, needsInitialTransfer, etc.) is deleted (net −42 lines). Defensive guards are kept/added: a device that still needs a transfer never shows an approval prompt, never approves its own request, and never requests from itself (client + server); approving now dismisses the prompt immediately so it can't hang.

Files changed

  • server/src/db.tsinitialized column + migration; registerDevice(…, initializedIfNew); markDeviceInitialized; DeviceRecord.initialized.
  • server/src/index.ts — role from DB; mark initialized on transfer-complete.
  • src/utils/sync.ts — trust server needsTransfer; delete client flag layer; dismiss prompt on approve.
  • src/components/SettingsPanel.tsx — revert to plain enableSync.

Scenario audit (20)

Legitimate:

  1. Fresh origin generates, alone → founder, Connected, no prompt. ✓
  2. Second fresh device joins, both online → joiner auto-requests, origin prompted, approve → notes flow. ✓
  3. Two already-synced devices after deploy → both initialized=1 (column default) → no spurious prompts. ✓ (the reported regression)
  4. New device requests while source offline → transfer-target-offline → waits; source returns → auto-request + fix(sync): show device-approval prompt after reconnect (replay pending transfers) #8 replay → prompt. ✓
  5. Requester drops mid-transfer → not initialized → re-requests on reconnect. ✓
  6. Requester finishes then refreshes → server initialized=1 → Connected, doesn't re-ask. ✓ (fixes "Connecting again")
  7. Three devices, new one joins → picker lets you choose the source; any initialized device can approve. ✓
  8. Device removed then rejoins → row gone → re-bootstraps as new. ✓
  9. Disable then re-enable same code → device_id persists → existing row keeps initialized → Connected. ✓
  10. PIN-locked vault → sync doesn't start until unlock → no stray prompts. ✓
  11. Offline → connect retries, no prompts. ✓
  12. Join token expires (60s) → client refreshes token and rejoins. ✓
  13. Joining a different chain's code → new row in that room, initialized=0 → requests. ✓
  14. Large media transfer → byte-budgeted chunks + resume (unchanged). ✓

Adversarial:
15. Join without the code → no valid HMAC join token → rejected. ✓
16. Forged/expired token → HMAC/expiry check fails → rejected. ✓
17. Flood generate/validate/join → rate-limited (per-IP windows + WS join cap). ✓
18. Client lies about its role on join → server ignores client hints entirely (authoritative DB). ✓ (hardened by this PR)
19. Request a transfer from yourself → server rejects + client guard. ✓ (hardened)
20. A code-holder reading notes / approving for themselves → the sync code is the documented sole auth and the E2E key; approval is a UX gate, not a security boundary, so no new exposure. ✓

Residual hardening (pre-existing, noted, not addressed here to keep this focused): no per-sender rate-limit on request-transfer-from (prompt spam by an in-chain device), and a malicious in-chain client could ACK an inflated cursor to truncate ops early. Both require already knowing the sync code. Happy to follow up.

Verification

  • Reviewed end-to-end against the scenarios above; the regression path (stale notesRef → false "needs transfer") is eliminated because the decision no longer touches the client at all.
  • Not run locally: tsc/build/lint — sandbox has no installed toolchain for either workspace and installs are blocked (registry 403). Changes use only existing in-scope symbols; SQL column counts and run() arities checked by hand.

Manual test plan

  1. Deploy, then just reload both already-synced devices → both show Connected, neither asks to sync. (Fixes the reported bug with no user action.)
  2. Fresh pair: generate on A → A Connected. Enter code on B (both open) → only A is prompted; approve → B receives notes, B Connected.
  3. Refresh B → stays Connected (no re-ask, no "Connecting…" loop).
  4. Deny on A → B can retry via the picker.
  5. Add a 3rd device → picker lets you choose which device to copy from.

…s already-synced devices asking to re-sync)

Root cause of the regression: 'does this device need an initial transfer?' was
decided client-side. The PR #12 backfill read callbacks.getNotes() during
startSync, but App runs initSync() right after loadFromVault() before React
re-renders, so notesRef.current was the stale empty array -> every already-synced
device was marked 'needs transfer' permanently, so established devices prompted
each other and the bogus transfer hung.

Move the decision to authoritative server state: add devices.initialized
(existing rows default to 1 via guarded ALTER, so already-paired devices are
instantly correct). Founder (no other devices) registers initialized=1; a joiner
registers 0 and is marked initialized on transfer-complete. isNewDevice is
computed from the DB. The client deletes its whole flag layer and just trusts
welcome.needsTransfer. Guards kept/added: a device needing a transfer never shows
an approval prompt, never approves its own request, and never requests from
itself (client+server); approving dismisses the prompt immediately.
@khokonm khokonm merged commit 68430a7 into main Jun 26, 2026
3 checks passed
@khokonm khokonm deleted the fix/sync-server-authoritative-initialized branch June 26, 2026 09:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant