@geolonia/geonicdb-sdk 0.17.0 → 0.18.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
@@ -2,6 +2,16 @@
2
2
 
3
3
  GeonicDB JavaScript SDK — NGSI-LD Context Broker client for browser and Node.js.
4
4
 
5
+ > **Server-side reference**: most responses carry a
6
+ > `Link: </llms.txt>; rel="describedby"` header pointing at
7
+ > `https://<your-broker>/llms.txt` — a single Markdown document covering the API
8
+ > surface, the auth flow, the entity data model, and a **Troubleshooting**
9
+ > section for symptoms whose cause is not what the error text suggests. NGSI-LD
10
+ > paginated list responses are the exception: their `Link` header carries only
11
+ > `rel="next"`/`rel="prev"`, not the discovery link. Read `/llms.txt` directly
12
+ > alongside this README when a request fails for a reason the SDK cannot
13
+ > explain on its own.
14
+
5
15
  ## Installation
6
16
 
7
17
  ```bash
@@ -209,6 +219,7 @@ const entities = await db.getEntities({ type: 'GeoJSON' });
209
219
  ```
210
220
 
211
221
  - `apiKey` and `anonymous: true` cannot be combined (constructor throws).
222
+ - A **presented but invalid** Bearer / DPoP token returns HTTP 401 from the server (#2794), not an anonymous 403 — so refresh can fire. True anonymous mode sends **no** `Authorization` header.
212
223
  - `db.login()` upgrades the SDK out of anonymous mode (Bearer JWT).
213
224
  - `db.setCredentials({ token, tokenType: 'Bearer', ... })` upgrades from
214
225
  anonymous. **`tokenType: 'DPoP'` is rejected** for anonymous-built instances
@@ -322,6 +333,12 @@ const result = await db.request('GET', '/ngsi-ld/v1/subscriptions');
322
333
  > other `/v2/*` caller are **not** delivered — entities are protocol-isolated and the socket is an
323
334
  > NGSI-LD reader. Use HTTP subscriptions (`/v2/subscriptions`) for NGSIv2 changes.
324
335
 
336
+ > **Dedicated deployments (#2867):** REST resolves the database from the custom domain (`Host`), but
337
+ > WebSocket uses a shared execute-api endpoint. The SDK calls `GET /sdk/v1/streaming` on your
338
+ > `baseUrl`, reads the optional `deployment` hostname from the response, and appends
339
+ > `?deployment=<hostname>` on `$connect`. Raw `WebSocket` clients must set the same query parameter
340
+ > manually when connecting to a non-default deployment.
341
+
325
342
  ```typescript
326
343
  db.subscribe({ entityTypes: ['Room'] });
327
344
 
@@ -342,6 +359,9 @@ await db.connect();
342
359
  | `entityCreated` | `EntityEvent` | Entity was created |
343
360
  | `entityUpdated` | `EntityEvent` | Entity was updated |
344
361
  | `entityDeleted` | `EntityEvent` | Entity was deleted |
362
+
363
+ `EntityEvent.entityType` is `string | string[]` (#2477): a single type is a string; multi-type entities use a string array (same rule as NGSI-LD GET / Table 5.2.4).
364
+
345
365
  | `open` | — | WebSocket connection opened |
346
366
  | `connected` | — | WebSocket ready |
347
367
  | `subscribed` | — | `subscribe()` filter is now active on the server. Applying a subscription is asynchronous (the server resolves the supplied `@context` to expand `entityTypes` for matching), so events published before this fires are not guaranteed to be delivered |
@@ -624,13 +644,23 @@ handle.stop();
624
644
  All async methods throw typed errors extending `GeonicDBError`. Use `instanceof` to distinguish error types.
625
645
 
626
646
  ```typescript
627
- import { GeonicDBError, AuthenticationError, NotFoundError } from '@geolonia/geonicdb-sdk';
647
+ import {
648
+ GeonicDBError,
649
+ AuthenticationError,
650
+ PasswordResetRequiredError,
651
+ TemporaryPasswordExpiredError,
652
+ NotFoundError,
653
+ } from '@geolonia/geonicdb-sdk';
628
654
 
629
655
  try {
630
656
  await db.getEntity('urn:ngsi-ld:Room:404');
631
657
  } catch (err) {
632
- if (err instanceof AuthenticationError) {
633
- // 401 — re-login needed
658
+ if (err instanceof PasswordResetRequiredError) {
659
+ // 409 — collect a new password and call login(..., { newPassword })
660
+ } else if (err instanceof TemporaryPasswordExpiredError) {
661
+ // 403 — temporary password expired; ask an administrator to re-issue it
662
+ } else if (err instanceof AuthenticationError) {
663
+ // Other auth failures — re-login needed (typically 401; for HTTP-derived errors see err.statusCode)
634
664
  } else if (err instanceof NotFoundError) {
635
665
  // 404 — entity not found
636
666
  } else if (err instanceof GeonicDBError) {
@@ -644,7 +674,9 @@ try {
644
674
  | Class | Status | Description |
645
675
  |-------|--------|-------------|
646
676
  | `GeonicDBError` | — | Base class (`statusCode` property) |
647
- | `AuthenticationError` | 401 | Invalid credentials or expired token |
677
+ | `AuthenticationError` | Real HTTP status (default 401) | Invalid credentials, expired token, or an HTTP failure on an auth endpoint. HTTP-derived errors carry the server status |
678
+ | `PasswordResetRequiredError` | 409 | First login with a temporary password — set a new password (`AuthenticationError` subclass) |
679
+ | `TemporaryPasswordExpiredError` | 403 | Temporary password expired (`AuthenticationError` subclass) |
648
680
  | `AuthorizationError` | 403 | Insufficient permissions |
649
681
  | `NotFoundError` | 404 | Resource not found |
650
682
  | `ConflictError` | 409 | Entity already exists |
@@ -652,6 +684,54 @@ try {
652
684
  | `RateLimitError` | 429 | Rate limited (`retryAfter` property) |
653
685
  | `NetworkError` | — | Fetch failure, DNS error, timeout |
654
686
 
687
+ ### Which request failed (`request`)
688
+
689
+ `login()` makes up to two requests, the second one conditionally: `POST /auth/login`,
690
+ followed by `POST /auth/dpop-bind` (RFC 9449 session sender-constraining) **only when
691
+ that login succeeded and a DPoP key pair exists**. Where Web Crypto (`crypto.subtle`)
692
+ is unavailable no key pair is generated, so the bind is skipped and login succeeds with
693
+ a plain Bearer token.
694
+
695
+ Both requests surface the server's `detail` / `description` verbatim as an
696
+ `AuthenticationError` (or one of its subclasses, `PasswordResetRequiredError` /
697
+ `TemporaryPasswordExpiredError`). When the server answers both with the same body, the
698
+ error alone cannot tell a rejected credential from a rejected bind.
699
+
700
+ Errors from those two requests carry a `request` property (`{ method, path }`), and
701
+ their message is prefixed with `[METHOD path]`.
702
+
703
+ ```ts
704
+ try {
705
+ await db.login(email, password);
706
+ } catch (err) {
707
+ if (err instanceof AuthenticationError) {
708
+ if (err.request?.path === '/auth/dpop-bind') {
709
+ // The credentials were accepted; the DPoP bind is what failed.
710
+ }
711
+ console.error(err.message); // "[POST /auth/dpop-bind] Access denied: no applicable policy"
712
+ } else {
713
+ console.error(err);
714
+ }
715
+ }
716
+ ```
717
+
718
+ | Property | Type | Description |
719
+ |----------|------|-------------|
720
+ | `request` | `RequestContext \| undefined` | `{ method, path }`. `path` is the endpoint path without the base URL — if the base URL itself carries a path prefix, the request path on the wire includes that prefix too. |
721
+
722
+ Only `POST /auth/login` (`AuthenticationError`, `PasswordResetRequiredError`,
723
+ `TemporaryPasswordExpiredError`) and `POST /auth/dpop-bind` set it. Other auth failures
724
+ already name their step in the message (`Token refresh failed: <status>` and friends),
725
+ so they leave `request` as `undefined` — but `statusCode` still reflects the real HTTP
726
+ status. When the bind never runs — no Web Crypto, or a
727
+ failed `/auth/login` — there is no `/auth/dpop-bind` error to attribute in the first
728
+ place.
729
+
730
+ > `PasswordResetRequiredError` / `TemporaryPasswordExpiredError` are control-flow
731
+ > errors whose `message` an app may render as a prompt. With the prefix that text reads
732
+ > `[POST /auth/login] Password reset required: …`, so branch on `err.request` and supply
733
+ > your own wording rather than displaying the message verbatim.
734
+
655
735
  ## React Hooks (`@geolonia/geonicdb-sdk/react`)
656
736
 
657
737
  Optional React integration, shipped as a separate subpath so the core SDK stays
package/geonicdb.cjs CHANGED
@@ -296,13 +296,20 @@ var GeonicDBError = class extends Error {
296
296
  super(message);
297
297
  /** HTTP status code (if applicable) */
298
298
  __publicField(this, "statusCode");
299
+ /** The request that failed, when the SDK knows it. */
300
+ __publicField(this, "request");
299
301
  this.name = "GeonicDBError";
300
302
  this.statusCode = statusCode;
301
303
  }
302
304
  };
305
+ function withRequest(error, request) {
306
+ error.request = request;
307
+ error.message = `[${request.method} ${request.path}] ${error.message}`;
308
+ return error;
309
+ }
303
310
  var AuthenticationError = class extends GeonicDBError {
304
- constructor(message = "Authentication failed") {
305
- super(message, 401);
311
+ constructor(message = "Authentication failed", statusCode = 401) {
312
+ super(message, statusCode);
306
313
  this.name = "AuthenticationError";
307
314
  }
308
315
  };
@@ -326,16 +333,14 @@ var ConflictError = class extends GeonicDBError {
326
333
  };
327
334
  var PasswordResetRequiredError = class extends AuthenticationError {
328
335
  constructor(message = "Password reset required: set a new password to complete login") {
329
- super(message);
336
+ super(message, 409);
330
337
  this.name = "PasswordResetRequiredError";
331
- this.statusCode = 409;
332
338
  }
333
339
  };
334
340
  var TemporaryPasswordExpiredError = class extends AuthenticationError {
335
341
  constructor(message = "Temporary password expired: ask an administrator to re-issue it") {
336
- super(message);
342
+ super(message, 403);
337
343
  this.name = "TemporaryPasswordExpiredError";
338
- this.statusCode = 403;
339
344
  }
340
345
  };
341
346
  var ValidationError = class extends GeonicDBError {
@@ -740,12 +745,21 @@ var _AuthManager = class _AuthManager {
740
745
  const message = e.detail || e.description || "Login failed: " + res.status;
741
746
  const code = e.error || e.type || e.title || "";
742
747
  if (res.status === 409 && code.includes("PasswordResetRequired")) {
743
- throw new PasswordResetRequiredError(message);
748
+ throw withRequest(new PasswordResetRequiredError(message), {
749
+ method: "POST",
750
+ path: "/auth/login"
751
+ });
744
752
  }
745
753
  if (res.status === 403 && code.includes("TemporaryPasswordExpired")) {
746
- throw new TemporaryPasswordExpiredError(message);
754
+ throw withRequest(new TemporaryPasswordExpiredError(message), {
755
+ method: "POST",
756
+ path: "/auth/login"
757
+ });
747
758
  }
748
- throw new AuthenticationError(message);
759
+ throw withRequest(new AuthenticationError(message, res.status), {
760
+ method: "POST",
761
+ path: "/auth/login"
762
+ });
749
763
  }
750
764
  const data = await res.json();
751
765
  this._anonymous = false;
@@ -848,8 +862,12 @@ var _AuthManager = class _AuthManager {
848
862
  }
849
863
  if (!res.ok) {
850
864
  const e = await res.json().catch(() => ({}));
851
- throw new AuthenticationError(
852
- e.detail || e.description || "DPoP bind failed: " + res.status
865
+ throw withRequest(
866
+ new AuthenticationError(
867
+ e.detail || e.description || "DPoP bind failed: " + res.status,
868
+ res.status
869
+ ),
870
+ { method: "POST", path: "/auth/dpop-bind" }
853
871
  );
854
872
  }
855
873
  return await res.json();
@@ -1021,7 +1039,7 @@ var _AuthManager = class _AuthManager {
1021
1039
  this._refreshToken = null;
1022
1040
  this._token = null;
1023
1041
  this._tokenPromise = null;
1024
- throw new AuthenticationError("Token refresh failed: " + res.status);
1042
+ throw new AuthenticationError("Token refresh failed: " + res.status, res.status);
1025
1043
  }
1026
1044
  const data = await res.json();
1027
1045
  this._token = data.accessToken;
@@ -1089,7 +1107,7 @@ var _AuthManager = class _AuthManager {
1089
1107
  body: JSON.stringify({ api_key: this._apiKey })
1090
1108
  });
1091
1109
  if (!nonceRes.ok)
1092
- throw new AuthenticationError("Nonce request failed: " + nonceRes.status);
1110
+ throw new AuthenticationError("Nonce request failed: " + nonceRes.status, nonceRes.status);
1093
1111
  const nonceData = await nonceRes.json();
1094
1112
  if (nonceData.dpop_nonce) this._dpopNonce = nonceData.dpop_nonce;
1095
1113
  const proof = await solvePoW(nonceData.challenge, nonceData.difficulty);
@@ -1102,7 +1120,7 @@ var _AuthManager = class _AuthManager {
1102
1120
  });
1103
1121
  const res = await this._doTokenExchange(tokenUrl, tokenBody, this._dpopNonce);
1104
1122
  if (!res.ok)
1105
- throw new AuthenticationError("Token request failed: " + res.status);
1123
+ throw new AuthenticationError("Token request failed: " + res.status, res.status);
1106
1124
  const newNonce = res.headers.get("DPoP-Nonce");
1107
1125
  if (newNonce) this._dpopNonce = newNonce;
1108
1126
  const data = await res.json();
@@ -1144,8 +1162,8 @@ var _AuthManager = class _AuthManager {
1144
1162
  return this._doTokenExchange(tokenUrl, tokenBody, serverNonce, retryCount + 1);
1145
1163
  }
1146
1164
  }
1147
- throw new GeonicDBError(
1148
- "Token request failed: " + (errBody.error_description || errBody.error),
1165
+ throw new AuthenticationError(
1166
+ "Token request failed: " + (errBody.error_description || errBody.error || "400"),
1149
1167
  400
1150
1168
  );
1151
1169
  }
@@ -1325,6 +1343,7 @@ var WebSocketManager = class {
1325
1343
  __publicField(this, "_emit");
1326
1344
  __publicField(this, "_ws", null);
1327
1345
  __publicField(this, "_wsEndpoint", null);
1346
+ __publicField(this, "_deploymentHostname", null);
1328
1347
  __publicField(this, "_wsIntentionalClose", false);
1329
1348
  __publicField(this, "_reconnectAttempts", 0);
1330
1349
  __publicField(this, "_tokenRefreshTimer", null);
@@ -1414,6 +1433,9 @@ var WebSocketManager = class {
1414
1433
  const info = await res.json();
1415
1434
  if (info.enabled && info.endpoint) {
1416
1435
  this._wsEndpoint = info.endpoint;
1436
+ if (info.deployment) {
1437
+ this._deploymentHostname = info.deployment;
1438
+ }
1417
1439
  return this._wsEndpoint;
1418
1440
  }
1419
1441
  }
@@ -1425,7 +1447,11 @@ var WebSocketManager = class {
1425
1447
  async _openWebSocket(token) {
1426
1448
  const endpoint = await this._discoverWsEndpoint();
1427
1449
  return new Promise((resolve, reject) => {
1428
- const wsUrl = endpoint + (endpoint.indexOf("?") === -1 ? "?" : "&") + "tenant=" + encodeURIComponent(this._tenant);
1450
+ const params = new URLSearchParams({ tenant: this._tenant });
1451
+ if (this._deploymentHostname) {
1452
+ params.set("deployment", this._deploymentHostname);
1453
+ }
1454
+ const wsUrl = endpoint + (endpoint.indexOf("?") === -1 ? "?" : "&") + params.toString();
1429
1455
  this._log("connecting", wsUrl);
1430
1456
  const ws = new WebSocket(wsUrl, [SUB_PROTOCOL, token]);
1431
1457
  this._ws = ws;
@@ -1724,6 +1750,7 @@ var GeonicDB = class extends EventEmitter {
1724
1750
  if (!tenant) tenant = script?.getAttribute?.("data-tenant") || "";
1725
1751
  if (!baseUrl) baseUrl = script?.getAttribute?.("data-base-url") || "";
1726
1752
  }
1753
+ baseUrl = baseUrl.replace(/\/+$/, "");
1727
1754
  this._auth = new AuthManager(baseUrl, apiKey, tenant, opts.debug, opts.anonymous);
1728
1755
  this._auth.onTokenRefresh = (creds) => {
1729
1756
  this.onTokenRefresh?.(creds);