@glance-apps/sync 1.1.2 → 1.2.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 CHANGED
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [1.2.0] - 2026-06-13
11
+
12
+ ### Added
13
+
14
+ - **Phase 3: database (GLANCEvault) sync transport**, selected via
15
+ `transportMode: 'database'` in the engine config. The file-tier transport is
16
+ completely unchanged; file-tier users run the identical code path as before.
17
+ - `src/dbCrypto.js`: per-entity encryption. One PBKDF2 root key per account
18
+ (salt from the GLANCEvault salt endpoint), per-entity AES-256-GCM keys
19
+ HKDF-derived from it using the entityId as context, fresh IV per entity.
20
+ - `src/vaultClient.js`: Bearer-authenticated HTTP client for the vault batch,
21
+ list, get, delete, device, and salt endpoints, with an injectable fetch.
22
+ - `src/dbEngine.js`: row-grained engine with per-entity dirty tracking
23
+ (`markDirty`), seq-based incremental pull, entity-grain last-writer-wins,
24
+ insert-only union, partial-write safety, and a best-effort device cursor.
25
+ - New exports: `createDbSyncEngine`, `createVaultClient`, `setupDbRootKey`,
26
+ `initDbRootKey`, `clearDbRootKey`, `hasDbRootKey`, `encryptEntity`,
27
+ `decryptEntity`.
28
+ - See `docs/PHASE3_DB_TRANSPORT.md` for design decisions, the coordinated
29
+ GLANCEvault `POST /sync/:app/device` endpoint, and the Phase 4 cutover steps.
30
+
10
31
  ## [1.1.2] - 2026-06-04
11
32
 
12
33
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glance-apps/sync",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -0,0 +1,307 @@
1
+ // Per-entity encryption for the database sync transport (Phase 3).
2
+ //
3
+ // The file-tier transport (crypto.js) encrypts the entire entity array as one
4
+ // AES-GCM blob. The database transport instead encrypts one entity at a time so
5
+ // that each row on GLANCEvault is an independent ciphertext that can be fetched,
6
+ // upserted, and decrypted on its own.
7
+ //
8
+ // Crypto design (mirrors the per-envelope pattern in @glance-apps/intents):
9
+ // 1. A single root key is derived once from the passphrase with PBKDF2
10
+ // (SHA-256, 310 000 iterations) using a per-account salt fetched from the
11
+ // GLANCEvault salt endpoint. This matches the file-tier derivation cost.
12
+ // 2. Per entity, an AES-256-GCM key is HKDF-derived from the root key using
13
+ // the entityId as the HKDF info context. HKDF is cheap, so deriving a fresh
14
+ // key per row is fast (no extra PBKDF2 work per entity).
15
+ // 3. Each entity is encrypted with a fresh 12-byte IV. The stored ciphertext
16
+ // is base64(IV || AES-GCM output).
17
+ //
18
+ // This module is entirely additive: it does not import from, modify, or share
19
+ // mutable state with crypto.js beyond reading the shared session passphrase,
20
+ // which the engine wires up via getSyncPassphrase().
21
+
22
+ const PBKDF2_ITERATIONS = 310_000;
23
+ const ROOT_KEY_BYTES = 32; // 256 bits
24
+ const IV_BYTES = 12;
25
+
26
+ // HKDF info prefix. Namespacing the entityId keeps these derivations distinct
27
+ // from any other HKDF use of the same root key.
28
+ const HKDF_INFO_PREFIX = 'glance-sync:entity:';
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Session state (in-memory only, never persisted in plaintext)
32
+ // ---------------------------------------------------------------------------
33
+ let _rootKey = null; // CryptoKey | null (an HKDF base key, non-extractable)
34
+
35
+ export function hasDbRootKey() { return _rootKey !== null; }
36
+ export function getDbRootKey() { return _rootKey; }
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Byte helpers
40
+ // ---------------------------------------------------------------------------
41
+ function packBytes(...arrays) {
42
+ const total = arrays.reduce((n, a) => n + a.length, 0);
43
+ const out = new Uint8Array(total);
44
+ let offset = 0;
45
+ for (const a of arrays) { out.set(a, offset); offset += a.length; }
46
+ return out;
47
+ }
48
+
49
+ export function toBase64(bytes) {
50
+ let binary = '';
51
+ const chunk = 8192;
52
+ for (let i = 0; i < bytes.length; i += chunk) {
53
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
54
+ }
55
+ return btoa(binary);
56
+ }
57
+
58
+ export function fromBase64(b64) {
59
+ return Uint8Array.from(atob(b64), c => c.charCodeAt(0));
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Key derivation
64
+ // ---------------------------------------------------------------------------
65
+
66
+ // Derives the raw 32-byte root key from a passphrase and salt using the same
67
+ // PBKDF2 parameters as the file-tier crypto. Returned as raw bytes so the
68
+ // caller can persist them in device storage; HKDF base keys are non-extractable
69
+ // and cannot be re-exported once imported.
70
+ async function deriveRootBits(passphrase, salt) {
71
+ const enc = new TextEncoder();
72
+ const keyMaterial = await crypto.subtle.importKey(
73
+ 'raw', enc.encode(passphrase), 'PBKDF2', false, ['deriveBits']
74
+ );
75
+ const bits = await crypto.subtle.deriveBits(
76
+ { name: 'PBKDF2', salt, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' },
77
+ keyMaterial,
78
+ ROOT_KEY_BYTES * 8
79
+ );
80
+ return new Uint8Array(bits);
81
+ }
82
+
83
+ // Imports raw root bytes as a non-extractable HKDF base key usable only to
84
+ // derive per-entity keys.
85
+ async function importRootKey(rootBytes) {
86
+ return crypto.subtle.importKey('raw', rootBytes, 'HKDF', false, ['deriveKey']);
87
+ }
88
+
89
+ // HKDF-derives a fresh AES-256-GCM key from the root key, scoped to one entity.
90
+ async function deriveEntityKey(rootKey, entityId) {
91
+ const enc = new TextEncoder();
92
+ return crypto.subtle.deriveKey(
93
+ {
94
+ name: 'HKDF',
95
+ hash: 'SHA-256',
96
+ salt: new Uint8Array(0),
97
+ info: enc.encode(HKDF_INFO_PREFIX + entityId),
98
+ },
99
+ rootKey,
100
+ { name: 'AES-GCM', length: 256 },
101
+ false,
102
+ ['encrypt', 'decrypt']
103
+ );
104
+ }
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Root key lifecycle (storage parallels crypto.js but uses its own DB/record)
108
+ // ---------------------------------------------------------------------------
109
+ const KEY_DB_VERSION = 1;
110
+ const KEY_STORE = 'db-root-keys';
111
+ const ROOT_KEY_ID = 'db-root-key';
112
+
113
+ function openRootKeyDB(cryptoDBName) {
114
+ return new Promise((resolve, reject) => {
115
+ const req = indexedDB.open(`${cryptoDBName}-db`, KEY_DB_VERSION);
116
+ req.onupgradeneeded = (e) => {
117
+ const db = e.target.result;
118
+ if (!db.objectStoreNames.contains(KEY_STORE)) {
119
+ db.createObjectStore(KEY_STORE, { keyPath: 'id' });
120
+ }
121
+ };
122
+ req.onsuccess = () => resolve(req.result);
123
+ req.onerror = () => reject(req.error);
124
+ });
125
+ }
126
+
127
+ async function saveRootToIndexedDB(cryptoDBName, rootBytes, salt) {
128
+ const db = await openRootKeyDB(cryptoDBName);
129
+ return new Promise((resolve, reject) => {
130
+ const tx = db.transaction(KEY_STORE, 'readwrite');
131
+ tx.objectStore(KEY_STORE).put({
132
+ id: ROOT_KEY_ID,
133
+ rootBytes: Array.from(rootBytes),
134
+ salt: Array.from(salt),
135
+ });
136
+ tx.oncomplete = () => resolve();
137
+ tx.onerror = () => reject(tx.error);
138
+ });
139
+ }
140
+
141
+ async function loadRootFromIndexedDB(cryptoDBName) {
142
+ try {
143
+ const db = await openRootKeyDB(cryptoDBName);
144
+ const record = await new Promise((resolve, reject) => {
145
+ const tx = db.transaction(KEY_STORE, 'readonly');
146
+ const req = tx.objectStore(KEY_STORE).get(ROOT_KEY_ID);
147
+ req.onsuccess = () => resolve(req.result);
148
+ req.onerror = () => reject(req.error);
149
+ });
150
+ if (!record) return null;
151
+ return { rootBytes: new Uint8Array(record.rootBytes), salt: new Uint8Array(record.salt) };
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ async function clearRootFromIndexedDB(cryptoDBName) {
158
+ try {
159
+ const db = await openRootKeyDB(cryptoDBName);
160
+ await new Promise((resolve, reject) => {
161
+ const tx = db.transaction(KEY_STORE, 'readwrite');
162
+ tx.objectStore(KEY_STORE).delete(ROOT_KEY_ID);
163
+ tx.oncomplete = () => resolve();
164
+ tx.onerror = () => reject(tx.error);
165
+ });
166
+ } catch { /* ignore */ }
167
+ }
168
+
169
+ // Native bridge (Android Keystore) stores the same record as a base64 JSON blob.
170
+ async function loadRootFromNative(nativeGetSyncKey) {
171
+ try {
172
+ const b64 = await nativeGetSyncKey();
173
+ if (!b64) return null;
174
+ const record = JSON.parse(atob(b64));
175
+ return { rootBytes: new Uint8Array(record.rootBytes), salt: new Uint8Array(record.salt) };
176
+ } catch {
177
+ return null;
178
+ }
179
+ }
180
+
181
+ async function saveRootToNative(nativeStoreSyncKey, rootBytes, salt) {
182
+ if (!nativeStoreSyncKey) return;
183
+ const record = { rootBytes: Array.from(rootBytes), salt: Array.from(salt) };
184
+ nativeStoreSyncKey(btoa(JSON.stringify(record)));
185
+ }
186
+
187
+ async function clearRootFromNative(nativeStoreSyncKey) {
188
+ try { if (nativeStoreSyncKey) nativeStoreSyncKey(null); }
189
+ catch { /* ignore */ }
190
+ }
191
+
192
+ // ---------------------------------------------------------------------------
193
+ // Public lifecycle API
194
+ // ---------------------------------------------------------------------------
195
+
196
+ /**
197
+ * First-time setup: derive the root key from the passphrase + account salt and
198
+ * cache it in device storage. The salt is provided by the engine (fetched from
199
+ * or registered with the GLANCEvault salt endpoint).
200
+ *
201
+ * @param {string} passphrase
202
+ * @param {Uint8Array} salt
203
+ * @param {object} config
204
+ * @param {string} config.cryptoDBName
205
+ * @param {Function|null} [config.nativeGetSyncKey]
206
+ * @param {Function|null} [config.nativeStoreSyncKey]
207
+ */
208
+ export async function setupDbRootKey(passphrase, salt, { cryptoDBName, nativeGetSyncKey = null, nativeStoreSyncKey = null } = {}) {
209
+ const rootBytes = await deriveRootBits(passphrase, salt);
210
+ _rootKey = await importRootKey(rootBytes);
211
+ if (nativeGetSyncKey != null) {
212
+ await saveRootToNative(nativeStoreSyncKey, rootBytes, salt);
213
+ } else {
214
+ await saveRootToIndexedDB(cryptoDBName, rootBytes, salt);
215
+ }
216
+ }
217
+
218
+ /**
219
+ * Restores the root key from device storage without requiring the passphrase.
220
+ * Returns true if a key was restored, false if setup is still required.
221
+ *
222
+ * @param {object} config
223
+ * @param {string} config.cryptoDBName
224
+ * @param {Function|null} [config.nativeGetSyncKey]
225
+ * @returns {Promise<boolean>}
226
+ */
227
+ export async function initDbRootKey({ cryptoDBName, nativeGetSyncKey = null } = {}) {
228
+ try {
229
+ const result = nativeGetSyncKey != null
230
+ ? await loadRootFromNative(nativeGetSyncKey)
231
+ : await loadRootFromIndexedDB(cryptoDBName);
232
+ if (result) {
233
+ _rootKey = await importRootKey(result.rootBytes);
234
+ return true;
235
+ }
236
+ } catch { /* storage unavailable */ }
237
+ return false;
238
+ }
239
+
240
+ /**
241
+ * Clears the cached root key from device storage and session memory.
242
+ *
243
+ * @param {object} config
244
+ */
245
+ export async function clearDbRootKey({ cryptoDBName, nativeGetSyncKey = null, nativeStoreSyncKey = null } = {}) {
246
+ _rootKey = null;
247
+ if (nativeGetSyncKey != null) {
248
+ await clearRootFromNative(nativeStoreSyncKey);
249
+ } else {
250
+ await clearRootFromIndexedDB(cryptoDBName);
251
+ }
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Encrypt / decrypt a single entity
256
+ // ---------------------------------------------------------------------------
257
+
258
+ /**
259
+ * Encrypts one entity into a base64 ciphertext string for the row's
260
+ * `ciphertext` field. The output encodes [ 12-byte IV ][ AES-GCM ciphertext ].
261
+ *
262
+ * @param {*} entity - plain JS value to encrypt (serialised with JSON)
263
+ * @param {string} entityId - stable entity UUID, used as HKDF context
264
+ * @param {CryptoKey} [rootKey] - explicit root key (defaults to the session key)
265
+ * @returns {Promise<string>} base64 ciphertext
266
+ */
267
+ export async function encryptEntity(entity, entityId, rootKey = _rootKey) {
268
+ if (!rootKey) {
269
+ const err = new Error('Database root key not available. Please enter your sync passphrase.');
270
+ err.code = 'PASSPHRASE_REQUIRED';
271
+ throw err;
272
+ }
273
+ const key = await deriveEntityKey(rootKey, entityId);
274
+ const plaintext = new TextEncoder().encode(JSON.stringify(entity));
275
+ const iv = crypto.getRandomValues(new Uint8Array(IV_BYTES));
276
+ const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext);
277
+ return toBase64(packBytes(iv, new Uint8Array(ciphertext)));
278
+ }
279
+
280
+ /**
281
+ * Decrypts a base64 ciphertext produced by encryptEntity back into the entity.
282
+ *
283
+ * @param {string} ciphertextB64 - the row's `ciphertext` field
284
+ * @param {string} entityId - stable entity UUID, used as HKDF context
285
+ * @param {CryptoKey} [rootKey] - explicit root key (defaults to the session key)
286
+ * @returns {Promise<*>} the decrypted entity
287
+ */
288
+ export async function decryptEntity(ciphertextB64, entityId, rootKey = _rootKey) {
289
+ if (!rootKey) {
290
+ const err = new Error('Database root key not available. Please enter your sync passphrase.');
291
+ err.code = 'PASSPHRASE_REQUIRED';
292
+ throw err;
293
+ }
294
+ const packed = fromBase64(ciphertextB64);
295
+ const iv = packed.slice(0, IV_BYTES);
296
+ const ciphertext = packed.slice(IV_BYTES);
297
+ const key = await deriveEntityKey(rootKey, entityId);
298
+ try {
299
+ const plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ciphertext);
300
+ return JSON.parse(new TextDecoder().decode(plaintext));
301
+ } catch (err) {
302
+ if (err.name === 'OperationError') {
303
+ throw new Error('Decryption failed: wrong passphrase or corrupted data.');
304
+ }
305
+ throw err;
306
+ }
307
+ }
@@ -0,0 +1,371 @@
1
+ // Database sync engine for @glance-apps/sync (Phase 3).
2
+ //
3
+ // This is the row-grained transport that runs when config.transportMode is
4
+ // 'database'. It sits alongside the file-tier engine in engine.js, which is left
5
+ // completely untouched: file-tier users never reach this code.
6
+ //
7
+ // Where the file tier downloads the whole entity array, merges it, and uploads
8
+ // the whole array back, this engine tracks dirty entities locally and exchanges
9
+ // only the rows that changed, using GLANCEvault's seq-based incremental sync:
10
+ //
11
+ // 1. PUSH dirty rows (encrypted per-entity, cleared only on full ack)
12
+ // 2. PULL remote rows (seq cursor, paginated, entity-grain LWW)
13
+ // 3. UPDATE device cursor (best-effort)
14
+ //
15
+ // Local sync state (dbSyncHighWaterMark, dbSyncDirtySet) is persisted via the
16
+ // same localStorage that the file engine uses for its sync metadata, keyed by
17
+ // storageKeyPrefix. See the Phase 3 summary for why localStorage is used here in
18
+ // place of the spec's IndexedDB suggestion.
19
+
20
+ import { createVaultClient } from './vaultClient.js';
21
+ import { getSyncPassphrase } from './crypto.js';
22
+ import {
23
+ setupDbRootKey,
24
+ initDbRootKey,
25
+ hasDbRootKey,
26
+ encryptEntity,
27
+ decryptEntity,
28
+ } from './dbCrypto.js';
29
+
30
+ const ts = (v) => {
31
+ if (v == null) return 0;
32
+ const t = new Date(v).getTime();
33
+ return Number.isNaN(t) ? 0 : t;
34
+ };
35
+
36
+ /**
37
+ * Creates the database sync engine.
38
+ *
39
+ * @param {object} config
40
+ * @param {string} config.storageKeyPrefix - namespace for local sync state
41
+ * @param {string} config.appId - app identity (fallback URL segment)
42
+ * @param {string} [config.vaultApp] - URL path segment for the app (defaults to appId)
43
+ * @param {string} config.vaultUrl
44
+ * @param {string} config.vaultToken
45
+ * @param {string} config.accountId
46
+ * @param {string} [config.deviceId]
47
+ * @param {string} config.cryptoDBName - device storage name for the root key
48
+ * @param {Function|null} [config.nativeGetSyncKey]
49
+ * @param {Function|null} [config.nativeStoreSyncKey]
50
+ * @param {Function} [config.fetchImpl] - injectable fetch (tests / native shells)
51
+ *
52
+ * Data callbacks (the engine is data-shape agnostic):
53
+ * @param {Function} config.getLocalEntity - (entityId) => entity | null | Promise
54
+ * @param {Function} config.applyRemoteEntity - (entityId, entity) => void | Promise
55
+ * @param {Function} config.applyRemoteDelete - (entityId) => void | Promise
56
+ * @param {Function} [config.isInsertOnly] - (entity, entityId) => boolean
57
+ * @param {Function} [config.getEntityLastModified] - (entity) => string|number (defaults to entity.lastModified)
58
+ *
59
+ * Event callbacks (optional):
60
+ * @param {Function} [config.onStatusChange]
61
+ * @param {Function} [config.onError]
62
+ */
63
+ export const createDbSyncEngine = (config) => {
64
+ const {
65
+ storageKeyPrefix,
66
+ appId,
67
+ vaultApp,
68
+ vaultUrl,
69
+ vaultToken,
70
+ accountId,
71
+ deviceId,
72
+ cryptoDBName,
73
+ nativeGetSyncKey = null,
74
+ nativeStoreSyncKey = null,
75
+ fetchImpl,
76
+ getLocalEntity,
77
+ applyRemoteEntity,
78
+ applyRemoteDelete,
79
+ isInsertOnly,
80
+ getEntityLastModified,
81
+ onStatusChange,
82
+ onError,
83
+ } = config;
84
+
85
+ if (!storageKeyPrefix) throw new Error('createDbSyncEngine: storageKeyPrefix is required');
86
+ if (!config.vaultClient && !vaultUrl) throw new Error('createDbSyncEngine: vaultUrl is required');
87
+ if (!config.vaultClient && !vaultToken) throw new Error('createDbSyncEngine: vaultToken is required');
88
+ if (!accountId) throw new Error('createDbSyncEngine: accountId is required');
89
+ if (typeof getLocalEntity !== 'function') throw new Error('createDbSyncEngine: getLocalEntity is required');
90
+ if (typeof applyRemoteEntity !== 'function') throw new Error('createDbSyncEngine: applyRemoteEntity is required');
91
+ if (typeof applyRemoteDelete !== 'function') throw new Error('createDbSyncEngine: applyRemoteDelete is required');
92
+
93
+ const app = vaultApp || appId;
94
+ const lastModifiedOf = typeof getEntityLastModified === 'function'
95
+ ? getEntityLastModified
96
+ : (entity) => entity && entity.lastModified;
97
+ const insertOnly = typeof isInsertOnly === 'function'
98
+ ? isInsertOnly
99
+ : () => false;
100
+
101
+ const cryptoCfg = { cryptoDBName, nativeGetSyncKey, nativeStoreSyncKey };
102
+ // config.vaultClient lets tests and native shells supply a pre-built client;
103
+ // otherwise build the default HTTP client from the vault credentials.
104
+ const vault = config.vaultClient || createVaultClient({ vaultUrl, vaultToken, fetchImpl });
105
+
106
+ // localStorage keys owned by this engine.
107
+ const KEY_CONFIG = `${storageKeyPrefix}-db-sync-config`;
108
+ const KEY_HWM = `${storageKeyPrefix}-db-sync-hwm`;
109
+ const KEY_DIRTY = `${storageKeyPrefix}-db-sync-dirty`;
110
+ const KEY_LAST_SYNCED = `${storageKeyPrefix}-db-sync-last-synced`;
111
+
112
+ // ── In-memory guard ───────────────────────────────────────────────────────
113
+ let syncing = false;
114
+
115
+ // ── Local state accessors ─────────────────────────────────────────────────
116
+ const getConfig = () => {
117
+ const saved = localStorage.getItem(KEY_CONFIG);
118
+ return saved ? JSON.parse(saved) : null;
119
+ };
120
+ const setConfig = (cfg) => {
121
+ if (cfg) localStorage.setItem(KEY_CONFIG, JSON.stringify(cfg));
122
+ else localStorage.removeItem(KEY_CONFIG);
123
+ };
124
+ const getLastSynced = () => localStorage.getItem(KEY_LAST_SYNCED) || null;
125
+
126
+ const getHighWaterMark = () => {
127
+ const raw = localStorage.getItem(KEY_HWM);
128
+ const n = raw == null ? 0 : Number(raw);
129
+ return Number.isFinite(n) ? n : 0;
130
+ };
131
+ const setHighWaterMark = (seq) => {
132
+ localStorage.setItem(KEY_HWM, String(seq));
133
+ };
134
+
135
+ // Dirty set persisted as a JSON array; deduped via Set on read/write.
136
+ const getDirtySet = () => {
137
+ const raw = localStorage.getItem(KEY_DIRTY);
138
+ if (!raw) return [];
139
+ try {
140
+ const arr = JSON.parse(raw);
141
+ return Array.isArray(arr) ? arr : [];
142
+ } catch {
143
+ return [];
144
+ }
145
+ };
146
+ const writeDirtySet = (ids) => {
147
+ localStorage.setItem(KEY_DIRTY, JSON.stringify([...new Set(ids)]));
148
+ };
149
+ const clearDirty = () => localStorage.removeItem(KEY_DIRTY);
150
+
151
+ // markDirty: called by the app on every local write (and at creation time for
152
+ // insert-only types). Idempotent and synchronous so it can run inside the
153
+ // app's own write path.
154
+ const markDirty = (entityId) => {
155
+ if (entityId == null) return;
156
+ const id = String(entityId);
157
+ const current = getDirtySet();
158
+ if (current.includes(id)) return;
159
+ current.push(id);
160
+ writeDirtySet(current);
161
+ };
162
+
163
+ // ── Root key / salt bootstrap ─────────────────────────────────────────────
164
+ // Ensures the per-account root key is available, fetching or registering the
165
+ // salt with the vault on first use.
166
+ const ensureRootKey = async () => {
167
+ if (hasDbRootKey()) return;
168
+ if (await initDbRootKey(cryptoCfg)) return;
169
+
170
+ const passphrase = getSyncPassphrase();
171
+ if (!passphrase) {
172
+ const err = new Error('Encryption passphrase not available. Please enter your sync passphrase.');
173
+ err.code = 'PASSPHRASE_REQUIRED';
174
+ throw err;
175
+ }
176
+
177
+ let salt = await vault.getSalt(accountId);
178
+ if (!salt) {
179
+ const fresh = crypto.getRandomValues(new Uint8Array(16));
180
+ // First-write-wins: use whatever the server returns (another device may
181
+ // have registered a salt between our GET and PUT).
182
+ salt = await vault.putSalt(accountId, fresh);
183
+ }
184
+ await setupDbRootKey(passphrase, salt, cryptoCfg);
185
+ };
186
+
187
+ // ── Step 1: push dirty rows ───────────────────────────────────────────────
188
+ // Encrypts every dirty entity and upserts it as a batch. Entities that no
189
+ // 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 acknowledges,
191
+ // so a network failure leaves un-acked rows dirty for an idempotent re-send.
192
+ const pushDirtyRows = async () => {
193
+ const dirty = getDirtySet();
194
+ if (dirty.length === 0) return { written: 0, deleted: 0, maxSeq: getHighWaterMark() };
195
+
196
+ await ensureRootKey();
197
+
198
+ const upserts = [];
199
+ const deletes = [];
200
+ for (const entityId of dirty) {
201
+ const entity = await getLocalEntity(entityId);
202
+ if (entity == null) {
203
+ deletes.push(entityId);
204
+ } else {
205
+ const ciphertext = await encryptEntity(entity, entityId);
206
+ upserts.push({ entityId, ciphertext, createdAt: Date.now() });
207
+ }
208
+ }
209
+
210
+ // Soft-delete first, then batch the upserts. If any call throws, we fall out
211
+ // before clearing the dirty set or advancing the cursor.
212
+ let maxSeq = getHighWaterMark();
213
+ for (const entityId of deletes) {
214
+ const r = await vault.deleteRow(app, entityId, accountId);
215
+ if (r && typeof r.seq === 'number') maxSeq = Math.max(maxSeq, r.seq);
216
+ if (r && typeof r.maxSeq === 'number') maxSeq = Math.max(maxSeq, r.maxSeq);
217
+ }
218
+ if (upserts.length > 0) {
219
+ const result = await vault.batch(app, { accountId, rows: upserts });
220
+ if (result && typeof result.maxSeq === 'number') maxSeq = Math.max(maxSeq, result.maxSeq);
221
+ }
222
+
223
+ // Full acknowledgment: safe to mark clean.
224
+ clearDirty();
225
+ setHighWaterMark(maxSeq);
226
+ return { written: upserts.length, deleted: deletes.length, maxSeq };
227
+ };
228
+
229
+ // ── Step 2: pull remote changes ───────────────────────────────────────────
230
+ // Paginates from the high water mark, applying each row with entity-grain LWW.
231
+ // Rows that are also locally dirty are resolved against the dirty local copy
232
+ // (contended path): remote wins only if its lastModified is newer, and when it
233
+ // does win the entity is dropped from the dirty set so we do not re-push a
234
+ // superseded version.
235
+ const pullRemoteChanges = async () => {
236
+ let since = getHighWaterMark();
237
+ let maxSeq = since;
238
+ let appliedRemote = false;
239
+
240
+ let hasMore = true;
241
+ while (hasMore) {
242
+ const { rows, hasMore: more } = await vault.list(app, { accountId, since });
243
+ if (!Array.isArray(rows) || rows.length === 0) break;
244
+
245
+ // Snapshot the dirty set once per page for contended-path resolution.
246
+ const dirty = new Set(getDirtySet());
247
+
248
+ for (const R of rows) {
249
+ if (typeof R.seq === 'number' && R.seq > maxSeq) maxSeq = R.seq;
250
+
251
+ if (R.deleted) {
252
+ await applyRemoteDelete(R.entityId);
253
+ if (dirty.delete(R.entityId)) writeDirtySet([...dirty]);
254
+ appliedRemote = true;
255
+ continue;
256
+ }
257
+
258
+ await ensureRootKey();
259
+ const remoteEntity = await decryptEntity(R.ciphertext, R.entityId);
260
+ const local = await getLocalEntity(R.entityId);
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]);
277
+ }
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
+ }
281
+
282
+ if (typeof R.seq === 'number') since = Math.max(since, R.seq);
283
+ }
284
+
285
+ hasMore = !!more;
286
+ }
287
+
288
+ setHighWaterMark(Math.max(getHighWaterMark(), maxSeq));
289
+ return { maxSeq, appliedRemote };
290
+ };
291
+
292
+ // ── Step 3: update device cursor ──────────────────────────────────────────
293
+ // Best-effort: a failure here only affects tombstone GC timing, never sync
294
+ // correctness, so it is swallowed.
295
+ const updateDeviceCursor = async () => {
296
+ if (!deviceId) return { updated: false };
297
+ try {
298
+ return await vault.device(app, {
299
+ accountId,
300
+ deviceId,
301
+ lastSeenSeq: getHighWaterMark(),
302
+ });
303
+ } catch (err) {
304
+ // eslint-disable-next-line no-console
305
+ console.warn(`[${appId}] db sync device cursor update failed (non-fatal):`, err);
306
+ return { updated: false };
307
+ }
308
+ };
309
+
310
+ // ── Full cycle ────────────────────────────────────────────────────────────
311
+ const dbSyncCycle = async () => {
312
+ const cfg = getConfig();
313
+ if (cfg && cfg.enabled === false) return;
314
+ if (syncing) return;
315
+ syncing = true;
316
+ onStatusChange?.('uploading');
317
+ onError?.(null, null, false);
318
+ try {
319
+ await pushDirtyRows();
320
+ onStatusChange?.('downloading');
321
+ await pullRemoteChanges();
322
+ await updateDeviceCursor();
323
+
324
+ const now = new Date().toISOString();
325
+ localStorage.setItem(KEY_LAST_SYNCED, now);
326
+ onStatusChange?.('success');
327
+ } catch (err) {
328
+ // eslint-disable-next-line no-console
329
+ console.error(`[${appId}] db sync cycle error:`, err);
330
+ const code = err && err.code ? err.code : 'NETWORK_ERROR';
331
+ onError?.(err?.message || String(err), code, false);
332
+ onStatusChange?.('error');
333
+ } finally {
334
+ syncing = false;
335
+ }
336
+ };
337
+
338
+ // ── Public API ────────────────────────────────────────────────────────────
339
+ return {
340
+ transportMode: 'database',
341
+
342
+ // Lifecycle
343
+ sync: dbSyncCycle,
344
+ dbSyncCycle,
345
+ pushDirtyRows,
346
+ pullRemoteChanges,
347
+ updateDeviceCursor,
348
+ ensureRootKey,
349
+
350
+ // Dirty tracking
351
+ markDirty,
352
+ getDirtySet,
353
+ clearDirty,
354
+
355
+ // Cursor
356
+ getHighWaterMark,
357
+ setHighWaterMark,
358
+
359
+ // Config
360
+ getConfig,
361
+ setConfig,
362
+ getLastSynced,
363
+
364
+ // State queries
365
+ isSyncing: () => syncing,
366
+ hasEncryptionReady: hasDbRootKey,
367
+
368
+ // Sub-modules
369
+ vault,
370
+ };
371
+ };
package/src/engine.js CHANGED
@@ -22,6 +22,7 @@
22
22
  // written before Step 6 don't include either field).
23
23
 
24
24
  import { createProviders, webdavFetch } from './providers.js';
25
+ import { createDbSyncEngine } from './dbEngine.js';
25
26
  import {
26
27
  hasEncryptionReady,
27
28
  } from './crypto.js';
@@ -56,6 +57,13 @@ const MIN_SYNC_DURATION_MS = 2000;
56
57
  * config schema and behavioral contract.
57
58
  */
58
59
  export const createSyncEngine = (config) => {
60
+ // Transport selection (Phase 3). When transportMode is 'database', delegate to
61
+ // the row-grained DB engine. The default ('file', or unset) falls through to
62
+ // the original file-tier engine below, whose code path is unchanged.
63
+ if (config.transportMode === 'database') {
64
+ return createDbSyncEngine(config);
65
+ }
66
+
59
67
  const {
60
68
  storageKeyPrefix,
61
69
  appId,
package/src/index.js CHANGED
@@ -38,3 +38,15 @@ export {
38
38
  SCHEMA_VERSION,
39
39
  SUPPORTED_MAX_SCHEMA_VERSION,
40
40
  } from './engine.js';
41
+
42
+ // Phase 3: database transport (selected via transportMode: 'database').
43
+ export { createDbSyncEngine } from './dbEngine.js';
44
+ export { createVaultClient } from './vaultClient.js';
45
+ export {
46
+ setupDbRootKey,
47
+ initDbRootKey,
48
+ clearDbRootKey,
49
+ hasDbRootKey,
50
+ encryptEntity,
51
+ decryptEntity,
52
+ } from './dbCrypto.js';
package/src/providers.js CHANGED
@@ -246,7 +246,12 @@ export function createProviders(engineConfig) {
246
246
  // nativeHttpRequest != null means the Android HTTP bridge is available,
247
247
  // which bypasses Koofr's server-side block on Vercel proxy IPs.
248
248
  const onAndroid = engineConfig.nativeHttpRequest != null;
249
- if (!onAndroid) return { success: false, error: "Koofr is blocking requests from the web app's server. Use the Android app — it connects to Koofr directly." };
249
+ if (!onAndroid) {
250
+ if (engineConfig.isHostedApp) {
251
+ return { success: false, error: "Koofr blocks requests from dayglance.app's servers. Use a self-hosted instance or the Android app to connect directly." };
252
+ }
253
+ return { success: false, error: "Access forbidden. If requests are going through a proxy, Koofr may be blocking that server's IP." };
254
+ }
250
255
  return { success: false, error: 'Access forbidden. Check that your app password is correct and has not been revoked.' };
251
256
  }
252
257
  return { success: false, error: `Unexpected response: ${res.status}${res.statusText ? ' ' + res.statusText : ''}` };
@@ -0,0 +1,162 @@
1
+ // HTTP client for the GLANCEvault database backend (Phase 3).
2
+ //
3
+ // GLANCEvault is a self-hosted Express/SQLite server that stores one encrypted
4
+ // row per entity. This client wraps its sync endpoints. All requests carry a
5
+ // Bearer token. The fetch implementation is injectable so tests can supply a
6
+ // synthetic transport and native/Electron shells can route through their own
7
+ // network bridge if needed; it defaults to the platform fetch.
8
+ //
9
+ // Endpoints:
10
+ // POST /sync/:app/batch upsert rows, returns { written, maxSeq }
11
+ // GET /sync/:app/list?accountId=&since= incremental fetch, { rows, hasMore }
12
+ // GET /sync/:app/:entityId?accountId= fetch one row
13
+ // DELETE /sync/:app/:entityId?accountId= soft-delete a row
14
+ // POST /sync/:app/device update device cursor, { updated }
15
+ // GET /salt/:accountId fetch the account root-key salt
16
+ // PUT /salt/:accountId register a salt (first-write-wins)
17
+ //
18
+ // Salts cross the wire as base64 in a { salt } field. The client converts to and
19
+ // from Uint8Array at the boundary so callers always deal in bytes.
20
+
21
+ import { toBase64, fromBase64 } from './dbCrypto.js';
22
+
23
+ class VaultError extends Error {
24
+ constructor(message, status) {
25
+ super(message);
26
+ this.name = 'VaultError';
27
+ this.code = 'VAULT_ERROR';
28
+ this.status = status;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Creates a vault client bound to a server URL and token.
34
+ *
35
+ * @param {object} config
36
+ * @param {string} config.vaultUrl - base URL of the GLANCEvault server
37
+ * @param {string} config.vaultToken - Bearer token
38
+ * @param {Function} [config.fetchImpl] - fetch implementation (defaults to global fetch)
39
+ */
40
+ export function createVaultClient({ vaultUrl, vaultToken, fetchImpl } = {}) {
41
+ if (!vaultUrl) throw new Error('createVaultClient: vaultUrl is required');
42
+ if (!vaultToken) throw new Error('createVaultClient: vaultToken is required');
43
+ const doFetch = fetchImpl || globalThis.fetch;
44
+ if (typeof doFetch !== 'function') {
45
+ throw new Error('createVaultClient: no fetch implementation available');
46
+ }
47
+
48
+ const base = vaultUrl.replace(/\/+$/, '');
49
+ const authHeaders = (extra = {}) => ({
50
+ Authorization: `Bearer ${vaultToken}`,
51
+ ...extra,
52
+ });
53
+
54
+ const request = async (method, path, { body, query } = {}) => {
55
+ let url = base + path;
56
+ if (query) {
57
+ const qs = new URLSearchParams(query).toString();
58
+ if (qs) url += `?${qs}`;
59
+ }
60
+ const init = { method, headers: authHeaders() };
61
+ if (body !== undefined) {
62
+ init.headers['Content-Type'] = 'application/json';
63
+ init.body = JSON.stringify(body);
64
+ }
65
+ const res = await doFetch(url, init);
66
+ return res;
67
+ };
68
+
69
+ const jsonOrThrow = async (res, context) => {
70
+ if (!res.ok) {
71
+ throw new VaultError(`${context} failed: ${res.status}`, res.status);
72
+ }
73
+ return res.json();
74
+ };
75
+
76
+ return {
77
+ /**
78
+ * Upserts a batch of rows. Each row is { entityId, ciphertext, createdAt }.
79
+ * @returns {Promise<{ written: number, maxSeq: number }>}
80
+ */
81
+ async batch(app, { accountId, rows }) {
82
+ const res = await request('POST', `/sync/${encodeURIComponent(app)}/batch`, {
83
+ body: { accountId, rows },
84
+ });
85
+ return jsonOrThrow(res, 'batch upsert');
86
+ },
87
+
88
+ /**
89
+ * Fetches rows changed since `since` (a seq). One page per call.
90
+ * @returns {Promise<{ rows: Array, hasMore: boolean }>}
91
+ */
92
+ async list(app, { accountId, since }) {
93
+ const res = await request('GET', `/sync/${encodeURIComponent(app)}/list`, {
94
+ query: { accountId, since: String(since) },
95
+ });
96
+ return jsonOrThrow(res, 'list');
97
+ },
98
+
99
+ /**
100
+ * Fetches a single row by entityId. Returns null on 404.
101
+ */
102
+ async getRow(app, entityId, accountId) {
103
+ const res = await request('GET', `/sync/${encodeURIComponent(app)}/${encodeURIComponent(entityId)}`, {
104
+ query: { accountId },
105
+ });
106
+ if (res.status === 404) return null;
107
+ return jsonOrThrow(res, 'get row');
108
+ },
109
+
110
+ /**
111
+ * Soft-deletes a row by entityId. Returns the server response (may include
112
+ * the new seq for the tombstone).
113
+ */
114
+ async deleteRow(app, entityId, accountId) {
115
+ const res = await request('DELETE', `/sync/${encodeURIComponent(app)}/${encodeURIComponent(entityId)}`, {
116
+ query: { accountId },
117
+ });
118
+ if (res.status === 404) return null;
119
+ return jsonOrThrow(res, 'delete row');
120
+ },
121
+
122
+ /**
123
+ * Updates this device's cursor. Best-effort: callers should not let a
124
+ * failure here abort the sync cycle.
125
+ * @returns {Promise<{ updated: boolean }>}
126
+ */
127
+ async device(app, { accountId, deviceId, lastSeenSeq }) {
128
+ const res = await request('POST', `/sync/${encodeURIComponent(app)}/device`, {
129
+ body: { accountId, deviceId, lastSeenSeq },
130
+ });
131
+ return jsonOrThrow(res, 'device cursor');
132
+ },
133
+
134
+ /**
135
+ * Fetches the account's root-key salt. Returns a Uint8Array, or null if no
136
+ * salt is registered yet (404).
137
+ */
138
+ async getSalt(accountId) {
139
+ const res = await request('GET', `/salt/${encodeURIComponent(accountId)}`);
140
+ if (res.status === 404) return null;
141
+ const body = await jsonOrThrow(res, 'get salt');
142
+ return body && body.salt ? fromBase64(body.salt) : null;
143
+ },
144
+
145
+ /**
146
+ * Registers a salt (first-write-wins). Returns whatever salt the server has
147
+ * stored as a Uint8Array, which may differ from the one sent if another
148
+ * device registered first.
149
+ *
150
+ * @param {string} accountId
151
+ * @param {Uint8Array} salt
152
+ * @returns {Promise<Uint8Array>}
153
+ */
154
+ async putSalt(accountId, salt) {
155
+ const res = await request('PUT', `/salt/${encodeURIComponent(accountId)}`, {
156
+ body: { salt: toBase64(salt) },
157
+ });
158
+ const body = await jsonOrThrow(res, 'put salt');
159
+ return body && body.salt ? fromBase64(body.salt) : salt;
160
+ },
161
+ };
162
+ }
package/types/index.d.ts CHANGED
@@ -265,6 +265,9 @@ export interface ValidationResult {
265
265
  }
266
266
 
267
267
  export interface SyncEngineConfig {
268
+ // Transport selection (Phase 3). Defaults to 'file' when unset.
269
+ transportMode?: 'file' | 'database';
270
+
268
271
  // Identity
269
272
  storageKeyPrefix: string;
270
273
  cryptoDBName: string;
@@ -336,7 +339,119 @@ export interface SyncEngine {
336
339
  webdavFetch: WebdavFetch;
337
340
  }
338
341
 
342
+ // createSyncEngine returns the file-tier engine by default, or the DB engine
343
+ // when config.transportMode is 'database'.
339
344
  export function createSyncEngine(config: SyncEngineConfig): SyncEngine;
345
+ export function createSyncEngine(config: DbSyncEngineConfig & { transportMode: 'database' }): DbSyncEngine;
340
346
 
341
347
  export const SCHEMA_VERSION: number;
342
348
  export const SUPPORTED_MAX_SCHEMA_VERSION: number;
349
+
350
+ // ---------------------------------------------------------------------------
351
+ // Database transport (Phase 3)
352
+ // ---------------------------------------------------------------------------
353
+
354
+ // Per-entity crypto. The root key is PBKDF2-derived from the passphrase and a
355
+ // per-account salt; per-entity AES-GCM keys are HKDF-derived from it.
356
+ export interface DbCryptoConfig {
357
+ cryptoDBName: string;
358
+ nativeGetSyncKey?: (() => string | null | Promise<string | null>) | null;
359
+ nativeStoreSyncKey?: ((value: string | null) => void) | null;
360
+ }
361
+
362
+ export function setupDbRootKey(passphrase: string, salt: Uint8Array, config: DbCryptoConfig): Promise<void>;
363
+ export function initDbRootKey(config: DbCryptoConfig): Promise<boolean>;
364
+ export function clearDbRootKey(config: DbCryptoConfig): Promise<void>;
365
+ export function hasDbRootKey(): boolean;
366
+ export function encryptEntity(entity: unknown, entityId: string, rootKey?: CryptoKey): Promise<string>;
367
+ export function decryptEntity<T = unknown>(ciphertext: string, entityId: string, rootKey?: CryptoKey): Promise<T>;
368
+
369
+ // Row exchanged with GLANCEvault. ciphertext is base64(IV || AES-GCM output).
370
+ export interface VaultRow {
371
+ entityId: string;
372
+ ciphertext: string;
373
+ createdAt: number;
374
+ }
375
+
376
+ export interface VaultPulledRow extends Partial<VaultRow> {
377
+ entityId: string;
378
+ seq: number;
379
+ deleted?: boolean;
380
+ ciphertext?: string;
381
+ }
382
+
383
+ export interface VaultClient {
384
+ batch(app: string, args: { accountId: string; rows: VaultRow[] }): Promise<{ written: number; maxSeq: number }>;
385
+ list(app: string, args: { accountId: string; since: number }): Promise<{ rows: VaultPulledRow[]; hasMore: boolean }>;
386
+ getRow(app: string, entityId: string, accountId: string): Promise<VaultPulledRow | null>;
387
+ deleteRow(app: string, entityId: string, accountId: string): Promise<{ seq?: number } | null>;
388
+ device(app: string, args: { accountId: string; deviceId: string; lastSeenSeq: number }): Promise<{ updated: boolean }>;
389
+ getSalt(accountId: string): Promise<Uint8Array | null>;
390
+ putSalt(accountId: string, salt: Uint8Array): Promise<Uint8Array>;
391
+ }
392
+
393
+ export function createVaultClient(config: {
394
+ vaultUrl: string;
395
+ vaultToken: string;
396
+ fetchImpl?: typeof fetch;
397
+ }): VaultClient;
398
+
399
+ export interface DbSyncEngineConfig {
400
+ // Identity / local state
401
+ storageKeyPrefix: string;
402
+ appId: string;
403
+ vaultApp?: string;
404
+ cryptoDBName: string;
405
+ nativeGetSyncKey?: (() => string | null | Promise<string | null>) | null;
406
+ nativeStoreSyncKey?: ((value: string | null) => void) | null;
407
+
408
+ // Transport selection
409
+ transportMode?: 'database';
410
+
411
+ // Vault connection
412
+ vaultUrl?: string;
413
+ vaultToken?: string;
414
+ accountId: string;
415
+ deviceId?: string;
416
+ fetchImpl?: typeof fetch;
417
+ vaultClient?: VaultClient;
418
+
419
+ // Data callbacks
420
+ getLocalEntity: (entityId: string) => unknown | Promise<unknown>;
421
+ applyRemoteEntity: (entityId: string, entity: unknown) => void | Promise<void>;
422
+ applyRemoteDelete: (entityId: string) => void | Promise<void>;
423
+ isInsertOnly?: (entity: unknown, entityId: string) => boolean;
424
+ getEntityLastModified?: (entity: unknown) => string | number | undefined;
425
+
426
+ // Event callbacks
427
+ onStatusChange?: (status: SyncStatus, hints?: { from?: SyncStatus }) => void;
428
+ onError?: (message: string | null, code: SyncErrorCode | null, isHardStop: boolean) => void;
429
+ }
430
+
431
+ export interface DbSyncEngine {
432
+ transportMode: 'database';
433
+ sync(): Promise<void>;
434
+ dbSyncCycle(): Promise<void>;
435
+ pushDirtyRows(): Promise<{ written: number; deleted: number; maxSeq: number }>;
436
+ pullRemoteChanges(): Promise<{ maxSeq: number; appliedRemote: boolean }>;
437
+ updateDeviceCursor(): Promise<{ updated: boolean }>;
438
+ ensureRootKey(): Promise<void>;
439
+
440
+ markDirty(entityId: string): void;
441
+ getDirtySet(): string[];
442
+ clearDirty(): void;
443
+
444
+ getHighWaterMark(): number;
445
+ setHighWaterMark(seq: number): void;
446
+
447
+ getConfig(): Record<string, unknown> | null;
448
+ setConfig(config: Record<string, unknown> | null): void;
449
+ getLastSynced(): string | null;
450
+
451
+ isSyncing(): boolean;
452
+ hasEncryptionReady(): boolean;
453
+
454
+ vault: VaultClient;
455
+ }
456
+
457
+ export function createDbSyncEngine(config: DbSyncEngineConfig): DbSyncEngine;