@geolonia/geonicdb-sdk 0.9.0 → 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.
package/geonicdb.mjs CHANGED
@@ -38,8 +38,23 @@ var EventEmitter = class {
38
38
  }
39
39
  };
40
40
 
41
+ // src/sdk/constants.ts
42
+ var DEFAULT_TOKEN_TTL_SEC = 3600;
43
+ var TOKEN_REFRESH_LEEWAY_MS = 12e4;
44
+ var TOKEN_REFRESH_MIN_MS = 1e4;
45
+ var RECONNECT_MAX_ATTEMPTS = 10;
46
+ var RECONNECT_BASE_MS = 1e3;
47
+ var RECONNECT_MAX_DELAY_MS = 3e4;
48
+ var SUB_PROTOCOL = "access_token";
49
+ var SDK_CACHE_MAX_ENTRIES_DEFAULT = 1e3;
50
+ var SDK_POLL_INTERVAL_MS_DEFAULT = 5e3;
51
+ var DPOP_IDB_NAME = "geonicdb-sdk";
52
+ var DPOP_IDB_STORE = "dpop-sessions";
53
+ var DPOP_IDB_VERSION = 1;
54
+
41
55
  // src/sdk/dpop.ts
42
56
  var dpopSupported = typeof crypto !== "undefined" && !!crypto.subtle && !!crypto.subtle.generateKey;
57
+ var dpopPersistenceSupported = typeof globalThis.indexedDB !== "undefined" && typeof structuredClone !== "undefined";
43
58
  function b64url(buf) {
44
59
  const arr = buf instanceof ArrayBuffer ? new Uint8Array(buf) : buf;
45
60
  let str = "";
@@ -96,6 +111,102 @@ async function createDPoPProof(keyPair, htm, htu, accessToken, nonce) {
96
111
  );
97
112
  return signingInput + "." + b64url(new Uint8Array(sigBuf));
98
113
  }
114
+ function openSessionDB() {
115
+ return new Promise((resolve, reject) => {
116
+ const req = globalThis.indexedDB.open(DPOP_IDB_NAME, DPOP_IDB_VERSION);
117
+ req.onupgradeneeded = () => {
118
+ const db = req.result;
119
+ if (!db.objectStoreNames.contains(DPOP_IDB_STORE)) {
120
+ db.createObjectStore(DPOP_IDB_STORE);
121
+ }
122
+ };
123
+ req.onsuccess = () => {
124
+ resolve(req.result);
125
+ };
126
+ req.onerror = () => {
127
+ reject(req.error ?? new Error("IndexedDB open failed"));
128
+ };
129
+ });
130
+ }
131
+ async function saveDPoPSession(session) {
132
+ if (!dpopPersistenceSupported) return false;
133
+ let db = null;
134
+ try {
135
+ db = await openSessionDB();
136
+ await new Promise((resolve, reject) => {
137
+ const tx = db.transaction(DPOP_IDB_STORE, "readwrite");
138
+ tx.objectStore(DPOP_IDB_STORE).put(session, session.tenant);
139
+ tx.oncomplete = () => {
140
+ resolve();
141
+ };
142
+ tx.onerror = () => {
143
+ reject(tx.error ?? new Error("IndexedDB put failed"));
144
+ };
145
+ tx.onabort = () => {
146
+ reject(tx.error ?? new Error("IndexedDB transaction aborted"));
147
+ };
148
+ });
149
+ return true;
150
+ } catch {
151
+ return false;
152
+ } finally {
153
+ db?.close();
154
+ }
155
+ }
156
+ async function loadDPoPSession(tenant) {
157
+ if (!dpopPersistenceSupported) return null;
158
+ let db = null;
159
+ try {
160
+ db = await openSessionDB();
161
+ const data = await new Promise((resolve, reject) => {
162
+ const tx = db.transaction(DPOP_IDB_STORE, "readonly");
163
+ const req = tx.objectStore(DPOP_IDB_STORE).get(tenant);
164
+ req.onsuccess = () => {
165
+ resolve(req.result);
166
+ };
167
+ req.onerror = () => {
168
+ reject(req.error ?? new Error("IndexedDB get failed"));
169
+ };
170
+ });
171
+ return validatePersistedSession(data);
172
+ } catch {
173
+ return null;
174
+ } finally {
175
+ db?.close();
176
+ }
177
+ }
178
+ async function clearDPoPSession(tenant) {
179
+ if (!dpopPersistenceSupported) return;
180
+ let db = null;
181
+ try {
182
+ db = await openSessionDB();
183
+ await new Promise((resolve, reject) => {
184
+ const tx = db.transaction(DPOP_IDB_STORE, "readwrite");
185
+ tx.objectStore(DPOP_IDB_STORE).delete(tenant);
186
+ tx.oncomplete = () => {
187
+ resolve();
188
+ };
189
+ tx.onerror = () => {
190
+ reject(tx.error ?? new Error("IndexedDB delete failed"));
191
+ };
192
+ tx.onabort = () => {
193
+ reject(tx.error ?? new Error("IndexedDB transaction aborted"));
194
+ };
195
+ });
196
+ } catch {
197
+ } finally {
198
+ db?.close();
199
+ }
200
+ }
201
+ function validatePersistedSession(data) {
202
+ if (!data || typeof data !== "object") return null;
203
+ const s = data;
204
+ const kp = s.keyPair;
205
+ if (typeof s.tenant !== "string" || typeof s.accessToken !== "string" || typeof s.refreshToken !== "string" || typeof s.expiresAt !== "number" || !kp || !kp.privateKey || !kp.publicJwk) {
206
+ return null;
207
+ }
208
+ return data;
209
+ }
99
210
 
100
211
  // src/sdk/pow.ts
101
212
  var BATCH_SIZE = 1e3;
@@ -139,17 +250,6 @@ async function solvePoW(challenge, difficulty) {
139
250
  return tryBatch();
140
251
  }
141
252
 
142
- // src/sdk/constants.ts
143
- var DEFAULT_TOKEN_TTL_SEC = 3600;
144
- var TOKEN_REFRESH_LEEWAY_MS = 12e4;
145
- var TOKEN_REFRESH_MIN_MS = 1e4;
146
- var RECONNECT_MAX_ATTEMPTS = 10;
147
- var RECONNECT_BASE_MS = 1e3;
148
- var RECONNECT_MAX_DELAY_MS = 3e4;
149
- var SUB_PROTOCOL = "access_token";
150
- var SDK_CACHE_MAX_ENTRIES_DEFAULT = 1e3;
151
- var SDK_POLL_INTERVAL_MS_DEFAULT = 5e3;
152
-
153
253
  // src/sdk/errors.ts
154
254
  var GeonicDBError = class extends Error {
155
255
  constructor(message, statusCode = 0) {
@@ -342,6 +442,14 @@ var _AuthManager = class _AuthManager {
342
442
  __publicField(this, "_dpopKeyPair", null);
343
443
  __publicField(this, "_dpopReady", null);
344
444
  __publicField(this, "_dpopNonce", null);
445
+ /**
446
+ * #1230: when true, every DPoP token mutation (login bind, refresh) is
447
+ * mirrored to IndexedDB so the session survives a page reload. Toggled
448
+ * by `login({ dpopPersist: true })` and `restoreSession()`.
449
+ */
450
+ __publicField(this, "_dpopPersist", false);
451
+ /** Email captured at login, persisted for UI display. */
452
+ __publicField(this, "_email", null);
345
453
  __publicField(this, "_tokenPromise", null);
346
454
  /** Callback to emit tokenRefresh events. Set by GeonicDB class. */
347
455
  __publicField(this, "onTokenRefresh", null);
@@ -368,9 +476,9 @@ var _AuthManager = class _AuthManager {
368
476
  this._anonymous = anonymous;
369
477
  if (!anonymous && dpopSupported) {
370
478
  this._dpopReady = generateDPoPKeyPair().then((kp) => {
371
- this._dpopKeyPair = kp;
479
+ if (!this._dpopKeyPair) this._dpopKeyPair = kp;
372
480
  }).catch(() => {
373
- this._dpopKeyPair = null;
481
+ if (!this._dpopKeyPair) this._dpopKeyPair = null;
374
482
  });
375
483
  }
376
484
  }
@@ -414,6 +522,12 @@ var _AuthManager = class _AuthManager {
414
522
  */
415
523
  async login(email, password, options) {
416
524
  this._log("login", email);
525
+ const wantsDpopPersist = options?.dpopPersist === true;
526
+ if (wantsDpopPersist && !options?.dpop) {
527
+ throw new AuthenticationError(
528
+ "dpopPersist: true requires dpop: true. Persisting Bearer tokens is the caller's responsibility (localStorage / sessionStorage)."
529
+ );
530
+ }
417
531
  const headers = {
418
532
  "Content-Type": "application/json"
419
533
  };
@@ -435,9 +549,14 @@ var _AuthManager = class _AuthManager {
435
549
  this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
436
550
  this._tokenType = "Bearer";
437
551
  this._refreshToken = data.refreshToken;
552
+ this._email = email;
553
+ this._dpopPersist = wantsDpopPersist;
438
554
  this._invalidateAuthScopedState();
439
555
  if (options?.dpop) {
440
556
  const bound = await this._bindDpopSession();
557
+ if (this._dpopPersist) {
558
+ await this._persistDpopSession();
559
+ }
441
560
  return bound;
442
561
  }
443
562
  return data;
@@ -531,9 +650,81 @@ var _AuthManager = class _AuthManager {
531
650
  this._tokenExpiry = 0;
532
651
  this._refreshToken = null;
533
652
  this._tokenPromise = null;
653
+ this._email = null;
654
+ if (this._dpopPersist && this._tenant) {
655
+ void clearDPoPSession(this._tenant);
656
+ }
657
+ this._dpopPersist = false;
534
658
  this._anonymous = this._initialAnonymous;
535
659
  this._invalidateAuthScopedState();
536
660
  }
661
+ /**
662
+ * Restore a DPoP session previously persisted via `login({ dpop: true,
663
+ * dpopPersist: true })`. Returns true if a usable session was found and
664
+ * loaded into the SDK, false otherwise (no entry, IndexedDB unavailable,
665
+ * or the entry was for a different tenant).
666
+ *
667
+ * After a successful restore the SDK behaves as if `login()` had just
668
+ * succeeded: tokens are set, the DPoP key pair is rehydrated as a
669
+ * non-extractable `CryptoKey`, and subsequent requests automatically
670
+ * carry a fresh DPoP proof. Token refresh continues to flow through
671
+ * `/auth/refresh` and emits `tokenRefresh` events as usual.
672
+ *
673
+ * Token expiry is NOT validated here — the next `ensureToken()` call will
674
+ * trigger a refresh if the access token has expired (#1230). An expired
675
+ * refresh token will manifest as an `AuthenticationError` from that
676
+ * refresh attempt; consumers should treat such errors as "session ended,
677
+ * fall back to login()".
678
+ */
679
+ async restoreSession() {
680
+ if (this._initialAnonymous) return false;
681
+ if (!this._tenant) return false;
682
+ const persisted = await loadDPoPSession(this._tenant);
683
+ if (!persisted) return false;
684
+ if (persisted.tenant !== this._tenant) return false;
685
+ this._anonymous = false;
686
+ this._token = persisted.accessToken;
687
+ this._refreshToken = persisted.refreshToken;
688
+ this._tokenExpiry = persisted.expiresAt;
689
+ this._tokenType = "DPoP";
690
+ this._dpopKeyPair = persisted.keyPair;
691
+ this._dpopReady = Promise.resolve();
692
+ this._dpopNonce = null;
693
+ this._dpopPersist = true;
694
+ this._email = persisted.email ?? null;
695
+ this._tokenPromise = null;
696
+ this._invalidateAuthScopedState();
697
+ return true;
698
+ }
699
+ /**
700
+ * Whether this AuthManager currently has DPoP persistence enabled.
701
+ * Exposed for the `GeonicDB` wrapper to advertise behavior; internal
702
+ * consumers should read `_dpopPersist` directly.
703
+ */
704
+ isDpopPersisted() {
705
+ return this._dpopPersist;
706
+ }
707
+ /**
708
+ * Persist the current DPoP session to IndexedDB. Best-effort: a failed
709
+ * write is logged in debug mode but never propagated, since persistence
710
+ * is purely a UX optimization.
711
+ *
712
+ * @internal
713
+ */
714
+ async _persistDpopSession() {
715
+ if (!this._dpopPersist || !this._tenant || !this._dpopKeyPair || !this._token || !this._refreshToken) {
716
+ return;
717
+ }
718
+ const ok = await saveDPoPSession({
719
+ tenant: this._tenant,
720
+ accessToken: this._token,
721
+ refreshToken: this._refreshToken,
722
+ expiresAt: this._tokenExpiry,
723
+ email: this._email ?? void 0,
724
+ keyPair: this._dpopKeyPair
725
+ });
726
+ if (!ok) this._log("DPoP session persistence failed (IndexedDB unavailable?)");
727
+ }
537
728
  /**
538
729
  * Drop everything that may have been associated with the previous auth
539
730
  * context. Called on `login()` (after token assignment), `setCredentials()`,
@@ -604,6 +795,7 @@ var _AuthManager = class _AuthManager {
604
795
  expiresIn: data.expiresIn,
605
796
  refreshToken: this._refreshToken
606
797
  });
798
+ if (this._dpopPersist) void this._persistDpopSession();
607
799
  return this._token;
608
800
  } catch (err) {
609
801
  this._tokenPromise = null;
@@ -1199,11 +1391,39 @@ var GeonicDB = class extends EventEmitter {
1199
1391
  *
1200
1392
  * // DPoP-bound (recommended for SPAs)
1201
1393
  * await db.login('user@example.com', 'password', { dpop: true });
1394
+ *
1395
+ * // DPoP-bound + persisted across reloads (browser SPA UX)
1396
+ * await db.login('user@example.com', 'password', {
1397
+ * dpop: true,
1398
+ * dpopPersist: true,
1399
+ * });
1202
1400
  * ```
1203
1401
  */
1204
1402
  async login(email, password, options) {
1205
1403
  return this._auth.login(email, password, options);
1206
1404
  }
1405
+ /**
1406
+ * Rehydrate a DPoP session previously persisted via
1407
+ * `login({ dpop: true, dpopPersist: true })`.
1408
+ *
1409
+ * Returns true if a session for this tenant was found and loaded; false
1410
+ * if there was no entry, IndexedDB is unavailable, or the persisted
1411
+ * session was malformed. Call this at app startup before deciding
1412
+ * whether to render the login form.
1413
+ *
1414
+ * @example
1415
+ * ```ts
1416
+ * const db = new GeonicDB({ baseUrl, tenant });
1417
+ * if (await db.restoreSession()) {
1418
+ * // Tokens + DPoP private key recovered from IndexedDB — boot the app.
1419
+ * } else {
1420
+ * // No session; show the login form.
1421
+ * }
1422
+ * ```
1423
+ */
1424
+ async restoreSession() {
1425
+ return this._auth.restoreSession();
1426
+ }
1207
1427
  /**
1208
1428
  * Whether the SDK is currently operating in anonymous mode (no token held).
1209
1429
  * Returns true only when `anonymous: true` was passed to the constructor