@glance-apps/sync 1.4.0 → 1.5.1

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,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.5.1] - 2026-06-19
11
+
12
+ ### Fixed
13
+
14
+ - **Key verification fails clearly when the server can't host the verifier.**
15
+ 1.5.0's `verifyAccountKey` did `GET /sync/{app}/__glance_keycheck`; against a
16
+ server that doesn't yet support the reserved id or the single-row GET, that
17
+ threw a raw `get row failed: 400` and aborted the whole sync with a meaningless
18
+ message. `verifyAccountKey` now distinguishes three outcomes: a
19
+ present-but-undecryptable verifier still throws `KEY_MISMATCH`; an absent
20
+ verifier (404) is still written; but any *other* error from the single-row GET
21
+ or the verifier write (HTTP 400/405/500, or a 404 on the route itself) now
22
+ throws a typed `VERIFIER_UNSUPPORTED` with an actionable "update your server"
23
+ message. The engine never silently proceeds unverified (the verifier gates push
24
+ and quarantine does not, so a wrong-key device with verification skipped would
25
+ still push poison rows), so sync pauses with a clear reason. Surfaced via
26
+ `config.onError(message, code)` like `KEY_MISMATCH`.
27
+
28
+ ### Added
29
+
30
+ - `config.allowUnverified` (default `false`): an opt-in operator escape hatch
31
+ that downgrades `VERIFIER_UNSUPPORTED` to a logged warning and proceeds, for
32
+ operators who knowingly accept the risk of syncing against a server that can't
33
+ host the verifier.
34
+ - `VERIFIER_UNSUPPORTED` standardized on the `SyncErrorCode` type.
35
+
36
+ ## [1.5.0] - 2026-06-19
37
+
38
+ ### Added
39
+
40
+ - **DB sync key verifier (fail fast; never upload under an unverified key).**
41
+ Before pushing, the DB engine now proves the derived root key matches the
42
+ account's existing data. It reserves an engine-owned entity
43
+ (`__glance_keycheck`, never routed to `getLocalEntity` / `applyRemoteEntity`)
44
+ holding a fixed known plaintext; decryptability under the derived key is the
45
+ signal. `ensureRootKey` runs `verifyAccountKey()` once per session: an existing
46
+ verifier that fails to decrypt throws a typed `KEY_MISMATCH` and gates the push
47
+ (nothing is uploaded), so a wrong passphrase — or a per-account salt that
48
+ drifted across server redeploys — can no longer poison an account. A new
49
+ account writes the verifier insert-only (first-write-wins), matching the salt's
50
+ concurrency model. Exposed via `engine.isKeyVerified()` and
51
+ `config.onError(message, 'KEY_MISMATCH')`.
52
+ - **DB sync per-row decrypt quarantine (one bad row must not wedge the cycle).**
53
+ `pullRemoteChanges` now wraps each row's decrypt + apply in try/catch: an
54
+ individual undecryptable row is counted, recorded in a persisted quarantine set
55
+ (localStorage, keyed by `storageKeyPrefix`), and the cursor is advanced past it
56
+ — the cycle no longer throws and wedges all future sync. Reserved `__glance_*`
57
+ rows are skipped from normal routing. Key verification (Part A) runs first, so
58
+ a globally wrong key aborts once with `KEY_MISMATCH` instead of quarantining
59
+ rows one by one. Each cycle re-attempts quarantined rows by id and drops them
60
+ on a successful decrypt + apply, so a row self-heals once the correct key is in
61
+ use. Surfaced via `config.onRowsSkipped(count, entityIds)`; `dbSyncCycle()` now
62
+ resolves to `{ applied, skipped, skippedEntityIds }` and `engine.getQuarantine()`
63
+ exposes the set.
64
+ - Error codes `KEY_MISMATCH` and `ROW_DECRYPT_FAILED` standardized on the
65
+ `SyncErrorCode` type.
66
+
10
67
  ## [1.4.0] - 2026-06-18
11
68
 
12
69
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "1.4.0",
3
+ "version": "1.5.1",
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,9 @@ 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, or 'VERIFIER_UNSUPPORTED' when the server can't host the verifier
65
+ * @param {Function} [config.onRowsSkipped] - (count, entityIds) called when a cycle skipped undecryptable rows
66
+ * @param {boolean} [config.allowUnverified=false] - operator escape hatch: downgrade VERIFIER_UNSUPPORTED to a logged warning and proceed (unsafe; off by default)
62
67
  */
63
68
  export const createDbSyncEngine = (config) => {
64
69
  const {
@@ -80,6 +85,8 @@ export const createDbSyncEngine = (config) => {
80
85
  getEntityLastModified,
81
86
  onStatusChange,
82
87
  onError,
88
+ onRowsSkipped,
89
+ allowUnverified = false,
83
90
  } = config;
84
91
 
85
92
  if (!storageKeyPrefix) throw new Error('createDbSyncEngine: storageKeyPrefix is required');
@@ -117,9 +124,17 @@ export const createDbSyncEngine = (config) => {
117
124
  const KEY_PUSH_ACK = `${storageKeyPrefix}-db-sync-push-ack`;
118
125
  const KEY_DIRTY = `${storageKeyPrefix}-db-sync-dirty`;
119
126
  const KEY_LAST_SYNCED = `${storageKeyPrefix}-db-sync-last-synced`;
127
+ // KEY_QUARANTINE persists the set of rows that failed to decrypt under the
128
+ // (otherwise verified) account key, as [{ entityId, seq }]. They are skipped
129
+ // on pull so one bad row cannot wedge the cycle, and re-attempted each cycle
130
+ // so a row recovers if the correct key later becomes available.
131
+ const KEY_QUARANTINE = `${storageKeyPrefix}-db-sync-quarantine`;
120
132
 
121
- // ── In-memory guard ───────────────────────────────────────────────────────
133
+ // ── In-memory guards ──────────────────────────────────────────────────────
122
134
  let syncing = false;
135
+ // Per-session cache: once the derived root key is proven against the account's
136
+ // existing data (Part A), we never re-verify for the life of this engine.
137
+ let verified = false;
123
138
 
124
139
  // ── Local state accessors ─────────────────────────────────────────────────
125
140
  const getConfig = () => {
@@ -172,6 +187,32 @@ export const createDbSyncEngine = (config) => {
172
187
  };
173
188
  const clearDirty = () => localStorage.removeItem(KEY_DIRTY);
174
189
 
190
+ // Quarantine set persisted as a JSON array of { entityId, seq }.
191
+ const getQuarantine = () => {
192
+ const raw = localStorage.getItem(KEY_QUARANTINE);
193
+ if (!raw) return [];
194
+ try {
195
+ const arr = JSON.parse(raw);
196
+ return Array.isArray(arr) ? arr : [];
197
+ } catch {
198
+ return [];
199
+ }
200
+ };
201
+ const writeQuarantine = (list) => {
202
+ if (list.length === 0) localStorage.removeItem(KEY_QUARANTINE);
203
+ else localStorage.setItem(KEY_QUARANTINE, JSON.stringify(list));
204
+ };
205
+ const addToQuarantine = (entityId, seq) => {
206
+ const list = getQuarantine();
207
+ const i = list.findIndex((q) => q.entityId === entityId);
208
+ if (i >= 0) list[i] = { entityId, seq };
209
+ else list.push({ entityId, seq });
210
+ writeQuarantine(list);
211
+ };
212
+ const removeFromQuarantine = (entityId) => {
213
+ writeQuarantine(getQuarantine().filter((q) => q.entityId !== entityId));
214
+ };
215
+
175
216
  // markDirty: called by the app on every local write (and at creation time for
176
217
  // insert-only types). Idempotent and synchronous so it can run inside the
177
218
  // app's own write path.
@@ -184,28 +225,108 @@ export const createDbSyncEngine = (config) => {
184
225
  writeDirtySet(current);
185
226
  };
186
227
 
228
+ // ── Key verifier (Part A) ─────────────────────────────────────────────────
229
+ // Proves the derived root key matches the account's existing data BEFORE we
230
+ // are allowed to push. A wrong passphrase — or a per-account salt that drifted
231
+ // across server redeploys — derives a non-matching key; without this check it
232
+ // would only surface as a cryptic per-row failure mid-sync, after a device may
233
+ // already have uploaded rows under the bad key and poisoned the account.
234
+ //
235
+ // The signal is decryptability: AES-GCM tag validation means a wrong key
236
+ // simply fails, so the verifier payload's contents need not match. Runs once
237
+ // per session (cached via `verified`).
238
+ // A server that can't host the verifier (doesn't support the reserved id or
239
+ // the single-row GET endpoint) must fail with a CLEAR reason, not a raw
240
+ // "get row failed: 400". We never silently proceed unverified: the verifier
241
+ // gates push and quarantine does NOT, so a wrong-key device with verification
242
+ // skipped would still push poison rows. Pausing sync with a clear reason is
243
+ // the correct degradation. (config.allowUnverified is an explicit operator
244
+ // escape hatch, off by default, that downgrades this to a logged warning.)
245
+ const verifierUnsupported = (cause) => {
246
+ if (allowUnverified) {
247
+ // eslint-disable-next-line no-console
248
+ console.warn(`[${appId}] db sync proceeding UNVERIFIED: the server cannot host the key verifier (${KEYCHECK_ENTITY_ID}).`, cause);
249
+ verified = true;
250
+ return; // caller returns from verifyAccountKey and sync proceeds
251
+ }
252
+ const err = new Error('Your GLANCEvault server needs to be updated to support key verification (the __glance_keycheck row).');
253
+ err.code = 'VERIFIER_UNSUPPORTED';
254
+ if (cause) err.cause = cause;
255
+ throw err;
256
+ };
257
+
258
+ const verifyAccountKey = async () => {
259
+ if (verified) return;
260
+ // Verification needs a single-row fetch. Clients that don't implement getRow
261
+ // (e.g. minimal test stubs) cannot be verified; don't block sync on them.
262
+ if (typeof vault.getRow !== 'function') { verified = true; return; }
263
+
264
+ // Outcome 3: any error from the GET (HTTP 400/405/500, or a 404 on the route
265
+ // itself) means the server can't host the verifier — fail clearly.
266
+ let row;
267
+ try {
268
+ row = await vault.getRow(app, KEYCHECK_ENTITY_ID, accountId);
269
+ } catch (cause) {
270
+ verifierUnsupported(cause);
271
+ return; // only reached when downgraded via allowUnverified
272
+ }
273
+
274
+ if (row && !row.deleted && row.envelope) {
275
+ // Outcome 1: existing account — the verifier must decrypt under our key.
276
+ try {
277
+ await decryptEntity(row.envelope, KEYCHECK_ENTITY_ID);
278
+ verified = true;
279
+ } catch {
280
+ const err = new Error("This sync passphrase doesn't match this account's existing data.");
281
+ err.code = 'KEY_MISMATCH';
282
+ throw err;
283
+ }
284
+ } else {
285
+ // Outcome 2: row absent (404 -> getRow returned null) — new account.
286
+ // Establish the verifier insert-only / first-write-wins so concurrent
287
+ // establishers don't clobber; since the salt is already first-write-wins,
288
+ // they derive the same key and their verifiers are mutually decryptable.
289
+ const envelope = await encryptEntity(KEYCHECK_PAYLOAD, KEYCHECK_ENTITY_ID);
290
+ try {
291
+ await vault.batch(app, {
292
+ accountId,
293
+ rows: [{ entityId: KEYCHECK_ENTITY_ID, envelope, createdAt: Date.now(), insertOnly: true }],
294
+ });
295
+ } catch (cause) {
296
+ // The write itself rejecting the reserved id (400/405/…) is the same
297
+ // "server can't host the verifier" condition.
298
+ verifierUnsupported(cause);
299
+ return;
300
+ }
301
+ verified = true;
302
+ }
303
+ };
304
+
187
305
  // ── Root key / salt bootstrap ─────────────────────────────────────────────
188
306
  // Ensures the per-account root key is available, fetching or registering the
189
- // salt with the vault on first use.
307
+ // salt with the vault on first use, then verifies it once per session.
190
308
  const ensureRootKey = async () => {
191
- if (hasDbRootKey()) return;
192
- if (await initDbRootKey(cryptoCfg)) return;
193
-
194
- const passphrase = getSyncPassphrase();
195
- if (!passphrase) {
196
- const err = new Error('Encryption passphrase not available. Please enter your sync passphrase.');
197
- err.code = 'PASSPHRASE_REQUIRED';
198
- throw err;
199
- }
309
+ if (!hasDbRootKey() && !(await initDbRootKey(cryptoCfg))) {
310
+ const passphrase = getSyncPassphrase();
311
+ if (!passphrase) {
312
+ const err = new Error('Encryption passphrase not available. Please enter your sync passphrase.');
313
+ err.code = 'PASSPHRASE_REQUIRED';
314
+ throw err;
315
+ }
200
316
 
201
- let salt = await vault.getSalt(accountId);
202
- if (!salt) {
203
- const fresh = crypto.getRandomValues(new Uint8Array(16));
204
- // First-write-wins: use whatever the server returns (another device may
205
- // have registered a salt between our GET and PUT).
206
- salt = await vault.putSalt(accountId, fresh);
317
+ let salt = await vault.getSalt(accountId);
318
+ if (!salt) {
319
+ const fresh = crypto.getRandomValues(new Uint8Array(16));
320
+ // First-write-wins: use whatever the server returns (another device may
321
+ // have registered a salt between our GET and PUT).
322
+ salt = await vault.putSalt(accountId, fresh);
323
+ }
324
+ await setupDbRootKey(passphrase, salt, cryptoCfg);
207
325
  }
208
- await setupDbRootKey(passphrase, salt, cryptoCfg);
326
+
327
+ // Key is now available (restored or freshly derived). Prove it matches the
328
+ // account before anything else relies on it; this gates the push.
329
+ await verifyAccountKey();
209
330
  };
210
331
 
211
332
  // ── Step 1: push dirty rows ───────────────────────────────────────────────
@@ -216,6 +337,11 @@ export const createDbSyncEngine = (config) => {
216
337
  // idempotent re-send. Push deliberately does NOT touch the pull cursor
217
338
  // (getHighWaterMark): the server assigns pushed rows the highest seqs, so
218
339
  // advancing `since` here would skip any remote row whose seq sits below them.
340
+ //
341
+ // The push is GATED on key verification: ensureRootKey runs Part A's
342
+ // verifyAccountKey, which throws KEY_MISMATCH (before any upsert) if the
343
+ // derived key doesn't match the account — so a device can never upload rows
344
+ // under a non-matching key.
219
345
  const pushDirtyRows = async () => {
220
346
  const dirty = getDirtySet();
221
347
  if (dirty.length === 0) return { written: 0, deleted: 0, maxSeq: getPushAck() };
@@ -255,57 +381,116 @@ export const createDbSyncEngine = (config) => {
255
381
  return { written: upserts.length, deleted: deletes.length, maxSeq };
256
382
  };
257
383
 
384
+ // Decrypts and applies one row with entity-grain LWW. Mutates the supplied
385
+ // `dirty` Set in place (and persists it) when a remote write supersedes a
386
+ // dirty local copy, so we do not re-push a superseded version. Returns true if
387
+ // a remote write/delete was applied. Throws (ROW_DECRYPT_FAILED) on a decrypt
388
+ // failure — the caller decides whether to quarantine.
389
+ const applyRemoteRow = async (R, dirty) => {
390
+ if (R.deleted) {
391
+ await applyRemoteDelete(R.entityId);
392
+ if (dirty.delete(R.entityId)) writeDirtySet([...dirty]);
393
+ return true;
394
+ }
395
+
396
+ const remoteEntity = await decryptEntity(R.envelope, R.entityId);
397
+ const local = await getLocalEntity(R.entityId);
398
+
399
+ if (local == null) {
400
+ await applyRemoteEntity(R.entityId, remoteEntity);
401
+ return true;
402
+ }
403
+ if (insertOnly(remoteEntity, R.entityId)) {
404
+ // Insert-only types never conflict: applying is an idempotent union.
405
+ await applyRemoteEntity(R.entityId, remoteEntity);
406
+ return true;
407
+ }
408
+ // Entity-grain last-writer-wins. The same comparison covers both the clean
409
+ // case and the contended (locally dirty) case.
410
+ const remoteWins = ts(lastModifiedOf(remoteEntity)) > ts(lastModifiedOf(local));
411
+ if (remoteWins) {
412
+ await applyRemoteEntity(R.entityId, remoteEntity);
413
+ if (dirty.delete(R.entityId)) writeDirtySet([...dirty]);
414
+ return true;
415
+ }
416
+ // local is newer or equal: keep local and discard remote. If the entity is
417
+ // dirty it stays dirty and will be re-pushed next cycle.
418
+ return false;
419
+ };
420
+
421
+ // ── Quarantine self-heal (Part B) ─────────────────────────────────────────
422
+ // Quarantined rows sit BELOW the pull cursor (we advanced past them), so they
423
+ // never reappear in list() — re-fetching by id is the only recovery path. A
424
+ // row drops out of quarantine on successful decrypt+apply (e.g. once the
425
+ // correct key is in use) or once it is gone from the server.
426
+ const healQuarantine = async (dirty) => {
427
+ const list = getQuarantine();
428
+ if (list.length === 0 || typeof vault.getRow !== 'function') return 0;
429
+
430
+ let healed = 0;
431
+ for (const { entityId } of list) {
432
+ let R;
433
+ try {
434
+ R = await vault.getRow(app, entityId, accountId);
435
+ } catch {
436
+ continue; // transient network error: leave quarantined, retry next cycle
437
+ }
438
+ if (R == null) {
439
+ removeFromQuarantine(entityId); // gone from server: nothing to recover
440
+ continue;
441
+ }
442
+ try {
443
+ const didApply = await applyRemoteRow(R, dirty);
444
+ removeFromQuarantine(entityId);
445
+ if (didApply) healed += 1;
446
+ } catch {
447
+ // Still undecryptable under the current key: leave quarantined.
448
+ }
449
+ }
450
+ return healed;
451
+ };
452
+
258
453
  // ── Step 2: pull remote changes ───────────────────────────────────────────
259
454
  // Paginates from the high water mark, applying each row with entity-grain LWW.
260
- // Rows that are also locally dirty are resolved against the dirty local copy
261
- // (contended path): remote wins only if its lastModified is newer, and when it
262
- // does win the entity is dropped from the dirty set so we do not re-push a
263
- // superseded version.
455
+ // Part A's key verification has already run (via ensureRootKey), so a globally
456
+ // wrong key has aborted with KEY_MISMATCH before we get here; any decrypt
457
+ // failure now is an INDIVIDUAL bad row. Such a row is counted, quarantined,
458
+ // and its cursor advanced past — it must never throw and wedge the cycle.
264
459
  const pullRemoteChanges = async () => {
460
+ await ensureRootKey();
461
+
265
462
  let since = getHighWaterMark();
266
463
  let maxSeq = since;
267
464
  let appliedRemote = false;
465
+ let applied = 0;
466
+ let skipped = 0;
467
+ const skippedEntityIds = [];
468
+
469
+ // Re-attempt previously quarantined rows first; a snapshot dirty set keeps
470
+ // contended-path resolution consistent across heal + page application.
471
+ const dirty = new Set(getDirtySet());
472
+ applied += await healQuarantine(dirty);
268
473
 
269
474
  let hasMore = true;
270
475
  while (hasMore) {
271
476
  const { rows, hasMore: more } = await vault.list(app, { accountId, since });
272
477
  if (!Array.isArray(rows) || rows.length === 0) break;
273
478
 
274
- // Snapshot the dirty set once per page for contended-path resolution.
275
- const dirty = new Set(getDirtySet());
276
-
277
479
  for (const R of rows) {
278
480
  if (typeof R.seq === 'number' && R.seq > maxSeq) maxSeq = R.seq;
279
481
 
280
- if (R.deleted) {
281
- await applyRemoteDelete(R.entityId);
282
- if (dirty.delete(R.entityId)) writeDirtySet([...dirty]);
283
- appliedRemote = true;
284
- continue;
285
- }
286
-
287
- await ensureRootKey();
288
- const remoteEntity = await decryptEntity(R.envelope, R.entityId);
289
- const local = await getLocalEntity(R.entityId);
290
-
291
- if (local == null) {
292
- await applyRemoteEntity(R.entityId, remoteEntity);
293
- appliedRemote = true;
294
- } else if (insertOnly(remoteEntity, R.entityId)) {
295
- // Insert-only types never conflict: applying is an idempotent union.
296
- await applyRemoteEntity(R.entityId, remoteEntity);
297
- appliedRemote = true;
298
- } else {
299
- // Entity-grain last-writer-wins. The same comparison covers both the
300
- // clean case and the contended (locally dirty) case.
301
- const remoteWins = ts(lastModifiedOf(remoteEntity)) > ts(lastModifiedOf(local));
302
- if (remoteWins) {
303
- await applyRemoteEntity(R.entityId, remoteEntity);
304
- appliedRemote = true;
305
- if (dirty.delete(R.entityId)) writeDirtySet([...dirty]);
482
+ // Engine-reserved rows (e.g. the key verifier) are not app entities and
483
+ // must never be routed to applyRemoteEntity. Advance past them.
484
+ if (!isReservedEntityId(R.entityId)) {
485
+ try {
486
+ const didApply = await applyRemoteRow(R, dirty);
487
+ if (didApply) { appliedRemote = true; applied += 1; }
488
+ } catch {
489
+ // Per-row decrypt failure: quarantine, count, advance, continue.
490
+ skipped += 1;
491
+ skippedEntityIds.push(R.entityId);
492
+ addToQuarantine(R.entityId, R.seq);
306
493
  }
307
- // else: local is newer or equal, so keep local and discard remote. If the
308
- // entity is dirty it stays dirty and will be re-pushed next cycle.
309
494
  }
310
495
 
311
496
  if (typeof R.seq === 'number') since = Math.max(since, R.seq);
@@ -317,7 +502,7 @@ export const createDbSyncEngine = (config) => {
317
502
  // Pull is the sole writer of the pull cursor. Advance it (monotonically)
318
503
  // from the highest seq we actually listed this run.
319
504
  setHighWaterMark(Math.max(getHighWaterMark(), maxSeq));
320
- return { maxSeq, appliedRemote };
505
+ return { maxSeq, appliedRemote, applied, skipped, skippedEntityIds };
321
506
  };
322
507
 
323
508
  // ── Step 3: update device cursor ──────────────────────────────────────────
@@ -350,20 +535,29 @@ export const createDbSyncEngine = (config) => {
350
535
  onStatusChange?.('uploading');
351
536
  onError?.(null, null, false);
352
537
  try {
538
+ // Part A first: prove the key before we push anything. A globally wrong
539
+ // key aborts here with KEY_MISMATCH (push gated, nothing uploaded) rather
540
+ // than quarantining thousands of rows one by one on the pull.
541
+ await ensureRootKey();
542
+
353
543
  await pushDirtyRows();
354
544
  onStatusChange?.('downloading');
355
- await pullRemoteChanges();
545
+ const pull = await pullRemoteChanges();
356
546
  await updateDeviceCursor();
357
547
 
358
548
  const now = new Date().toISOString();
359
549
  localStorage.setItem(KEY_LAST_SYNCED, now);
360
550
  onStatusChange?.('success');
551
+
552
+ if (pull.skipped > 0) onRowsSkipped?.(pull.skipped, pull.skippedEntityIds);
553
+ return { applied: pull.applied, skipped: pull.skipped, skippedEntityIds: pull.skippedEntityIds };
361
554
  } catch (err) {
362
555
  // eslint-disable-next-line no-console
363
556
  console.error(`[${appId}] db sync cycle error:`, err);
364
557
  const code = err && err.code ? err.code : 'NETWORK_ERROR';
365
558
  onError?.(err?.message || String(err), code, false);
366
559
  onStatusChange?.('error');
560
+ return { applied: 0, skipped: 0, skippedEntityIds: [] };
367
561
  } finally {
368
562
  syncing = false;
369
563
  }
@@ -386,6 +580,9 @@ export const createDbSyncEngine = (config) => {
386
580
  getDirtySet,
387
581
  clearDirty,
388
582
 
583
+ // Quarantine (per-row decrypt failures awaiting self-heal)
584
+ getQuarantine,
585
+
389
586
  // Cursor (pull-progress) and push-ack marker
390
587
  getHighWaterMark,
391
588
  setHighWaterMark,
@@ -399,6 +596,7 @@ export const createDbSyncEngine = (config) => {
399
596
 
400
597
  // State queries
401
598
  isSyncing: () => syncing,
599
+ isKeyVerified: () => verified,
402
600
  hasEncryptionReady: hasDbRootKey,
403
601
 
404
602
  // 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,15 @@ 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'
25
+ // DB transport: the server can't host the key verifier (doesn't support the
26
+ // reserved __glance_keycheck id or the single-row endpoint).
27
+ | 'VERIFIER_UNSUPPORTED';
20
28
 
21
29
  export type SyncStatus = 'idle' | 'uploading' | 'downloading' | 'success' | 'error';
22
30
 
@@ -366,6 +374,14 @@ export function hasDbRootKey(): boolean;
366
374
  export function encryptEntity(entity: unknown, entityId: string, rootKey?: CryptoKey): Promise<string>;
367
375
  export function decryptEntity<T = unknown>(ciphertext: string, entityId: string, rootKey?: CryptoKey): Promise<T>;
368
376
 
377
+ // Engine-reserved entities. Rows whose entityId starts with RESERVED_ENTITY_PREFIX
378
+ // are owned by the sync engine (e.g. the key verifier) and are never routed to
379
+ // getLocalEntity / applyRemoteEntity.
380
+ export const RESERVED_ENTITY_PREFIX: string;
381
+ export const KEYCHECK_ENTITY_ID: string;
382
+ export const KEYCHECK_PAYLOAD: { v: number; magic: string };
383
+ export function isReservedEntityId(entityId: string): boolean;
384
+
369
385
  // Row exchanged with GLANCEvault. envelope is base64(IV || AES-GCM output).
370
386
  export interface VaultRow {
371
387
  entityId: string;
@@ -425,21 +441,45 @@ export interface DbSyncEngineConfig {
425
441
 
426
442
  // Event callbacks
427
443
  onStatusChange?: (status: SyncStatus, hints?: { from?: SyncStatus }) => void;
444
+ // Called with code === 'KEY_MISMATCH' when the derived key doesn't match the
445
+ // account, or 'VERIFIER_UNSUPPORTED' when the server can't host the verifier.
428
446
  onError?: (message: string | null, code: SyncErrorCode | null, isHardStop: boolean) => void;
447
+ // Called once per cycle that skipped > 0 undecryptable rows (per-row quarantine).
448
+ onRowsSkipped?: (count: number, entityIds: string[]) => void;
449
+
450
+ // Operator escape hatch: downgrade VERIFIER_UNSUPPORTED to a logged warning
451
+ // and proceed without verification (unsafe — a wrong-key device could push
452
+ // poison rows). Off by default so the safe behavior is the default.
453
+ allowUnverified?: boolean;
454
+ }
455
+
456
+ /** Result of a DB sync cycle / pull: how many rows applied vs. quarantined. */
457
+ export interface DbSyncResult {
458
+ applied: number;
459
+ skipped: number;
460
+ skippedEntityIds: string[];
461
+ }
462
+
463
+ /** A row quarantined after failing to decrypt under the (verified) account key. */
464
+ export interface QuarantinedRow {
465
+ entityId: string;
466
+ seq: number;
429
467
  }
430
468
 
431
469
  export interface DbSyncEngine {
432
470
  transportMode: 'database';
433
- sync(): Promise<void>;
434
- dbSyncCycle(): Promise<void>;
471
+ sync(): Promise<DbSyncResult>;
472
+ dbSyncCycle(): Promise<DbSyncResult>;
435
473
  pushDirtyRows(): Promise<{ written: number; deleted: number; maxSeq: number }>;
436
- pullRemoteChanges(): Promise<{ maxSeq: number; appliedRemote: boolean }>;
474
+ pullRemoteChanges(): Promise<DbSyncResult & { maxSeq: number; appliedRemote: boolean }>;
437
475
  updateDeviceCursor(): Promise<{ updated: boolean }>;
438
476
  ensureRootKey(): Promise<void>;
439
477
 
440
478
  markDirty(entityId: string): void;
441
479
  getDirtySet(): string[];
442
480
  clearDirty(): void;
481
+ /** Rows quarantined after a per-row decrypt failure, awaiting self-heal. */
482
+ getQuarantine(): QuarantinedRow[];
443
483
 
444
484
  /** Pull cursor: highest seq actually listed + applied. Only pull advances it. */
445
485
  getHighWaterMark(): number;
@@ -453,6 +493,8 @@ export interface DbSyncEngine {
453
493
  getLastSynced(): string | null;
454
494
 
455
495
  isSyncing(): boolean;
496
+ /** True once the derived root key has been proven against the account (Part A). */
497
+ isKeyVerified(): boolean;
456
498
  hasEncryptionReady(): boolean;
457
499
 
458
500
  vault: VaultClient;