@dotrino/identity 0.10.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.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Almacén del peer book del vault (nicknames + ratings + endorsements firmados).
3
+ *
4
+ * Migrado de localStorage (~5 MB de techo, compartido) a IndexedDB (cuota grande,
5
+ * non-evictable con `persist()`), porque el peer book crece con los contactos y,
6
+ * sobre todo, con los endorsements del registro de reputación. Patrón idéntico
7
+ * al del store del ecosistema:
8
+ * - `_peers` en memoria es la fuente de verdad en runtime.
9
+ * - `loadPeers()` es SÍNCRONA (lee la cache) → los handlers del vault no cambian.
10
+ * - `initPeerStorage()` (async, en el bootstrap) la rellena desde IndexedDB,
11
+ * MIGRANDO una sola vez del localStorage viejo. Si IndexedDB no está (modo
12
+ * privado) cae a localStorage para no perder función.
13
+ * - `savePeers()` actualiza la cache y hace write-through async a IndexedDB.
14
+ *
15
+ * El módulo es independiente del resto del vault (testeable en aislamiento).
16
+ */
17
+
18
+ export const PEERS_STORAGE = 'dotrino.identity.peers' // clave del localStorage VIEJO (migración/fallback)
19
+ const IDB_NAME = 'cc-identity'
20
+ const IDB_STORE = 'kv'
21
+ const IDB_PEERS_KEY = 'peers.v1'
22
+ // Flag de reconciliación one-time. Distingue "IndexedDB vacío porque ya
23
+ // migramos y el usuario no tiene peers" de "vacío porque nunca migramos (o un
24
+ // bug previo escribió {} y enmascaró la migración)". Sin esto, un `{}` escrito
25
+ // por error quedaba como objeto truthy y la migración NO se reintentaba jamás,
26
+ // perdiendo contactos que SIGUEN intactos en el localStorage viejo.
27
+ const IDB_MIGRATED_KEY = 'peers.migrated.v1'
28
+
29
+ let _peers = {}
30
+ let _fallback = false
31
+ let _idb = null
32
+ let _writeChain = Promise.resolve()
33
+ let _markDirty = null
34
+
35
+ /** Registra el callback que marca el estado como "sucio" para el sync. */
36
+ export function onDirty (fn) { _markDirty = fn }
37
+
38
+ function openIdb () {
39
+ return new Promise((resolve, reject) => {
40
+ let req
41
+ try { req = indexedDB.open(IDB_NAME, 1) } catch (e) { reject(e); return }
42
+ req.onupgradeneeded = () => {
43
+ const db = req.result
44
+ if (!db.objectStoreNames.contains(IDB_STORE)) db.createObjectStore(IDB_STORE)
45
+ }
46
+ req.onsuccess = () => resolve(req.result)
47
+ req.onerror = () => reject(req.error)
48
+ })
49
+ }
50
+ function idbGet (db, key) {
51
+ return new Promise((resolve, reject) => {
52
+ const tx = db.transaction(IDB_STORE, 'readonly')
53
+ const r = tx.objectStore(IDB_STORE).get(key)
54
+ r.onsuccess = () => resolve(r.result)
55
+ r.onerror = () => reject(r.error)
56
+ })
57
+ }
58
+ function idbPut (db, key, val) {
59
+ return new Promise((resolve, reject) => {
60
+ const tx = db.transaction(IDB_STORE, 'readwrite')
61
+ tx.objectStore(IDB_STORE).put(val, key)
62
+ tx.oncomplete = () => resolve()
63
+ tx.onerror = () => reject(tx.error)
64
+ })
65
+ }
66
+
67
+ function readLocalPeers () {
68
+ try { const raw = localStorage.getItem(PEERS_STORAGE); return raw ? (JSON.parse(raw) || {}) : {} }
69
+ catch (_) { return {} }
70
+ }
71
+
72
+ export async function initPeerStorage () {
73
+ try { if (typeof navigator !== 'undefined' && navigator.storage?.persist) await navigator.storage.persist() }
74
+ catch (_) { /* best-effort */ }
75
+ try {
76
+ _idb = await openIdb()
77
+ const stored = await idbGet(_idb, IDB_PEERS_KEY)
78
+ const storedPeers = (stored && typeof stored === 'object') ? stored : {}
79
+ const migratedFlag = await idbGet(_idb, IDB_MIGRATED_KEY)
80
+ if (migratedFlag) {
81
+ // Ya reconciliado: IndexedDB es la fuente de verdad (ignora el LS viejo).
82
+ _peers = storedPeers
83
+ } else {
84
+ // Primera corrida (o reintento tras el bug que escribía {}): unimos el
85
+ // peer book del localStorage viejo con lo que haya en IndexedDB —unión,
86
+ // IndexedDB gana en conflictos— para recuperar contactos que un bug previo
87
+ // pudo enmascarar. Nunca borra. Marcamos el flag para no rehacerlo (así no
88
+ // se "resucitan" contactos que el usuario borre más adelante).
89
+ const local = readLocalPeers()
90
+ _peers = { ...local, ...storedPeers }
91
+ const recovered = Object.keys(local).filter(k => !(k in storedPeers)).length
92
+ await idbPut(_idb, IDB_PEERS_KEY, _peers)
93
+ await idbPut(_idb, IDB_MIGRATED_KEY, true)
94
+ if (recovered) console.log(`[cc-identity] ${recovered} peer(s) recuperados del localStorage viejo → IndexedDB`)
95
+ }
96
+ } catch (e) {
97
+ console.warn('[cc-identity] IndexedDB no disponible, uso localStorage:', e?.message)
98
+ _fallback = true
99
+ _idb = null
100
+ _peers = readLocalPeers()
101
+ }
102
+ return _peers
103
+ }
104
+
105
+ function persistPeers () {
106
+ if (_fallback || !_idb) {
107
+ try { localStorage.setItem(PEERS_STORAGE, JSON.stringify(_peers)) }
108
+ catch (e) { console.warn('[cc-identity] persist (ls) falló:', e?.message) }
109
+ return _writeChain
110
+ }
111
+ const snapshot = _peers
112
+ _writeChain = _writeChain
113
+ .then(() => idbPut(_idb, IDB_PEERS_KEY, snapshot))
114
+ .catch(e => console.warn('[cc-identity] persist (idb) falló:', e?.message))
115
+ return _writeChain
116
+ }
117
+
118
+ /** Promesa que resuelve cuando se completaron los write-through pendientes (tests). */
119
+ export function flushPeers () { return _writeChain }
120
+
121
+ export function loadPeers () { return _peers }
122
+
123
+ export function savePeers (peers) {
124
+ _peers = peers
125
+ persistPeers()
126
+ if (_markDirty) _markDirty()
127
+ }
128
+
129
+ /** Escritura directa (merge del sync): persiste sin marcar dirty. */
130
+ export function setPeersDirect (peers) {
131
+ _peers = peers
132
+ persistPeers()
133
+ }
134
+
135
+ export function upsertPeer (publickey, patch) {
136
+ const peers = loadPeers()
137
+ const existing = peers[publickey] || { publickey, firstSeen: Date.now() }
138
+ peers[publickey] = { ...existing, ...patch, publickey, lastSeen: Date.now() }
139
+ savePeers(peers)
140
+ return peers[publickey]
141
+ }
142
+
143
+ // Sólo para tests: resetea el estado del módulo (y cierra la conexión IDB).
144
+ export function _resetForTest () {
145
+ try { if (_idb && _idb.close) _idb.close() } catch (_) {}
146
+ _peers = {}; _fallback = false; _idb = null; _writeChain = Promise.resolve(); _markDirty = null
147
+ }
package/vault/sync.js ADDED
@@ -0,0 +1,473 @@
1
+ /**
2
+ * Dotrino — Drive sync module.
3
+ *
4
+ * Loaded by vault.js inside the identity-vault iframe. Implements:
5
+ * - Google OAuth (Implicit / GIS token client) for the `drive.appdata` scope
6
+ * - PBKDF2-derived AES-256-GCM encryption of the vault export blob
7
+ * - Drive REST v3 read/write of a single file in `appDataFolder`
8
+ * - Auto-sync scheduler: pull-on-unlock, debounced push, periodic pull,
9
+ * optimistic-locked push (If-Match etag) with retry-and-merge on 412
10
+ *
11
+ * The same module ships in the message-store repo with the only differences
12
+ * being FILE_NAME and the merge function passed in by the caller.
13
+ */
14
+
15
+ const DRIVE_FILES_API = 'https://www.googleapis.com/drive/v3/files'
16
+ const DRIVE_UPLOAD_API = 'https://www.googleapis.com/upload/drive/v3/files'
17
+ const GIS_SCRIPT = 'https://accounts.google.com/gsi/client'
18
+ const SCOPE = 'https://www.googleapis.com/auth/drive.appdata'
19
+
20
+ const PUSH_DEBOUNCE_MS = 5_000
21
+ const PUSH_HARD_INTERVAL_MS = 60_000
22
+ const PULL_INTERVAL_MS = 2 * 60_000
23
+ const PBKDF2_ITER = 600_000
24
+
25
+ // Local state keys (stored in localStorage of the vault origin)
26
+ const LS_DEVICE_ID = 'cc.sync.deviceId'
27
+ const LS_OAUTH_CLIENT = 'cc.sync.oauthClientId'
28
+ const LS_LAST_ETAG = 'cc.sync.lastEtag'
29
+ const LS_LAST_VERSIONS = 'cc.sync.deviceVersions'
30
+ // Passphrase-derived key only lives in sessionStorage (per-tab, cleared on close)
31
+ const SS_PASSPHRASE_KEY = 'cc.sync.kHexEphemeral'
32
+
33
+ // ---------- low-level helpers ----------
34
+
35
+ function bufToBase64 (buf) {
36
+ const bytes = new Uint8Array(buf)
37
+ let s = ''
38
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i])
39
+ return btoa(s)
40
+ }
41
+ function base64ToBuf (b64) {
42
+ const s = atob(b64)
43
+ const bytes = new Uint8Array(s.length)
44
+ for (let i = 0; i < s.length; i++) bytes[i] = s.charCodeAt(i)
45
+ return bytes.buffer
46
+ }
47
+ function getDeviceId () {
48
+ let id = localStorage.getItem(LS_DEVICE_ID)
49
+ if (!id) {
50
+ id = crypto.randomUUID()
51
+ localStorage.setItem(LS_DEVICE_ID, id)
52
+ }
53
+ return id
54
+ }
55
+ function getDeviceVersions () {
56
+ try { return JSON.parse(localStorage.getItem(LS_LAST_VERSIONS) || '{}') } catch { return {} }
57
+ }
58
+ function setDeviceVersions (v) {
59
+ localStorage.setItem(LS_LAST_VERSIONS, JSON.stringify(v))
60
+ }
61
+
62
+ // ---------- crypto: passphrase → key, AES-GCM ----------
63
+
64
+ async function deriveKey (passphrase, salt) {
65
+ const baseKey = await crypto.subtle.importKey(
66
+ 'raw',
67
+ new TextEncoder().encode(passphrase),
68
+ { name: 'PBKDF2' },
69
+ false,
70
+ ['deriveKey']
71
+ )
72
+ return crypto.subtle.deriveKey(
73
+ { name: 'PBKDF2', salt, iterations: PBKDF2_ITER, hash: 'SHA-256' },
74
+ baseKey,
75
+ { name: 'AES-GCM', length: 256 },
76
+ true,
77
+ ['encrypt', 'decrypt']
78
+ )
79
+ }
80
+
81
+ async function encryptBlob (plaintextObj, passphrase) {
82
+ const salt = crypto.getRandomValues(new Uint8Array(16))
83
+ const iv = crypto.getRandomValues(new Uint8Array(12))
84
+ const key = await deriveKey(passphrase, salt)
85
+ const ct = await crypto.subtle.encrypt(
86
+ { name: 'AES-GCM', iv },
87
+ key,
88
+ new TextEncoder().encode(JSON.stringify(plaintextObj))
89
+ )
90
+ return {
91
+ v: 1,
92
+ kdf: { alg: 'PBKDF2-SHA256', iter: PBKDF2_ITER, salt: bufToBase64(salt) },
93
+ enc: { alg: 'AES-256-GCM', iv: bufToBase64(iv), ct: bufToBase64(new Uint8Array(ct)) },
94
+ createdAt: Date.now()
95
+ }
96
+ }
97
+
98
+ async function decryptBlob (envelope, passphrase) {
99
+ if (!envelope || envelope.v !== 1) throw new Error('Unsupported backup version')
100
+ const salt = new Uint8Array(base64ToBuf(envelope.kdf.salt))
101
+ const iv = new Uint8Array(base64ToBuf(envelope.enc.iv))
102
+ const key = await deriveKey(passphrase, salt)
103
+ let ptBytes
104
+ try {
105
+ ptBytes = await crypto.subtle.decrypt(
106
+ { name: 'AES-GCM', iv },
107
+ key,
108
+ base64ToBuf(envelope.enc.ct)
109
+ )
110
+ } catch {
111
+ throw new Error('Wrong passphrase or corrupted backup')
112
+ }
113
+ return JSON.parse(new TextDecoder().decode(ptBytes))
114
+ }
115
+
116
+ // ---------- Google OAuth (GIS Token Client) ----------
117
+
118
+ let _gisLoaded = null
119
+ function loadGis () {
120
+ if (_gisLoaded) return _gisLoaded
121
+ _gisLoaded = new Promise((resolve, reject) => {
122
+ if (window.google?.accounts?.oauth2) return resolve()
123
+ const s = document.createElement('script')
124
+ s.src = GIS_SCRIPT
125
+ s.async = true
126
+ s.defer = true
127
+ s.onload = () => resolve()
128
+ s.onerror = () => reject(new Error('Failed to load Google Identity Services'))
129
+ document.head.appendChild(s)
130
+ })
131
+ return _gisLoaded
132
+ }
133
+
134
+ let _accessToken = null
135
+ let _accessTokenExpiresAt = 0
136
+ let _tokenClient = null
137
+
138
+ async function ensureTokenClient (clientId) {
139
+ await loadGis()
140
+ if (_tokenClient && _tokenClient._clientId === clientId) return _tokenClient
141
+ _tokenClient = window.google.accounts.oauth2.initTokenClient({
142
+ client_id: clientId,
143
+ scope: SCOPE,
144
+ prompt: '',
145
+ callback: () => {} // overridden per-request
146
+ })
147
+ _tokenClient._clientId = clientId
148
+ return _tokenClient
149
+ }
150
+
151
+ async function requestAccessToken (clientId, { interactive }) {
152
+ const client = await ensureTokenClient(clientId)
153
+ return new Promise((resolve, reject) => {
154
+ client.callback = (resp) => {
155
+ if (resp?.error) return reject(new Error(resp.error))
156
+ if (!resp?.access_token) return reject(new Error('No access_token in response'))
157
+ _accessToken = resp.access_token
158
+ _accessTokenExpiresAt = Date.now() + ((Number(resp.expires_in) || 3600) - 60) * 1000
159
+ resolve({ accessToken: _accessToken, expiresAt: _accessTokenExpiresAt })
160
+ }
161
+ try {
162
+ client.requestAccessToken({ prompt: interactive ? 'consent' : '' })
163
+ } catch (e) { reject(e) }
164
+ })
165
+ }
166
+
167
+ async function getValidAccessToken () {
168
+ if (_accessToken && Date.now() < _accessTokenExpiresAt) return _accessToken
169
+ const clientId = localStorage.getItem(LS_OAUTH_CLIENT)
170
+ if (!clientId) return null
171
+ try {
172
+ const r = await requestAccessToken(clientId, { interactive: false })
173
+ return r.accessToken
174
+ } catch { return null }
175
+ }
176
+
177
+ // ---------- Drive REST helpers ----------
178
+
179
+ async function driveFindFile (token, fileName) {
180
+ const q = encodeURIComponent(`name='${fileName}' and 'appDataFolder' in parents and trashed=false`)
181
+ const url = `${DRIVE_FILES_API}?spaces=appDataFolder&q=${q}&fields=files(id,name,headRevisionId,modifiedTime)`
182
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
183
+ if (!res.ok) throw new Error(`Drive list failed: ${res.status}`)
184
+ const data = await res.json()
185
+ return (data.files && data.files[0]) || null
186
+ }
187
+
188
+ async function driveDownload (token, fileId) {
189
+ const url = `${DRIVE_FILES_API}/${fileId}?alt=media`
190
+ const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } })
191
+ if (!res.ok) throw new Error(`Drive download failed: ${res.status}`)
192
+ const etag = res.headers.get('ETag') || null
193
+ const body = await res.json()
194
+ return { body, etag }
195
+ }
196
+
197
+ async function driveCreate (token, fileName, contentObj) {
198
+ const metadata = { name: fileName, parents: ['appDataFolder'] }
199
+ const boundary = '-------ccsync' + Math.random().toString(36).slice(2)
200
+ const body =
201
+ `--${boundary}\r\n` +
202
+ 'Content-Type: application/json; charset=UTF-8\r\n\r\n' +
203
+ JSON.stringify(metadata) + '\r\n' +
204
+ `--${boundary}\r\n` +
205
+ 'Content-Type: application/json; charset=UTF-8\r\n\r\n' +
206
+ JSON.stringify(contentObj) + '\r\n' +
207
+ `--${boundary}--`
208
+ const res = await fetch(`${DRIVE_UPLOAD_API}?uploadType=multipart&fields=id`, {
209
+ method: 'POST',
210
+ headers: {
211
+ Authorization: `Bearer ${token}`,
212
+ 'Content-Type': `multipart/related; boundary=${boundary}`
213
+ },
214
+ body
215
+ })
216
+ if (!res.ok) throw new Error(`Drive create failed: ${res.status}`)
217
+ const data = await res.json()
218
+ return data.id
219
+ }
220
+
221
+ async function driveUpdate (token, fileId, contentObj, ifMatchEtag) {
222
+ const headers = {
223
+ Authorization: `Bearer ${token}`,
224
+ 'Content-Type': 'application/json; charset=UTF-8'
225
+ }
226
+ if (ifMatchEtag) headers['If-Match'] = ifMatchEtag
227
+ const url = `${DRIVE_UPLOAD_API}/${fileId}?uploadType=media`
228
+ const res = await fetch(url, { method: 'PATCH', headers, body: JSON.stringify(contentObj) })
229
+ if (res.status === 412) {
230
+ const err = new Error('etag mismatch')
231
+ err.code = 'PRECONDITION_FAILED'
232
+ throw err
233
+ }
234
+ if (!res.ok) throw new Error(`Drive update failed: ${res.status}`)
235
+ const newEtag = res.headers.get('ETag') || null
236
+ return { etag: newEtag }
237
+ }
238
+
239
+ // ---------- public createSync(): returns a controller wired to caller’s state ----------
240
+
241
+ /**
242
+ * Creates a sync controller for one logical state (identity OR store).
243
+ *
244
+ * @param {Object} cfg
245
+ * @param {string} cfg.fileName - e.g. 'dotrino-identity-backup.json'
246
+ * @param {() => Promise<Object>} cfg.exportLocal - returns plaintext-equivalent JSON
247
+ * @param {(merged: Object) => Promise<void>} cfg.applyMerged - apply merged state locally
248
+ * @param {(local: Object, remote: Object) => Promise<{merged: Object, changed: boolean}>} cfg.mergeFn
249
+ * @param {string} cfg.kind - 'identity' | 'store' (used in plaintext header)
250
+ */
251
+ export function createSync (cfg) {
252
+ const deviceId = getDeviceId()
253
+ let _passphrase = null // kept in module memory after unlock; mirrored to sessionStorage for tab refresh
254
+ let _dirty = false
255
+ let _debounceTimer = null
256
+ let _hardTimer = null
257
+ let _periodicTimer = null
258
+ let _running = false
259
+ let _lastError = null
260
+ let _statusListeners = new Set()
261
+
262
+ // Restore passphrase from sessionStorage if present (per-tab unlock survives refresh)
263
+ try {
264
+ const saved = sessionStorage.getItem(SS_PASSPHRASE_KEY)
265
+ if (saved) _passphrase = saved
266
+ } catch {}
267
+
268
+ function emitStatus (status, extra = {}) {
269
+ const payload = { kind: cfg.kind, status, ...extra, ts: Date.now() }
270
+ for (const fn of _statusListeners) {
271
+ try { fn(payload) } catch (e) { console.error(e) }
272
+ }
273
+ }
274
+
275
+ function isUnlocked () { return !!_passphrase }
276
+
277
+ async function unlock (passphrase) {
278
+ if (!passphrase || passphrase.length < 8) throw new Error('Passphrase too short')
279
+ _passphrase = passphrase
280
+ try { sessionStorage.setItem(SS_PASSPHRASE_KEY, passphrase) } catch {}
281
+ emitStatus('unlocked')
282
+ // Trigger an immediate pull on unlock
283
+ try { await pull() } catch (e) { _lastError = e; emitStatus('error', { error: e.message }) }
284
+ startTimers()
285
+ return { ok: true }
286
+ }
287
+
288
+ function lock () {
289
+ _passphrase = null
290
+ try { sessionStorage.removeItem(SS_PASSPHRASE_KEY) } catch {}
291
+ stopTimers()
292
+ emitStatus('locked')
293
+ }
294
+
295
+ function buildPlaintext (exported) {
296
+ const versions = getDeviceVersions()
297
+ versions[deviceId] = (versions[deviceId] || 0) + 1
298
+ setDeviceVersions(versions)
299
+ return {
300
+ version: 1,
301
+ kind: cfg.kind,
302
+ exportedAt: Date.now(),
303
+ deviceVersions: versions,
304
+ payload: exported
305
+ }
306
+ }
307
+
308
+ function dominates (localV, remoteV) {
309
+ if (!remoteV) return true
310
+ const all = new Set([...Object.keys(localV || {}), ...Object.keys(remoteV)])
311
+ let strictly = false
312
+ for (const k of all) {
313
+ const a = localV[k] || 0
314
+ const b = remoteV[k] || 0
315
+ if (a < b) return false
316
+ if (a > b) strictly = true
317
+ }
318
+ return strictly
319
+ }
320
+
321
+ async function pull () {
322
+ if (!isUnlocked()) return { skipped: 'locked' }
323
+ if (_running) return { skipped: 'busy' }
324
+ _running = true
325
+ emitStatus('syncing')
326
+ try {
327
+ const token = await getValidAccessToken()
328
+ if (!token) { emitStatus('offline'); return { skipped: 'no-token' } }
329
+ const file = await driveFindFile(token, cfg.fileName)
330
+ if (!file) { emitStatus('synced'); return { remote: null } }
331
+ const { body: envelope, etag } = await driveDownload(token, file.id)
332
+ localStorage.setItem(LS_LAST_ETAG + ':' + cfg.kind, etag || '')
333
+ const plaintext = await decryptBlob(envelope, _passphrase)
334
+ const local = await cfg.exportLocal()
335
+ const remoteVersions = plaintext.deviceVersions || {}
336
+ const localVersions = getDeviceVersions()
337
+ if (dominates(localVersions, remoteVersions)) {
338
+ // Local already covers remote — nothing to apply, but mark dirty to push
339
+ _dirty = true
340
+ emitStatus('synced')
341
+ return { applied: false, dominant: 'local' }
342
+ }
343
+ const { merged, changed } = await cfg.mergeFn(local, plaintext.payload)
344
+ await cfg.applyMerged(merged)
345
+ // Merge versions: max per device
346
+ const mergedVersions = { ...remoteVersions }
347
+ for (const k of Object.keys(localVersions)) {
348
+ mergedVersions[k] = Math.max(mergedVersions[k] || 0, localVersions[k] || 0)
349
+ }
350
+ setDeviceVersions(mergedVersions)
351
+ if (changed) _dirty = true
352
+ emitStatus('synced')
353
+ return { applied: true, changed }
354
+ } catch (e) {
355
+ _lastError = e
356
+ emitStatus('error', { error: e.message })
357
+ throw e
358
+ } finally {
359
+ _running = false
360
+ }
361
+ }
362
+
363
+ async function push (retries = 3) {
364
+ if (!isUnlocked()) return { skipped: 'locked' }
365
+ if (!_dirty) return { skipped: 'clean' }
366
+ if (_running) return { skipped: 'busy' }
367
+ _running = true
368
+ emitStatus('syncing')
369
+ try {
370
+ const token = await getValidAccessToken()
371
+ if (!token) { emitStatus('offline'); return { skipped: 'no-token' } }
372
+ const local = await cfg.exportLocal()
373
+ const plaintext = buildPlaintext(local)
374
+ const envelope = await encryptBlob(plaintext, _passphrase)
375
+ const existing = await driveFindFile(token, cfg.fileName)
376
+ if (!existing) {
377
+ await driveCreate(token, cfg.fileName, envelope)
378
+ _dirty = false
379
+ emitStatus('synced')
380
+ return { created: true }
381
+ }
382
+ const lastEtag = localStorage.getItem(LS_LAST_ETAG + ':' + cfg.kind) || null
383
+ try {
384
+ const { etag } = await driveUpdate(token, existing.id, envelope, lastEtag)
385
+ localStorage.setItem(LS_LAST_ETAG + ':' + cfg.kind, etag || '')
386
+ _dirty = false
387
+ emitStatus('synced')
388
+ return { updated: true }
389
+ } catch (e) {
390
+ if (e.code === 'PRECONDITION_FAILED' && retries > 0) {
391
+ emitStatus('conflict')
392
+ _running = false // pull() needs the slot
393
+ await pull()
394
+ _running = true
395
+ return push(retries - 1)
396
+ }
397
+ throw e
398
+ }
399
+ } catch (e) {
400
+ _lastError = e
401
+ emitStatus('error', { error: e.message })
402
+ throw e
403
+ } finally {
404
+ _running = false
405
+ }
406
+ }
407
+
408
+ function markDirty () {
409
+ _dirty = true
410
+ if (!isUnlocked()) return
411
+ if (_debounceTimer) clearTimeout(_debounceTimer)
412
+ _debounceTimer = setTimeout(() => {
413
+ push().catch(() => {})
414
+ }, PUSH_DEBOUNCE_MS)
415
+ }
416
+
417
+ function startTimers () {
418
+ if (_hardTimer) clearInterval(_hardTimer)
419
+ if (_periodicTimer) clearInterval(_periodicTimer)
420
+ _hardTimer = setInterval(() => { if (_dirty) push().catch(() => {}) }, PUSH_HARD_INTERVAL_MS)
421
+ _periodicTimer = setInterval(() => { pull().catch(() => {}) }, PULL_INTERVAL_MS)
422
+ }
423
+
424
+ function stopTimers () {
425
+ if (_debounceTimer) { clearTimeout(_debounceTimer); _debounceTimer = null }
426
+ if (_hardTimer) { clearInterval(_hardTimer); _hardTimer = null }
427
+ if (_periodicTimer) { clearInterval(_periodicTimer); _periodicTimer = null }
428
+ }
429
+
430
+ // Push pending writes when the tab becomes visible again (mobile / bg)
431
+ document.addEventListener('visibilitychange', () => {
432
+ if (document.visibilityState === 'visible' && _dirty && isUnlocked()) {
433
+ push().catch(() => {})
434
+ }
435
+ })
436
+ window.addEventListener('online', () => {
437
+ if (_dirty && isUnlocked()) push().catch(() => {})
438
+ })
439
+
440
+ return {
441
+ onStatus (fn) { _statusListeners.add(fn); return () => _statusListeners.delete(fn) },
442
+ isUnlocked,
443
+ isConnected: () => !!localStorage.getItem(LS_OAUTH_CLIENT),
444
+ async connectGoogle (clientId) {
445
+ if (!clientId) throw new Error('clientId required')
446
+ localStorage.setItem(LS_OAUTH_CLIENT, clientId)
447
+ const r = await requestAccessToken(clientId, { interactive: true })
448
+ emitStatus('connected')
449
+ return r
450
+ },
451
+ async disconnectGoogle () {
452
+ _accessToken = null
453
+ _accessTokenExpiresAt = 0
454
+ localStorage.removeItem(LS_OAUTH_CLIENT)
455
+ stopTimers()
456
+ emitStatus('disconnected')
457
+ },
458
+ unlock,
459
+ lock,
460
+ pull,
461
+ push,
462
+ markDirty,
463
+ getStatus () {
464
+ return {
465
+ kind: cfg.kind,
466
+ connected: !!localStorage.getItem(LS_OAUTH_CLIENT),
467
+ unlocked: isUnlocked(),
468
+ dirty: _dirty,
469
+ lastError: _lastError ? _lastError.message : null
470
+ }
471
+ }
472
+ }
473
+ }
package/vault/vault.js ADDED
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Dotrino Identity Vault (cáscara de navegador).
3
+ *
4
+ * Cargado dentro de un iframe oculto por las apps. La criptografía y todos los
5
+ * handlers viven en `./core.js` (runtime-agnóstico); este archivo sólo provee
6
+ * los backends del navegador —`localStorage` (kv), el peer book en IndexedDB
7
+ * (`./peerStore.js`) y el sync a Google Drive (`./sync.js`)— y el transporte
8
+ * `postMessage` con los embebedores. La clave privada nunca sale de esta página.
9
+ */
10
+
11
+ import { createSync } from './sync.js'
12
+ import {
13
+ initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty
14
+ } from './peerStore.js'
15
+ import { createIdentityCore } from './core.js'
16
+
17
+ ;(async () => {
18
+ // kv estilo localStorage (síncrono) para keypairs, me y nonces.
19
+ const kv = {
20
+ getItem: (k) => localStorage.getItem(k),
21
+ setItem: (k, v) => localStorage.setItem(k, v),
22
+ removeItem: (k) => localStorage.removeItem(k)
23
+ }
24
+
25
+ const core = await createIdentityCore({
26
+ kv,
27
+ peers: { initPeerStorage, loadPeers, savePeers, setPeersDirect, upsertPeer, onDirty },
28
+ makeSync: createSync
29
+ })
30
+
31
+ const { handlers } = core
32
+
33
+ // Broadcast de estado del sync a todos los embebedores.
34
+ const broadcastStatus = (payload) => {
35
+ for (const w of [window.parent, ...Array.from(document.querySelectorAll('iframe')).map(f => f.contentWindow)]) {
36
+ if (!w || w === window) continue
37
+ try { w.postMessage({ _cci: true, type: 'event', event: 'sync', payload }, '*') } catch {}
38
+ }
39
+ if (window.parent && window.parent !== window) {
40
+ try { window.parent.postMessage({ _cci: true, type: 'event', event: 'sync', payload }, '*') } catch {}
41
+ }
42
+ }
43
+ core.onSyncStatus(broadcastStatus)
44
+
45
+ window.addEventListener('message', async (event) => {
46
+ const msg = event.data
47
+ if (!msg || msg._cci !== true || msg.type !== 'request') return
48
+ const { id, method, params } = msg
49
+ const reply = (payload) => event.source?.postMessage(
50
+ { _cci: true, type: 'response', id, ...payload },
51
+ event.origin
52
+ )
53
+ const handler = handlers[method]
54
+ if (!handler) return reply({ error: `Unknown method: ${method}` })
55
+ try {
56
+ const result = await handler(params || {})
57
+ reply({ result })
58
+ } catch (e) {
59
+ reply({ error: e?.message || String(e) })
60
+ }
61
+ })
62
+
63
+ // Avisar a todo padre que el vault está listo.
64
+ if (window.parent && window.parent !== window) {
65
+ window.parent.postMessage({ _cci: true, type: 'ready', me: core.me }, '*')
66
+ }
67
+ })()