@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 +57 -0
- package/package.json +1 -1
- package/src/dbCrypto.js +24 -1
- package/src/dbEngine.js +254 -56
- package/src/index.js +4 -0
- package/types/index.d.ts +46 -4
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
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,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
|
|
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())
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
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
|
-
|
|
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]);
|
|
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
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<
|
|
434
|
-
dbSyncCycle(): Promise<
|
|
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;
|