@glance-apps/sync 1.3.2 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,58 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.5.0] - 2026-06-19
11
+
12
+ ### Added
13
+
14
+ - **DB sync key verifier (fail fast; never upload under an unverified key).**
15
+ Before pushing, the DB engine now proves the derived root key matches the
16
+ account's existing data. It reserves an engine-owned entity
17
+ (`__glance_keycheck`, never routed to `getLocalEntity` / `applyRemoteEntity`)
18
+ holding a fixed known plaintext; decryptability under the derived key is the
19
+ signal. `ensureRootKey` runs `verifyAccountKey()` once per session: an existing
20
+ verifier that fails to decrypt throws a typed `KEY_MISMATCH` and gates the push
21
+ (nothing is uploaded), so a wrong passphrase — or a per-account salt that
22
+ drifted across server redeploys — can no longer poison an account. A new
23
+ account writes the verifier insert-only (first-write-wins), matching the salt's
24
+ concurrency model. Exposed via `engine.isKeyVerified()` and
25
+ `config.onError(message, 'KEY_MISMATCH')`.
26
+ - **DB sync per-row decrypt quarantine (one bad row must not wedge the cycle).**
27
+ `pullRemoteChanges` now wraps each row's decrypt + apply in try/catch: an
28
+ individual undecryptable row is counted, recorded in a persisted quarantine set
29
+ (localStorage, keyed by `storageKeyPrefix`), and the cursor is advanced past it
30
+ — the cycle no longer throws and wedges all future sync. Reserved `__glance_*`
31
+ rows are skipped from normal routing. Key verification (Part A) runs first, so
32
+ a globally wrong key aborts once with `KEY_MISMATCH` instead of quarantining
33
+ rows one by one. Each cycle re-attempts quarantined rows by id and drops them
34
+ on a successful decrypt + apply, so a row self-heals once the correct key is in
35
+ use. Surfaced via `config.onRowsSkipped(count, entityIds)`; `dbSyncCycle()` now
36
+ resolves to `{ applied, skipped, skippedEntityIds }` and `engine.getQuarantine()`
37
+ exposes the set.
38
+ - Error codes `KEY_MISMATCH` and `ROW_DECRYPT_FAILED` standardized on the
39
+ `SyncErrorCode` type.
40
+
41
+ ## [1.4.0] - 2026-06-18
42
+
43
+ ### Fixed
44
+
45
+ - **DB sync push no longer skips unread remote rows.** The DB engine split the
46
+ single high-water mark into two cursors: a pull cursor (`getHighWaterMark`,
47
+ the highest seq actually listed and applied) that only the pull step
48
+ advances, and a separate push-ack marker (`getPushAck`) for push
49
+ idempotency. Previously `pushDirtyRows` advanced the shared high-water mark to
50
+ the max seq the server assigned its pushed rows; because pushed rows get the
51
+ highest seqs, the next pull resumed from `since` above any remote row whose
52
+ seq sat below them and silently skipped it — unrecoverable for insert-only
53
+ rows. A push consumes nothing and now never moves the pull cursor, so
54
+ push-then-pull and pull-then-push are both safe. An existing stored cursor is
55
+ read as pull-progress (the conservative interpretation), so upgrades are safe.
56
+
57
+ ### Added
58
+
59
+ - `getPushAck()` / `setPushAck()` on the DB sync engine expose the push-ack
60
+ high-water mark.
61
+
10
62
  ## [1.3.2] - 2026-06-14
11
63
 
12
64
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "1.3.2",
3
+ "version": "1.5.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/dbCrypto.js CHANGED
@@ -27,6 +27,27 @@ const IV_BYTES = 12;
27
27
  // from any other HKDF use of the same root key.
28
28
  const HKDF_INFO_PREFIX = 'glance-sync:entity:';
29
29
 
30
+ // ---------------------------------------------------------------------------
31
+ // Engine-reserved entities
32
+ // ---------------------------------------------------------------------------
33
+ // Rows whose entityId starts with this prefix are owned by the sync engine
34
+ // itself (e.g. the key verifier) and are NOT app entities: they must never be
35
+ // routed to getLocalEntity / applyRemoteEntity.
36
+ export const RESERVED_ENTITY_PREFIX = '__glance_';
37
+
38
+ // The key-verifier row. Its decryptability under the derived root key is the
39
+ // signal the engine uses to prove a passphrase matches an account's data.
40
+ export const KEYCHECK_ENTITY_ID = '__glance_keycheck';
41
+
42
+ // Any fixed known plaintext works: AES-GCM tag validation means a wrong key
43
+ // fails to decrypt regardless of contents, so decryption SUCCESS is the signal.
44
+ export const KEYCHECK_PAYLOAD = { v: 1, magic: 'glance-keycheck' };
45
+
46
+ /** True for engine-reserved rows (e.g. the key verifier), which apps never see. */
47
+ export function isReservedEntityId(entityId) {
48
+ return typeof entityId === 'string' && entityId.startsWith(RESERVED_ENTITY_PREFIX);
49
+ }
50
+
30
51
  // ---------------------------------------------------------------------------
31
52
  // Session state (in-memory only, never persisted in plaintext)
32
53
  // ---------------------------------------------------------------------------
@@ -300,7 +321,9 @@ export async function decryptEntity(ciphertextB64, entityId, rootKey = _rootKey)
300
321
  return JSON.parse(new TextDecoder().decode(plaintext));
301
322
  } catch (err) {
302
323
  if (err.name === 'OperationError') {
303
- throw new Error('Decryption failed: wrong passphrase or corrupted data.');
324
+ const failure = new Error('Decryption failed: wrong passphrase or corrupted data.');
325
+ failure.code = 'ROW_DECRYPT_FAILED';
326
+ throw failure;
304
327
  }
305
328
  throw err;
306
329
  }
package/src/dbEngine.js CHANGED
@@ -25,6 +25,9 @@ import {
25
25
  hasDbRootKey,
26
26
  encryptEntity,
27
27
  decryptEntity,
28
+ isReservedEntityId,
29
+ KEYCHECK_ENTITY_ID,
30
+ KEYCHECK_PAYLOAD,
28
31
  } from './dbCrypto.js';
29
32
 
30
33
  const ts = (v) => {
@@ -58,7 +61,8 @@ const ts = (v) => {
58
61
  *
59
62
  * Event callbacks (optional):
60
63
  * @param {Function} [config.onStatusChange]
61
- * @param {Function} [config.onError]
64
+ * @param {Function} [config.onError] - (message, code, isHardStop) called with code 'KEY_MISMATCH' on a wrong key
65
+ * @param {Function} [config.onRowsSkipped] - (count, entityIds) called when a cycle skipped undecryptable rows
62
66
  */
63
67
  export const createDbSyncEngine = (config) => {
64
68
  const {
@@ -80,6 +84,7 @@ export const createDbSyncEngine = (config) => {
80
84
  getEntityLastModified,
81
85
  onStatusChange,
82
86
  onError,
87
+ onRowsSkipped,
83
88
  } = config;
84
89
 
85
90
  if (!storageKeyPrefix) throw new Error('createDbSyncEngine: storageKeyPrefix is required');
@@ -105,12 +110,29 @@ export const createDbSyncEngine = (config) => {
105
110
 
106
111
  // localStorage keys owned by this engine.
107
112
  const KEY_CONFIG = `${storageKeyPrefix}-db-sync-config`;
113
+ // KEY_HWM is the PULL cursor: the highest seq this device has actually
114
+ // consumed (listed + applied). It is the `since` we resume pulling from, so
115
+ // only the pull step may advance it. A push consumes nothing and must never
116
+ // touch it — see KEY_PUSH_ACK below.
108
117
  const KEY_HWM = `${storageKeyPrefix}-db-sync-hwm`;
118
+ // KEY_PUSH_ACK is the PUSH high-water mark: the highest seq the server has
119
+ // assigned to rows this device has pushed and fully acknowledged. It exists
120
+ // purely for push idempotency/observability and is never read by the pull
121
+ // cursor, so it cannot cause unread remote rows to be skipped.
122
+ const KEY_PUSH_ACK = `${storageKeyPrefix}-db-sync-push-ack`;
109
123
  const KEY_DIRTY = `${storageKeyPrefix}-db-sync-dirty`;
110
124
  const KEY_LAST_SYNCED = `${storageKeyPrefix}-db-sync-last-synced`;
125
+ // KEY_QUARANTINE persists the set of rows that failed to decrypt under the
126
+ // (otherwise verified) account key, as [{ entityId, seq }]. They are skipped
127
+ // on pull so one bad row cannot wedge the cycle, and re-attempted each cycle
128
+ // so a row recovers if the correct key later becomes available.
129
+ const KEY_QUARANTINE = `${storageKeyPrefix}-db-sync-quarantine`;
111
130
 
112
- // ── In-memory guard ───────────────────────────────────────────────────────
131
+ // ── In-memory guards ──────────────────────────────────────────────────────
113
132
  let syncing = false;
133
+ // Per-session cache: once the derived root key is proven against the account's
134
+ // existing data (Part A), we never re-verify for the life of this engine.
135
+ let verified = false;
114
136
 
115
137
  // ── Local state accessors ─────────────────────────────────────────────────
116
138
  const getConfig = () => {
@@ -123,6 +145,10 @@ export const createDbSyncEngine = (config) => {
123
145
  };
124
146
  const getLastSynced = () => localStorage.getItem(KEY_LAST_SYNCED) || null;
125
147
 
148
+ // PULL cursor. Advanced only by pullRemoteChanges from rows actually applied.
149
+ // An existing stored value from before the pull/push split is read as
150
+ // pull-progress, which is the conservative interpretation (it never lets us
151
+ // resume ahead of what we have consumed).
126
152
  const getHighWaterMark = () => {
127
153
  const raw = localStorage.getItem(KEY_HWM);
128
154
  const n = raw == null ? 0 : Number(raw);
@@ -132,6 +158,17 @@ export const createDbSyncEngine = (config) => {
132
158
  localStorage.setItem(KEY_HWM, String(seq));
133
159
  };
134
160
 
161
+ // PUSH high-water mark. Tracked separately so a push can never advance the
162
+ // pull cursor (KEY_HWM) and skip lower-seq remote rows.
163
+ const getPushAck = () => {
164
+ const raw = localStorage.getItem(KEY_PUSH_ACK);
165
+ const n = raw == null ? 0 : Number(raw);
166
+ return Number.isFinite(n) ? n : 0;
167
+ };
168
+ const setPushAck = (seq) => {
169
+ localStorage.setItem(KEY_PUSH_ACK, String(seq));
170
+ };
171
+
135
172
  // Dirty set persisted as a JSON array; deduped via Set on read/write.
136
173
  const getDirtySet = () => {
137
174
  const raw = localStorage.getItem(KEY_DIRTY);
@@ -148,6 +185,32 @@ export const createDbSyncEngine = (config) => {
148
185
  };
149
186
  const clearDirty = () => localStorage.removeItem(KEY_DIRTY);
150
187
 
188
+ // Quarantine set persisted as a JSON array of { entityId, seq }.
189
+ const getQuarantine = () => {
190
+ const raw = localStorage.getItem(KEY_QUARANTINE);
191
+ if (!raw) return [];
192
+ try {
193
+ const arr = JSON.parse(raw);
194
+ return Array.isArray(arr) ? arr : [];
195
+ } catch {
196
+ return [];
197
+ }
198
+ };
199
+ const writeQuarantine = (list) => {
200
+ if (list.length === 0) localStorage.removeItem(KEY_QUARANTINE);
201
+ else localStorage.setItem(KEY_QUARANTINE, JSON.stringify(list));
202
+ };
203
+ const addToQuarantine = (entityId, seq) => {
204
+ const list = getQuarantine();
205
+ const i = list.findIndex((q) => q.entityId === entityId);
206
+ if (i >= 0) list[i] = { entityId, seq };
207
+ else list.push({ entityId, seq });
208
+ writeQuarantine(list);
209
+ };
210
+ const removeFromQuarantine = (entityId) => {
211
+ writeQuarantine(getQuarantine().filter((q) => q.entityId !== entityId));
212
+ };
213
+
151
214
  // markDirty: called by the app on every local write (and at creation time for
152
215
  // insert-only types). Idempotent and synchronous so it can run inside the
153
216
  // app's own write path.
@@ -160,38 +223,90 @@ export const createDbSyncEngine = (config) => {
160
223
  writeDirtySet(current);
161
224
  };
162
225
 
226
+ // ── Key verifier (Part A) ─────────────────────────────────────────────────
227
+ // Proves the derived root key matches the account's existing data BEFORE we
228
+ // are allowed to push. A wrong passphrase — or a per-account salt that drifted
229
+ // across server redeploys — derives a non-matching key; without this check it
230
+ // would only surface as a cryptic per-row failure mid-sync, after a device may
231
+ // already have uploaded rows under the bad key and poisoned the account.
232
+ //
233
+ // The signal is decryptability: AES-GCM tag validation means a wrong key
234
+ // simply fails, so the verifier payload's contents need not match. Runs once
235
+ // per session (cached via `verified`).
236
+ const verifyAccountKey = async () => {
237
+ if (verified) return;
238
+ // Verification needs a single-row fetch. Clients that don't implement getRow
239
+ // (e.g. minimal test stubs) cannot be verified; don't block sync on them.
240
+ if (typeof vault.getRow !== 'function') { verified = true; return; }
241
+
242
+ const row = await vault.getRow(app, KEYCHECK_ENTITY_ID, accountId);
243
+ if (row && !row.deleted && row.envelope) {
244
+ // Existing account: the verifier must decrypt under our derived key.
245
+ try {
246
+ await decryptEntity(row.envelope, KEYCHECK_ENTITY_ID);
247
+ verified = true;
248
+ } catch {
249
+ const err = new Error("This sync passphrase doesn't match this account's existing data.");
250
+ err.code = 'KEY_MISMATCH';
251
+ throw err;
252
+ }
253
+ } else {
254
+ // New account: establish the verifier. Insert-only / first-write-wins so
255
+ // concurrent establishers don't clobber — and since the salt is already
256
+ // first-write-wins, concurrent establishers derive the same key and their
257
+ // verifiers are mutually decryptable anyway.
258
+ const envelope = await encryptEntity(KEYCHECK_PAYLOAD, KEYCHECK_ENTITY_ID);
259
+ await vault.batch(app, {
260
+ accountId,
261
+ rows: [{ entityId: KEYCHECK_ENTITY_ID, envelope, createdAt: Date.now(), insertOnly: true }],
262
+ });
263
+ verified = true;
264
+ }
265
+ };
266
+
163
267
  // ── Root key / salt bootstrap ─────────────────────────────────────────────
164
268
  // Ensures the per-account root key is available, fetching or registering the
165
- // salt with the vault on first use.
269
+ // salt with the vault on first use, then verifies it once per session.
166
270
  const ensureRootKey = async () => {
167
- if (hasDbRootKey()) return;
168
- if (await initDbRootKey(cryptoCfg)) return;
169
-
170
- const passphrase = getSyncPassphrase();
171
- if (!passphrase) {
172
- const err = new Error('Encryption passphrase not available. Please enter your sync passphrase.');
173
- err.code = 'PASSPHRASE_REQUIRED';
174
- throw err;
175
- }
271
+ if (!hasDbRootKey() && !(await initDbRootKey(cryptoCfg))) {
272
+ const passphrase = getSyncPassphrase();
273
+ if (!passphrase) {
274
+ const err = new Error('Encryption passphrase not available. Please enter your sync passphrase.');
275
+ err.code = 'PASSPHRASE_REQUIRED';
276
+ throw err;
277
+ }
176
278
 
177
- let salt = await vault.getSalt(accountId);
178
- if (!salt) {
179
- const fresh = crypto.getRandomValues(new Uint8Array(16));
180
- // First-write-wins: use whatever the server returns (another device may
181
- // have registered a salt between our GET and PUT).
182
- salt = await vault.putSalt(accountId, fresh);
279
+ let salt = await vault.getSalt(accountId);
280
+ if (!salt) {
281
+ const fresh = crypto.getRandomValues(new Uint8Array(16));
282
+ // First-write-wins: use whatever the server returns (another device may
283
+ // have registered a salt between our GET and PUT).
284
+ salt = await vault.putSalt(accountId, fresh);
285
+ }
286
+ await setupDbRootKey(passphrase, salt, cryptoCfg);
183
287
  }
184
- await setupDbRootKey(passphrase, salt, cryptoCfg);
288
+
289
+ // Key is now available (restored or freshly derived). Prove it matches the
290
+ // account before anything else relies on it; this gates the push.
291
+ await verifyAccountKey();
185
292
  };
186
293
 
187
294
  // ── Step 1: push dirty rows ───────────────────────────────────────────────
188
295
  // Encrypts every dirty entity and upserts it as a batch. Entities that no
189
296
  // longer exist locally are pushed as soft-deletes. The dirty set is cleared
190
- // and the high water mark advanced only after the server fully acknowledges,
191
- // so a network failure leaves un-acked rows dirty for an idempotent re-send.
297
+ // and the push-ack high water mark advanced only after the server fully
298
+ // acknowledges, so a network failure leaves un-acked rows dirty for an
299
+ // idempotent re-send. Push deliberately does NOT touch the pull cursor
300
+ // (getHighWaterMark): the server assigns pushed rows the highest seqs, so
301
+ // advancing `since` here would skip any remote row whose seq sits below them.
302
+ //
303
+ // The push is GATED on key verification: ensureRootKey runs Part A's
304
+ // verifyAccountKey, which throws KEY_MISMATCH (before any upsert) if the
305
+ // derived key doesn't match the account — so a device can never upload rows
306
+ // under a non-matching key.
192
307
  const pushDirtyRows = async () => {
193
308
  const dirty = getDirtySet();
194
- if (dirty.length === 0) return { written: 0, deleted: 0, maxSeq: getHighWaterMark() };
309
+ if (dirty.length === 0) return { written: 0, deleted: 0, maxSeq: getPushAck() };
195
310
 
196
311
  await ensureRootKey();
197
312
 
@@ -208,8 +323,8 @@ export const createDbSyncEngine = (config) => {
208
323
  }
209
324
 
210
325
  // Soft-delete first, then batch the upserts. If any call throws, we fall out
211
- // before clearing the dirty set or advancing the cursor.
212
- let maxSeq = getHighWaterMark();
326
+ // before clearing the dirty set or advancing the push-ack marker.
327
+ let maxSeq = getPushAck();
213
328
  for (const entityId of deletes) {
214
329
  const r = await vault.deleteRow(app, entityId, accountId);
215
330
  if (r && typeof r.seq === 'number') maxSeq = Math.max(maxSeq, r.seq);
@@ -220,63 +335,124 @@ export const createDbSyncEngine = (config) => {
220
335
  if (result && typeof result.maxSeq === 'number') maxSeq = Math.max(maxSeq, result.maxSeq);
221
336
  }
222
337
 
223
- // Full acknowledgment: safe to mark clean.
338
+ // Full acknowledgment: safe to mark clean. Advance the push-ack marker only;
339
+ // the pull cursor stays put so the next pull still lists from the highest
340
+ // seq we have actually consumed.
224
341
  clearDirty();
225
- setHighWaterMark(maxSeq);
342
+ setPushAck(maxSeq);
226
343
  return { written: upserts.length, deleted: deletes.length, maxSeq };
227
344
  };
228
345
 
346
+ // Decrypts and applies one row with entity-grain LWW. Mutates the supplied
347
+ // `dirty` Set in place (and persists it) when a remote write supersedes a
348
+ // dirty local copy, so we do not re-push a superseded version. Returns true if
349
+ // a remote write/delete was applied. Throws (ROW_DECRYPT_FAILED) on a decrypt
350
+ // failure — the caller decides whether to quarantine.
351
+ const applyRemoteRow = async (R, dirty) => {
352
+ if (R.deleted) {
353
+ await applyRemoteDelete(R.entityId);
354
+ if (dirty.delete(R.entityId)) writeDirtySet([...dirty]);
355
+ return true;
356
+ }
357
+
358
+ const remoteEntity = await decryptEntity(R.envelope, R.entityId);
359
+ const local = await getLocalEntity(R.entityId);
360
+
361
+ if (local == null) {
362
+ await applyRemoteEntity(R.entityId, remoteEntity);
363
+ return true;
364
+ }
365
+ if (insertOnly(remoteEntity, R.entityId)) {
366
+ // Insert-only types never conflict: applying is an idempotent union.
367
+ await applyRemoteEntity(R.entityId, remoteEntity);
368
+ return true;
369
+ }
370
+ // Entity-grain last-writer-wins. The same comparison covers both the clean
371
+ // case and the contended (locally dirty) case.
372
+ const remoteWins = ts(lastModifiedOf(remoteEntity)) > ts(lastModifiedOf(local));
373
+ if (remoteWins) {
374
+ await applyRemoteEntity(R.entityId, remoteEntity);
375
+ if (dirty.delete(R.entityId)) writeDirtySet([...dirty]);
376
+ return true;
377
+ }
378
+ // local is newer or equal: keep local and discard remote. If the entity is
379
+ // dirty it stays dirty and will be re-pushed next cycle.
380
+ return false;
381
+ };
382
+
383
+ // ── Quarantine self-heal (Part B) ─────────────────────────────────────────
384
+ // Quarantined rows sit BELOW the pull cursor (we advanced past them), so they
385
+ // never reappear in list() — re-fetching by id is the only recovery path. A
386
+ // row drops out of quarantine on successful decrypt+apply (e.g. once the
387
+ // correct key is in use) or once it is gone from the server.
388
+ const healQuarantine = async (dirty) => {
389
+ const list = getQuarantine();
390
+ if (list.length === 0 || typeof vault.getRow !== 'function') return 0;
391
+
392
+ let healed = 0;
393
+ for (const { entityId } of list) {
394
+ let R;
395
+ try {
396
+ R = await vault.getRow(app, entityId, accountId);
397
+ } catch {
398
+ continue; // transient network error: leave quarantined, retry next cycle
399
+ }
400
+ if (R == null) {
401
+ removeFromQuarantine(entityId); // gone from server: nothing to recover
402
+ continue;
403
+ }
404
+ try {
405
+ const didApply = await applyRemoteRow(R, dirty);
406
+ removeFromQuarantine(entityId);
407
+ if (didApply) healed += 1;
408
+ } catch {
409
+ // Still undecryptable under the current key: leave quarantined.
410
+ }
411
+ }
412
+ return healed;
413
+ };
414
+
229
415
  // ── Step 2: pull remote changes ───────────────────────────────────────────
230
416
  // Paginates from the high water mark, applying each row with entity-grain LWW.
231
- // Rows that are also locally dirty are resolved against the dirty local copy
232
- // (contended path): remote wins only if its lastModified is newer, and when it
233
- // does win the entity is dropped from the dirty set so we do not re-push a
234
- // superseded version.
417
+ // Part A's key verification has already run (via ensureRootKey), so a globally
418
+ // wrong key has aborted with KEY_MISMATCH before we get here; any decrypt
419
+ // failure now is an INDIVIDUAL bad row. Such a row is counted, quarantined,
420
+ // and its cursor advanced past — it must never throw and wedge the cycle.
235
421
  const pullRemoteChanges = async () => {
422
+ await ensureRootKey();
423
+
236
424
  let since = getHighWaterMark();
237
425
  let maxSeq = since;
238
426
  let appliedRemote = false;
427
+ let applied = 0;
428
+ let skipped = 0;
429
+ const skippedEntityIds = [];
430
+
431
+ // Re-attempt previously quarantined rows first; a snapshot dirty set keeps
432
+ // contended-path resolution consistent across heal + page application.
433
+ const dirty = new Set(getDirtySet());
434
+ applied += await healQuarantine(dirty);
239
435
 
240
436
  let hasMore = true;
241
437
  while (hasMore) {
242
438
  const { rows, hasMore: more } = await vault.list(app, { accountId, since });
243
439
  if (!Array.isArray(rows) || rows.length === 0) break;
244
440
 
245
- // Snapshot the dirty set once per page for contended-path resolution.
246
- const dirty = new Set(getDirtySet());
247
-
248
441
  for (const R of rows) {
249
442
  if (typeof R.seq === 'number' && R.seq > maxSeq) maxSeq = R.seq;
250
443
 
251
- if (R.deleted) {
252
- await applyRemoteDelete(R.entityId);
253
- if (dirty.delete(R.entityId)) writeDirtySet([...dirty]);
254
- appliedRemote = true;
255
- continue;
256
- }
257
-
258
- await ensureRootKey();
259
- const remoteEntity = await decryptEntity(R.envelope, R.entityId);
260
- const local = await getLocalEntity(R.entityId);
261
-
262
- if (local == null) {
263
- await applyRemoteEntity(R.entityId, remoteEntity);
264
- appliedRemote = true;
265
- } else if (insertOnly(remoteEntity, R.entityId)) {
266
- // Insert-only types never conflict: applying is an idempotent union.
267
- await applyRemoteEntity(R.entityId, remoteEntity);
268
- appliedRemote = true;
269
- } else {
270
- // Entity-grain last-writer-wins. The same comparison covers both the
271
- // clean case and the contended (locally dirty) case.
272
- const remoteWins = ts(lastModifiedOf(remoteEntity)) > ts(lastModifiedOf(local));
273
- if (remoteWins) {
274
- await applyRemoteEntity(R.entityId, remoteEntity);
275
- appliedRemote = true;
276
- if (dirty.delete(R.entityId)) writeDirtySet([...dirty]);
444
+ // Engine-reserved rows (e.g. the key verifier) are not app entities and
445
+ // must never be routed to applyRemoteEntity. Advance past them.
446
+ if (!isReservedEntityId(R.entityId)) {
447
+ try {
448
+ const didApply = await applyRemoteRow(R, dirty);
449
+ if (didApply) { appliedRemote = true; applied += 1; }
450
+ } catch {
451
+ // Per-row decrypt failure: quarantine, count, advance, continue.
452
+ skipped += 1;
453
+ skippedEntityIds.push(R.entityId);
454
+ addToQuarantine(R.entityId, R.seq);
277
455
  }
278
- // else: local is newer or equal, so keep local and discard remote. If the
279
- // entity is dirty it stays dirty and will be re-pushed next cycle.
280
456
  }
281
457
 
282
458
  if (typeof R.seq === 'number') since = Math.max(since, R.seq);
@@ -285,13 +461,18 @@ export const createDbSyncEngine = (config) => {
285
461
  hasMore = !!more;
286
462
  }
287
463
 
464
+ // Pull is the sole writer of the pull cursor. Advance it (monotonically)
465
+ // from the highest seq we actually listed this run.
288
466
  setHighWaterMark(Math.max(getHighWaterMark(), maxSeq));
289
- return { maxSeq, appliedRemote };
467
+ return { maxSeq, appliedRemote, applied, skipped, skippedEntityIds };
290
468
  };
291
469
 
292
470
  // ── Step 3: update device cursor ──────────────────────────────────────────
293
471
  // Best-effort: a failure here only affects tombstone GC timing, never sync
294
- // correctness, so it is swallowed.
472
+ // correctness, so it is swallowed. lastSeenSeq reports the pull cursor (what
473
+ // we have truly consumed), not the push-ack marker: reporting consumed
474
+ // progress is the conservative value for server-side tombstone GC, since it
475
+ // never lets the server reclaim a tombstone this device has not yet seen.
295
476
  const updateDeviceCursor = async () => {
296
477
  if (!deviceId) return { updated: false };
297
478
  try {
@@ -316,20 +497,29 @@ export const createDbSyncEngine = (config) => {
316
497
  onStatusChange?.('uploading');
317
498
  onError?.(null, null, false);
318
499
  try {
500
+ // Part A first: prove the key before we push anything. A globally wrong
501
+ // key aborts here with KEY_MISMATCH (push gated, nothing uploaded) rather
502
+ // than quarantining thousands of rows one by one on the pull.
503
+ await ensureRootKey();
504
+
319
505
  await pushDirtyRows();
320
506
  onStatusChange?.('downloading');
321
- await pullRemoteChanges();
507
+ const pull = await pullRemoteChanges();
322
508
  await updateDeviceCursor();
323
509
 
324
510
  const now = new Date().toISOString();
325
511
  localStorage.setItem(KEY_LAST_SYNCED, now);
326
512
  onStatusChange?.('success');
513
+
514
+ if (pull.skipped > 0) onRowsSkipped?.(pull.skipped, pull.skippedEntityIds);
515
+ return { applied: pull.applied, skipped: pull.skipped, skippedEntityIds: pull.skippedEntityIds };
327
516
  } catch (err) {
328
517
  // eslint-disable-next-line no-console
329
518
  console.error(`[${appId}] db sync cycle error:`, err);
330
519
  const code = err && err.code ? err.code : 'NETWORK_ERROR';
331
520
  onError?.(err?.message || String(err), code, false);
332
521
  onStatusChange?.('error');
522
+ return { applied: 0, skipped: 0, skippedEntityIds: [] };
333
523
  } finally {
334
524
  syncing = false;
335
525
  }
@@ -352,9 +542,14 @@ export const createDbSyncEngine = (config) => {
352
542
  getDirtySet,
353
543
  clearDirty,
354
544
 
355
- // Cursor
545
+ // Quarantine (per-row decrypt failures awaiting self-heal)
546
+ getQuarantine,
547
+
548
+ // Cursor (pull-progress) and push-ack marker
356
549
  getHighWaterMark,
357
550
  setHighWaterMark,
551
+ getPushAck,
552
+ setPushAck,
358
553
 
359
554
  // Config
360
555
  getConfig,
@@ -363,6 +558,7 @@ export const createDbSyncEngine = (config) => {
363
558
 
364
559
  // State queries
365
560
  isSyncing: () => syncing,
561
+ isKeyVerified: () => verified,
366
562
  hasEncryptionReady: hasDbRootKey,
367
563
 
368
564
  // Sub-modules
package/src/index.js CHANGED
@@ -49,4 +49,8 @@ export {
49
49
  hasDbRootKey,
50
50
  encryptEntity,
51
51
  decryptEntity,
52
+ isReservedEntityId,
53
+ RESERVED_ENTITY_PREFIX,
54
+ KEYCHECK_ENTITY_ID,
55
+ KEYCHECK_PAYLOAD,
52
56
  } from './dbCrypto.js';
package/types/index.d.ts CHANGED
@@ -16,7 +16,12 @@ export type SyncErrorCode =
16
16
  | 'FORBIDDEN'
17
17
  | 'AUTH_FAILURE'
18
18
  | 'LOCKED'
19
- | 'NETWORK_ERROR';
19
+ | 'NETWORK_ERROR'
20
+ // DB transport: derived root key does not match the account's existing data.
21
+ | 'KEY_MISMATCH'
22
+ // DB transport: a single row failed to decrypt (surfaced as a count via
23
+ // onRowsSkipped, never thrown).
24
+ | 'ROW_DECRYPT_FAILED';
20
25
 
21
26
  export type SyncStatus = 'idle' | 'uploading' | 'downloading' | 'success' | 'error';
22
27
 
@@ -366,6 +371,14 @@ export function hasDbRootKey(): boolean;
366
371
  export function encryptEntity(entity: unknown, entityId: string, rootKey?: CryptoKey): Promise<string>;
367
372
  export function decryptEntity<T = unknown>(ciphertext: string, entityId: string, rootKey?: CryptoKey): Promise<T>;
368
373
 
374
+ // Engine-reserved entities. Rows whose entityId starts with RESERVED_ENTITY_PREFIX
375
+ // are owned by the sync engine (e.g. the key verifier) and are never routed to
376
+ // getLocalEntity / applyRemoteEntity.
377
+ export const RESERVED_ENTITY_PREFIX: string;
378
+ export const KEYCHECK_ENTITY_ID: string;
379
+ export const KEYCHECK_PAYLOAD: { v: number; magic: string };
380
+ export function isReservedEntityId(entityId: string): boolean;
381
+
369
382
  // Row exchanged with GLANCEvault. envelope is base64(IV || AES-GCM output).
370
383
  export interface VaultRow {
371
384
  entityId: string;
@@ -425,30 +438,54 @@ export interface DbSyncEngineConfig {
425
438
 
426
439
  // Event callbacks
427
440
  onStatusChange?: (status: SyncStatus, hints?: { from?: SyncStatus }) => void;
441
+ // Called with code === 'KEY_MISMATCH' when the derived key doesn't match the account.
428
442
  onError?: (message: string | null, code: SyncErrorCode | null, isHardStop: boolean) => void;
443
+ // Called once per cycle that skipped > 0 undecryptable rows (per-row quarantine).
444
+ onRowsSkipped?: (count: number, entityIds: string[]) => void;
445
+ }
446
+
447
+ /** Result of a DB sync cycle / pull: how many rows applied vs. quarantined. */
448
+ export interface DbSyncResult {
449
+ applied: number;
450
+ skipped: number;
451
+ skippedEntityIds: string[];
452
+ }
453
+
454
+ /** A row quarantined after failing to decrypt under the (verified) account key. */
455
+ export interface QuarantinedRow {
456
+ entityId: string;
457
+ seq: number;
429
458
  }
430
459
 
431
460
  export interface DbSyncEngine {
432
461
  transportMode: 'database';
433
- sync(): Promise<void>;
434
- dbSyncCycle(): Promise<void>;
462
+ sync(): Promise<DbSyncResult>;
463
+ dbSyncCycle(): Promise<DbSyncResult>;
435
464
  pushDirtyRows(): Promise<{ written: number; deleted: number; maxSeq: number }>;
436
- pullRemoteChanges(): Promise<{ maxSeq: number; appliedRemote: boolean }>;
465
+ pullRemoteChanges(): Promise<DbSyncResult & { maxSeq: number; appliedRemote: boolean }>;
437
466
  updateDeviceCursor(): Promise<{ updated: boolean }>;
438
467
  ensureRootKey(): Promise<void>;
439
468
 
440
469
  markDirty(entityId: string): void;
441
470
  getDirtySet(): string[];
442
471
  clearDirty(): void;
472
+ /** Rows quarantined after a per-row decrypt failure, awaiting self-heal. */
473
+ getQuarantine(): QuarantinedRow[];
443
474
 
475
+ /** Pull cursor: highest seq actually listed + applied. Only pull advances it. */
444
476
  getHighWaterMark(): number;
445
477
  setHighWaterMark(seq: number): void;
478
+ /** Push-ack marker: highest seq the server assigned to pushed rows. Never feeds the pull cursor. */
479
+ getPushAck(): number;
480
+ setPushAck(seq: number): void;
446
481
 
447
482
  getConfig(): Record<string, unknown> | null;
448
483
  setConfig(config: Record<string, unknown> | null): void;
449
484
  getLastSynced(): string | null;
450
485
 
451
486
  isSyncing(): boolean;
487
+ /** True once the derived root key has been proven against the account (Part A). */
488
+ isKeyVerified(): boolean;
452
489
  hasEncryptionReady(): boolean;
453
490
 
454
491
  vault: VaultClient;