@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.mjs CHANGED
@@ -189,6 +189,8 @@ class Database {
189
189
  constructor(database, tables, version2) {
190
190
  __publicField(this, "connection");
191
191
  __publicField(this, "tables");
192
+ /** Encrypts values at rest when set */
193
+ __publicField(this, "cipher");
192
194
  this.database = database;
193
195
  this.version = version2;
194
196
  this.tables = tables.map((table) => typeof table === "object" ? { ...table, name: table.name.toString() } : { name: table.toString() });
@@ -237,22 +239,46 @@ class Table {
237
239
  async tx(operation, readonly = false) {
238
240
  const db = await this.database.connection;
239
241
  return new Promise((resolve, reject) => {
240
- const request = operation(db.transaction(this.name, readonly ? "readonly" : "readwrite").objectStore(this.name));
241
- request.onsuccess = () => resolve(request.result);
242
- request.onerror = () => reject(request.error);
242
+ const transaction = db.transaction(this.name, readonly ? "readonly" : "readwrite");
243
+ let result;
244
+ transaction.oncomplete = () => resolve(result);
245
+ transaction.onabort = () => reject(transaction.error || new Error("IndexedDB transaction aborted"));
246
+ transaction.onerror = () => reject(transaction.error || new Error("IndexedDB transaction failed"));
247
+ try {
248
+ const request = operation(transaction.objectStore(this.name));
249
+ request.onsuccess = () => {
250
+ result = request.result;
251
+ };
252
+ request.onerror = () => reject(request.error || new Error("IndexedDB request failed"));
253
+ } catch (error) {
254
+ transaction.abort();
255
+ reject(error);
256
+ }
243
257
  });
244
258
  }
245
- add(value, key) {
246
- return this.tx((store) => store.add(value, key));
259
+ seal(value) {
260
+ const cipher = this.database.cipher;
261
+ return (cipher == null ? void 0 : cipher.enabled) ? cipher.seal(value) : Promise.resolve(value);
262
+ }
263
+ open(value) {
264
+ const cipher = this.database.cipher;
265
+ return cipher ? cipher.open(value) : Promise.resolve(value);
247
266
  }
248
- put(key, value) {
249
- return this.tx((store) => store.put(value, key));
267
+ async add(value, key) {
268
+ const sealed = await this.seal(value);
269
+ return this.tx((store) => store.add(sealed, key));
250
270
  }
251
- get(key) {
252
- return this.tx((store) => store.get(key), true);
271
+ async put(key, value) {
272
+ const sealed = await this.seal(value);
273
+ return this.tx((store) => store.put(sealed, key));
253
274
  }
254
- getAll() {
255
- return this.tx((store) => store.getAll(), true);
275
+ async get(key) {
276
+ return await this.open(await this.tx((store) => store.get(key), true));
277
+ }
278
+ /** All readable values. Values sealed with another key are skipped. */
279
+ async getAll() {
280
+ const values = await Promise.all((await this.tx((store) => store.getAll(), true)).map((v) => this.open(v)));
281
+ return values.filter((v) => v !== void 0);
256
282
  }
257
283
  getAllKeys() {
258
284
  return this.tx((store) => store.getAllKeys(), true);
@@ -263,6 +289,20 @@ class Table {
263
289
  clear() {
264
290
  return this.tx((store) => store.clear());
265
291
  }
292
+ /** Replace the whole table in one transaction (values are sealed first, so nothing is lost while locked) */
293
+ async replace(entries) {
294
+ const sealed = await Promise.all(entries.map(async ([key, value]) => [key, await this.seal(value)]));
295
+ const db = await this.database.connection;
296
+ await new Promise((resolve, reject) => {
297
+ const tx = db.transaction(this.name, "readwrite");
298
+ const store = tx.objectStore(this.name);
299
+ store.clear();
300
+ sealed.forEach(([key, value]) => store.put(value, key));
301
+ tx.oncomplete = () => resolve();
302
+ tx.onerror = () => reject(tx.error);
303
+ tx.onabort = () => reject(tx.error);
304
+ });
305
+ }
266
306
  count() {
267
307
  return this.tx((store) => store.count(), true);
268
308
  }
@@ -1016,6 +1056,9 @@ var BehaviorSubject = function(_super) {
1016
1056
  };
1017
1057
  return BehaviorSubject2;
1018
1058
  }(Subject);
1059
+ var EMPTY = new Observable(function(subscriber) {
1060
+ return subscriber.complete();
1061
+ });
1019
1062
  var EmptyError = createErrorClass(function(_super) {
1020
1063
  return function EmptyErrorImpl() {
1021
1064
  _super(this);
@@ -1043,6 +1086,23 @@ function lastValueFrom(source, config2) {
1043
1086
  });
1044
1087
  });
1045
1088
  }
1089
+ function firstValueFrom(source, config2) {
1090
+ return new Promise(function(resolve, reject) {
1091
+ var subscriber = new SafeSubscriber({
1092
+ next: function(value) {
1093
+ resolve(value);
1094
+ subscriber.unsubscribe();
1095
+ },
1096
+ error: reject,
1097
+ complete: function() {
1098
+ {
1099
+ reject(new EmptyError());
1100
+ }
1101
+ }
1102
+ });
1103
+ source.subscribe(subscriber);
1104
+ });
1105
+ }
1046
1106
  function filter(predicate, thisArg) {
1047
1107
  return operate(function(source, subscriber) {
1048
1108
  var index = 0;
@@ -1051,6 +1111,21 @@ function filter(predicate, thisArg) {
1051
1111
  }));
1052
1112
  });
1053
1113
  }
1114
+ function take(count) {
1115
+ return count <= 0 ? function() {
1116
+ return EMPTY;
1117
+ } : operate(function(source, subscriber) {
1118
+ var seen = 0;
1119
+ source.subscribe(createOperatorSubscriber(subscriber, function(value) {
1120
+ if (++seen <= count) {
1121
+ subscriber.next(value);
1122
+ if (count <= seen) {
1123
+ subscriber.complete();
1124
+ }
1125
+ }
1126
+ }));
1127
+ });
1128
+ }
1054
1129
  function distinctUntilChanged(comparator, keySelector) {
1055
1130
  if (keySelector === void 0) {
1056
1131
  keySelector = identity;
@@ -1532,52 +1607,304 @@ __publicField(_LoginPrompt, "template", (options) => {
1532
1607
  `;
1533
1608
  });
1534
1609
  let LoginPrompt = _LoginPrompt;
1535
- const KEY = "datalynk-offline-login";
1610
+ const RECORD_KEY = "datalynk-offline-login";
1611
+ const PROFILE_KEY = "datalynk-user";
1612
+ const VAULT_DB = "datalynk-vault";
1613
+ const VAULT_STORE = "keys";
1614
+ const DEVICE_KEY = "device";
1536
1615
  const ITERATIONS = 31e4;
1537
- async function hash(password, salt) {
1538
- const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveBits"]);
1539
- return Array.from(new Uint8Array(await crypto.subtle.deriveBits(
1540
- { name: "PBKDF2", salt: new Uint8Array(salt), iterations: ITERATIONS, hash: "SHA-256" },
1541
- key,
1542
- 256
1543
- )));
1544
- }
1545
- async function rememberOfflineLogin(realm, login, uid, password) {
1546
- const salt = crypto.getRandomValues(new Uint8Array(16));
1547
- const record = { realm, login: login.trim().toLowerCase(), uid, salt: Array.from(salt), hash: await hash(password, salt) };
1548
- localStorage.setItem(KEY, JSON.stringify(record));
1549
- }
1550
- async function verifyOfflineLogin(realm, login, uid, password) {
1551
- const saved = localStorage.getItem(KEY);
1552
- if (!saved) return false;
1553
- let record;
1554
- try {
1555
- record = JSON.parse(saved);
1556
- } catch {
1557
- return false;
1558
- }
1559
- if (record.realm !== realm || record.login !== login.trim().toLowerCase() || record.uid !== uid || !Array.isArray(record.salt) || !Array.isArray(record.hash)) return false;
1560
- const candidate = await hash(password, new Uint8Array(record.salt));
1561
- if (candidate.length !== record.hash.length) return false;
1562
- let difference = 0;
1563
- for (let i = 0; i < candidate.length; i++) difference |= candidate[i] ^ record.hash[i];
1564
- return difference === 0;
1616
+ const DAY = 864e5;
1617
+ const offlineLoginMessages = {
1618
+ "none": "Offline login is not available on this device. Connect to the internet to sign in.",
1619
+ "wrong-user": "Offline login is only available for the last user who signed in on this device.",
1620
+ "expired": "Offline login has expired on this device. Connect to the internet to sign in.",
1621
+ "locked": "Offline login is locked after too many attempts. Connect to the internet to sign in.",
1622
+ "bad-password": "Incorrect password."
1623
+ };
1624
+ const toB64 = (bytes) => {
1625
+ let out = "";
1626
+ new Uint8Array(bytes).forEach((b) => out += String.fromCharCode(b));
1627
+ return btoa(out);
1628
+ };
1629
+ const fromB64 = (text) => Uint8Array.from(atob(text), (c) => c.charCodeAt(0));
1630
+ const normalizeLogin = (login) => (login || "").trim().toLowerCase();
1631
+ function isSealed(value) {
1632
+ return !!value && typeof value == "object" && value.__dlv === 1 && "iv" in value && "ct" in value;
1565
1633
  }
1566
- function hasOfflineLogin(realm, uid) {
1567
- try {
1568
- const record = JSON.parse(localStorage.getItem(KEY) || "null");
1569
- return (record == null ? void 0 : record.realm) === realm && (record == null ? void 0 : record.uid) === uid;
1570
- } catch {
1571
- return false;
1634
+ class OfflineVault {
1635
+ constructor(enabled, options = {}, startOffline) {
1636
+ /** False when offline login is disabled or the environment lacks WebCrypto/IndexedDB/localStorage */
1637
+ __publicField(this, "enabled");
1638
+ /** True once the data key is available in memory */
1639
+ __publicField(this, "unlocked$", new BehaviorSubject(false));
1640
+ /** Resolves once startup key loading has finished */
1641
+ __publicField(this, "ready");
1642
+ __publicField(this, "dek", null);
1643
+ __publicField(this, "days");
1644
+ __publicField(this, "attempts");
1645
+ this.enabled = enabled && typeof crypto != "undefined" && !!crypto.subtle && typeof indexedDB != "undefined" && typeof localStorage != "undefined";
1646
+ this.days = options.days ?? 7;
1647
+ this.attempts = options.attempts ?? 5;
1648
+ if (startOffline === void 0) startOffline = typeof navigator != "undefined" && navigator.onLine === false;
1649
+ this.ready = this.enabled ? this.init(startOffline).catch((error) => {
1650
+ console.error("Unable to open the Datalynk offline vault", error);
1651
+ }) : Promise.resolve();
1652
+ }
1653
+ get unlocked() {
1654
+ return this.unlocked$.getValue();
1655
+ }
1656
+ /** A saved offline login exists, but this session has not unlocked it yet */
1657
+ get needsPassword() {
1658
+ return this.enabled && !this.unlocked && !!this.readRecord();
1659
+ }
1660
+ /** Wait until the data key is available (resolves immediately when the vault is disabled) */
1661
+ whenUnlocked() {
1662
+ if (!this.enabled || this.unlocked) return Promise.resolve(true);
1663
+ return firstValueFrom(this.unlocked$.pipe(filter(Boolean)));
1664
+ }
1665
+ /** Saved offline login matches this user */
1666
+ has(realm, uid) {
1667
+ const record = this.readRecord();
1668
+ return !!record && record.realm === realm && record.uid === uid;
1669
+ }
1670
+ /**
1671
+ * Called after a successful online password login. Wraps the data key with the
1672
+ * password so this user can sign in offline later.
1673
+ */
1674
+ async rememberLogin(realm, login, uid, password) {
1675
+ if (!this.enabled) return;
1676
+ await this.ready;
1677
+ const record = this.readRecord();
1678
+ let dek = null;
1679
+ if (record && record.realm === realm && record.uid === uid) {
1680
+ dek = await this.unwrapWithPassword(record, password).catch(() => null);
1681
+ if (!dek) dek = this.dek;
1682
+ } else if (!record) {
1683
+ dek = this.dek;
1684
+ }
1685
+ if (!dek) dek = await this.newDataKey();
1686
+ const salt = crypto.getRandomValues(new Uint8Array(16));
1687
+ const iv = crypto.getRandomValues(new Uint8Array(12));
1688
+ const kek = await this.passwordKey(password, salt);
1689
+ const wrapped = await crypto.subtle.wrapKey("raw", dek, kek, { name: "AES-GCM", iv });
1690
+ this.writeRecord({
1691
+ v: 2,
1692
+ realm,
1693
+ uid,
1694
+ login: normalizeLogin(login),
1695
+ salt: toB64(salt),
1696
+ iv: toB64(iv),
1697
+ wrapped: toB64(wrapped),
1698
+ verifiedAt: Date.now(),
1699
+ failures: 0
1700
+ });
1701
+ await this.setKey(dek);
1702
+ }
1703
+ /** Try to unlock offline with a password */
1704
+ async unlockOffline(realm, login, uid, password) {
1705
+ if (!this.enabled) return "none";
1706
+ await this.ready;
1707
+ const record = this.readRecord();
1708
+ if (!record) return "none";
1709
+ if (record.realm !== realm || record.login !== normalizeLogin(login) || uid != null && record.uid !== uid) return "wrong-user";
1710
+ if (record.locked) return "locked";
1711
+ if (Date.now() - record.verifiedAt > this.days * DAY) return "expired";
1712
+ try {
1713
+ const dek = await this.unwrapWithPassword(record, password);
1714
+ this.writeRecord({ ...record, failures: 0 });
1715
+ await this.setKey(dek);
1716
+ return "ok";
1717
+ } catch {
1718
+ const failures = (record.failures || 0) + 1;
1719
+ const locked = failures >= this.attempts;
1720
+ this.writeRecord({ ...record, failures, locked });
1721
+ return locked ? "locked" : "bad-password";
1722
+ }
1723
+ }
1724
+ /** The server confirmed this user's session: restart the offline window */
1725
+ touch(realm, uid) {
1726
+ if (!this.enabled || realm == null || uid == null) return;
1727
+ const record = this.readRecord();
1728
+ if (record && record.realm === realm && record.uid === uid) this.writeRecord({ ...record, verifiedAt: Date.now() });
1729
+ }
1730
+ /** Drop the in-memory and device keys but keep the password copy (queued work stays recoverable) */
1731
+ async lock() {
1732
+ this.dek = null;
1733
+ if (this.unlocked) this.unlocked$.next(false);
1734
+ if (this.enabled) await this.deleteDevice();
1735
+ }
1736
+ /** Remove offline login completely; anything sealed becomes unreadable */
1737
+ async forget() {
1738
+ if (typeof localStorage != "undefined") {
1739
+ localStorage.removeItem(RECORD_KEY);
1740
+ localStorage.removeItem(PROFILE_KEY);
1741
+ }
1742
+ await this.lock();
1743
+ }
1744
+ /** Encrypt a value for IndexedDB (passes through when disabled) */
1745
+ async seal(value) {
1746
+ if (!this.enabled || value === void 0) return value;
1747
+ await this.whenUnlocked();
1748
+ const iv = crypto.getRandomValues(new Uint8Array(12));
1749
+ const ct = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, this.dek, new TextEncoder().encode(JSON.stringify(value)));
1750
+ return { __dlv: 1, iv, ct };
1751
+ }
1752
+ /** Decrypt a value from IndexedDB. Unreadable values (old key / other user) return undefined. */
1753
+ async open(value) {
1754
+ if (!isSealed(value)) return value;
1755
+ if (!this.enabled) return void 0;
1756
+ await this.whenUnlocked();
1757
+ try {
1758
+ const iv = typeof value.iv == "string" ? fromB64(value.iv) : value.iv;
1759
+ const ct = typeof value.ct == "string" ? fromB64(value.ct) : value.ct;
1760
+ const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv }, this.dek, ct);
1761
+ return JSON.parse(new TextDecoder().decode(plain));
1762
+ } catch {
1763
+ return void 0;
1764
+ }
1765
+ }
1766
+ /** Save the cached user profile (sealed when enabled) */
1767
+ async setProfile(user) {
1768
+ if (typeof localStorage == "undefined") return;
1769
+ if (user == null) return localStorage.removeItem(PROFILE_KEY);
1770
+ if (!this.enabled) return localStorage.setItem(PROFILE_KEY, JSON.stringify(user));
1771
+ const sealed = await this.seal(user);
1772
+ localStorage.setItem(PROFILE_KEY, JSON.stringify({ __dlv: 1, iv: toB64(sealed.iv), ct: toB64(sealed.ct) }));
1773
+ }
1774
+ /** Read the cached user profile; null while locked */
1775
+ async getProfile() {
1776
+ if (typeof localStorage == "undefined") return null;
1777
+ let saved;
1778
+ try {
1779
+ saved = JSON.parse(localStorage.getItem(PROFILE_KEY) || "null");
1780
+ } catch {
1781
+ return null;
1782
+ }
1783
+ if (!isSealed(saved)) return this.enabled ? null : saved;
1784
+ if (!this.unlocked) return null;
1785
+ return await this.open(saved) ?? null;
1786
+ }
1787
+ // ----- internals -----
1788
+ async init(startOffline) {
1789
+ if (startOffline) {
1790
+ await this.deleteDevice();
1791
+ return;
1792
+ }
1793
+ const device = await this.readDevice();
1794
+ if (device) {
1795
+ try {
1796
+ const dek = await crypto.subtle.unwrapKey(
1797
+ "raw",
1798
+ device.wrapped,
1799
+ device.key,
1800
+ { name: "AES-GCM", iv: device.iv },
1801
+ { name: "AES-GCM" },
1802
+ true,
1803
+ ["encrypt", "decrypt"]
1804
+ );
1805
+ this.dek = dek;
1806
+ this.unlocked$.next(true);
1807
+ return;
1808
+ } catch {
1809
+ await this.deleteDevice();
1810
+ }
1811
+ }
1812
+ if (!this.readRecord()) await this.setKey(await this.newDataKey());
1813
+ }
1814
+ async setKey(dek) {
1815
+ this.dek = dek;
1816
+ try {
1817
+ await this.writeDevice(dek);
1818
+ } catch (error) {
1819
+ console.warn("Unable to store the Datalynk device key; offline data will need the password", error);
1820
+ }
1821
+ if (!this.unlocked) this.unlocked$.next(true);
1822
+ }
1823
+ newDataKey() {
1824
+ return crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]);
1825
+ }
1826
+ async passwordKey(password, salt) {
1827
+ const base = await crypto.subtle.importKey("raw", new TextEncoder().encode(password), "PBKDF2", false, ["deriveKey"]);
1828
+ return crypto.subtle.deriveKey(
1829
+ { name: "PBKDF2", salt, iterations: ITERATIONS, hash: "SHA-256" },
1830
+ base,
1831
+ { name: "AES-GCM", length: 256 },
1832
+ false,
1833
+ ["wrapKey", "unwrapKey"]
1834
+ );
1835
+ }
1836
+ async unwrapWithPassword(record, password) {
1837
+ const kek = await this.passwordKey(password, fromB64(record.salt));
1838
+ return crypto.subtle.unwrapKey(
1839
+ "raw",
1840
+ fromB64(record.wrapped),
1841
+ kek,
1842
+ { name: "AES-GCM", iv: fromB64(record.iv) },
1843
+ { name: "AES-GCM" },
1844
+ true,
1845
+ ["encrypt", "decrypt"]
1846
+ );
1847
+ }
1848
+ readRecord() {
1849
+ if (typeof localStorage == "undefined") return null;
1850
+ try {
1851
+ const record = JSON.parse(localStorage.getItem(RECORD_KEY) || "null");
1852
+ if ((record == null ? void 0 : record.v) === 2) return record;
1853
+ if (record) localStorage.removeItem(RECORD_KEY);
1854
+ } catch {
1855
+ localStorage.removeItem(RECORD_KEY);
1856
+ }
1857
+ return null;
1858
+ }
1859
+ writeRecord(record) {
1860
+ localStorage.setItem(RECORD_KEY, JSON.stringify(record));
1861
+ }
1862
+ vaultDb() {
1863
+ return new Promise((resolve, reject) => {
1864
+ const request = indexedDB.open(VAULT_DB, 1);
1865
+ request.onupgradeneeded = () => request.result.createObjectStore(VAULT_STORE);
1866
+ request.onsuccess = () => resolve(request.result);
1867
+ request.onerror = () => reject(request.error);
1868
+ });
1869
+ }
1870
+ async vaultTx(mode, op) {
1871
+ const db = await this.vaultDb();
1872
+ try {
1873
+ return await new Promise((resolve, reject) => {
1874
+ const tx = db.transaction(VAULT_STORE, mode);
1875
+ const request = op(tx.objectStore(VAULT_STORE));
1876
+ tx.oncomplete = () => resolve(request.result);
1877
+ tx.onerror = () => reject(tx.error);
1878
+ tx.onabort = () => reject(tx.error);
1879
+ });
1880
+ } finally {
1881
+ db.close();
1882
+ }
1883
+ }
1884
+ async readDevice() {
1885
+ try {
1886
+ return await this.vaultTx("readonly", (store) => store.get(DEVICE_KEY)) || null;
1887
+ } catch {
1888
+ return null;
1889
+ }
1890
+ }
1891
+ async writeDevice(dek) {
1892
+ const key = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["wrapKey", "unwrapKey"]);
1893
+ const iv = crypto.getRandomValues(new Uint8Array(12));
1894
+ const wrapped = await crypto.subtle.wrapKey("raw", dek, key, { name: "AES-GCM", iv });
1895
+ await this.vaultTx("readwrite", (store) => store.put({ key, iv, wrapped }, DEVICE_KEY));
1896
+ }
1897
+ async deleteDevice() {
1898
+ try {
1899
+ await this.vaultTx("readwrite", (store) => store.delete(DEVICE_KEY));
1900
+ } catch (error) {
1901
+ console.warn("Unable to remove the Datalynk device key", error);
1902
+ }
1572
1903
  }
1573
- }
1574
- function forgetOfflineLogin() {
1575
- localStorage.removeItem(KEY);
1576
1904
  }
1577
1905
  class Auth {
1578
1906
  constructor(api) {
1579
1907
  __publicField(this, "onlinePrompt");
1580
- __publicField(this, "offlineUnlocked", false);
1581
1908
  __publicField(this, "wasOffline", false);
1582
1909
  /** Current user as an observable */
1583
1910
  __publicField(this, "user$", new BehaviorSubject(void 0));
@@ -1587,21 +1914,12 @@ class Auth {
1587
1914
  if (status === "offline" || status === "unavailable") this.wasOffline = true;
1588
1915
  if (status !== "online" || !this.wasOffline) return;
1589
1916
  this.wasOffline = false;
1590
- const wasUnlocked = this.offlineUnlocked;
1591
1917
  const previousUser = this.user;
1592
- this.offlineUnlocked = false;
1593
- this.user = void 0;
1594
1918
  try {
1595
- const user = await this.current();
1596
- const identity2 = this.api.jwtPayload;
1597
- if (!user || user.guest || !identity2 || !hasOfflineLogin(identity2.realm, identity2.uid))
1598
- forgetOfflineLogin();
1599
- this.user = user;
1919
+ this.user = await this.current(this.api.token, true);
1600
1920
  } catch (error) {
1601
- if (this.api.status === "unauthorized") forgetOfflineLogin();
1602
- else {
1921
+ if (this.api.status !== "unauthorized") {
1603
1922
  this.wasOffline = true;
1604
- this.offlineUnlocked = wasUnlocked;
1605
1923
  this.user = previousUser;
1606
1924
  }
1607
1925
  console.error("Unable to recheck the Datalynk user", error);
@@ -1618,11 +1936,22 @@ class Auth {
1618
1936
  });
1619
1937
  if ((_a = this.api.options.offline) == null ? void 0 : _a.length)
1620
1938
  this.user$.pipe(filter((u) => u !== void 0)).subscribe((u) => {
1621
- if (u == null) {
1622
- if (this.api.online) localStorage.removeItem("datalynk-user");
1623
- } else localStorage.setItem("datalynk-user", JSON.stringify(u));
1939
+ if (u == null && !this.api.online) return;
1940
+ const vault = this.api.vault;
1941
+ if (vault) vault.setProfile(u).catch((error) => console.warn("Unable to cache the Datalynk user", error));
1942
+ else if (u == null) localStorage.removeItem("datalynk-user");
1943
+ else localStorage.setItem("datalynk-user", JSON.stringify(u));
1624
1944
  });
1625
1945
  }
1946
+ /** Offline login/encryption is active for this app */
1947
+ get vault() {
1948
+ var _a;
1949
+ return ((_a = this.api.vault) == null ? void 0 : _a.enabled) ? this.api.vault : void 0;
1950
+ }
1951
+ /** This session may use offline data (always true when offline login is opted out) */
1952
+ get offlineUnlocked() {
1953
+ return !this.vault || this.vault.unlocked;
1954
+ }
1626
1955
  /** Current user */
1627
1956
  get user() {
1628
1957
  return this.user$.getValue();
@@ -1640,14 +1969,18 @@ class Auth {
1640
1969
  *
1641
1970
  * @return {Promise<User | null>}
1642
1971
  */
1643
- async current(token = this.api.token) {
1644
- var _a;
1972
+ async current(token = this.api.token, refresh = false) {
1973
+ var _a, _b, _c;
1645
1974
  if (!token) return null;
1646
- else if (token == ((_a = this.user) == null ? void 0 : _a.token)) return this.user;
1647
- else if (!this.api.online && this.offlineUnlocked && typeof localStorage != "undefined" && localStorage.getItem("datalynk-user"))
1648
- return JSON.parse(localStorage.getItem("datalynk-user"));
1649
- else if (!this.api.online) return null;
1650
- return this.api.request([{ "$/auth/current": {} }, { "$/env/me": {} }], { token }).then((resp) => resp[0] || resp[1] ? { ...resp[0], ...resp[1] } : null);
1975
+ else if (!refresh && token == ((_a = this.user) == null ? void 0 : _a.token)) return this.user;
1976
+ else if (!this.api.online) {
1977
+ if (!this.offlineUnlocked || !((_b = this.api.options.offline) == null ? void 0 : _b.length)) return null;
1978
+ if (this.api.vault) return this.api.vault.getProfile();
1979
+ return null;
1980
+ }
1981
+ const user = await this.api.request([{ "$/auth/current": {} }, { "$/env/me": {} }], { token }).then((resp) => resp[0] || resp[1] ? { ...resp[0], ...resp[1] } : null);
1982
+ if (user && !user.guest && this.api.jwtPayload) (_c = this.vault) == null ? void 0 : _c.touch(this.api.jwtPayload.realm, this.api.jwtPayload.uid);
1983
+ return user;
1651
1984
  }
1652
1985
  /**
1653
1986
  * Automatically handle sessions by checking localStorage & URL parameters for a token & prompting
@@ -1658,16 +1991,17 @@ class Auth {
1658
1991
  * @return {Promise<void>} Login complete
1659
1992
  */
1660
1993
  async handleLogin(spoke, options) {
1661
- var _a, _b, _c, _d;
1994
+ var _a, _b, _c, _d, _e;
1662
1995
  if (this.onlinePrompt) window.removeEventListener("online", this.onlinePrompt);
1663
1996
  if ((_a = this.api.options.offline) == null ? void 0 : _a.length) window.removeEventListener("online", this.onlinePrompt = () => this.handleLogin(spoke, options));
1997
+ await ((_b = this.api.vault) == null ? void 0 : _b.ready);
1664
1998
  const urlParams = new URLSearchParams(location.search);
1665
1999
  const urlToken = urlParams.getAll("datalynkToken").filter(Boolean).pop();
1666
2000
  if (urlToken) {
1667
2001
  this.api.token = urlToken;
1668
2002
  urlParams.delete("datalynkToken");
1669
2003
  location.href = location.pathname + (urlParams.toString() ? "?" + urlParams.toString() : "") + location.hash;
1670
- } else if (!this.api.online && ((_b = this.api.options.offline) == null ? void 0 : _b.length) && !this.offlineUnlocked) {
2004
+ } else if (this.isOffline() && this.vault && !this.vault.unlocked) {
1671
2005
  await this.loginPrompt(spoke, options).done;
1672
2006
  return;
1673
2007
  } else if (this.api.reauthenticationPending && !this.api.online && ((_c = this.api.jwtPayload) == null ? void 0 : _c.realm) == spoke) {
@@ -1676,6 +2010,8 @@ class Auth {
1676
2010
  if (((_d = this.api.jwtPayload) == null ? void 0 : _d.realm) != spoke) {
1677
2011
  this.api.token = null;
1678
2012
  location.reload();
2013
+ } else if ((_e = this.vault) == null ? void 0 : _e.needsPassword) {
2014
+ await this.loginPrompt(spoke, options).done;
1679
2015
  }
1680
2016
  } else {
1681
2017
  this.api.token = null;
@@ -1683,6 +2019,9 @@ class Auth {
1683
2019
  location.reload();
1684
2020
  }
1685
2021
  }
2022
+ isOffline() {
2023
+ return !this.api.online || typeof navigator !== "undefined" && navigator.onLine === false;
2024
+ }
1686
2025
  /**
1687
2026
  * Check whether user has a token
1688
2027
  *
@@ -1734,16 +2073,14 @@ class Auth {
1734
2073
  * @returns {Promise<User>} User account
1735
2074
  */
1736
2075
  async login(spoke, login, password, opts) {
1737
- var _a, _b, _c;
1738
- if (((_a = this.api.options.offline) == null ? void 0 : _a.length) && (!this.api.online || typeof navigator !== "undefined" && !navigator.onLine)) {
2076
+ var _a;
2077
+ if (this.vault && this.isOffline()) {
1739
2078
  const identity2 = this.api.jwtPayload;
1740
- const saved = localStorage.getItem("datalynk-user");
1741
- if (!identity2 || identity2.realm !== spoke || !saved || !await verifyOfflineLogin(spoke, login, identity2.uid, password.trim()))
1742
- throw new Error("Offline login is not available for this account on this device.");
1743
- const user = JSON.parse(saved);
1744
- if (![user.login, user.email].some((value) => (value == null ? void 0 : value.toLowerCase()) === login.trim().toLowerCase()))
1745
- throw new Error("Offline login is not available for this account on this device.");
1746
- this.offlineUnlocked = true;
2079
+ if (!identity2 || identity2.realm !== spoke) throw new Error(offlineLoginMessages.none);
2080
+ const result = await this.vault.unlockOffline(spoke, login, identity2.uid, password.trim());
2081
+ if (result !== "ok") throw new Error(offlineLoginMessages[result]);
2082
+ const user = await this.vault.getProfile();
2083
+ if (!user) throw new Error(offlineLoginMessages.none);
1747
2084
  this.user = user;
1748
2085
  return user;
1749
2086
  }
@@ -1764,8 +2101,8 @@ class Auth {
1764
2101
  const data = await resp.json().catch(() => ({}));
1765
2102
  if (!resp.ok || data["error"]) throw Object.assign(errorFromCode(resp.status, data.error) || {}, data);
1766
2103
  this.api.token = data["token"];
1767
- 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)
1768
- await rememberOfflineLogin(spoke, login, this.api.jwtPayload.uid, password.trim());
2104
+ if (this.vault && ((_a = this.api.jwtPayload) == null ? void 0 : _a.uid) != null)
2105
+ await this.vault.rememberLogin(spoke, login, this.api.jwtPayload.uid, password.trim()).catch((error) => console.error("Unable to enable offline login", error));
1769
2106
  return data;
1770
2107
  }
1771
2108
  /**
@@ -1797,8 +2134,9 @@ class Auth {
1797
2134
  * @return {Promise<{closed: string, new: string}>}
1798
2135
  */
1799
2136
  logout(reload = true) {
2137
+ var _a;
1800
2138
  localStorage.removeItem("datalynk-user");
1801
- forgetOfflineLogin();
2139
+ void ((_a = this.api.vault) == null ? void 0 : _a.forget());
1802
2140
  return this.api.request({ "$/auth/logout": {} }).then((resp) => {
1803
2141
  if (reload) location.reload();
1804
2142
  this.api.token = null;
@@ -2746,15 +3084,17 @@ const _Slice = class _Slice {
2746
3084
  const lowest = resp.reduce((acc, r) => Math.min(acc, r.id), 0);
2747
3085
  if (lowest < _Slice.nextId) _Slice.nextId = lowest;
2748
3086
  }).finally(() => this.loaded = true);
2749
- this.cache$.pipe(skip(1)).subscribe(async (cache) => {
3087
+ this.cache$.pipe(skip(1)).subscribe((cache) => {
3088
+ const table = this.table;
3089
+ if (!table) return;
3090
+ 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))));
3091
+ save.catch((error) => console.warn("Unable to save offline slice cache", error));
3092
+ });
3093
+ this.api.token$.pipe(filter((token) => !!token), take(1)).subscribe(async () => {
2750
3094
  var _a2;
2751
- await ((_a2 = this.table) == null ? void 0 : _a2.clear());
2752
- await Promise.all(cache.map((c) => {
2753
- var _a3;
2754
- return (_a3 = this.table) == null ? void 0 : _a3.put(c.id, c);
2755
- }));
3095
+ await ((_a2 = this.api.vault) == null ? void 0 : _a2.whenUnlocked());
3096
+ this.sync();
2756
3097
  });
2757
- this.sync();
2758
3098
  this.api.online$.pipe(distinctUntilChanged()).subscribe(async (online) => {
2759
3099
  if (!online) return;
2760
3100
  if (this.api.expired) await lastValueFrom(this.api.auth.user$.pipe(skip(1), takeWhile((u) => !u || this.api.expired, true)));
@@ -3406,7 +3746,7 @@ class Superuser {
3406
3746
  } });
3407
3747
  }
3408
3748
  }
3409
- const version = "1.6.2";
3749
+ const version = "1.6.4";
3410
3750
  class WebRtc {
3411
3751
  constructor(api) {
3412
3752
  __publicField(this, "ice");
@@ -3996,6 +4336,8 @@ const _Api = class _Api {
3996
4336
  __publicField(this, "webrtc");
3997
4337
  /** Offline database */
3998
4338
  __publicField(this, "database");
4339
+ /** Offline login + offline cache encryption */
4340
+ __publicField(this, "vault");
3999
4341
  /** Options */
4000
4342
  __publicField(this, "options");
4001
4343
  /** Created slices */
@@ -4021,7 +4363,7 @@ const _Api = class _Api {
4021
4363
  __publicField(this, "online$", new BehaviorSubject(this.initialOnline));
4022
4364
  /** API Session token */
4023
4365
  __publicField(this, "token$", new BehaviorSubject(void 0));
4024
- var _a, _b;
4366
+ var _a, _b, _c;
4025
4367
  this.origin = origin;
4026
4368
  this.url = `${new URL(origin).origin}/api/`;
4027
4369
  const development = this.isDevelopmentEnvironment(origin);
@@ -4045,6 +4387,11 @@ const _Api = class _Api {
4045
4387
  }
4046
4388
  };
4047
4389
  if (development) this.options.watchTokenExpiry = false;
4390
+ const offlineLogin = this.options.offlineLogin ?? true;
4391
+ this.vault = new OfflineVault(
4392
+ !!((_a = this.options.offline) == null ? void 0 : _a.length) && !!this.options.saveSession && offlineLogin !== false,
4393
+ typeof offlineLogin == "object" ? offlineLogin : {}
4394
+ );
4048
4395
  if (this.options.saveSession && typeof localStorage != "undefined") {
4049
4396
  this.token = localStorage.getItem(this.localStorageKey) || null;
4050
4397
  this.token$.pipe(distinctUntilChanged()).subscribe((token) => {
@@ -4076,6 +4423,7 @@ const _Api = class _Api {
4076
4423
  this.webrtc = new WebRtc(this);
4077
4424
  if (typeof indexedDB != "undefined") {
4078
4425
  this.database = new Database("datalynk", ["pending", ...this.options.offline || []]);
4426
+ this.database.cipher = this.vault;
4079
4427
  if (this.options.replayOfflineQueue !== false) this.online$.subscribe((online) => {
4080
4428
  if (online) void this.replayPendingQueue();
4081
4429
  });
@@ -4085,10 +4433,10 @@ const _Api = class _Api {
4085
4433
  window.addEventListener("offline", () => this.setConnectionStatus("offline"));
4086
4434
  this.startHeartbeat();
4087
4435
  }
4088
- if ((_a = this.options.offline) == null ? void 0 : _a.length) {
4436
+ if ((_b = this.options.offline) == null ? void 0 : _b.length) {
4089
4437
  this.pwa.setup();
4090
4438
  if (typeof indexedDB == "undefined") throw new Error("Cannot enable offline support, indexedDB is not available in this environment");
4091
- (_b = this.options.offline) == null ? void 0 : _b.forEach((id) => this.slice(id));
4439
+ (_c = this.options.offline) == null ? void 0 : _c.forEach((id) => this.slice(id));
4092
4440
  if (this.options.serviceWorker && typeof navigator["serviceWorker"] != "undefined") {
4093
4441
  (async () => {
4094
4442
  let initialized = false;
@@ -4400,7 +4748,7 @@ const _Api = class _Api {
4400
4748
  } catch {
4401
4749
  throw new UnexpectedApiResponseError(resp, body);
4402
4750
  }
4403
- if (resp.status === 401) this.markUnauthorized(token);
4751
+ if (resp.status === 401 && token) this.markUnauthorized(token);
4404
4752
  if (!resp.ok || (data == null ? void 0 : data.error)) {
4405
4753
  const error = Object.assign(errorFromCode(resp.status, data == null ? void 0 : data.error), data);
4406
4754
  Object.defineProperty(error, "response", { value: resp, configurable: true });
@@ -4652,9 +5000,10 @@ const _Api = class _Api {
4652
5000
  }));
4653
5001
  }
4654
5002
  async finishUnauthorizedLogout(preservePending = false) {
4655
- var _a;
5003
+ var _a, _b, _c;
4656
5004
  if (this.options.manageSession === false) return;
4657
- forgetOfflineLogin();
5005
+ if (preservePending) await ((_a = this.vault) == null ? void 0 : _a.lock());
5006
+ else await ((_b = this.vault) == null ? void 0 : _b.forget());
4658
5007
  if (typeof localStorage != "undefined") {
4659
5008
  localStorage.removeItem(this.localStorageKey);
4660
5009
  localStorage.removeItem("datalynk-user");
@@ -4675,7 +5024,7 @@ const _Api = class _Api {
4675
5024
  console.error("Unable to clear Datalynk service worker cache", error);
4676
5025
  }
4677
5026
  }
4678
- if (typeof navigator != "undefined" && ((_a = navigator.serviceWorker) == null ? void 0 : _a.controller)) {
5027
+ if (typeof navigator != "undefined" && ((_c = navigator.serviceWorker) == null ? void 0 : _c.controller)) {
4679
5028
  navigator.serviceWorker.controller.postMessage({ token: null });
4680
5029
  }
4681
5030
  if (typeof location != "undefined" && typeof location.reload == "function")
@@ -4816,7 +5165,10 @@ const _Api = class _Api {
4816
5165
  if (!this.online || this.authenticationInvalid || this.offlineAuthenticationExpired) break;
4817
5166
  if (owner && !this.ownersMatch(owner, this.tokenIdentity(this.token))) break;
4818
5167
  const request = await table.get(key);
4819
- if (request == null) continue;
5168
+ if (request == null) {
5169
+ await table.delete(key);
5170
+ continue;
5171
+ }
4820
5172
  try {
4821
5173
  await this.request(request);
4822
5174
  await table.delete(key);
@@ -4942,7 +5294,6 @@ const _Api = class _Api {
4942
5294
  });
4943
5295
  }
4944
5296
  request(data, options = {}) {
4945
- var _a, _b;
4946
5297
  data = typeof data == "string" ? { [data]: {} } : data;
4947
5298
  let key = JSON.stringify(data);
4948
5299
  if (this.offline && typeof navigator != "undefined") {
@@ -4950,8 +5301,8 @@ const _Api = class _Api {
4950
5301
  if (options.offline) {
4951
5302
  if (!this.ensureOfflineSessionOwner(this.token))
4952
5303
  return Promise.reject(errorFromCode(409, "Offline queue belongs to another authenticated user"));
4953
- (_b = (_a = this.database) == null ? void 0 : _a.table("pending")) == null ? void 0 : _b.add(data, key);
4954
- return Promise.resolve();
5304
+ if (!this.database) return Promise.reject(new Error("Offline queue storage is unavailable"));
5305
+ return this.database.table("pending").add(data, key).then(() => void 0);
4955
5306
  }
4956
5307
  return Promise.reject(errorFromCode(503, this.status === "unavailable" ? "Datalynk is unavailable" : "You are offline"));
4957
5308
  }