@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.iife.js CHANGED
@@ -72,8 +72,23 @@ var GeonicDBModule = (() => {
72
72
  }
73
73
  };
74
74
 
75
+ // src/sdk/constants.ts
76
+ var DEFAULT_TOKEN_TTL_SEC = 3600;
77
+ var TOKEN_REFRESH_LEEWAY_MS = 12e4;
78
+ var TOKEN_REFRESH_MIN_MS = 1e4;
79
+ var RECONNECT_MAX_ATTEMPTS = 10;
80
+ var RECONNECT_BASE_MS = 1e3;
81
+ var RECONNECT_MAX_DELAY_MS = 3e4;
82
+ var SUB_PROTOCOL = "access_token";
83
+ var SDK_CACHE_MAX_ENTRIES_DEFAULT = 1e3;
84
+ var SDK_POLL_INTERVAL_MS_DEFAULT = 5e3;
85
+ var DPOP_IDB_NAME = "geonicdb-sdk";
86
+ var DPOP_IDB_STORE = "dpop-sessions";
87
+ var DPOP_IDB_VERSION = 1;
88
+
75
89
  // src/sdk/dpop.ts
76
90
  var dpopSupported = typeof crypto !== "undefined" && !!crypto.subtle && !!crypto.subtle.generateKey;
91
+ var dpopPersistenceSupported = typeof globalThis.indexedDB !== "undefined" && typeof structuredClone !== "undefined";
77
92
  function b64url(buf) {
78
93
  const arr = buf instanceof ArrayBuffer ? new Uint8Array(buf) : buf;
79
94
  let str = "";
@@ -130,6 +145,102 @@ var GeonicDBModule = (() => {
130
145
  );
131
146
  return signingInput + "." + b64url(new Uint8Array(sigBuf));
132
147
  }
148
+ function openSessionDB() {
149
+ return new Promise((resolve, reject) => {
150
+ const req = globalThis.indexedDB.open(DPOP_IDB_NAME, DPOP_IDB_VERSION);
151
+ req.onupgradeneeded = () => {
152
+ const db = req.result;
153
+ if (!db.objectStoreNames.contains(DPOP_IDB_STORE)) {
154
+ db.createObjectStore(DPOP_IDB_STORE);
155
+ }
156
+ };
157
+ req.onsuccess = () => {
158
+ resolve(req.result);
159
+ };
160
+ req.onerror = () => {
161
+ reject(req.error ?? new Error("IndexedDB open failed"));
162
+ };
163
+ });
164
+ }
165
+ async function saveDPoPSession(session) {
166
+ if (!dpopPersistenceSupported) return false;
167
+ let db = null;
168
+ try {
169
+ db = await openSessionDB();
170
+ await new Promise((resolve, reject) => {
171
+ const tx = db.transaction(DPOP_IDB_STORE, "readwrite");
172
+ tx.objectStore(DPOP_IDB_STORE).put(session, session.tenant);
173
+ tx.oncomplete = () => {
174
+ resolve();
175
+ };
176
+ tx.onerror = () => {
177
+ reject(tx.error ?? new Error("IndexedDB put failed"));
178
+ };
179
+ tx.onabort = () => {
180
+ reject(tx.error ?? new Error("IndexedDB transaction aborted"));
181
+ };
182
+ });
183
+ return true;
184
+ } catch {
185
+ return false;
186
+ } finally {
187
+ db?.close();
188
+ }
189
+ }
190
+ async function loadDPoPSession(tenant) {
191
+ if (!dpopPersistenceSupported) return null;
192
+ let db = null;
193
+ try {
194
+ db = await openSessionDB();
195
+ const data = await new Promise((resolve, reject) => {
196
+ const tx = db.transaction(DPOP_IDB_STORE, "readonly");
197
+ const req = tx.objectStore(DPOP_IDB_STORE).get(tenant);
198
+ req.onsuccess = () => {
199
+ resolve(req.result);
200
+ };
201
+ req.onerror = () => {
202
+ reject(req.error ?? new Error("IndexedDB get failed"));
203
+ };
204
+ });
205
+ return validatePersistedSession(data);
206
+ } catch {
207
+ return null;
208
+ } finally {
209
+ db?.close();
210
+ }
211
+ }
212
+ async function clearDPoPSession(tenant) {
213
+ if (!dpopPersistenceSupported) return;
214
+ let db = null;
215
+ try {
216
+ db = await openSessionDB();
217
+ await new Promise((resolve, reject) => {
218
+ const tx = db.transaction(DPOP_IDB_STORE, "readwrite");
219
+ tx.objectStore(DPOP_IDB_STORE).delete(tenant);
220
+ tx.oncomplete = () => {
221
+ resolve();
222
+ };
223
+ tx.onerror = () => {
224
+ reject(tx.error ?? new Error("IndexedDB delete failed"));
225
+ };
226
+ tx.onabort = () => {
227
+ reject(tx.error ?? new Error("IndexedDB transaction aborted"));
228
+ };
229
+ });
230
+ } catch {
231
+ } finally {
232
+ db?.close();
233
+ }
234
+ }
235
+ function validatePersistedSession(data) {
236
+ if (!data || typeof data !== "object") return null;
237
+ const s = data;
238
+ const kp = s.keyPair;
239
+ if (typeof s.tenant !== "string" || typeof s.accessToken !== "string" || typeof s.refreshToken !== "string" || typeof s.expiresAt !== "number" || !kp || !kp.privateKey || !kp.publicJwk) {
240
+ return null;
241
+ }
242
+ return data;
243
+ }
133
244
 
134
245
  // src/sdk/pow.ts
135
246
  var BATCH_SIZE = 1e3;
@@ -173,17 +284,6 @@ var GeonicDBModule = (() => {
173
284
  return tryBatch();
174
285
  }
175
286
 
176
- // src/sdk/constants.ts
177
- var DEFAULT_TOKEN_TTL_SEC = 3600;
178
- var TOKEN_REFRESH_LEEWAY_MS = 12e4;
179
- var TOKEN_REFRESH_MIN_MS = 1e4;
180
- var RECONNECT_MAX_ATTEMPTS = 10;
181
- var RECONNECT_BASE_MS = 1e3;
182
- var RECONNECT_MAX_DELAY_MS = 3e4;
183
- var SUB_PROTOCOL = "access_token";
184
- var SDK_CACHE_MAX_ENTRIES_DEFAULT = 1e3;
185
- var SDK_POLL_INTERVAL_MS_DEFAULT = 5e3;
186
-
187
287
  // src/sdk/errors.ts
188
288
  var GeonicDBError = class extends Error {
189
289
  constructor(message, statusCode = 0) {
@@ -376,6 +476,14 @@ var GeonicDBModule = (() => {
376
476
  __publicField(this, "_dpopKeyPair", null);
377
477
  __publicField(this, "_dpopReady", null);
378
478
  __publicField(this, "_dpopNonce", null);
479
+ /**
480
+ * #1230: when true, every DPoP token mutation (login bind, refresh) is
481
+ * mirrored to IndexedDB so the session survives a page reload. Toggled
482
+ * by `login({ dpopPersist: true })` and `restoreSession()`.
483
+ */
484
+ __publicField(this, "_dpopPersist", false);
485
+ /** Email captured at login, persisted for UI display. */
486
+ __publicField(this, "_email", null);
379
487
  __publicField(this, "_tokenPromise", null);
380
488
  /** Callback to emit tokenRefresh events. Set by GeonicDB class. */
381
489
  __publicField(this, "onTokenRefresh", null);
@@ -402,9 +510,9 @@ var GeonicDBModule = (() => {
402
510
  this._anonymous = anonymous;
403
511
  if (!anonymous && dpopSupported) {
404
512
  this._dpopReady = generateDPoPKeyPair().then((kp) => {
405
- this._dpopKeyPair = kp;
513
+ if (!this._dpopKeyPair) this._dpopKeyPair = kp;
406
514
  }).catch(() => {
407
- this._dpopKeyPair = null;
515
+ if (!this._dpopKeyPair) this._dpopKeyPair = null;
408
516
  });
409
517
  }
410
518
  }
@@ -448,6 +556,12 @@ var GeonicDBModule = (() => {
448
556
  */
449
557
  async login(email, password, options) {
450
558
  this._log("login", email);
559
+ const wantsDpopPersist = options?.dpopPersist === true;
560
+ if (wantsDpopPersist && !options?.dpop) {
561
+ throw new AuthenticationError(
562
+ "dpopPersist: true requires dpop: true. Persisting Bearer tokens is the caller's responsibility (localStorage / sessionStorage)."
563
+ );
564
+ }
451
565
  const headers = {
452
566
  "Content-Type": "application/json"
453
567
  };
@@ -469,9 +583,14 @@ var GeonicDBModule = (() => {
469
583
  this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
470
584
  this._tokenType = "Bearer";
471
585
  this._refreshToken = data.refreshToken;
586
+ this._email = email;
587
+ this._dpopPersist = wantsDpopPersist;
472
588
  this._invalidateAuthScopedState();
473
589
  if (options?.dpop) {
474
590
  const bound = await this._bindDpopSession();
591
+ if (this._dpopPersist) {
592
+ await this._persistDpopSession();
593
+ }
475
594
  return bound;
476
595
  }
477
596
  return data;
@@ -565,9 +684,81 @@ var GeonicDBModule = (() => {
565
684
  this._tokenExpiry = 0;
566
685
  this._refreshToken = null;
567
686
  this._tokenPromise = null;
687
+ this._email = null;
688
+ if (this._dpopPersist && this._tenant) {
689
+ void clearDPoPSession(this._tenant);
690
+ }
691
+ this._dpopPersist = false;
568
692
  this._anonymous = this._initialAnonymous;
569
693
  this._invalidateAuthScopedState();
570
694
  }
695
+ /**
696
+ * Restore a DPoP session previously persisted via `login({ dpop: true,
697
+ * dpopPersist: true })`. Returns true if a usable session was found and
698
+ * loaded into the SDK, false otherwise (no entry, IndexedDB unavailable,
699
+ * or the entry was for a different tenant).
700
+ *
701
+ * After a successful restore the SDK behaves as if `login()` had just
702
+ * succeeded: tokens are set, the DPoP key pair is rehydrated as a
703
+ * non-extractable `CryptoKey`, and subsequent requests automatically
704
+ * carry a fresh DPoP proof. Token refresh continues to flow through
705
+ * `/auth/refresh` and emits `tokenRefresh` events as usual.
706
+ *
707
+ * Token expiry is NOT validated here — the next `ensureToken()` call will
708
+ * trigger a refresh if the access token has expired (#1230). An expired
709
+ * refresh token will manifest as an `AuthenticationError` from that
710
+ * refresh attempt; consumers should treat such errors as "session ended,
711
+ * fall back to login()".
712
+ */
713
+ async restoreSession() {
714
+ if (this._initialAnonymous) return false;
715
+ if (!this._tenant) return false;
716
+ const persisted = await loadDPoPSession(this._tenant);
717
+ if (!persisted) return false;
718
+ if (persisted.tenant !== this._tenant) return false;
719
+ this._anonymous = false;
720
+ this._token = persisted.accessToken;
721
+ this._refreshToken = persisted.refreshToken;
722
+ this._tokenExpiry = persisted.expiresAt;
723
+ this._tokenType = "DPoP";
724
+ this._dpopKeyPair = persisted.keyPair;
725
+ this._dpopReady = Promise.resolve();
726
+ this._dpopNonce = null;
727
+ this._dpopPersist = true;
728
+ this._email = persisted.email ?? null;
729
+ this._tokenPromise = null;
730
+ this._invalidateAuthScopedState();
731
+ return true;
732
+ }
733
+ /**
734
+ * Whether this AuthManager currently has DPoP persistence enabled.
735
+ * Exposed for the `GeonicDB` wrapper to advertise behavior; internal
736
+ * consumers should read `_dpopPersist` directly.
737
+ */
738
+ isDpopPersisted() {
739
+ return this._dpopPersist;
740
+ }
741
+ /**
742
+ * Persist the current DPoP session to IndexedDB. Best-effort: a failed
743
+ * write is logged in debug mode but never propagated, since persistence
744
+ * is purely a UX optimization.
745
+ *
746
+ * @internal
747
+ */
748
+ async _persistDpopSession() {
749
+ if (!this._dpopPersist || !this._tenant || !this._dpopKeyPair || !this._token || !this._refreshToken) {
750
+ return;
751
+ }
752
+ const ok = await saveDPoPSession({
753
+ tenant: this._tenant,
754
+ accessToken: this._token,
755
+ refreshToken: this._refreshToken,
756
+ expiresAt: this._tokenExpiry,
757
+ email: this._email ?? void 0,
758
+ keyPair: this._dpopKeyPair
759
+ });
760
+ if (!ok) this._log("DPoP session persistence failed (IndexedDB unavailable?)");
761
+ }
571
762
  /**
572
763
  * Drop everything that may have been associated with the previous auth
573
764
  * context. Called on `login()` (after token assignment), `setCredentials()`,
@@ -638,6 +829,7 @@ var GeonicDBModule = (() => {
638
829
  expiresIn: data.expiresIn,
639
830
  refreshToken: this._refreshToken
640
831
  });
832
+ if (this._dpopPersist) void this._persistDpopSession();
641
833
  return this._token;
642
834
  } catch (err) {
643
835
  this._tokenPromise = null;
@@ -1233,11 +1425,39 @@ var GeonicDBModule = (() => {
1233
1425
  *
1234
1426
  * // DPoP-bound (recommended for SPAs)
1235
1427
  * await db.login('user@example.com', 'password', { dpop: true });
1428
+ *
1429
+ * // DPoP-bound + persisted across reloads (browser SPA UX)
1430
+ * await db.login('user@example.com', 'password', {
1431
+ * dpop: true,
1432
+ * dpopPersist: true,
1433
+ * });
1236
1434
  * ```
1237
1435
  */
1238
1436
  async login(email, password, options) {
1239
1437
  return this._auth.login(email, password, options);
1240
1438
  }
1439
+ /**
1440
+ * Rehydrate a DPoP session previously persisted via
1441
+ * `login({ dpop: true, dpopPersist: true })`.
1442
+ *
1443
+ * Returns true if a session for this tenant was found and loaded; false
1444
+ * if there was no entry, IndexedDB is unavailable, or the persisted
1445
+ * session was malformed. Call this at app startup before deciding
1446
+ * whether to render the login form.
1447
+ *
1448
+ * @example
1449
+ * ```ts
1450
+ * const db = new GeonicDB({ baseUrl, tenant });
1451
+ * if (await db.restoreSession()) {
1452
+ * // Tokens + DPoP private key recovered from IndexedDB — boot the app.
1453
+ * } else {
1454
+ * // No session; show the login form.
1455
+ * }
1456
+ * ```
1457
+ */
1458
+ async restoreSession() {
1459
+ return this._auth.restoreSession();
1460
+ }
1241
1461
  /**
1242
1462
  * Whether the SDK is currently operating in anonymous mode (no token held).
1243
1463
  * Returns true only when `anonymous: true` was passed to the constructor