@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 +31 -0
- package/package.json +1 -1
- package/src/dbCrypto.js +24 -1
- package/src/dbEngine.js +216 -56
- package/src/index.js +4 -0
- package/types/index.d.ts +37 -4
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
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
|
-
|
|
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
|
|
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())
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
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
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
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<
|
|
434
|
-
dbSyncCycle(): Promise<
|
|
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;
|