@glance-apps/sync 1.5.0 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@ 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
+
10
36
  ## [1.5.0] - 2026-06-19
11
37
 
12
38
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/dbEngine.js CHANGED
@@ -61,8 +61,9 @@ const ts = (v) => {
61
61
  *
62
62
  * Event callbacks (optional):
63
63
  * @param {Function} [config.onStatusChange]
64
- * @param {Function} [config.onError] - (message, code, isHardStop) called with code 'KEY_MISMATCH' on a wrong key
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
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)
66
67
  */
67
68
  export const createDbSyncEngine = (config) => {
68
69
  const {
@@ -85,6 +86,7 @@ export const createDbSyncEngine = (config) => {
85
86
  onStatusChange,
86
87
  onError,
87
88
  onRowsSkipped,
89
+ allowUnverified = false,
88
90
  } = config;
89
91
 
90
92
  if (!storageKeyPrefix) throw new Error('createDbSyncEngine: storageKeyPrefix is required');
@@ -233,15 +235,44 @@ export const createDbSyncEngine = (config) => {
233
235
  // The signal is decryptability: AES-GCM tag validation means a wrong key
234
236
  // simply fails, so the verifier payload's contents need not match. Runs once
235
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
+
236
258
  const verifyAccountKey = async () => {
237
259
  if (verified) return;
238
260
  // Verification needs a single-row fetch. Clients that don't implement getRow
239
261
  // (e.g. minimal test stubs) cannot be verified; don't block sync on them.
240
262
  if (typeof vault.getRow !== 'function') { verified = true; return; }
241
263
 
242
- const row = await vault.getRow(app, KEYCHECK_ENTITY_ID, accountId);
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
+
243
274
  if (row && !row.deleted && row.envelope) {
244
- // Existing account: the verifier must decrypt under our derived key.
275
+ // Outcome 1: existing account — the verifier must decrypt under our key.
245
276
  try {
246
277
  await decryptEntity(row.envelope, KEYCHECK_ENTITY_ID);
247
278
  verified = true;
@@ -251,15 +282,22 @@ export const createDbSyncEngine = (config) => {
251
282
  throw err;
252
283
  }
253
284
  } 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.
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.
258
289
  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
- });
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
+ }
263
301
  verified = true;
264
302
  }
265
303
  };
package/types/index.d.ts CHANGED
@@ -21,7 +21,10 @@ export type SyncErrorCode =
21
21
  | 'KEY_MISMATCH'
22
22
  // DB transport: a single row failed to decrypt (surfaced as a count via
23
23
  // onRowsSkipped, never thrown).
24
- | 'ROW_DECRYPT_FAILED';
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';
25
28
 
26
29
  export type SyncStatus = 'idle' | 'uploading' | 'downloading' | 'success' | 'error';
27
30
 
@@ -438,10 +441,16 @@ export interface DbSyncEngineConfig {
438
441
 
439
442
  // Event callbacks
440
443
  onStatusChange?: (status: SyncStatus, hints?: { from?: SyncStatus }) => void;
441
- // Called with code === 'KEY_MISMATCH' when the derived key doesn't match the account.
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.
442
446
  onError?: (message: string | null, code: SyncErrorCode | null, isHardStop: boolean) => void;
443
447
  // Called once per cycle that skipped > 0 undecryptable rows (per-row quarantine).
444
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;
445
454
  }
446
455
 
447
456
  /** Result of a DB sync cycle / pull: how many rows applied vs. quarantined. */