@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 +52 -0
- package/package.json +1 -1
- package/src/dbCrypto.js +24 -1
- package/src/dbEngine.js +261 -65
- package/src/index.js +4 -0
- package/types/index.d.ts +41 -4
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
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');
|
|
@@ -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
|
|
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())
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
|
191
|
-
// so a network failure leaves un-acked rows dirty for an
|
|
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:
|
|
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
|
|
212
|
-
let maxSeq =
|
|
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
|
-
|
|
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
|
-
//
|
|
232
|
-
//
|
|
233
|
-
//
|
|
234
|
-
//
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
//
|
|
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
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<
|
|
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
|
|
|
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;
|