@auxilium/datalynk-client 1.6.2 → 1.6.4

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/dist/index.cjs CHANGED
@@ -193,6 +193,8 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
193
193
  constructor(database, tables, version2) {
194
194
  __publicField(this, "connection");
195
195
  __publicField(this, "tables");
196
+ /** Encrypts values at rest when set */
197
+ __publicField(this, "cipher");
196
198
  this.database = database;
197
199
  this.version = version2;
198
200
  this.tables = tables.map((table) => typeof table === "object" ? { ...table, name: table.name.toString() } : { name: table.toString() });
@@ -241,22 +243,46 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
241
243
  async tx(operation, readonly = false) {
242
244
  const db = await this.database.connection;
243
245
  return new Promise((resolve, reject) => {
244
- const request = operation(db.transaction(this.name, readonly ? "readonly" : "readwrite").objectStore(this.name));
245
- request.onsuccess = () => resolve(request.result);
246
- request.onerror = () => reject(request.error);
246
+ const transaction = db.transaction(this.name, readonly ? "readonly" : "readwrite");
247
+ let result;
248
+ transaction.oncomplete = () => resolve(result);
249
+ transaction.onabort = () => reject(transaction.error || new Error("IndexedDB transaction aborted"));
250
+ transaction.onerror = () => reject(transaction.error || new Error("IndexedDB transaction failed"));
251
+ try {
252
+ const request = operation(transaction.objectStore(this.name));
253
+ request.onsuccess = () => {
254
+ result = request.result;
255
+ };
256
+ request.onerror = () => reject(request.error || new Error("IndexedDB request failed"));
257
+ } catch (error) {
258
+ transaction.abort();
259
+ reject(error);
260
+ }
247
261
  });
248
262
  }
249
- add(value, key) {
250
- return this.tx((store) => store.add(value, key));
263
+ seal(value) {
264
+ const cipher = this.database.cipher;
265
+ return (cipher == null ? void 0 : cipher.enabled) ? cipher.seal(value) : Promise.resolve(value);
266
+ }
267
+ open(value) {
268
+ const cipher = this.database.cipher;
269
+ return cipher ? cipher.open(value) : Promise.resolve(value);
251
270
  }
252
- put(key, value) {
253
- return this.tx((store) => store.put(value, key));
271
+ async add(value, key) {
272
+ const sealed = await this.seal(value);
273
+ return this.tx((store) => store.add(sealed, key));
254
274
  }
255
- get(key) {
256
- return this.tx((store) => store.get(key), true);
275
+ async put(key, value) {
276
+ const sealed = await this.seal(value);
277
+ return this.tx((store) => store.put(sealed, key));
257
278
  }
258
- getAll() {
259
- return this.tx((store) => store.getAll(), true);
279
+ async get(key) {
280
+ return await this.open(await this.tx((store) => store.get(key), true));
281
+ }
282
+ /** All readable values. Values sealed with another key are skipped. */
283
+ async getAll() {
284
+ const values = await Promise.all((await this.tx((store) => store.getAll(), true)).map((v) => this.open(v)));
285
+ return values.filter((v) => v !== void 0);
260
286
  }
261
287
  getAllKeys() {
262
288
  return this.tx((store) => store.getAllKeys(), true);
@@ -267,6 +293,20 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
267
293
  clear() {
268
294
  return this.tx((store) => store.clear());
269
295
  }
296
+ /** Replace the whole table in one transaction (values are sealed first, so nothing is lost while locked) */
297
+ async replace(entries) {
298
+ const sealed = await Promise.all(entries.map(async ([key, value]) => [key, await this.seal(value)]));
299
+ const db = await this.database.connection;
300
+ await new Promise((resolve, reject) => {
301
+ const tx = db.transaction(this.name, "readwrite");
302
+ const store = tx.objectStore(this.name);
303
+ store.clear();
304
+ sealed.forEach(([key, value]) => store.put(value, key));
305
+ tx.oncomplete = () => resolve();
306
+ tx.onerror = () => reject(tx.error);
307
+ tx.onabort = () => reject(tx.error);
308
+ });
309
+ }
270
310
  count() {
271
311
  return this.tx((store) => store.count(), true);
272
312
  }
@@ -1020,6 +1060,9 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1020
1060
  };
1021
1061
  return BehaviorSubject2;
1022
1062
  }(Subject);
1063
+ var EMPTY = new Observable(function(subscriber) {
1064
+ return subscriber.complete();
1065
+ });
1023
1066
  var EmptyError = createErrorClass(function(_super) {
1024
1067
  return function EmptyErrorImpl() {
1025
1068
  _super(this);
@@ -1047,6 +1090,23 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1047
1090
  });
1048
1091
  });
1049
1092
  }
1093
+ function firstValueFrom(source, config2) {
1094
+ return new Promise(function(resolve, reject) {
1095
+ var subscriber = new SafeSubscriber({
1096
+ next: function(value) {
1097
+ resolve(value);
1098
+ subscriber.unsubscribe();
1099
+ },
1100
+ error: reject,
1101
+ complete: function() {
1102
+ {
1103
+ reject(new EmptyError());
1104
+ }
1105
+ }
1106
+ });
1107
+ source.subscribe(subscriber);
1108
+ });
1109
+ }
1050
1110
  function filter(predicate, thisArg) {
1051
1111
  return operate(function(source, subscriber) {
1052
1112
  var index = 0;
@@ -1055,6 +1115,21 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1055
1115
  }));
1056
1116
  });
1057
1117
  }
1118
+ function take(count) {
1119
+ return count <= 0 ? function() {
1120
+ return EMPTY;
1121
+ } : operate(function(source, subscriber) {
1122
+ var seen = 0;
1123
+ source.subscribe(createOperatorSubscriber(subscriber, function(value) {
1124
+ if (++seen <= count) {
1125
+ subscriber.next(value);
1126
+ if (count <= seen) {
1127
+ subscriber.complete();
1128
+ }
1129
+ }
1130
+ }));
1131
+ });
1132
+ }
1058
1133
  function distinctUntilChanged(comparator, keySelector) {
1059
1134
  if (keySelector === void 0) {
1060
1135
  keySelector = identity;
@@ -1536,52 +1611,304 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1536
1611
  `;
1537
1612
  });
1538
1613
  let LoginPrompt = _LoginPrompt;
1539
- const KEY = "datalynk-offline-login";
1614
+ const RECORD_KEY = "datalynk-offline-login";
1615
+ const PROFILE_KEY = "datalynk-user";
1616
+ const VAULT_DB = "datalynk-vault";
1617
+ const VAULT_STORE = "keys";
1618
+ const DEVICE_KEY = "device";
1540
1619
  const ITERATIONS = 31e4;
1541
- async function hash(password, salt) {
1542
- const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]);
1543
- return Array.from(new Uint8Array(await crypto.subtle.deriveBits(
1544
- { name: "PBKDF2", salt: new Uint8Array(salt), iterations: ITERATIONS, hash: "SHA-256" },
1545
- key,
1546
- 256
1547
- )));
1548
- }
1549
- async function rememberOfflineLogin(realm, login, uid, password) {
1550
- const salt = crypto.getRandomValues(new Uint8Array(16));
1551
- const record = { realm, login: login.trim().toLowerCase(), uid, salt: Array.from(salt), hash: await hash(password, salt) };
1552
- localStorage.setItem(KEY, JSON.stringify(record));
1553
- }
1554
- async function verifyOfflineLogin(realm, login, uid, password) {
1555
- const saved = localStorage.getItem(KEY);
1556
- if (!saved) return false;
1557
- let record;
1558
- try {
1559
- record = JSON.parse(saved);
1560
- } catch {
1561
- return false;
1562
- }
1563
- if (record.realm !== realm || record.login !== login.trim().toLowerCase() || record.uid !== uid || !Array.isArray(record.salt) || !Array.isArray(record.hash)) return false;
1564
- const candidate = await hash(password, new Uint8Array(record.salt));
1565
- if (candidate.length !== record.hash.length) return false;
1566
- let difference = 0;
1567
- for (let i = 0; i < candidate.length; i++) difference |= candidate[i] ^ record.hash[i];
1568
- return difference === 0;
1620
+ const DAY = 864e5;
1621
+ const offlineLoginMessages = {
1622
+ "none": "Offline login is not available on this device. Connect to the internet to sign in.",
1623
+ "wrong-user": "Offline login is only available for the last user who signed in on this device.",
1624
+ "expired": "Offline login has expired on this device. Connect to the internet to sign in.",
1625
+ "locked": "Offline login is locked after too many attempts. Connect to the internet to sign in.",
1626
+ "bad-password": "Incorrect password."
1627
+ };
1628
+ const toB64 = (bytes) => {
1629
+ let out = "";
1630
+ new Uint8Array(bytes).forEach((b) => out += String.fromCharCode(b));
1631
+ return btoa(out);
1632
+ };
1633
+ const fromB64 = (text) => Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
1634
+ const normalizeLogin = (login) => (login || "").trim().toLowerCase();
1635
+ function isSealed(value) {
1636
+ return !!value && typeof value == "object" && value.__dlv === 1 && "iv" in value && "ct" in value;
1569
1637
  }
1570
- function hasOfflineLogin(realm, uid) {
1571
- try {
1572
- const record = JSON.parse(localStorage.getItem(KEY) || "null");
1573
- return (record == null ? void 0 : record.realm) === realm && (record == null ? void 0 : record.uid) === uid;
1574
- } catch {
1575
- return false;
1638
+ class OfflineVault {
1639
+ constructor(enabled, options = {}, startOffline) {
1640
+ /** False when offline login is disabled or the environment lacks WebCrypto/IndexedDB/localStorage */
1641
+ __publicField(this, "enabled");
1642
+ /** True once the data key is available in memory */
1643
+ __publicField(this, "unlocked$", new BehaviorSubject(false));
1644
+ /** Resolves once startup key loading has finished */
1645
+ __publicField(this, "ready");
1646
+ __publicField(this, "dek", null);
1647
+ __publicField(this, "days");
1648
+ __publicField(this, "attempts");
1649
+ this.enabled = enabled && typeof crypto != "undefined" && !!crypto.subtle && typeof indexedDB != "undefined" && typeof localStorage != "undefined";
1650
+ this.days = options.days ?? 7;
1651
+ this.attempts = options.attempts ?? 5;
1652
+ if (startOffline === void 0) startOffline = typeof navigator != "undefined" && navigator.onLine === false;
1653
+ this.ready = this.enabled ? this.init(startOffline).catch((error) => {
1654
+ console.error("Unable to open the Datalynk offline vault", error);
1655
+ }) : Promise.resolve();
1656
+ }
1657
+ get unlocked() {
1658
+ return this.unlocked$.getValue();
1659
+ }
1660
+ /** A saved offline login exists, but this session has not unlocked it yet */
1661
+ get needsPassword() {
1662
+ return this.enabled && !this.unlocked && !!this.readRecord();
1663
+ }
1664
+ /** Wait until the data key is available (resolves immediately when the vault is disabled) */
1665
+ whenUnlocked() {
1666
+ if (!this.enabled || this.unlocked) return Promise.resolve(true);
1667
+ return firstValueFrom(this.unlocked$.pipe(filter(Boolean)));
1668
+ }
1669
+ /** Saved offline login matches this user */
1670
+ has(realm, uid) {
1671
+ const record = this.readRecord();
1672
+ return !!record && record.realm === realm && record.uid === uid;
1673
+ }
1674
+ /**
1675
+ * Called after a successful online password login. Wraps the data key with the
1676
+ * password so this user can sign in offline later.
1677
+ */
1678
+ async rememberLogin(realm, login, uid, password) {
1679
+ if (!this.enabled) return;
1680
+ await this.ready;
1681
+ const record = this.readRecord();
1682
+ let dek = null;
1683
+ if (record && record.realm === realm && record.uid === uid) {
1684
+ dek = await this.unwrapWithPassword(record, password).catch(() => null);
1685
+ if (!dek) dek = this.dek;
1686
+ } else if (!record) {
1687
+ dek = this.dek;
1688
+ }
1689
+ if (!dek) dek = await this.newDataKey();
1690
+ const salt = crypto.getRandomValues(new Uint8Array(16));
1691
+ const iv = crypto.getRandomValues(new Uint8Array(12));
1692
+ const kek = await this.passwordKey(password, salt);
1693
+ const wrapped = await crypto.subtle.wrapKey("raw", dek, kek, { name: "AES-GCM", iv });
1694
+ this.writeRecord({
1695
+ v: 2,
1696
+ realm,
1697
+ uid,
1698
+ login: normalizeLogin(login),
1699
+ salt: toB64(salt),
1700
+ iv: toB64(iv),
1701
+ wrapped: toB64(wrapped),
1702
+ verifiedAt: Date.now(),
1703
+ failures: 0
1704
+ });
1705
+ await this.setKey(dek);
1706
+ }
1707
+ /** Try to unlock offline with a password */
1708
+ async unlockOffline(realm, login, uid, password) {
1709
+ if (!this.enabled) return "none";
1710
+ await this.ready;
1711
+ const record = this.readRecord();
1712
+ if (!record) return "none";
1713
+ if (record.realm !== realm || record.login !== normalizeLogin(login) || uid != null && record.uid !== uid) return "wrong-user";
1714
+ if (record.locked) return "locked";
1715
+ if (Date.now() - record.verifiedAt > this.days * DAY) return "expired";
1716
+ try {
1717
+ const dek = await this.unwrapWithPassword(record, password);
1718
+ this.writeRecord({ ...record, failures: 0 });
1719
+ await this.setKey(dek);
1720
+ return "ok";
1721
+ } catch {
1722
+ const failures = (record.failures || 0) + 1;
1723
+ const locked = failures >= this.attempts;
1724
+ this.writeRecord({ ...record, failures, locked });
1725
+ return locked ? "locked" : "bad-password";
1726
+ }
1727
+ }
1728
+ /** The server confirmed this user's session: restart the offline window */
1729
+ touch(realm, uid) {
1730
+ if (!this.enabled || realm == null || uid == null) return;
1731
+ const record = this.readRecord();
1732
+ if (record && record.realm === realm && record.uid === uid) this.writeRecord({ ...record, verifiedAt: Date.now() });
1733
+ }
1734
+ /** Drop the in-memory and device keys but keep the password copy (queued work stays recoverable) */
1735
+ async lock() {
1736
+ this.dek = null;
1737
+ if (this.unlocked) this.unlocked$.next(false);
1738
+ if (this.enabled) await this.deleteDevice();
1739
+ }
1740
+ /** Remove offline login completely; anything sealed becomes unreadable */
1741
+ async forget() {
1742
+ if (typeof localStorage != "undefined") {
1743
+ localStorage.removeItem(RECORD_KEY);
1744
+ localStorage.removeItem(PROFILE_KEY);
1745
+ }
1746
+ await this.lock();
1747
+ }
1748
+ /** Encrypt a value for IndexedDB (passes through when disabled) */
1749
+ async seal(value) {
1750
+ if (!this.enabled || value === void 0) return value;
1751
+ await this.whenUnlocked();
1752
+ const iv = crypto.getRandomValues(new Uint8Array(12));
1753
+ const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, this.dek, new TextEncoder().encode(JSON.stringify(value)));
1754
+ return { __dlv: 1, iv, ct };
1755
+ }
1756
+ /** Decrypt a value from IndexedDB. Unreadable values (old key / other user) return undefined. */
1757
+ async open(value) {
1758
+ if (!isSealed(value)) return value;
1759
+ if (!this.enabled) return void 0;
1760
+ await this.whenUnlocked();
1761
+ try {
1762
+ const iv = typeof value.iv == "string" ? fromB64(value.iv) : value.iv;
1763
+ const ct = typeof value.ct == "string" ? fromB64(value.ct) : value.ct;
1764
+ const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, this.dek, ct);
1765
+ return JSON.parse(new TextDecoder().decode(plain));
1766
+ } catch {
1767
+ return void 0;
1768
+ }
1769
+ }
1770
+ /** Save the cached user profile (sealed when enabled) */
1771
+ async setProfile(user) {
1772
+ if (typeof localStorage == "undefined") return;
1773
+ if (user == null) return localStorage.removeItem(PROFILE_KEY);
1774
+ if (!this.enabled) return localStorage.setItem(PROFILE_KEY, JSON.stringify(user));
1775
+ const sealed = await this.seal(user);
1776
+ localStorage.setItem(PROFILE_KEY, JSON.stringify({ __dlv: 1, iv: toB64(sealed.iv), ct: toB64(sealed.ct) }));
1777
+ }
1778
+ /** Read the cached user profile; null while locked */
1779
+ async getProfile() {
1780
+ if (typeof localStorage == "undefined") return null;
1781
+ let saved;
1782
+ try {
1783
+ saved = JSON.parse(localStorage.getItem(PROFILE_KEY) || "null");
1784
+ } catch {
1785
+ return null;
1786
+ }
1787
+ if (!isSealed(saved)) return this.enabled ? null : saved;
1788
+ if (!this.unlocked) return null;
1789
+ return await this.open(saved) ?? null;
1790
+ }
1791
+ // ----- internals -----
1792
+ async init(startOffline) {
1793
+ if (startOffline) {
1794
+ await this.deleteDevice();
1795
+ return;
1796
+ }
1797
+ const device = await this.readDevice();
1798
+ if (device) {
1799
+ try {
1800
+ const dek = await crypto.subtle.unwrapKey(
1801
+ "raw",
1802
+ device.wrapped,
1803
+ device.key,
1804
+ { name: "AES-GCM", iv: device.iv },
1805
+ { name: "AES-GCM" },
1806
+ true,
1807
+ ["encrypt", "decrypt"]
1808
+ );
1809
+ this.dek = dek;
1810
+ this.unlocked$.next(true);
1811
+ return;
1812
+ } catch {
1813
+ await this.deleteDevice();
1814
+ }
1815
+ }
1816
+ if (!this.readRecord()) await this.setKey(await this.newDataKey());
1817
+ }
1818
+ async setKey(dek) {
1819
+ this.dek = dek;
1820
+ try {
1821
+ await this.writeDevice(dek);
1822
+ } catch (error) {
1823
+ console.warn("Unable to store the Datalynk device key; offline data will need the password", error);
1824
+ }
1825
+ if (!this.unlocked) this.unlocked$.next(true);
1826
+ }
1827
+ newDataKey() {
1828
+ return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
1829
+ }
1830
+ async passwordKey(password, salt) {
1831
+ const base = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveKey"]);
1832
+ return crypto.subtle.deriveKey(
1833
+ { name: "PBKDF2", salt, iterations: ITERATIONS, hash: "SHA-256" },
1834
+ base,
1835
+ { name: "AES-GCM", length: 256 },
1836
+ false,
1837
+ ["wrapKey", "unwrapKey"]
1838
+ );
1839
+ }
1840
+ async unwrapWithPassword(record, password) {
1841
+ const kek = await this.passwordKey(password, fromB64(record.salt));
1842
+ return crypto.subtle.unwrapKey(
1843
+ "raw",
1844
+ fromB64(record.wrapped),
1845
+ kek,
1846
+ { name: "AES-GCM", iv: fromB64(record.iv) },
1847
+ { name: "AES-GCM" },
1848
+ true,
1849
+ ["encrypt", "decrypt"]
1850
+ );
1851
+ }
1852
+ readRecord() {
1853
+ if (typeof localStorage == "undefined") return null;
1854
+ try {
1855
+ const record = JSON.parse(localStorage.getItem(RECORD_KEY) || "null");
1856
+ if ((record == null ? void 0 : record.v) === 2) return record;
1857
+ if (record) localStorage.removeItem(RECORD_KEY);
1858
+ } catch {
1859
+ localStorage.removeItem(RECORD_KEY);
1860
+ }
1861
+ return null;
1862
+ }
1863
+ writeRecord(record) {
1864
+ localStorage.setItem(RECORD_KEY, JSON.stringify(record));
1865
+ }
1866
+ vaultDb() {
1867
+ return new Promise((resolve, reject) => {
1868
+ const request = indexedDB.open(VAULT_DB, 1);
1869
+ request.onupgradeneeded = () => request.result.createObjectStore(VAULT_STORE);
1870
+ request.onsuccess = () => resolve(request.result);
1871
+ request.onerror = () => reject(request.error);
1872
+ });
1873
+ }
1874
+ async vaultTx(mode, op) {
1875
+ const db = await this.vaultDb();
1876
+ try {
1877
+ return await new Promise((resolve, reject) => {
1878
+ const tx = db.transaction(VAULT_STORE, mode);
1879
+ const request = op(tx.objectStore(VAULT_STORE));
1880
+ tx.oncomplete = () => resolve(request.result);
1881
+ tx.onerror = () => reject(tx.error);
1882
+ tx.onabort = () => reject(tx.error);
1883
+ });
1884
+ } finally {
1885
+ db.close();
1886
+ }
1887
+ }
1888
+ async readDevice() {
1889
+ try {
1890
+ return await this.vaultTx("readonly", (store) => store.get(DEVICE_KEY)) || null;
1891
+ } catch {
1892
+ return null;
1893
+ }
1894
+ }
1895
+ async writeDevice(dek) {
1896
+ const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["wrapKey", "unwrapKey"]);
1897
+ const iv = crypto.getRandomValues(new Uint8Array(12));
1898
+ const wrapped = await crypto.subtle.wrapKey("raw", dek, key, { name: "AES-GCM", iv });
1899
+ await this.vaultTx("readwrite", (store) => store.put({ key, iv, wrapped }, DEVICE_KEY));
1900
+ }
1901
+ async deleteDevice() {
1902
+ try {
1903
+ await this.vaultTx("readwrite", (store) => store.delete(DEVICE_KEY));
1904
+ } catch (error) {
1905
+ console.warn("Unable to remove the Datalynk device key", error);
1906
+ }
1576
1907
  }
1577
- }
1578
- function forgetOfflineLogin() {
1579
- localStorage.removeItem(KEY);
1580
1908
  }
1581
1909
  class Auth {
1582
1910
  constructor(api) {
1583
1911
  __publicField(this, "onlinePrompt");
1584
- __publicField(this, "offlineUnlocked", false);
1585
1912
  __publicField(this, "wasOffline", false);
1586
1913
  /** Current user as an observable */
1587
1914
  __publicField(this, "user$", new BehaviorSubject(void 0));
@@ -1591,21 +1918,12 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1591
1918
  if (status === "offline" || status === "unavailable") this.wasOffline = true;
1592
1919
  if (status !== "online" || !this.wasOffline) return;
1593
1920
  this.wasOffline = false;
1594
- const wasUnlocked = this.offlineUnlocked;
1595
1921
  const previousUser = this.user;
1596
- this.offlineUnlocked = false;
1597
- this.user = void 0;
1598
1922
  try {
1599
- const user = await this.current();
1600
- const identity2 = this.api.jwtPayload;
1601
- if (!user || user.guest || !identity2 || !hasOfflineLogin(identity2.realm, identity2.uid))
1602
- forgetOfflineLogin();
1603
- this.user = user;
1923
+ this.user = await this.current(this.api.token, true);
1604
1924
  } catch (error) {
1605
- if (this.api.status === "unauthorized") forgetOfflineLogin();
1606
- else {
1925
+ if (this.api.status !== "unauthorized") {
1607
1926
  this.wasOffline = true;
1608
- this.offlineUnlocked = wasUnlocked;
1609
1927
  this.user = previousUser;
1610
1928
  }
1611
1929
  console.error("Unable to recheck the Datalynk user", error);
@@ -1622,11 +1940,22 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1622
1940
  });
1623
1941
  if ((_a = this.api.options.offline) == null ? void 0 : _a.length)
1624
1942
  this.user$.pipe(filter((u) => u !== void 0)).subscribe((u) => {
1625
- if (u == null) {
1626
- if (this.api.online) localStorage.removeItem("datalynk-user");
1627
- } else localStorage.setItem("datalynk-user", JSON.stringify(u));
1943
+ if (u == null && !this.api.online) return;
1944
+ const vault = this.api.vault;
1945
+ if (vault) vault.setProfile(u).catch((error) => console.warn("Unable to cache the Datalynk user", error));
1946
+ else if (u == null) localStorage.removeItem("datalynk-user");
1947
+ else localStorage.setItem("datalynk-user", JSON.stringify(u));
1628
1948
  });
1629
1949
  }
1950
+ /** Offline login/encryption is active for this app */
1951
+ get vault() {
1952
+ var _a;
1953
+ return ((_a = this.api.vault) == null ? void 0 : _a.enabled) ? this.api.vault : void 0;
1954
+ }
1955
+ /** This session may use offline data (always true when offline login is opted out) */
1956
+ get offlineUnlocked() {
1957
+ return !this.vault || this.vault.unlocked;
1958
+ }
1630
1959
  /** Current user */
1631
1960
  get user() {
1632
1961
  return this.user$.getValue();
@@ -1644,14 +1973,18 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1644
1973
  *
1645
1974
  * @return {Promise<User | null>}
1646
1975
  */
1647
- async current(token = this.api.token) {
1648
- var _a;
1976
+ async current(token = this.api.token, refresh = false) {
1977
+ var _a, _b, _c;
1649
1978
  if (!token) return null;
1650
- else if (token == ((_a = this.user) == null ? void 0 : _a.token)) return this.user;
1651
- else if (!this.api.online && this.offlineUnlocked && typeof localStorage != "undefined" && localStorage.getItem("datalynk-user"))
1652
- return JSON.parse(localStorage.getItem("datalynk-user"));
1653
- else if (!this.api.online) return null;
1654
- return this.api.request([{ "$/auth/current": {} }, { "$/env/me": {} }], { token }).then((resp) => resp[0] || resp[1] ? { ...resp[0], ...resp[1] } : null);
1979
+ else if (!refresh && token == ((_a = this.user) == null ? void 0 : _a.token)) return this.user;
1980
+ else if (!this.api.online) {
1981
+ if (!this.offlineUnlocked || !((_b = this.api.options.offline) == null ? void 0 : _b.length)) return null;
1982
+ if (this.api.vault) return this.api.vault.getProfile();
1983
+ return null;
1984
+ }
1985
+ const user = await this.api.request([{ "$/auth/current": {} }, { "$/env/me": {} }], { token }).then((resp) => resp[0] || resp[1] ? { ...resp[0], ...resp[1] } : null);
1986
+ if (user && !user.guest && this.api.jwtPayload) (_c = this.vault) == null ? void 0 : _c.touch(this.api.jwtPayload.realm, this.api.jwtPayload.uid);
1987
+ return user;
1655
1988
  }
1656
1989
  /**
1657
1990
  * Automatically handle sessions by checking localStorage & URL parameters for a token & prompting
@@ -1662,16 +1995,17 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1662
1995
  * @return {Promise<void>} Login complete
1663
1996
  */
1664
1997
  async handleLogin(spoke, options) {
1665
- var _a, _b, _c, _d;
1998
+ var _a, _b, _c, _d, _e;
1666
1999
  if (this.onlinePrompt) window.removeEventListener("online", this.onlinePrompt);
1667
2000
  if ((_a = this.api.options.offline) == null ? void 0 : _a.length) window.removeEventListener("online", this.onlinePrompt = () => this.handleLogin(spoke, options));
2001
+ await ((_b = this.api.vault) == null ? void 0 : _b.ready);
1668
2002
  const urlParams = new URLSearchParams(location.search);
1669
2003
  const urlToken = urlParams.getAll("datalynkToken").filter(Boolean).pop();
1670
2004
  if (urlToken) {
1671
2005
  this.api.token = urlToken;
1672
2006
  urlParams.delete("datalynkToken");
1673
2007
  location.href = location.pathname + (urlParams.toString() ? "?" + urlParams.toString() : "") + location.hash;
1674
- } else if (!this.api.online && ((_b = this.api.options.offline) == null ? void 0 : _b.length) && !this.offlineUnlocked) {
2008
+ } else if (this.isOffline() && this.vault && !this.vault.unlocked) {
1675
2009
  await this.loginPrompt(spoke, options).done;
1676
2010
  return;
1677
2011
  } else if (this.api.reauthenticationPending && !this.api.online && ((_c = this.api.jwtPayload) == null ? void 0 : _c.realm) == spoke) {
@@ -1680,6 +2014,8 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1680
2014
  if (((_d = this.api.jwtPayload) == null ? void 0 : _d.realm) != spoke) {
1681
2015
  this.api.token = null;
1682
2016
  location.reload();
2017
+ } else if ((_e = this.vault) == null ? void 0 : _e.needsPassword) {
2018
+ await this.loginPrompt(spoke, options).done;
1683
2019
  }
1684
2020
  } else {
1685
2021
  this.api.token = null;
@@ -1687,6 +2023,9 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1687
2023
  location.reload();
1688
2024
  }
1689
2025
  }
2026
+ isOffline() {
2027
+ return !this.api.online || typeof navigator !== "undefined" && navigator.onLine === false;
2028
+ }
1690
2029
  /**
1691
2030
  * Check whether user has a token
1692
2031
  *
@@ -1738,16 +2077,14 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1738
2077
  * @returns {Promise<User>} User account
1739
2078
  */
1740
2079
  async login(spoke, login, password, opts) {
1741
- var _a, _b, _c;
1742
- if (((_a = this.api.options.offline) == null ? void 0 : _a.length) && (!this.api.online || typeof navigator !== "undefined" && !navigator.onLine)) {
2080
+ var _a;
2081
+ if (this.vault && this.isOffline()) {
1743
2082
  const identity2 = this.api.jwtPayload;
1744
- const saved = localStorage.getItem("datalynk-user");
1745
- if (!identity2 || identity2.realm !== spoke || !saved || !await verifyOfflineLogin(spoke, login, identity2.uid, password.trim()))
1746
- throw new Error("Offline login is not available for this account on this device.");
1747
- const user = JSON.parse(saved);
1748
- if (![user.login, user.email].some((value) => (value == null ? void 0 : value.toLowerCase()) === login.trim().toLowerCase()))
1749
- throw new Error("Offline login is not available for this account on this device.");
1750
- this.offlineUnlocked = true;
2083
+ if (!identity2 || identity2.realm !== spoke) throw new Error(offlineLoginMessages.none);
2084
+ const result = await this.vault.unlockOffline(spoke, login, identity2.uid, password.trim());
2085
+ if (result !== "ok") throw new Error(offlineLoginMessages[result]);
2086
+ const user = await this.vault.getProfile();
2087
+ if (!user) throw new Error(offlineLoginMessages.none);
1751
2088
  this.user = user;
1752
2089
  return user;
1753
2090
  }
@@ -1768,8 +2105,8 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1768
2105
  const data = await resp.json().catch(() => ({}));
1769
2106
  if (!resp.ok || data["error"]) throw Object.assign(errorFromCode(resp.status, data.error) || {}, data);
1770
2107
  this.api.token = data["token"];
1771
- if (((_b = this.api.options.offline) == null ? void 0 : _b.length) && this.api.options.saveSession && ((_c = this.api.jwtPayload) == null ? void 0 : _c.uid) != null)
1772
- await rememberOfflineLogin(spoke, login, this.api.jwtPayload.uid, password.trim());
2108
+ if (this.vault && ((_a = this.api.jwtPayload) == null ? void 0 : _a.uid) != null)
2109
+ await this.vault.rememberLogin(spoke, login, this.api.jwtPayload.uid, password.trim()).catch((error) => console.error("Unable to enable offline login", error));
1773
2110
  return data;
1774
2111
  }
1775
2112
  /**
@@ -1801,8 +2138,9 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
1801
2138
  * @return {Promise<{closed: string, new: string}>}
1802
2139
  */
1803
2140
  logout(reload = true) {
2141
+ var _a;
1804
2142
  localStorage.removeItem("datalynk-user");
1805
- forgetOfflineLogin();
2143
+ void ((_a = this.api.vault) == null ? void 0 : _a.forget());
1806
2144
  return this.api.request({ "$/auth/logout": {} }).then((resp) => {
1807
2145
  if (reload) location.reload();
1808
2146
  this.api.token = null;
@@ -2750,15 +3088,17 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
2750
3088
  const lowest = resp.reduce((acc, r) => Math.min(acc, r.id), 0);
2751
3089
  if (lowest < _Slice.nextId) _Slice.nextId = lowest;
2752
3090
  }).finally(() => this.loaded = true);
2753
- this.cache$.pipe(skip(1)).subscribe(async (cache) => {
3091
+ this.cache$.pipe(skip(1)).subscribe((cache) => {
3092
+ const table = this.table;
3093
+ if (!table) return;
3094
+ const save = typeof table.replace == "function" ? table.replace(cache.map((c) => [c.id, c])) : table.clear().then(() => Promise.all(cache.map((c) => table.put(c.id, c))));
3095
+ save.catch((error) => console.warn("Unable to save offline slice cache", error));
3096
+ });
3097
+ this.api.token$.pipe(filter((token) => !!token), take(1)).subscribe(async () => {
2754
3098
  var _a2;
2755
- await ((_a2 = this.table) == null ? void 0 : _a2.clear());
2756
- await Promise.all(cache.map((c) => {
2757
- var _a3;
2758
- return (_a3 = this.table) == null ? void 0 : _a3.put(c.id, c);
2759
- }));
3099
+ await ((_a2 = this.api.vault) == null ? void 0 : _a2.whenUnlocked());
3100
+ this.sync();
2760
3101
  });
2761
- this.sync();
2762
3102
  this.api.online$.pipe(distinctUntilChanged()).subscribe(async (online) => {
2763
3103
  if (!online) return;
2764
3104
  if (this.api.expired) await lastValueFrom(this.api.auth.user$.pipe(skip(1), takeWhile((u) => !u || this.api.expired, true)));
@@ -3410,7 +3750,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
3410
3750
  } });
3411
3751
  }
3412
3752
  }
3413
- const version = "1.6.2";
3753
+ const version = "1.6.4";
3414
3754
  class WebRtc {
3415
3755
  constructor(api) {
3416
3756
  __publicField(this, "ice");
@@ -4000,6 +4340,8 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4000
4340
  __publicField(this, "webrtc");
4001
4341
  /** Offline database */
4002
4342
  __publicField(this, "database");
4343
+ /** Offline login + offline cache encryption */
4344
+ __publicField(this, "vault");
4003
4345
  /** Options */
4004
4346
  __publicField(this, "options");
4005
4347
  /** Created slices */
@@ -4025,7 +4367,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4025
4367
  __publicField(this, "online$", new BehaviorSubject(this.initialOnline));
4026
4368
  /** API Session token */
4027
4369
  __publicField(this, "token$", new BehaviorSubject(void 0));
4028
- var _a, _b;
4370
+ var _a, _b, _c;
4029
4371
  this.origin = origin;
4030
4372
  this.url = `${new URL(origin).origin}/api/`;
4031
4373
  const development = this.isDevelopmentEnvironment(origin);
@@ -4049,6 +4391,11 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4049
4391
  }
4050
4392
  };
4051
4393
  if (development) this.options.watchTokenExpiry = false;
4394
+ const offlineLogin = this.options.offlineLogin ?? true;
4395
+ this.vault = new OfflineVault(
4396
+ !!((_a = this.options.offline) == null ? void 0 : _a.length) && !!this.options.saveSession && offlineLogin !== false,
4397
+ typeof offlineLogin == "object" ? offlineLogin : {}
4398
+ );
4052
4399
  if (this.options.saveSession && typeof localStorage != "undefined") {
4053
4400
  this.token = localStorage.getItem(this.localStorageKey) || null;
4054
4401
  this.token$.pipe(distinctUntilChanged()).subscribe((token) => {
@@ -4080,6 +4427,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4080
4427
  this.webrtc = new WebRtc(this);
4081
4428
  if (typeof indexedDB != "undefined") {
4082
4429
  this.database = new Database("datalynk", ["pending", ...this.options.offline || []]);
4430
+ this.database.cipher = this.vault;
4083
4431
  if (this.options.replayOfflineQueue !== false) this.online$.subscribe((online) => {
4084
4432
  if (online) void this.replayPendingQueue();
4085
4433
  });
@@ -4089,10 +4437,10 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4089
4437
  window.addEventListener("offline", () => this.setConnectionStatus("offline"));
4090
4438
  this.startHeartbeat();
4091
4439
  }
4092
- if ((_a = this.options.offline) == null ? void 0 : _a.length) {
4440
+ if ((_b = this.options.offline) == null ? void 0 : _b.length) {
4093
4441
  this.pwa.setup();
4094
4442
  if (typeof indexedDB == "undefined") throw new Error("Cannot enable offline support, indexedDB is not available in this environment");
4095
- (_b = this.options.offline) == null ? void 0 : _b.forEach((id) => this.slice(id));
4443
+ (_c = this.options.offline) == null ? void 0 : _c.forEach((id) => this.slice(id));
4096
4444
  if (this.options.serviceWorker && typeof navigator["serviceWorker"] != "undefined") {
4097
4445
  (async () => {
4098
4446
  let initialized = false;
@@ -4404,7 +4752,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4404
4752
  } catch {
4405
4753
  throw new UnexpectedApiResponseError(resp, body);
4406
4754
  }
4407
- if (resp.status === 401) this.markUnauthorized(token);
4755
+ if (resp.status === 401 && token) this.markUnauthorized(token);
4408
4756
  if (!resp.ok || (data == null ? void 0 : data.error)) {
4409
4757
  const error = Object.assign(errorFromCode(resp.status, data == null ? void 0 : data.error), data);
4410
4758
  Object.defineProperty(error, "response", { value: resp, configurable: true });
@@ -4656,9 +5004,10 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4656
5004
  }));
4657
5005
  }
4658
5006
  async finishUnauthorizedLogout(preservePending = false) {
4659
- var _a;
5007
+ var _a, _b, _c;
4660
5008
  if (this.options.manageSession === false) return;
4661
- forgetOfflineLogin();
5009
+ if (preservePending) await ((_a = this.vault) == null ? void 0 : _a.lock());
5010
+ else await ((_b = this.vault) == null ? void 0 : _b.forget());
4662
5011
  if (typeof localStorage != "undefined") {
4663
5012
  localStorage.removeItem(this.localStorageKey);
4664
5013
  localStorage.removeItem("datalynk-user");
@@ -4679,7 +5028,7 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4679
5028
  console.error("Unable to clear Datalynk service worker cache", error);
4680
5029
  }
4681
5030
  }
4682
- if (typeof navigator != "undefined" && ((_a = navigator.serviceWorker) == null ? void 0 : _a.controller)) {
5031
+ if (typeof navigator != "undefined" && ((_c = navigator.serviceWorker) == null ? void 0 : _c.controller)) {
4683
5032
  navigator.serviceWorker.controller.postMessage({ token: null });
4684
5033
  }
4685
5034
  if (typeof location != "undefined" && typeof location.reload == "function")
@@ -4820,7 +5169,10 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4820
5169
  if (!this.online || this.authenticationInvalid || this.offlineAuthenticationExpired) break;
4821
5170
  if (owner && !this.ownersMatch(owner, this.tokenIdentity(this.token))) break;
4822
5171
  const request = await table.get(key);
4823
- if (request == null) continue;
5172
+ if (request == null) {
5173
+ await table.delete(key);
5174
+ continue;
5175
+ }
4824
5176
  try {
4825
5177
  await this.request(request);
4826
5178
  await table.delete(key);
@@ -4946,7 +5298,6 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4946
5298
  });
4947
5299
  }
4948
5300
  request(data, options = {}) {
4949
- var _a, _b;
4950
5301
  data = typeof data == "string" ? { [data]: {} } : data;
4951
5302
  let key = JSON.stringify(data);
4952
5303
  if (this.offline && typeof navigator != "undefined") {
@@ -4954,8 +5305,8 @@ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "sy
4954
5305
  if (options.offline) {
4955
5306
  if (!this.ensureOfflineSessionOwner(this.token))
4956
5307
  return Promise.reject(errorFromCode(409, "Offline queue belongs to another authenticated user"));
4957
- (_b = (_a = this.database) == null ? void 0 : _a.table("pending")) == null ? void 0 : _b.add(data, key);
4958
- return Promise.resolve();
5308
+ if (!this.database) return Promise.reject(new Error("Offline queue storage is unavailable"));
5309
+ return this.database.table("pending").add(data, key).then(() => void 0);
4959
5310
  }
4960
5311
  return Promise.reject(errorFromCode(503, this.status === "unavailable" ? "Datalynk is unavailable" : "You are offline"));
4961
5312
  }