@geolonia/geonicdb-sdk 0.10.1 → 0.11.1

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/README.md CHANGED
@@ -96,58 +96,39 @@ await db.login('user@example.com', 'password');
96
96
  // Token refresh is automatic via refresh token.
97
97
  ```
98
98
 
99
- #### DPoP-bound session (recommended for SPAs)
100
-
101
- If you persist tokens in storage that may be exposed to XSS
102
- (`localStorage` / `sessionStorage`), pass `{ dpop: true }` to immediately
103
- exchange the Bearer session for a **DPoP sender-constrained session**
104
- (RFC 9449). The SDK generates a non-extractable ECDSA P-256 key pair via Web
105
- Crypto and binds the access / refresh tokens to it, so a leaked token cannot
106
- be replayed from another origin.
99
+ Sessions are **DPoP sender-constrained automatically** (RFC 9449). After
100
+ `POST /auth/login` succeeds, the SDK transparently exchanges the Bearer
101
+ session for a DPoP-bound one, generating a non-extractable ECDSA P-256 key
102
+ pair via Web Crypto and binding the access / refresh tokens to it. A token
103
+ leaked from storage (e.g. localStorage XSS) therefore cannot be replayed
104
+ from another origin. There is no DPoP option to set — you never deal with
105
+ DPoP directly.
107
106
 
108
107
  ```typescript
109
- const result = await db.login('user@example.com', 'password', { dpop: true });
110
- // result.tokenType === 'DPoP'
108
+ const result = await db.login('user@example.com', 'password');
109
+ // result.tokenType === 'DPoP' when Web Crypto is available.
111
110
  // Subsequent requests use `Authorization: DPoP <token>` + per-request proof.
112
- // Refresh calls send the DPoP proof on /auth/refresh as well.
113
- localStorage.setItem('accessToken', result.accessToken);
114
- localStorage.setItem('refreshToken', result.refreshToken);
111
+ // The bind step and the RFC 9449 §8 `use_dpop_nonce` handshake are internal.
115
112
  ```
116
113
 
117
- Notes:
118
-
119
- - The bind step (`POST /auth/dpop-bind`) is performed transparently after
120
- `POST /auth/login` succeeds.
121
- - The RFC 9449 §8 `use_dpop_nonce` handshake is handled internally.
122
- - Fails with `AuthenticationError` if Web Crypto `subtle.generateKey` is
123
- unavailable, instead of silently falling back to Bearer (fail-closed).
124
- - Omitting the option preserves the previous behavior (Bearer only) for
125
- backward compatibility.
114
+ On runtimes without Web Crypto (`subtle.generateKey`, e.g. a non-HTTPS
115
+ context), the session cannot be sender-constrained: the SDK silently keeps
116
+ the plain Bearer session and **login still succeeds** (`tokenType === 'Bearer'`).
126
117
 
127
- #### Persisting DPoP across page reloads
118
+ #### Keeping the user signed in across page reloads
128
119
 
129
- By default, a DPoP session is held in memory only. Because the private key
130
- is generated as a **non-extractable** `CryptoKey`, it cannot be serialized
131
- to `localStorage` — and reloading the page would lose it, forcing the user
132
- to log in again.
133
-
134
- Pass `{ dpop: true, dpopPersist: true }` to persist the session to
135
- **IndexedDB**. The structured clone algorithm stores the `CryptoKey` as-is,
136
- preserving `extractable: false`: the key survives reloads, but JavaScript
137
- on the page still cannot read it back as JWK / PEM. Tokens leaked from any
138
- other storage remain unusable without this origin's IndexedDB.
120
+ By default a session is held in memory only — reloading the page loses it
121
+ and forces the user to log in again. Pass `{ rememberSession: true }` to keep
122
+ the user signed in:
139
123
 
140
124
  ```typescript
141
125
  const db = new GeonicDB({ baseUrl, tenant });
142
- await db.login('user@example.com', 'password', {
143
- dpop: true,
144
- dpopPersist: true,
145
- });
126
+ await db.login('user@example.com', 'password', { rememberSession: true });
146
127
 
147
128
  // On the next page load:
148
129
  const db2 = new GeonicDB({ baseUrl, tenant });
149
130
  if (await db2.restoreSession()) {
150
- // Session was rehydrated from IndexedDB — boot the app.
131
+ // Session was rehydrated — boot the app.
151
132
  } else {
152
133
  // No persisted session; show the login form.
153
134
  }
@@ -156,14 +137,19 @@ if (await db2.restoreSession()) {
156
137
  await db2.logout();
157
138
  ```
158
139
 
140
+ Under the hood, the session is persisted to **IndexedDB**. The structured
141
+ clone algorithm stores the DPoP `CryptoKey` as-is, preserving
142
+ `extractable: false`: the key survives reloads, but JavaScript on the page
143
+ still cannot read it back as JWK / PEM. Tokens leaked from any other storage
144
+ remain unusable without this origin's IndexedDB.
145
+
159
146
  Notes:
160
147
 
161
- - `dpopPersist: true` requires `dpop: true` (throws otherwise). The
162
- persistence mechanism is for DPoP key pairs specifically; persisting
163
- Bearer tokens is the caller's responsibility.
164
148
  - The IndexedDB store is keyed by `tenant`, so multi-tenant apps in the
165
149
  same origin keep separate sessions.
166
- - Persistence is best-effort: a failed IDB write does not break login.
150
+ - Persistence is best-effort: a failed IDB write (or no Web Crypto, so the
151
+ session stays Bearer) does not break login — it just means no rehydrate.
152
+ - Opt-in by design: shared-device flows should leave `rememberSession` off.
167
153
  - iOS Safari may evict IndexedDB under ITP after 7 days of no user
168
154
  interaction with the origin.
169
155
 
@@ -403,8 +389,8 @@ handle.stop();
403
389
 
404
390
  | Method | Parameters | Returns | Description |
405
391
  |--------|-----------|---------|-------------|
406
- | `login(email, password, options?)` | `string, string, LoginOptions?` | `Promise<LoginResponse>` | Login with email and password (Bearer JWT). Upgrades the SDK out of anonymous mode. Pass `{ dpop: true }` to immediately exchange the Bearer session for a DPoP sender-constrained session (RFC 9449) — recommended when persisting tokens in storage exposed to XSS. Pass `{ dpop: true, dpopPersist: true }` to also persist the session to IndexedDB so it survives a page reload (#1230) |
407
- | `restoreSession()` | — | `Promise<boolean>` | Rehydrate a DPoP session previously persisted via `login({ dpop: true, dpopPersist: true })`. Returns `true` if a session was found and loaded, `false` otherwise. Call at app startup before showing the login form |
392
+ | `login(email, password, options?)` | `string, string, LoginOptions?` | `Promise<LoginResponse>` | Login with email and password. Upgrades the SDK out of anonymous mode. The session is DPoP sender-constrained (RFC 9449) **automatically and transparently** — no option to set; silently falls back to Bearer when Web Crypto is unavailable. Pass `{ rememberSession: true }` to keep the user signed in across page reloads (persisted to IndexedDB) |
393
+ | `restoreSession()` | — | `Promise<boolean>` | Rehydrate a session previously persisted via `login({ rememberSession: true })`. Returns `true` if a session was found and loaded, `false` otherwise. Call at app startup before showing the login form |
408
394
  | `setCredentials(opts)` | `CredentialsOptions` | `this` | Set credentials externally (e.g. from a login API response). Upgrades the SDK out of anonymous mode when `tokenType: 'Bearer'`. **Throws** when `tokenType: 'DPoP'` is passed to an anonymous-built instance — anonymous instances do not generate a DPoP key pair |
409
395
  | `logout()` | — | `void` | Clear all credentials and disconnect WebSocket. Reverts to anonymous mode if `anonymous: true` was set at construction |
410
396
  | `isAnonymous()` | — | `boolean` | Whether explicit anonymous mode is active. Returns the explicit mode flag — not derived from credential presence — so a refresh failure that clears `_token` does NOT silently flip this back to `true` |
@@ -428,6 +414,7 @@ handle.stop();
428
414
  | `limit` | `number` | Max results |
429
415
  | `offset` | `number` | Pagination offset |
430
416
  | `q` | `string` | NGSI-LD query expression |
417
+ | `options` | `string` | NGSI-LD representation options (comma-separated): `sysAttrs` (include `createdAt`/`modifiedAt`), `keyValues`, `concise`, `normalized` |
431
418
 
432
419
  #### `CountEntitiesParams`
433
420
 
@@ -467,6 +454,7 @@ handle.stop();
467
454
  | `timerel` | `string` | Temporal relation (`before`, `after`, `between`) |
468
455
  | `limit` | `number` | Max results |
469
456
  | `offset` | `number` | Pagination offset |
457
+ | `options` | `string` | NGSI-LD representation options (comma-separated): `sysAttrs`, `temporalValues`, `aggregatedValues` |
470
458
 
471
459
  ### Batch Operations
472
460
 
package/geonicdb.cjs CHANGED
@@ -477,11 +477,12 @@ var _AuthManager = class _AuthManager {
477
477
  __publicField(this, "_dpopReady", null);
478
478
  __publicField(this, "_dpopNonce", null);
479
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()`.
480
+ * When true, every DPoP token mutation (login bind, refresh) is mirrored to
481
+ * IndexedDB so the session survives a page reload (#1230). Toggled by
482
+ * `login(email, password, { rememberSession: true })` and
483
+ * `restoreSession()` (#1244).
483
484
  */
484
- __publicField(this, "_dpopPersist", false);
485
+ __publicField(this, "_rememberSession", false);
485
486
  /** Email captured at login, persisted for UI display. */
486
487
  __publicField(this, "_email", null);
487
488
  __publicField(this, "_tokenPromise", null);
@@ -546,22 +547,22 @@ var _AuthManager = class _AuthManager {
546
547
  this._emitCacheEvent?.(name, payload);
547
548
  }
548
549
  /**
549
- * Login with email and password (Bearer JWT).
550
+ * Login with email and password.
550
551
  *
551
- * Pass `{ dpop: true }` to immediately exchange the Bearer session for a
552
- * DPoP sender-constrained session via `/auth/dpop-bind` (RFC 9449). The
553
- * returned tokens become unusable without this SDK instance's
554
- * non-extractable private key, so a token leaked from storage (e.g.
555
- * localStorage XSS) cannot be replayed from another origin.
552
+ * #1244: DPoP (RFC 9449) is transparent and always-on — there is no `dpop`
553
+ * option to set. After the Bearer login succeeds, the SDK automatically
554
+ * exchanges it for a DPoP sender-constrained session so a token leaked from
555
+ * storage (e.g. localStorage XSS) cannot be replayed from another origin.
556
+ *
557
+ * When the runtime lacks Web Crypto (`crypto.subtle.generateKey`, e.g. a
558
+ * non-HTTPS context), DPoP cannot be used and the SDK silently keeps the
559
+ * Bearer session — login still succeeds. Callers never deal with DPoP
560
+ * directly; pass `{ rememberSession: true }` only to keep the user signed in
561
+ * across reloads.
556
562
  */
557
563
  async login(email, password, options) {
558
564
  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
- }
565
+ const wantsRemember = options?.rememberSession === true;
565
566
  const headers = {
566
567
  "Content-Type": "application/json"
567
568
  };
@@ -584,14 +585,30 @@ var _AuthManager = class _AuthManager {
584
585
  this._tokenType = "Bearer";
585
586
  this._refreshToken = data.refreshToken;
586
587
  this._email = email;
587
- this._dpopPersist = wantsDpopPersist;
588
+ this._rememberSession = wantsRemember;
588
589
  this._invalidateAuthScopedState();
589
- if (options?.dpop) {
590
- const bound = await this._bindDpopSession();
591
- if (this._dpopPersist) {
592
- await this._persistDpopSession();
590
+ if (!wantsRemember && this._tenant) {
591
+ void clearDPoPSession(this._tenant);
592
+ }
593
+ if (this._dpopReady) await this._dpopReady;
594
+ if (this._dpopKeyPair) {
595
+ try {
596
+ const bound = await this._bindDpopSession();
597
+ if (this._rememberSession) {
598
+ await this._persistDpopSession();
599
+ }
600
+ return bound;
601
+ } catch (err) {
602
+ this._token = null;
603
+ this._tokenExpiry = 0;
604
+ this._tokenType = "Bearer";
605
+ this._refreshToken = null;
606
+ this._tokenPromise = null;
607
+ this._rememberSession = false;
608
+ this._email = null;
609
+ this._invalidateAuthScopedState();
610
+ throw err;
593
611
  }
594
- return bound;
595
612
  }
596
613
  return data;
597
614
  }
@@ -685,18 +702,19 @@ var _AuthManager = class _AuthManager {
685
702
  this._refreshToken = null;
686
703
  this._tokenPromise = null;
687
704
  this._email = null;
688
- if (this._dpopPersist && this._tenant) {
705
+ if (this._tenant) {
689
706
  void clearDPoPSession(this._tenant);
690
707
  }
691
- this._dpopPersist = false;
708
+ this._rememberSession = false;
692
709
  this._anonymous = this._initialAnonymous;
693
710
  this._invalidateAuthScopedState();
694
711
  }
695
712
  /**
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).
713
+ * Restore a session previously persisted via
714
+ * `login(email, password, { rememberSession: true })`. Returns true if a
715
+ * usable session was found and loaded into the
716
+ * SDK, false otherwise (no entry, IndexedDB unavailable, or the entry was
717
+ * for a different tenant).
700
718
  *
701
719
  * After a successful restore the SDK behaves as if `login()` had just
702
720
  * succeeded: tokens are set, the DPoP key pair is rehydrated as a
@@ -724,19 +742,19 @@ var _AuthManager = class _AuthManager {
724
742
  this._dpopKeyPair = persisted.keyPair;
725
743
  this._dpopReady = Promise.resolve();
726
744
  this._dpopNonce = null;
727
- this._dpopPersist = true;
745
+ this._rememberSession = true;
728
746
  this._email = persisted.email ?? null;
729
747
  this._tokenPromise = null;
730
748
  this._invalidateAuthScopedState();
731
749
  return true;
732
750
  }
733
751
  /**
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.
752
+ * Whether this AuthManager currently keeps the session persisted across
753
+ * reloads (`rememberSession`). Exposed for the `GeonicDB` wrapper; internal
754
+ * consumers should read `_rememberSession` directly.
737
755
  */
738
- isDpopPersisted() {
739
- return this._dpopPersist;
756
+ isSessionRemembered() {
757
+ return this._rememberSession;
740
758
  }
741
759
  /**
742
760
  * Persist the current DPoP session to IndexedDB. Best-effort: a failed
@@ -746,7 +764,7 @@ var _AuthManager = class _AuthManager {
746
764
  * @internal
747
765
  */
748
766
  async _persistDpopSession() {
749
- if (!this._dpopPersist || !this._tenant || !this._dpopKeyPair || !this._token || !this._refreshToken) {
767
+ if (!this._rememberSession || !this._tenant || !this._dpopKeyPair || !this._token || !this._refreshToken) {
750
768
  return;
751
769
  }
752
770
  const ok = await saveDPoPSession({
@@ -829,7 +847,7 @@ var _AuthManager = class _AuthManager {
829
847
  expiresIn: data.expiresIn,
830
848
  refreshToken: this._refreshToken
831
849
  });
832
- if (this._dpopPersist) void this._persistDpopSession();
850
+ if (this._rememberSession) void this._persistDpopSession();
833
851
  return this._token;
834
852
  } catch (err) {
835
853
  this._tokenPromise = null;
@@ -1410,35 +1428,29 @@ var GeonicDB = class extends EventEmitter {
1410
1428
  }
1411
1429
  // --- Authentication ---
1412
1430
  /**
1413
- * Login with email and password (Bearer JWT).
1431
+ * Login with email and password.
1414
1432
  *
1415
- * Pass `{ dpop: true }` to immediately upgrade the session to a DPoP
1416
- * sender-constrained token (RFC 9449). Recommended for browser apps that
1417
- * persist tokens in storage exposed to XSS (localStorage / sessionStorage),
1418
- * since DPoP-bound tokens are unusable without this SDK instance's
1419
- * non-extractable private key.
1433
+ * The session is sender-constrained with DPoP (RFC 9449) automatically and
1434
+ * transparently — there is no DPoP option to set. A token leaked from
1435
+ * storage (e.g. localStorage XSS) is therefore unusable from another origin.
1436
+ * On runtimes without Web Crypto, the SDK silently keeps a plain Bearer
1437
+ * session and login still succeeds.
1420
1438
  *
1421
1439
  * @example
1422
1440
  * ```ts
1423
- * // Bearer (default, backward compatible)
1441
+ * // Standard login — DPoP applied transparently when supported.
1424
1442
  * await db.login('user@example.com', 'password');
1425
1443
  *
1426
- * // DPoP-bound (recommended for SPAs)
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
- * });
1444
+ * // Keep the user signed in across page reloads (browser SPA UX).
1445
+ * await db.login('user@example.com', 'password', { rememberSession: true });
1434
1446
  * ```
1435
1447
  */
1436
1448
  async login(email, password, options) {
1437
1449
  return this._auth.login(email, password, options);
1438
1450
  }
1439
1451
  /**
1440
- * Rehydrate a DPoP session previously persisted via
1441
- * `login({ dpop: true, dpopPersist: true })`.
1452
+ * Rehydrate a session previously persisted via
1453
+ * `login(email, password, { rememberSession: true })`.
1442
1454
  *
1443
1455
  * Returns true if a session for this tenant was found and loaded; false
1444
1456
  * if there was no entry, IndexedDB is unavailable, or the persisted
@@ -1449,7 +1461,7 @@ var GeonicDB = class extends EventEmitter {
1449
1461
  * ```ts
1450
1462
  * const db = new GeonicDB({ baseUrl, tenant });
1451
1463
  * if (await db.restoreSession()) {
1452
- * // Tokens + DPoP private key recovered from IndexedDB — boot the app.
1464
+ * // Session (tokens + non-extractable key) recovered — boot the app.
1453
1465
  * } else {
1454
1466
  * // No session; show the login form.
1455
1467
  * }