@geolonia/geonicdb-sdk 0.4.0 → 0.6.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/README.md CHANGED
@@ -84,6 +84,36 @@ db.setCredentials({
84
84
  });
85
85
  ```
86
86
 
87
+ ### Anonymous Mode (public viewers)
88
+
89
+ For public-facing apps such as GeoJSON viewers or BI dashboards that should
90
+ serve **unregistered visitors** without forcing a login, pass `anonymous: true`
91
+ to skip token acquisition entirely:
92
+
93
+ ```typescript
94
+ const db = new GeonicDB({
95
+ baseUrl: 'https://your-geonicdb.example.com',
96
+ tenant: 'public',
97
+ anonymous: true, // no Authorization header, no /auth/nonce, no /oauth/token
98
+ });
99
+
100
+ // The server treats this as `role: 'anonymous'` and the XACML policy engine
101
+ // decides whether to permit. Tenant admins must register a custom policy that
102
+ // permits the anonymous role for the resources to expose — the default
103
+ // ANONYMOUS_DEFAULT_POLICY denies everything.
104
+ const entities = await db.getEntities({ type: 'GeoJSON' });
105
+ ```
106
+
107
+ - `apiKey` and `anonymous: true` cannot be combined (constructor throws).
108
+ - `db.login()` upgrades the SDK out of anonymous mode (Bearer JWT).
109
+ - `db.setCredentials({ token, tokenType: 'Bearer', ... })` upgrades from
110
+ anonymous. **`tokenType: 'DPoP'` is rejected** for anonymous-built instances
111
+ (no DPoP key pair is generated for them) — use a non-anonymous SDK instance
112
+ for DPoP credentials.
113
+ - `db.logout()` reverts back to anonymous (`db.isAnonymous() === true` again).
114
+ - WebSocket `db.connect()` is **not supported** in anonymous mode — call
115
+ `login()` / `setCredentials()` first.
116
+
87
117
  ## Entity CRUD
88
118
 
89
119
  ```typescript
@@ -178,7 +208,7 @@ await db.connect();
178
208
  | `tokenRefresh` | `RefreshedCredentials` | Bearer token was refreshed |
179
209
  | `cacheHit` | `CacheEvent` | Request served from in-memory cache (304 received) |
180
210
  | `cacheMiss` | `CacheEvent` | Cache miss — fresh response fetched from origin |
181
- | `cacheInvalidated` | `CacheEvent` | Cache entry dropped (e.g. after a WebSocket entity event) |
211
+ | `cacheInvalidated` | `CacheEvent` | Cache entry dropped via explicit `clearCache()`. WebSocket entity events do **not** trigger cache invalidation — see "Client-side Cache & Polling" |
182
212
 
183
213
  ## Client-side Cache & Polling
184
214
 
@@ -204,6 +234,26 @@ const [a, b, c] = await Promise.all([
204
234
 
205
235
  Cache is enabled by default; pass `cache: false` to opt out.
206
236
 
237
+ ### Cache lifetime under WebSocket activity
238
+
239
+ WebSocket entity events (`entityCreated` / `entityUpdated` / `entityDeleted`)
240
+ do **not** automatically invalidate the SDK cache. This is intentional:
241
+
242
+ - Data endpoints respond with `Cache-Control: private, no-cache`, so every
243
+ cached read already revalidates with the origin via `If-None-Match`.
244
+ - If the entity actually changed, the server's freshly computed `ETag` will
245
+ not match the client's `If-None-Match` → server returns `200` with the
246
+ fresh body → SDK replaces the cache entry.
247
+ - If the WebSocket event was noisy / unrelated to a particular cached query
248
+ (or the data hasn't propagated to the read replica yet), the server returns
249
+ `304 Not Modified` → SDK serves the cached body with no body bytes
250
+ transferred.
251
+
252
+ Deleting cache entries on every WebSocket event would force the next read to
253
+ miss the cache and fetch a full `200` body — defeating the bandwidth-saving
254
+ `304` path. To force a full reset (e.g. on auth change), call `clearCache()`
255
+ explicitly.
256
+
207
257
  ### Polling
208
258
 
209
259
  `db.poll(params, options)` repeats `getEntities()` at an interval and reports
@@ -222,12 +272,6 @@ const handle = db.poll({ type: 'Room' }, {
222
272
  handle.stop();
223
273
  ```
224
274
 
225
- ### WebSocket-driven cache invalidation
226
-
227
- When the SDK receives `entityCreated` / `entityUpdated` / `entityDeleted`
228
- WebSocket events, it drops affected cache entries automatically. The next
229
- read re-validates with the server.
230
-
231
275
  ## API Reference
232
276
 
233
277
  ### Constructor
@@ -247,14 +291,16 @@ read re-validates with the server.
247
291
  | `debug` | `boolean` | No | Enable debug logging to console (default: `false`) |
248
292
  | `cache` | `boolean` | No | Enable in-memory cache with ETag/304 + request dedup (default: `true`) |
249
293
  | `cacheMaxEntries` | `number` | No | LRU cache capacity when `cache` is enabled (default: `1000`) |
294
+ | `anonymous` | `boolean` | No | Anonymous mode — skip token acquisition and send no `Authorization` header. Cannot be combined with `apiKey`. See [Anonymous Mode](#anonymous-mode-public-viewers) (default: `false`) |
250
295
 
251
296
  ### Authentication
252
297
 
253
298
  | Method | Parameters | Returns | Description |
254
299
  |--------|-----------|---------|-------------|
255
- | `login(email, password)` | `string, string` | `Promise<LoginResponse>` | Login with email and password (Bearer JWT) |
256
- | `setCredentials(opts)` | `CredentialsOptions` | `this` | Set credentials externally (e.g. from a login API response) |
257
- | `logout()` | — | `void` | Clear all credentials and disconnect WebSocket |
300
+ | `login(email, password)` | `string, string` | `Promise<LoginResponse>` | Login with email and password (Bearer JWT). Upgrades the SDK out of anonymous mode |
301
+ | `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 |
302
+ | `logout()` | — | `void` | Clear all credentials and disconnect WebSocket. Reverts to anonymous mode if `anonymous: true` was set at construction |
303
+ | `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` |
258
304
 
259
305
  ### Entity CRUD
260
306
 
package/geonicdb.cjs CHANGED
@@ -347,11 +347,27 @@ function isCacheableMethod(method) {
347
347
 
348
348
  // src/sdk/auth.ts
349
349
  var _AuthManager = class _AuthManager {
350
- constructor(baseUrl, apiKey, tenant, debug = false) {
350
+ constructor(baseUrl, apiKey, tenant, debug = false, anonymous = false) {
351
351
  __publicField(this, "_baseUrl");
352
352
  __publicField(this, "_apiKey");
353
353
  __publicField(this, "_tenant");
354
354
  __publicField(this, "_debug");
355
+ /**
356
+ * #1105: anonymous モード。true の場合、トークンを取得せず Authorization
357
+ * ヘッダ無しで送信する。サーバー側 (`optionalAuth`) で role='anonymous' として
358
+ * 通り、XACML が認可判定する。
359
+ *
360
+ * `_initialAnonymous` はコンストラクタで指定された希望モード (immutable)。
361
+ * `_anonymous` は現在のモード状態で、`login()` / `setCredentials()` で false
362
+ * に降り、`logout()` で `_initialAnonymous` の値に戻る。
363
+ *
364
+ * トークン有無 (`_token === null`) からは推論しない。`_token` は refresh
365
+ * 失敗時に null へ落ちるが、ユーザが明示的に `logout()` していない以上、
366
+ * 次のリクエストを勝手に Authorization ヘッダ無しで送ってはならない
367
+ * (#1113 review)。
368
+ */
369
+ __publicField(this, "_initialAnonymous");
370
+ __publicField(this, "_anonymous");
355
371
  __publicField(this, "_token", null);
356
372
  __publicField(this, "_tokenExpiry", 0);
357
373
  __publicField(this, "_tokenType", "Bearer");
@@ -372,11 +388,18 @@ var _AuthManager = class _AuthManager {
372
388
  __publicField(this, "_emitCacheEvent", null);
373
389
  /** In-flight request map keyed by `${METHOD}:${path}` for request deduplication. */
374
390
  __publicField(this, "_inFlight", /* @__PURE__ */ new Map());
391
+ if (anonymous && apiKey) {
392
+ throw new Error(
393
+ "GeonicDB SDK: `anonymous: true` cannot be combined with `apiKey`. Anonymous mode skips token acquisition entirely."
394
+ );
395
+ }
375
396
  this._baseUrl = baseUrl;
376
397
  this._apiKey = apiKey;
377
398
  this._tenant = tenant;
378
399
  this._debug = debug;
379
- if (dpopSupported) {
400
+ this._initialAnonymous = anonymous;
401
+ this._anonymous = anonymous;
402
+ if (!anonymous && dpopSupported) {
380
403
  this._dpopReady = generateDPoPKeyPair().then((kp) => {
381
404
  this._dpopKeyPair = kp;
382
405
  }).catch(() => {
@@ -384,6 +407,16 @@ var _AuthManager = class _AuthManager {
384
407
  });
385
408
  }
386
409
  }
410
+ /**
411
+ * True when running unauthenticated.
412
+ *
413
+ * Returns the explicit mode flag, NOT derived from `_token === null`. After
414
+ * a refresh failure clears `_token`, the SDK must NOT silently switch to
415
+ * anonymous; the caller must explicitly `logout()` to revert (#1113 review).
416
+ */
417
+ isAnonymous() {
418
+ return this._anonymous;
419
+ }
387
420
  _log(...args) {
388
421
  if (this._debug) console.log("[GeonicDB]", ...args);
389
422
  }
@@ -422,6 +455,7 @@ var _AuthManager = class _AuthManager {
422
455
  );
423
456
  }
424
457
  const data = await res.json();
458
+ this._anonymous = false;
425
459
  this._token = data.accessToken;
426
460
  this._tokenExpiry = Date.now() + (data.expiresIn - 60) * 1e3;
427
461
  this._tokenType = "Bearer";
@@ -436,6 +470,12 @@ var _AuthManager = class _AuthManager {
436
470
  */
437
471
  setCredentials(opts) {
438
472
  if (!opts || !opts.token) throw new Error("token is required");
473
+ if (this._initialAnonymous && opts.tokenType === "DPoP") {
474
+ throw new Error(
475
+ "DPoP credentials cannot be set on an SDK instance constructed with `anonymous: true` (no DPoP key pair is generated for anonymous instances). Use Bearer credentials, or construct the SDK in non-anonymous mode."
476
+ );
477
+ }
478
+ this._anonymous = false;
439
479
  this._token = opts.token;
440
480
  this._tokenType = opts.tokenType || "Bearer";
441
481
  this._tokenExpiry = opts.expiresIn != null ? Date.now() + (opts.expiresIn - 60) * 1e3 : Date.now() + DEFAULT_TOKEN_TTL_SEC * 1e3;
@@ -449,6 +489,7 @@ var _AuthManager = class _AuthManager {
449
489
  this._tokenExpiry = 0;
450
490
  this._refreshToken = null;
451
491
  this._tokenPromise = null;
492
+ this._anonymous = this._initialAnonymous;
452
493
  this._invalidateAuthScopedState();
453
494
  }
454
495
  /**
@@ -461,7 +502,13 @@ var _AuthManager = class _AuthManager {
461
502
  this._cache?.clear();
462
503
  this._inFlight.clear();
463
504
  }
464
- /** Ensure a valid token is available, refreshing or acquiring as needed. */
505
+ /**
506
+ * Ensure a valid token is available, refreshing or acquiring as needed.
507
+ *
508
+ * #1105: In anonymous mode (no apiKey, no Bearer credentials) this throws
509
+ * `AuthenticationError`. Callers that may run anonymously should branch on
510
+ * `isAnonymous()` first instead of calling `ensureToken()` blindly.
511
+ */
465
512
  async ensureToken() {
466
513
  if (this._token && Date.now() < this._tokenExpiry) {
467
514
  return this._token;
@@ -473,6 +520,16 @@ var _AuthManager = class _AuthManager {
473
520
  this._tokenPromise = this._refreshBearerToken();
474
521
  return this._tokenPromise;
475
522
  }
523
+ if (this._anonymous) {
524
+ throw new AuthenticationError(
525
+ "Anonymous mode: no token available. Call login() or setCredentials() to authenticate."
526
+ );
527
+ }
528
+ if (!this._apiKey) {
529
+ throw new AuthenticationError(
530
+ "No authentication method available. Call login() / setCredentials() to authenticate, or construct the SDK with an apiKey."
531
+ );
532
+ }
476
533
  this._tokenPromise = this._acquireTokenViaPow();
477
534
  return this._tokenPromise;
478
535
  }
@@ -593,7 +650,7 @@ var _AuthManager = class _AuthManager {
593
650
  async request(method, path, body) {
594
651
  this._log(method, path);
595
652
  if (!this._cache || !isCacheableMethod(method) || body !== void 0) {
596
- const token = await this.ensureToken();
653
+ const token = this.isAnonymous() ? null : await this.ensureToken();
597
654
  const res = await this._doAuthenticatedRequest(method, path, body, token);
598
655
  this._log(method, path, "\u2192", res.status);
599
656
  return res;
@@ -614,14 +671,14 @@ var _AuthManager = class _AuthManager {
614
671
  async _cachedRequest(method, path, key) {
615
672
  const cache = this._cache;
616
673
  if (!cache) {
617
- const token2 = await this.ensureToken();
674
+ const token2 = this.isAnonymous() ? null : await this.ensureToken();
618
675
  return this._doAuthenticatedRequest(method, path, void 0, token2);
619
676
  }
620
677
  const cached = cache.get(key);
621
678
  const conditional = {};
622
679
  if (cached?.etag) conditional["If-None-Match"] = cached.etag;
623
680
  if (cached?.lastModified) conditional["If-Modified-Since"] = cached.lastModified;
624
- const token = await this.ensureToken();
681
+ const token = this.isAnonymous() ? null : await this.ensureToken();
625
682
  const res = await this._doAuthenticatedRequest(method, path, void 0, token, 0, conditional);
626
683
  this._log(method, path, "\u2192", res.status);
627
684
  if (res.status === 304 && cached) {
@@ -662,11 +719,19 @@ var _AuthManager = class _AuthManager {
662
719
  return res;
663
720
  }
664
721
  async _doAuthenticatedRequest(method, path, body, token, retryCount = 0, extraHeaders = {}) {
722
+ if (this._tokenType === "DPoP" && token !== null) {
723
+ if (this._dpopReady) await this._dpopReady;
724
+ if (!this._dpopKeyPair) {
725
+ throw new AuthenticationError(
726
+ "DPoP credentials require a generated DPoP key pair, but key generation was unavailable or failed."
727
+ );
728
+ }
729
+ }
665
730
  const url = this._baseUrl + path;
666
- const isDPoP = this._tokenType === "DPoP" && !!this._dpopKeyPair;
731
+ const isDPoP = this._tokenType === "DPoP" && !!this._dpopKeyPair && token !== null;
667
732
  const bodyStr = body !== void 0 ? JSON.stringify(body) : void 0;
668
733
  const doRequest = async (reqToken, dpopNonce) => {
669
- const reqIsDPoP = this._tokenType === "DPoP" && !!this._dpopKeyPair;
734
+ const reqIsDPoP = this._tokenType === "DPoP" && !!this._dpopKeyPair && reqToken !== null;
670
735
  const dpopProof = reqIsDPoP ? await createDPoPProof(
671
736
  this._dpopKeyPair,
672
737
  method,
@@ -675,11 +740,13 @@ var _AuthManager = class _AuthManager {
675
740
  dpopNonce
676
741
  ) : null;
677
742
  const headers = {
678
- Authorization: (reqIsDPoP ? "DPoP " : "Bearer ") + reqToken,
679
743
  "Content-Type": "application/ld+json",
680
744
  Accept: "application/ld+json",
681
745
  ...extraHeaders
682
746
  };
747
+ if (reqToken !== null) {
748
+ headers["Authorization"] = (reqIsDPoP ? "DPoP " : "Bearer ") + reqToken;
749
+ }
683
750
  if (dpopProof) headers["DPoP"] = dpopProof;
684
751
  if (this._tenant) headers["Fiware-Service"] = this._tenant;
685
752
  return fetch(url, { method, headers, body: bodyStr });
@@ -689,11 +756,12 @@ var _AuthManager = class _AuthManager {
689
756
  const previousNonce = this._dpopNonce;
690
757
  const newNonce = res.headers.get("DPoP-Nonce");
691
758
  if (newNonce) this._dpopNonce = newNonce;
759
+ const canRetry401 = currentToken !== null;
692
760
  if (res.status === 401 && isDPoP && newNonce && newNonce !== previousNonce) {
693
761
  res = await doRequest(currentToken, newNonce);
694
762
  const rn = res.headers.get("DPoP-Nonce");
695
763
  if (rn) this._dpopNonce = rn;
696
- if (res.status === 401) {
764
+ if (res.status === 401 && canRetry401) {
697
765
  this._token = null;
698
766
  this._tokenPromise = null;
699
767
  currentToken = await this.ensureToken();
@@ -701,7 +769,7 @@ var _AuthManager = class _AuthManager {
701
769
  const fn = res.headers.get("DPoP-Nonce");
702
770
  if (fn) this._dpopNonce = fn;
703
771
  }
704
- } else if (res.status === 401) {
772
+ } else if (res.status === 401 && canRetry401) {
705
773
  this._token = null;
706
774
  this._tokenPromise = null;
707
775
  currentToken = await this.ensureToken();
@@ -750,6 +818,13 @@ var WebSocketManager = class {
750
818
  }
751
819
  /** Establish WebSocket connection (authentication is automatic). */
752
820
  async connect() {
821
+ if (this._auth.isAnonymous()) {
822
+ const err = new Error(
823
+ "WebSocket connect() is not supported in anonymous mode. Authenticate via login() or setCredentials() before connect()."
824
+ );
825
+ this._emit("error", err);
826
+ throw err;
827
+ }
753
828
  if (this._reconnectTimer) {
754
829
  clearTimeout(this._reconnectTimer);
755
830
  this._reconnectTimer = null;
@@ -985,7 +1060,7 @@ var GeonicDB = class extends EventEmitter {
985
1060
  if (!tenant) tenant = script?.getAttribute?.("data-tenant") || "";
986
1061
  if (!baseUrl) baseUrl = script?.getAttribute?.("data-base-url") || "";
987
1062
  }
988
- this._auth = new AuthManager(baseUrl, apiKey, tenant, opts.debug);
1063
+ this._auth = new AuthManager(baseUrl, apiKey, tenant, opts.debug, opts.anonymous);
989
1064
  this._auth.onTokenRefresh = (creds) => {
990
1065
  this.onTokenRefresh?.(creds);
991
1066
  this.emit("tokenRefresh", creds);
@@ -1001,44 +1076,39 @@ var GeonicDB = class extends EventEmitter {
1001
1076
  tenant,
1002
1077
  (event, data) => {
1003
1078
  this.emit(event, data);
1004
- if (event === "entityCreated" || event === "entityUpdated" || event === "entityDeleted") {
1005
- this._invalidateCacheForEntityEvent(data);
1006
- }
1007
1079
  },
1008
1080
  opts.wsEndpoint
1009
1081
  );
1010
1082
  }
1011
1083
  /**
1012
- * Drop cache entries that may have been affected by an entity change.
1084
+ * Drop every cached response. Useful in tests and on manual auth changes.
1013
1085
  *
1014
- * The SDK does not know which lists exactly contain the entity, so the
1015
- * conservative strategy is to drop all NGSI-LD / NGSIv2 entity-related
1016
- * paths. ETag re-validation on the next read brings the data back in one
1017
- * round trip with `If-None-Match`, so this remains efficient even when
1018
- * over-invalidating.
1086
+ * Emits a `cacheInvalidated` event for each removed entry so listeners can
1087
+ * track explicit flushes (the WebSocket-driven auto-invalidation was removed
1088
+ * in #1060 to preserve the ETag/304 revalidation path; `clearCache()` is now
1089
+ * the only path that emits this event).
1019
1090
  */
1020
- _invalidateCacheForEntityEvent(event) {
1091
+ clearCache() {
1021
1092
  const cache = this._auth.getCache();
1022
1093
  if (!cache) return;
1023
- const entityId = event?.entityId;
1024
- const removed = cache.deleteWhere((key) => {
1025
- if (key.includes("/ngsi-ld/v1/entities") || key.includes("/v2/entities")) return true;
1026
- if (entityId && key.includes(entityId)) return true;
1027
- return false;
1028
- });
1094
+ const removed = cache.deleteWhere(() => true);
1029
1095
  for (const key of removed) {
1030
1096
  this._auth.emitCacheEvent("cacheInvalidated", { key, path: key.split(":").slice(1).join(":") });
1031
1097
  }
1032
1098
  }
1033
- /** Drop every cached response. Useful in tests and on manual auth changes. */
1034
- clearCache() {
1035
- this._auth.getCache()?.clear();
1036
- }
1037
1099
  // --- Authentication ---
1038
1100
  /** Login with email and password (Bearer JWT). */
1039
1101
  async login(email, password) {
1040
1102
  return this._auth.login(email, password);
1041
1103
  }
1104
+ /**
1105
+ * Whether the SDK is currently operating in anonymous mode (no token held).
1106
+ * Returns true only when `anonymous: true` was passed to the constructor
1107
+ * AND no credentials have been set via `login()` / `setCredentials()`.
1108
+ */
1109
+ isAnonymous() {
1110
+ return this._auth.isAnonymous();
1111
+ }
1042
1112
  /**
1043
1113
  * Set credentials externally (e.g. from a login API response).
1044
1114
  * When tokenType is 'Bearer' with a refreshToken, DPoP/PoW is bypassed entirely.