@geolonia/geonicdb-sdk 0.5.0 → 0.7.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
@@ -261,14 +291,16 @@ handle.stop();
261
291
  | `debug` | `boolean` | No | Enable debug logging to console (default: `false`) |
262
292
  | `cache` | `boolean` | No | Enable in-memory cache with ETag/304 + request dedup (default: `true`) |
263
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`) |
264
295
 
265
296
  ### Authentication
266
297
 
267
298
  | Method | Parameters | Returns | Description |
268
299
  |--------|-----------|---------|-------------|
269
- | `login(email, password)` | `string, string` | `Promise<LoginResponse>` | Login with email and password (Bearer JWT) |
270
- | `setCredentials(opts)` | `CredentialsOptions` | `this` | Set credentials externally (e.g. from a login API response) |
271
- | `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` |
272
304
 
273
305
  ### Entity CRUD
274
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);
@@ -1026,6 +1101,14 @@ var GeonicDB = class extends EventEmitter {
1026
1101
  async login(email, password) {
1027
1102
  return this._auth.login(email, password);
1028
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
+ }
1029
1112
  /**
1030
1113
  * Set credentials externally (e.g. from a login API response).
1031
1114
  * When tokenType is 'Bearer' with a refreshToken, DPoP/PoW is bypassed entirely.