@glance-apps/sync 1.4.0 → 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,37 @@ 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
+
10
41
  ## [1.4.0] - 2026-06-18
11
42
 
12
43
  ### 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.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');
@@ -117,9 +122,17 @@ export const createDbSyncEngine = (config) => {
117
122
  const KEY_PUSH_ACK = `${storageKeyPrefix}-db-sync-push-ack`;
118
123
  const KEY_DIRTY = `${storageKeyPrefix}-db-sync-dirty`;
119
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`;
120
130
 
121
- // ── In-memory guard ───────────────────────────────────────────────────────
131
+ // ── In-memory guards ──────────────────────────────────────────────────────
122
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;
123
136
 
124
137
  // ── Local state accessors ─────────────────────────────────────────────────
125
138
  const getConfig = () => {
@@ -172,6 +185,32 @@ export const createDbSyncEngine = (config) => {
172
185
  };
173
186
  const clearDirty = () => localStorage.removeItem(KEY_DIRTY);
174
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
+
175
214
  // markDirty: called by the app on every local write (and at creation time for
176
215
  // insert-only types). Idempotent and synchronous so it can run inside the
177
216
  // app's own write path.
@@ -184,28 +223,72 @@ export const createDbSyncEngine = (config) => {
184
223
  writeDirtySet(current);
185
224
  };
186
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
+
187
267
  // ── Root key / salt bootstrap ─────────────────────────────────────────────
188
268
  // Ensures the per-account root key is available, fetching or registering the
189
- // salt with the vault on first use.
269
+ // salt with the vault on first use, then verifies it once per session.
190
270
  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
- }
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
+ }
200
278
 
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);
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);
207
287
  }
208
- 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();
209
292
  };
210
293
 
211
294
  // ── Step 1: push dirty rows ───────────────────────────────────────────────
@@ -216,6 +299,11 @@ export const createDbSyncEngine = (config) => {
216
299
  // idempotent re-send. Push deliberately does NOT touch the pull cursor
217
300
  // (getHighWaterMark): the server assigns pushed rows the highest seqs, so
218
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.
219
307
  const pushDirtyRows = async () => {
220
308
  const dirty = getDirtySet();
221
309
  if (dirty.length === 0) return { written: 0, deleted: 0, maxSeq: getPushAck() };
@@ -255,57 +343,116 @@ export const createDbSyncEngine = (config) => {
255
343
  return { written: upserts.length, deleted: deletes.length, maxSeq };
256
344
  };
257
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
+
258
415
  // ── Step 2: pull remote changes ───────────────────────────────────────────
259
416
  // 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.
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.
264
421
  const pullRemoteChanges = async () => {
422
+ await ensureRootKey();
423
+
265
424
  let since = getHighWaterMark();
266
425
  let maxSeq = since;
267
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);
268
435
 
269
436
  let hasMore = true;
270
437
  while (hasMore) {
271
438
  const { rows, hasMore: more } = await vault.list(app, { accountId, since });
272
439
  if (!Array.isArray(rows) || rows.length === 0) break;
273
440
 
274
- // Snapshot the dirty set once per page for contended-path resolution.
275
- const dirty = new Set(getDirtySet());
276
-
277
441
  for (const R of rows) {
278
442
  if (typeof R.seq === 'number' && R.seq > maxSeq) maxSeq = R.seq;
279
443
 
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]);
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);
306
455
  }
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
456
  }
310
457
 
311
458
  if (typeof R.seq === 'number') since = Math.max(since, R.seq);
@@ -317,7 +464,7 @@ export const createDbSyncEngine = (config) => {
317
464
  // Pull is the sole writer of the pull cursor. Advance it (monotonically)
318
465
  // from the highest seq we actually listed this run.
319
466
  setHighWaterMark(Math.max(getHighWaterMark(), maxSeq));
320
- return { maxSeq, appliedRemote };
467
+ return { maxSeq, appliedRemote, applied, skipped, skippedEntityIds };
321
468
  };
322
469
 
323
470
  // ── Step 3: update device cursor ──────────────────────────────────────────
@@ -350,20 +497,29 @@ export const createDbSyncEngine = (config) => {
350
497
  onStatusChange?.('uploading');
351
498
  onError?.(null, null, false);
352
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
+
353
505
  await pushDirtyRows();
354
506
  onStatusChange?.('downloading');
355
- await pullRemoteChanges();
507
+ const pull = await pullRemoteChanges();
356
508
  await updateDeviceCursor();
357
509
 
358
510
  const now = new Date().toISOString();
359
511
  localStorage.setItem(KEY_LAST_SYNCED, now);
360
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 };
361
516
  } catch (err) {
362
517
  // eslint-disable-next-line no-console
363
518
  console.error(`[${appId}] db sync cycle error:`, err);
364
519
  const code = err && err.code ? err.code : 'NETWORK_ERROR';
365
520
  onError?.(err?.message || String(err), code, false);
366
521
  onStatusChange?.('error');
522
+ return { applied: 0, skipped: 0, skippedEntityIds: [] };
367
523
  } finally {
368
524
  syncing = false;
369
525
  }
@@ -386,6 +542,9 @@ export const createDbSyncEngine = (config) => {
386
542
  getDirtySet,
387
543
  clearDirty,
388
544
 
545
+ // Quarantine (per-row decrypt failures awaiting self-heal)
546
+ getQuarantine,
547
+
389
548
  // Cursor (pull-progress) and push-ack marker
390
549
  getHighWaterMark,
391
550
  setHighWaterMark,
@@ -399,6 +558,7 @@ export const createDbSyncEngine = (config) => {
399
558
 
400
559
  // State queries
401
560
  isSyncing: () => syncing,
561
+ isKeyVerified: () => verified,
402
562
  hasEncryptionReady: hasDbRootKey,
403
563
 
404
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,21 +438,39 @@ 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
 
444
475
  /** Pull cursor: highest seq actually listed + applied. Only pull advances it. */
445
476
  getHighWaterMark(): number;
@@ -453,6 +484,8 @@ export interface DbSyncEngine {
453
484
  getLastSynced(): string | null;
454
485
 
455
486
  isSyncing(): boolean;
487
+ /** True once the derived root key has been proven against the account (Part A). */
488
+ isKeyVerified(): boolean;
456
489
  hasEncryptionReady(): boolean;
457
490
 
458
491
  vault: VaultClient;