@glance-apps/sync 1.5.0 → 1.5.2
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 +53 -0
- package/package.json +1 -1
- package/src/dbEngine.js +63 -12
- package/src/vaultClient.js +19 -0
- package/types/index.d.ts +14 -2
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,59 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.5.2] - 2026-06-19
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Key verifier no longer fails with a cryptic `get row failed: 400`.** The
|
|
15
|
+
verifier's single-row GET (`GET /sync/{app}/__glance_keycheck`) is scoped by
|
|
16
|
+
`accountId`, which GLANCEvault requires as a query param on every row-scoped
|
|
17
|
+
endpoint. When the verifier ran with an `accountId` that was empty or
|
|
18
|
+
whitespace — e.g. fired during reconstruction before the account id was
|
|
19
|
+
populated — the request went out as `?accountId=` (or `?accountId=+++`), which
|
|
20
|
+
the server correctly rejects with `400 {"error":"accountId is required"}`,
|
|
21
|
+
aborting enable. The default `createVaultClient` now validates `accountId` on
|
|
22
|
+
every row-scoped call (`batch`, `list`, single-row GET, `DELETE`, `device`) and
|
|
23
|
+
throws a clear, typed, retryable `ACCOUNT_ID_REQUIRED` instead of putting a
|
|
24
|
+
malformed `accountId` on the wire — so the next cycle (once the id is loaded)
|
|
25
|
+
succeeds. `createDbSyncEngine` also rejects an empty/whitespace `accountId` at
|
|
26
|
+
construction (a whitespace-only id previously passed the plain falsy check).
|
|
27
|
+
The verifier path surfaces `ACCOUNT_ID_REQUIRED` (and `PASSPHRASE_REQUIRED`)
|
|
28
|
+
with their own code rather than mislabeling them `VERIFIER_UNSUPPORTED`. The
|
|
29
|
+
verifier GET correctly carries `?accountId=...` (like the working `list` call)
|
|
30
|
+
and, on a fresh account, 404 → writes the verifier via `batch` with
|
|
31
|
+
`accountId` + `insertOnly: true`.
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- `ACCOUNT_ID_REQUIRED` standardized on the `SyncErrorCode` type.
|
|
36
|
+
|
|
37
|
+
## [1.5.1] - 2026-06-19
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- **Key verification fails clearly when the server can't host the verifier.**
|
|
42
|
+
1.5.0's `verifyAccountKey` did `GET /sync/{app}/__glance_keycheck`; against a
|
|
43
|
+
server that doesn't yet support the reserved id or the single-row GET, that
|
|
44
|
+
threw a raw `get row failed: 400` and aborted the whole sync with a meaningless
|
|
45
|
+
message. `verifyAccountKey` now distinguishes three outcomes: a
|
|
46
|
+
present-but-undecryptable verifier still throws `KEY_MISMATCH`; an absent
|
|
47
|
+
verifier (404) is still written; but any *other* error from the single-row GET
|
|
48
|
+
or the verifier write (HTTP 400/405/500, or a 404 on the route itself) now
|
|
49
|
+
throws a typed `VERIFIER_UNSUPPORTED` with an actionable "update your server"
|
|
50
|
+
message. The engine never silently proceeds unverified (the verifier gates push
|
|
51
|
+
and quarantine does not, so a wrong-key device with verification skipped would
|
|
52
|
+
still push poison rows), so sync pauses with a clear reason. Surfaced via
|
|
53
|
+
`config.onError(message, code)` like `KEY_MISMATCH`.
|
|
54
|
+
|
|
55
|
+
### Added
|
|
56
|
+
|
|
57
|
+
- `config.allowUnverified` (default `false`): an opt-in operator escape hatch
|
|
58
|
+
that downgrades `VERIFIER_UNSUPPORTED` to a logged warning and proceeds, for
|
|
59
|
+
operators who knowingly accept the risk of syncing against a server that can't
|
|
60
|
+
host the verifier.
|
|
61
|
+
- `VERIFIER_UNSUPPORTED` standardized on the `SyncErrorCode` type.
|
|
62
|
+
|
|
10
63
|
## [1.5.0] - 2026-06-19
|
|
11
64
|
|
|
12
65
|
### Added
|
package/package.json
CHANGED
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,12 +86,19 @@ 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');
|
|
91
93
|
if (!config.vaultClient && !vaultUrl) throw new Error('createDbSyncEngine: vaultUrl is required');
|
|
92
94
|
if (!config.vaultClient && !vaultToken) throw new Error('createDbSyncEngine: vaultToken is required');
|
|
93
|
-
if (
|
|
95
|
+
if (typeof accountId !== 'string' || accountId.trim() === '') {
|
|
96
|
+
// Reject empty/whitespace too: a whitespace-only id passes a plain falsy
|
|
97
|
+
// check but serialises to `?accountId=+++`, which the server trims to empty
|
|
98
|
+
// and rejects with a cryptic 400 on every row-scoped call (incl. the key
|
|
99
|
+
// verifier's single-row GET).
|
|
100
|
+
throw new Error('createDbSyncEngine: accountId is required');
|
|
101
|
+
}
|
|
94
102
|
if (typeof getLocalEntity !== 'function') throw new Error('createDbSyncEngine: getLocalEntity is required');
|
|
95
103
|
if (typeof applyRemoteEntity !== 'function') throw new Error('createDbSyncEngine: applyRemoteEntity is required');
|
|
96
104
|
if (typeof applyRemoteDelete !== 'function') throw new Error('createDbSyncEngine: applyRemoteDelete is required');
|
|
@@ -233,15 +241,51 @@ export const createDbSyncEngine = (config) => {
|
|
|
233
241
|
// The signal is decryptability: AES-GCM tag validation means a wrong key
|
|
234
242
|
// simply fails, so the verifier payload's contents need not match. Runs once
|
|
235
243
|
// per session (cached via `verified`).
|
|
244
|
+
// A server that can't host the verifier (doesn't support the reserved id or
|
|
245
|
+
// the single-row GET endpoint) must fail with a CLEAR reason, not a raw
|
|
246
|
+
// "get row failed: 400". We never silently proceed unverified: the verifier
|
|
247
|
+
// gates push and quarantine does NOT, so a wrong-key device with verification
|
|
248
|
+
// skipped would still push poison rows. Pausing sync with a clear reason is
|
|
249
|
+
// the correct degradation. (config.allowUnverified is an explicit operator
|
|
250
|
+
// escape hatch, off by default, that downgrades this to a logged warning.)
|
|
251
|
+
// Client-readiness failures (no passphrase yet, or accountId not populated)
|
|
252
|
+
// are NOT a server problem: re-throw them as-is so they surface with their own
|
|
253
|
+
// typed, retryable code instead of being mislabeled "update your server".
|
|
254
|
+
const isClientNotReady = (err) =>
|
|
255
|
+
err && (err.code === 'ACCOUNT_ID_REQUIRED' || err.code === 'PASSPHRASE_REQUIRED');
|
|
256
|
+
|
|
257
|
+
const verifierUnsupported = (cause) => {
|
|
258
|
+
if (isClientNotReady(cause)) throw cause;
|
|
259
|
+
if (allowUnverified) {
|
|
260
|
+
// eslint-disable-next-line no-console
|
|
261
|
+
console.warn(`[${appId}] db sync proceeding UNVERIFIED: the server cannot host the key verifier (${KEYCHECK_ENTITY_ID}).`, cause);
|
|
262
|
+
verified = true;
|
|
263
|
+
return; // caller returns from verifyAccountKey and sync proceeds
|
|
264
|
+
}
|
|
265
|
+
const err = new Error('Your GLANCEvault server needs to be updated to support key verification (the __glance_keycheck row).');
|
|
266
|
+
err.code = 'VERIFIER_UNSUPPORTED';
|
|
267
|
+
if (cause) err.cause = cause;
|
|
268
|
+
throw err;
|
|
269
|
+
};
|
|
270
|
+
|
|
236
271
|
const verifyAccountKey = async () => {
|
|
237
272
|
if (verified) return;
|
|
238
273
|
// Verification needs a single-row fetch. Clients that don't implement getRow
|
|
239
274
|
// (e.g. minimal test stubs) cannot be verified; don't block sync on them.
|
|
240
275
|
if (typeof vault.getRow !== 'function') { verified = true; return; }
|
|
241
276
|
|
|
242
|
-
|
|
277
|
+
// Outcome 3: any error from the GET (HTTP 400/405/500, or a 404 on the route
|
|
278
|
+
// itself) means the server can't host the verifier — fail clearly.
|
|
279
|
+
let row;
|
|
280
|
+
try {
|
|
281
|
+
row = await vault.getRow(app, KEYCHECK_ENTITY_ID, accountId);
|
|
282
|
+
} catch (cause) {
|
|
283
|
+
verifierUnsupported(cause);
|
|
284
|
+
return; // only reached when downgraded via allowUnverified
|
|
285
|
+
}
|
|
286
|
+
|
|
243
287
|
if (row && !row.deleted && row.envelope) {
|
|
244
|
-
//
|
|
288
|
+
// Outcome 1: existing account — the verifier must decrypt under our key.
|
|
245
289
|
try {
|
|
246
290
|
await decryptEntity(row.envelope, KEYCHECK_ENTITY_ID);
|
|
247
291
|
verified = true;
|
|
@@ -251,15 +295,22 @@ export const createDbSyncEngine = (config) => {
|
|
|
251
295
|
throw err;
|
|
252
296
|
}
|
|
253
297
|
} else {
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
// verifiers are mutually decryptable
|
|
298
|
+
// Outcome 2: row absent (404 -> getRow returned null) — new account.
|
|
299
|
+
// Establish the verifier insert-only / first-write-wins so concurrent
|
|
300
|
+
// establishers don't clobber; since the salt is already first-write-wins,
|
|
301
|
+
// they derive the same key and their verifiers are mutually decryptable.
|
|
258
302
|
const envelope = await encryptEntity(KEYCHECK_PAYLOAD, KEYCHECK_ENTITY_ID);
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
303
|
+
try {
|
|
304
|
+
await vault.batch(app, {
|
|
305
|
+
accountId,
|
|
306
|
+
rows: [{ entityId: KEYCHECK_ENTITY_ID, envelope, createdAt: Date.now(), insertOnly: true }],
|
|
307
|
+
});
|
|
308
|
+
} catch (cause) {
|
|
309
|
+
// The write itself rejecting the reserved id (400/405/…) is the same
|
|
310
|
+
// "server can't host the verifier" condition.
|
|
311
|
+
verifierUnsupported(cause);
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
263
314
|
verified = true;
|
|
264
315
|
}
|
|
265
316
|
};
|
package/src/vaultClient.js
CHANGED
|
@@ -73,12 +73,27 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
|
|
|
73
73
|
return res.json();
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
+
// accountId scopes every row-scoped endpoint (batch, list, single-row GET,
|
|
77
|
+
// DELETE, device). The server has no default and rejects a missing/empty value
|
|
78
|
+
// with a cryptic 400 {"error":"accountId is required"}. Validate it here so a
|
|
79
|
+
// caller that fires before the account id is populated (e.g. the key verifier
|
|
80
|
+
// running during reconstruction) fails with a clear, typed, retryable error
|
|
81
|
+
// and we never put a malformed `?accountId=` on the wire.
|
|
82
|
+
const requireAccountId = (accountId, context) => {
|
|
83
|
+
if (typeof accountId !== 'string' || accountId.trim() === '') {
|
|
84
|
+
const err = new Error(`${context}: accountId is required but was missing or empty.`);
|
|
85
|
+
err.code = 'ACCOUNT_ID_REQUIRED';
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
76
90
|
return {
|
|
77
91
|
/**
|
|
78
92
|
* Upserts a batch of rows. Each row is { entityId, envelope, createdAt }.
|
|
79
93
|
* @returns {Promise<{ written: number, maxSeq: number }>}
|
|
80
94
|
*/
|
|
81
95
|
async batch(app, { accountId, rows }) {
|
|
96
|
+
requireAccountId(accountId, 'batch upsert');
|
|
82
97
|
const res = await request('POST', `/sync/${encodeURIComponent(app)}/batch`, {
|
|
83
98
|
body: { accountId, rows },
|
|
84
99
|
});
|
|
@@ -90,6 +105,7 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
|
|
|
90
105
|
* @returns {Promise<{ rows: Array, hasMore: boolean }>}
|
|
91
106
|
*/
|
|
92
107
|
async list(app, { accountId, since }) {
|
|
108
|
+
requireAccountId(accountId, 'list');
|
|
93
109
|
const res = await request('GET', `/sync/${encodeURIComponent(app)}/list`, {
|
|
94
110
|
query: { accountId, since: String(since) },
|
|
95
111
|
});
|
|
@@ -100,6 +116,7 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
|
|
|
100
116
|
* Fetches a single row by entityId. Returns null on 404.
|
|
101
117
|
*/
|
|
102
118
|
async getRow(app, entityId, accountId) {
|
|
119
|
+
requireAccountId(accountId, 'get row');
|
|
103
120
|
const res = await request('GET', `/sync/${encodeURIComponent(app)}/${encodeURIComponent(entityId)}`, {
|
|
104
121
|
query: { accountId },
|
|
105
122
|
});
|
|
@@ -112,6 +129,7 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
|
|
|
112
129
|
* the new seq for the tombstone).
|
|
113
130
|
*/
|
|
114
131
|
async deleteRow(app, entityId, accountId) {
|
|
132
|
+
requireAccountId(accountId, 'delete row');
|
|
115
133
|
const res = await request('DELETE', `/sync/${encodeURIComponent(app)}/${encodeURIComponent(entityId)}`, {
|
|
116
134
|
query: { accountId },
|
|
117
135
|
});
|
|
@@ -125,6 +143,7 @@ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
|
|
|
125
143
|
* @returns {Promise<{ updated: boolean }>}
|
|
126
144
|
*/
|
|
127
145
|
async device(app, { accountId, deviceId, lastSeenSeq }) {
|
|
146
|
+
requireAccountId(accountId, 'device cursor');
|
|
128
147
|
const res = await request('POST', `/sync/${encodeURIComponent(app)}/device`, {
|
|
129
148
|
body: { accountId, deviceId, lastSeenSeq },
|
|
130
149
|
});
|
package/types/index.d.ts
CHANGED
|
@@ -21,7 +21,13 @@ 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'
|
|
28
|
+
// DB transport: a row-scoped call (incl. the key verifier) was made before the
|
|
29
|
+
// accountId was populated — retryable once the account id is available.
|
|
30
|
+
| 'ACCOUNT_ID_REQUIRED';
|
|
25
31
|
|
|
26
32
|
export type SyncStatus = 'idle' | 'uploading' | 'downloading' | 'success' | 'error';
|
|
27
33
|
|
|
@@ -438,10 +444,16 @@ export interface DbSyncEngineConfig {
|
|
|
438
444
|
|
|
439
445
|
// Event callbacks
|
|
440
446
|
onStatusChange?: (status: SyncStatus, hints?: { from?: SyncStatus }) => void;
|
|
441
|
-
// Called with code === 'KEY_MISMATCH' when the derived key doesn't match the
|
|
447
|
+
// Called with code === 'KEY_MISMATCH' when the derived key doesn't match the
|
|
448
|
+
// account, or 'VERIFIER_UNSUPPORTED' when the server can't host the verifier.
|
|
442
449
|
onError?: (message: string | null, code: SyncErrorCode | null, isHardStop: boolean) => void;
|
|
443
450
|
// Called once per cycle that skipped > 0 undecryptable rows (per-row quarantine).
|
|
444
451
|
onRowsSkipped?: (count: number, entityIds: string[]) => void;
|
|
452
|
+
|
|
453
|
+
// Operator escape hatch: downgrade VERIFIER_UNSUPPORTED to a logged warning
|
|
454
|
+
// and proceed without verification (unsafe — a wrong-key device could push
|
|
455
|
+
// poison rows). Off by default so the safe behavior is the default.
|
|
456
|
+
allowUnverified?: boolean;
|
|
445
457
|
}
|
|
446
458
|
|
|
447
459
|
/** Result of a DB sync cycle / pull: how many rows applied vs. quarantined. */
|