Source: bemap-maplibre/bemap-tiles-auth.js

/**
 * BeNomad BeMap JavaScript API — TilesAuth (v2.0)
 *
 * JWT lifecycle for the BeNomad Tiles Worker. Replaces the buggy
 * global `window.fetch` patch that the v1 prototype shipped with.
 *
 * Responsibilities:
 *  - POST credentials to `Context.getTilesLoginUrl()` and store the JWT.
 *  - Parse the `exp` claim and schedule **proactive** renewal 5 min before
 *    expiry; fall back to 55 min when `exp` is unparseable.
 *  - Renew **reactively** on `401` errors caught from MapLibre's `error`
 *    event, then trigger a `setStyle()` reload to retry pending tiles.
 *  - On `document.visibilitychange` → `visible`: full re-login if the token
 *    is already expired, proactive refresh if within 5 min, no-op otherwise.
 *  - On `navigator.onLine === false`: queue the renewal until `online`.
 *  - Expose `attachTransformRequest(nativeMap)` that injects the
 *    `X-Session-Token` header on every request whose origin matches the
 *    configured `tilesHost`. The closure captures `getToken()` not the
 *    token value, so renewal is transparent.
 *  - Keep the last 3 tokens in `_history` for diagnostics.
 *
 * Tokens are stored according to `Context.tokenStorage`:
 *  - `'session'` — `sessionStorage` (default; cleared on tab close)
 *  - `'local'`   — `localStorage` (persists across reloads)
 *  - `'memory'`  — never persisted
 *
 * @since 2.0.0
 * @public
 * @constructor
 * @param {bemap.Context} ctx
 * @param {Object} [options]
 * @param {Function} [options.onError] Called with a `bemap.Error` on auth / network failures.
 * @param {Function} [options.onToken] Called with `{ reason, token }` on token changes.
 */
bemap.TilesAuth = function (ctx, options) {
  var opts = options || {};
  this.ctx = ctx;
  this._token = null;
  this._exp = 0;
  this._history = [];
  this._renewalTimer = null;
  this._queuedRenewal = false;
  this._onError = typeof opts.onError === 'function' ? opts.onError : function () {};
  this._onToken = typeof opts.onToken === 'function' ? opts.onToken : function () {};

  // Resolved tile-auth wire config `{ mode, credentials, tokenHeader, tokenParam }`.
  // Per-map `options.tilesAuth` overrides the Context's `tilesAuth`, both
  // normalised (unset ⇒ `mode:'auto'`). Read by the fetch interceptor,
  // transformRequest, login and logout so the consumer chooses the wire auth.
  this._authConfig = (bemap.Context && bemap.Context._resolveTilesAuth)
    ? bemap.Context._resolveTilesAuth(opts.tilesAuth, ctx && ctx.tilesAuth)
    : { mode: 'header', credentials: 'include', tokenHeader: 'X-Session-Token', tokenParam: 'token' };

  // Turn the default `mode:'auto'` into a concrete wire mode ONCE, here, so every
  // downstream site (interceptor, transformRequest, login, logout, TilesStyle)
  // sees a resolved `cookie`/`header`/`query`: same registrable domain as
  // `tilesHost` ⇒ cookie (first-party, zero preflight); cross-site ⇒ header
  // (token carried explicitly, works in incognito). Explicit modes pass through.
  if (bemap.Context && bemap.Context._resolveAutoMode) {
    this._authConfig = bemap.Context._resolveAutoMode(this._authConfig, ctx && ctx.tilesHost);
  }

  // PROXY MODE (EVMOVE-307 / decision D1) — the cookie wire CANNOT work behind a
  // proxy: the session cookie is set by the login RESPONSE, which now comes from
  // the proxy's origin, so it is never sent on the DIRECT tile requests to
  // `tilesHost` ⇒ every tile 401s. Force the header wire (the token comes back in
  // the login body, which the proxy relays unchanged). An explicit `query` pin is
  // honoured (it also carries the token explicitly); an explicit `cookie` pin is
  // overridden with a one-shot warning — a config that cannot work must not fail
  // silently. Unreachable without `ctx.proxy`, so no existing consumer changes.
  var _ctxProxy = ctx && (typeof ctx.getProxy === 'function' ? ctx.getProxy() : ctx.proxy);
  if (_ctxProxy && this._authConfig && this._authConfig.mode !== 'query') {
    if (this._authConfig.mode === 'cookie' && !bemap.TilesAuth._proxyCookieWarned) {
      bemap.TilesAuth._proxyCookieWarned = true;
      if (typeof console !== 'undefined' && console.warn) {
        console.warn(
          '[bemap.TilesAuth] tilesAuth:\'cookie\' cannot work with `proxy` set —\n' +
          '   the session cookie would belong to the proxy origin and would never\n' +
          '   be sent on the direct tile requests to tilesHost. Using the\n' +
          '   `header` wire (X-Session-Token) instead.'
        );
      }
    }
    this._authConfig.mode = 'header';
  }

  this._storageKey = 'bemap_tiles_token';
  this._storage = this._resolveStorage(ctx && ctx.tokenStorage);
  this._restoreFromStorage();

  this._installLifecycleHooks();
};

bemap.TilesAuth.STORAGE_KEY = 'bemap_tiles_token';

/* ============================================================
 * Module-scope interceptor state — NOT enumerable on the
 * `bemap.TilesAuth` constructor so that same-origin scripts
 * cannot pre-poison the registry by writing to
 * `bemap.TilesAuth._registry['mptiles-api-beta.benomad.net'] = …`.
 * Test code reaches in via the accessor helpers below.
 * ============================================================ */
var _tilesAuthState = {
  registry: {},           // { tilesHost → auth }
  patched: false,
  originalFetch: null,
  fastHostList: [],       // for cheap substring short-circuit in the wrapper
  rangePolicy: null       // shared bemap.RangeFetchPolicy (lazily created)
};

// Accessors retained for tests (they need to reset / inspect state).
bemap.TilesAuth._getRegistry = function () { return _tilesAuthState.registry; };
bemap.TilesAuth._resetForTest = function () {
  _tilesAuthState.registry = {};
  _tilesAuthState.patched = false;
  _tilesAuthState.originalFetch = null;
  _tilesAuthState.fastHostList = [];
  _tilesAuthState.rangePolicy = null;
};
// Backwards-compatibility shims for the older test file — read-only.
Object.defineProperty(bemap.TilesAuth, '_registry',      { get: function () { return _tilesAuthState.registry; }, set: function (v) { _tilesAuthState.registry = v || {}; } });
Object.defineProperty(bemap.TilesAuth, '_fetchPatched',  { get: function () { return _tilesAuthState.patched;  }, set: function (v) { _tilesAuthState.patched = !!v; } });
Object.defineProperty(bemap.TilesAuth, '_originalFetch', { get: function () { return _tilesAuthState.originalFetch; }, set: function (v) { _tilesAuthState.originalFetch = v; } });

/**
 * Install a scoped `window.fetch` wrapper that injects `X-Session-Token`
 * on every request whose host matches `tilesHost`. The wrapper awaits
 * the in-flight login when the token is not yet available, so the very
 * first PMTiles range request fired by `pmtiles.js` (which bypasses
 * MapLibre's `transformRequest`) transparently waits for the JWT.
 *
 * Idempotent — calling it twice on the same page is a no-op. Multi-host
 * scenarios accumulate by registering the second auth's host into a
 * shared map.
 *
 * @public
 * @since 2.0.0
 * @param {bemap.TilesAuth} auth
 * @param {String} tilesHost
 */
/**
 * Stamp auth onto a Worker-origin tile/style request per the resolved config.
 * Returns `{ input, init }` (query mode may rewrite the URL). NEVER mutates the
 * caller's `init`. `token` is only needed for `header` / `query` modes.
 *   - `cookie` (default): `init.credentials = cfg.credentials` — no custom header
 *     and no query param, so a Range GET stays a CORS-safelisted "simple" request
 *     (no preflight).
 *   - `header`: set `cfg.tokenHeader` to the token; credentials left at the
 *     browser default (no cookie).
 *   - `query`: append `cfg.tokenParam=<token>` to the URL. (The SW strips
 *     token/jwt/X-Session-Token from its cache key, so caching still works.)
 * @private
 */
function _applyTileAuth(input, init, cfg, token) {
  var out = Object.assign({}, init || {});
  // By the time a request reaches here the mode is concrete (the constructor
  // resolved any `'auto'`); `header` is the safe fallback if a mode ever went missing.
  var mode = (cfg && cfg.mode) || 'header';
  if (mode === 'header') {
    if (token) {
      var headers = new Headers((init && init.headers) || (input && input.headers) || {});
      headers.set((cfg && cfg.tokenHeader) || 'X-Session-Token', token);
      out.headers = headers;
    }
    return { input: input, init: out };
  }
  if (mode === 'query') {
    if (token) {
      var param = (cfg && cfg.tokenParam) || 'token';
      var url = (typeof input === 'string') ? input : (input && input.url) || '';
      if (url) {
        var sep = (url.indexOf('?') === -1) ? '?' : '&';
        var newUrl = url + sep + encodeURIComponent(param) + '=' + encodeURIComponent(token);
        // Query mode targets idempotent tile/style GETs (which have no body).
        // `new Request(newUrl, input)` MOVES the body stream, so it must not be
        // used for a Request with a body (POST/PUT) — safe here because tile reads
        // are always GET (and /api/login is short-circuited above).
        input = (typeof input === 'string') ? newUrl : new Request(newUrl, input);
      }
    }
    return { input: input, init: out };
  }
  // 'cookie' (default)
  out.credentials = (cfg && cfg.credentials) || 'include';
  return { input: input, init: out };
}

bemap.TilesAuth.installFetchInterceptor = function (auth, tilesHost) {
  if (!auth || !tilesHost || typeof window === 'undefined' || typeof window.fetch !== 'function') return;
  _tilesAuthState.registry[tilesHost] = auth;
  if (_tilesAuthState.fastHostList.indexOf(tilesHost) === -1) _tilesAuthState.fastHostList.push(tilesHost);
  if (_tilesAuthState.patched) return;
  _tilesAuthState.patched = true;
  _tilesAuthState.originalFetch = window.fetch.bind(window);
  var orig = function (input, init) { return _tilesAuthState.originalFetch(input, init); };
  window.fetch = function (input, init) {
    var url = (typeof input === 'string') ? input : (input && input.url) || '';
    if (!url) return orig(input, init);
    // Cheap substring short-circuit — avoids the cost of `new URL()`
    // parsing on the dozens of unrelated fetches a typical page issues.
    var fast = false;
    for (var i = 0; i < _tilesAuthState.fastHostList.length; i++) {
      if (url.indexOf(_tilesAuthState.fastHostList[i]) !== -1) { fast = true; break; }
    }
    if (!fast) return orig(input, init);
    // Now confirm with strict host parsing (substring would match
    // `https://evil.com/?h=mptiles-api-beta.benomad.net`).
    var host = null;
    try { host = new URL(url, window.location.href).host; } catch (e) { /* malformed */ }
    if (!host) return orig(input, init);
    var registered = _tilesAuthState.registry[host];
    if (!registered) return orig(input, init);
    // Login itself uses Basic Auth (+ the Set-Cookie it stores) — never touch
    // /api/login here.
    if (url.indexOf('/api/login') !== -1) return orig(input, init);
    // For every other Worker URL, await login, then stamp auth per the
    // registered auth's resolved config (`_applyTileAuth`): cookie (default) →
    // `credentials:'include'`, no custom header → no CORS preflight; header →
    // the token header; query → `?token=` on the URL. RangeFetchPolicy still
    // applies `cache:'no-store'` afterwards (it only touches `cache`, so
    // whatever `_applyTileAuth` set survives). Never mutates the caller's init.
    return registered.whenTokenReady().then(function (token) {
      var applied = _applyTileAuth(input, init, registered._authConfig, token);
      var newInit = applied.init;
      if (bemap.RangeFetchPolicy) {
        if (!_tilesAuthState.rangePolicy) _tilesAuthState.rangePolicy = new bemap.RangeFetchPolicy({ rangeCacheMode: bemap.RangeFetchPolicy.defaultMode || 'auto' });
        newInit = _tilesAuthState.rangePolicy.decorate(applied.input, newInit);
      }
      return orig(applied.input, newInit);
    }, function () {
      // Login failed — let the request fly unchanged so the server's 401 surfaces
      // through the normal error channels (this wrapper never swallows it).
      return orig(input, init);
    });
  };
};

/**
 * Uninstall the per-host registration. When the registry becomes empty,
 * restore the original `window.fetch` so SPA navigation does not leak
 * the patch across map lifecycles.
 * @public
 * @since 2.0.0
 */
bemap.TilesAuth.uninstallFetchInterceptor = function (tilesHost) {
  if (!tilesHost) return;
  delete _tilesAuthState.registry[tilesHost];
  var idx = _tilesAuthState.fastHostList.indexOf(tilesHost);
  if (idx !== -1) _tilesAuthState.fastHostList.splice(idx, 1);
  var empty = true;
  for (var k in _tilesAuthState.registry) { if (Object.prototype.hasOwnProperty.call(_tilesAuthState.registry, k)) { empty = false; break; } }
  if (empty && _tilesAuthState.patched && _tilesAuthState.originalFetch) {
    window.fetch = _tilesAuthState.originalFetch;
    _tilesAuthState.patched = false;
    _tilesAuthState.originalFetch = null;
  }
};

bemap.TilesAuth.prototype._resolveStorage = function (mode) {
  if (mode === 'memory') return null;
  try {
    if (mode === 'local' && typeof localStorage !== 'undefined') return localStorage;
    if (typeof sessionStorage !== 'undefined') return sessionStorage;
  } catch (e) { /* private mode / disabled storage */ }
  return null;
};

bemap.TilesAuth.prototype._restoreFromStorage = function () {
  if (!this._storage) return;
  try {
    var raw = this._storage.getItem(this._storageKey);
    if (!raw) return;
    var parsed = JSON.parse(raw);
    if (parsed && parsed.token) {
      this._token = parsed.token;
      this._exp = parsed.exp || 0;
      this._scheduleRenewal();
    }
  } catch (e) { /* ignore corrupt storage */ }
};

bemap.TilesAuth.prototype._persist = function () {
  if (!this._storage) return;
  try {
    this._storage.setItem(this._storageKey, JSON.stringify({ token: this._token, exp: this._exp }));
  } catch (e) { /* quota / disabled */ }
};

bemap.TilesAuth.prototype._clearStorage = function () {
  if (!this._storage) return;
  try { this._storage.removeItem(this._storageKey); } catch (e) { /* ignore */ }
};

/**
 * Parse the `exp` claim from a JWT, return its value in seconds since epoch,
 * or `0` when the token cannot be parsed.
 * @private
 */
bemap.TilesAuth.prototype._parseExp = function (token) {
  if (!token || typeof token !== 'string') return 0;
  var parts = token.split('.');
  if (parts.length < 2) return 0;
  try {
    var payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
    while (payload.length % 4) payload += '=';
    var json = (typeof atob === 'function')
      ? atob(payload)
      : Buffer.from(payload, 'base64').toString('utf8');
    var claims = JSON.parse(json);
    return typeof claims.exp === 'number' ? claims.exp : 0;
  } catch (e) { return 0; }
};

/**
 * INTERNAL — how long `ctx.tilesSessionProvider` may take before the login is
 * failed. `whenTokenReady()` runs on EVERY tiles fetch, so a provider that never
 * settles would stall the whole map with no error and no grey-tile recovery.
 * A static (not an option) so it stays out of the public surface; tests override
 * it. Any value `<= 0` (or a non-number) disables the timeout.
 * @private
 */
bemap.TilesAuth._sessionTimeoutMs = 10000;

/**
 * INTERNAL — normalise every way the session provider can go wrong into ONE
 * `bemap.Error`. The session id is NEVER interpolated into the message: an
 * uncaught-error handler or crash reporter must not capture it.
 * @private
 * @param {String} message
 * @param {*} [cause]
 * @return {bemap.Error}
 */
bemap.TilesAuth._sessionError = function (message, cause, code) {
  var e = new bemap.Error({ code: code || bemap.Error.UNAUTHORIZED, message: message });
  // `!== undefined`, not truthiness: a provider may reject with 0 or '' and the
  // cause is still worth keeping for diagnostics.
  if (cause !== undefined) e.cause = cause;
  return e;
};

/**
 * INTERNAL — the longest session id we will put in a header. Not a protocol
 * limit; a guard so a buggy provider cannot push an unbounded value onto every
 * login and renewal.
 * @private
 */
bemap.TilesAuth._sessionMaxLength = 4096;

/**
 * INTERNAL — invoke the session provider under a timeout, converting a
 * synchronous throw, a rejected promise, a non-string, a blank string and a
 * header-hostile value into the same single error contract.
 * @private
 * @param {Function} provider
 * @return {Promise<String>} the trimmed session id
 */
bemap.TilesAuth._callSessionProvider = function (provider) {
  // `Promise.resolve().then(...)` so a SYNCHRONOUS throw inside the provider
  // takes exactly the same path as a rejected promise — one error contract.
  var call = Promise.resolve().then(function () {
    return provider();
  }).then(function (sessionId) {
    var sid = (typeof sessionId === 'string') ? sessionId.trim() : '';
    if (!sid) {
      throw bemap.TilesAuth._sessionError(
        'TilesAuth.login: the tiles session provider resolved no usable session');
    }
    // Anything outside printable US-ASCII cannot go in a header: `fetch` fails
    // the request while building it, and for a NUL/control byte the engine's
    // message QUOTES THE RAW VALUE — which would put the session id into an
    // error a crash reporter captures, defeating the whole redaction rule. So
    // the check is an allow-list, not a `[\r\n]` deny-list: CR, LF, NUL,
    // U+2028/U+2029 and every non-Latin-1 code point are refused here, by us,
    // with a message that names nothing.
    if (/[^\x20-\x7E]/.test(sid)) {
      throw bemap.TilesAuth._sessionError(
        'TilesAuth.login: the tiles session provider returned a session with characters that cannot be sent in a header');
    }
    if (sid.length > bemap.TilesAuth._sessionMaxLength) {
      throw bemap.TilesAuth._sessionError(
        'TilesAuth.login: the tiles session provider returned a session longer than ' +
        bemap.TilesAuth._sessionMaxLength + ' characters');
    }
    return sid;
  }, function (cause) {
    throw bemap.TilesAuth._sessionError('TilesAuth.login: the tiles session provider failed', cause);
  });

  var ms = bemap.TilesAuth._sessionTimeoutMs;
  if (!(ms > 0)) return call;
  return new Promise(function (resolve, reject) {
    var settled = false;
    var timer = setTimeout(function () {
      if (settled) return;
      settled = true;
      // NETWORK, not UNAUTHORIZED: a portal that is slow has not refused us, and
      // a caller reading `code` should be free to retry rather than treat it as
      // a hard auth failure.
      reject(bemap.TilesAuth._sessionError(
        'TilesAuth.login: the tiles session provider timed out after ' + ms + ' ms',
        undefined, bemap.Error.NETWORK));
    }, ms);
    call.then(function (v) {
      if (settled) return;
      settled = true; clearTimeout(timer); resolve(v);
    }, function (e) {
      if (settled) return;
      settled = true; clearTimeout(timer); reject(e);
    });
  });
};

/**
 * Issue the login POST and persist the resulting JWT.
 *
 * Extracted from `login()` verbatim so the CREDENTIAL-building step can be
 * synchronous (Basic / proxy) or asynchronous (session mode) without duplicating
 * the request chain — and, more importantly, so the existing paths keep their
 * exact scheduling: `login()` still issues their `fetch` during the call itself.
 * @private
 * @param {String} url
 * @param {Object} headers
 * @param {String} credentials
 * @param {String} [redirect] `fetch`'s redirect mode. Only session mode passes
 *        one; omitted for Basic/proxy so their request init stays identical.
 * @return {Promise<String>} the new JWT
 */
bemap.TilesAuth.prototype._startLogin = function (url, headers, credentials, redirect) {
  var self = this;
  var init = {
    method: 'POST',
    credentials: credentials,
    headers: headers
  };
  // Added only when asked for, so the Basic/proxy init object is byte-identical
  // to the one this code has always sent.
  if (redirect) init.redirect = redirect;
  return fetch(url, init).then(function (resp) {
    if (!resp.ok) {
      var code = resp.status === 401 ? bemap.Error.UNAUTHORIZED
        : resp.status === 403 ? bemap.Error.FORBIDDEN
        : resp.status === 429 ? bemap.Error.RATE_LIMITED
        : bemap.Error.NETWORK;
      var e = new bemap.Error({ code: code, status: resp.status, url: url, message: 'TilesAuth.login failed' });
      self._onError(e);
      throw e;
    }
    return resp.json().catch(function () { return null; });
  }).then(function (body) {
    var token = (body && (body.token || body.jwt || body.accessToken)) || null;
    if (!token) {
      var e = new bemap.Error({ code: bemap.Error.UNAUTHORIZED, message: 'TilesAuth.login: response had no token' });
      self._onError(e);
      throw e;
    }
    if (self._token) {
      // History records only the exp claim, NEVER the full JWT — see
      // SECURITY review. Subscribers that log `auth.getHistory()` must
      // not be able to exfiltrate a still-valid expired token.
      self._history.unshift({ exp: self._exp });
      if (self._history.length > 3) self._history.length = 3;
    }
    self._token = token;
    self._exp = self._parseExp(token);
    self._persist();
    self._scheduleRenewal();
    // Only emit `'refresh'` notifications from refresh(); skip the
    // synthetic `'login'` event during a refresh cycle so subscribers
    // see exactly one event per logical operation.
    if (!self._inRefresh) {
      self._onToken({ reason: 'login', exp: self._exp });
    }
    return token;
  });
};

/**
 * POST credentials and persist the new JWT.
 * @public
 * @param {String} [login]    Defaults to `ctx.login`.
 * @param {String} [password] Defaults to `ctx.password`.
 * @return {Promise<String>} The new JWT.
 */
bemap.TilesAuth.prototype.login = function (login, password) {
  var self = this;
  // Track the in-flight login promise so whenTokenReady() can await it
  // without starting a second parallel login on every tile request.
  if (this._loginInFlight) return this._loginInFlight;
  var url = this.ctx && this.ctx.getTilesLoginUrl();
  if (!url) {
    return Promise.reject(new bemap.Error({
      code: bemap.Error.UNAUTHORIZED,
      message: 'TilesAuth.login: no tilesHost configured on Context'
    }));
  }
  var user = login || (this.ctx && this.ctx.login);
  var pass = password || (this.ctx && this.ctx.password);
  // PROXY MODE (EVMOVE-307): credentials live on the proxy, so a credential-less
  // Context is the NORMAL, supported case — skip both the rejection and the
  // warning. No `Authorization` is ever sent; instead the proxy needs to know
  // which mptiles environment will be read (each signs tokens with its own
  // secret) via `X-BeMap-Tiles-Host`, plus the optional `X-BeMap-Env` selector.
  var isProxy = !!(this.ctx && (typeof this.ctx.getProxy === 'function' ? this.ctx.getProxy() : this.ctx.proxy));
  // SESSION MODE (INTERNAL) — the host application already holds a BeMap session
  // and cannot ship a password. Consulted ONLY when proxy has not already won:
  // the provider does real work (a call to the portal) and must never run for a
  // login that would not have used it. ONLY a function enables the mode — a
  // truthy non-function falls through to the normal credential path and its
  // existing error, instead of silently sending a request that can only 401.
  var sessionProvider = (!isProxy && this.ctx && typeof this.ctx.tilesSessionProvider === 'function')
    ? this.ctx.tilesSessionProvider : null;
  // A provider that was SET but is not callable is a wiring mistake in the host
  // application, not a customer misconfiguration — report it precisely, and only
  // to whoever already tried to use the internal mode. The customer-facing
  // missing-credentials message below stays deliberately unchanged: naming an
  // undocumented option there would advertise it to every customer who ever
  // forgets a password.
  // The constructor and setter already report this, so the only way to arrive
  // here is a RAW property write — which `bemap.RuntimeConfig`'s `set` trap does
  // for every unknown key, straight onto the shared Context.
  if (!isProxy && !sessionProvider && this.ctx && this.ctx.tilesSessionProvider &&
      bemap.Context && typeof bemap.Context._warnBadSessionProvider === 'function') {
    bemap.Context._warnBadSessionProvider(this.ctx.tilesSessionProvider);
  }
  if (!isProxy && !sessionProvider && (!user || !pass)) {
    // Loud, one-shot warning so the developer sees the cause immediately
    // in the DevTools console (instead of just an unexplained 401 on the
    // first PMTiles range request).
    if (!bemap.TilesAuth._missingCredsWarned) {
      bemap.TilesAuth._missingCredsWarned = true;
      if (typeof console !== 'undefined' && console.warn) {
        console.warn(
          '[bemap.TilesAuth] No credentials on the Context — BeNomad Tiles\n' +
          '   requests will all 401 because no /api/login POST can be sent.\n' +
          '   Set `login` and `password` on your `new bemap.Context({...})`,\n' +
          '   OR remove `tilesHost` to fall back to WMS.'
        );
      }
    }
    return Promise.reject(new bemap.Error({
      code: bemap.Error.UNAUTHORIZED,
      message: 'TilesAuth.login: missing credentials on Context (set ctx.login + ctx.password, or remove ctx.tilesHost)'
    }));
  }
  // Computed SYNCHRONOUSLY and exactly where it always was, so a `btoa` failure
  // (a non-Latin-1 password) still throws out of `login()` the way it does
  // today. Skipped entirely when a credential-less mode is active — session mode
  // must never evaluate `user + ':' + pass`.
  var basic = (isProxy || sessionProvider) ? null : ('Basic ' + ((typeof btoa === 'function')
    ? btoa(user + ':' + pass)
    : Buffer.from(user + ':' + pass).toString('base64')));

  if (typeof navigator !== 'undefined' && navigator.onLine === false) {
    self._queuedRenewal = true;
    var err = new bemap.Error({ code: bemap.Error.OFFLINE, message: 'TilesAuth.login: offline, queued' });
    self._onError(err);
    return Promise.reject(err);
  }

  // In COOKIE mode, `credentials` MUST be sent so the browser STORES the
  // Worker's `Set-Cookie: session=…; HttpOnly; Secure; SameSite=None` — without
  // it the cookie is never stored and every tile 401s despite a valid in-memory
  // token. In header/query mode the consumer opted out of cookies, so we use
  // `'same-origin'` (no cross-site cookie); the body token still drives auth.
  var loginCredentials = (self._authConfig && self._authConfig.mode === 'cookie')
    ? (self._authConfig.credentials || 'include')
    : 'same-origin';
  // Header set: credentials mode sends Basic; proxy mode sends NO credential and
  // instead tells the proxy which tiles env to mint for (+ the env selector when
  // configured). `getProxyHeaders()` only yields X-BeMap-Env for a non-empty
  // bemapEnv, so the literal string "null" can never reach the wire.
  var loginHeaders = { 'Accept': 'application/json' };
  var promise;
  if (sessionProvider) {
    // The ONLY asynchronous path: the provider returns a Promise, so the header
    // cannot be built synchronously. The two-argument `.then` is deliberate —
    // the rejection handler sees provider failures ONLY, never a failure of
    // `_startLogin` (which reports through `_onError` itself), so a login can
    // never fire `_onError` twice.
    promise = bemap.TilesAuth._callSessionProvider(sessionProvider).then(function (sessionId) {
      // The provider round trip is the one place a login can stay pending for
      // seconds, so it is the one place `destroy()` can realistically land in
      // the middle of. Stop here rather than let _startLogin re-arm a renewal
      // timer on a dead instance. (Basic/proxy issue their fetch in the same
      // tick, so this window is specific to session mode.)
      if (self._destroyed) {
        throw bemap.TilesAuth._sessionError('TilesAuth.login: aborted — the auth instance was destroyed');
      }
      loginHeaders['X-BeMap-Session'] = sessionId;
      // `redirect:'error'` for session mode ONLY. A browser strips
      // `Authorization` on a cross-origin redirect, but it forwards a CUSTOM
      // header — so a mis-pointed or hijacked login endpoint could bounce the
      // session id to another origin. Basic/proxy keep their existing init.
      return self._startLogin(url, loginHeaders, loginCredentials, 'error');
    }, function (err) {
      self._onError(err);
      throw err;
    });
  } else {
    if (isProxy) {
      if (self.ctx.tilesHost) loginHeaders['X-BeMap-Tiles-Host'] = self.ctx.tilesHost;
      if (typeof self.ctx.getProxyHeaders === 'function') {
        var ph = self.ctx.getProxyHeaders();
        for (var phk in ph) {
          if (Object.prototype.hasOwnProperty.call(ph, phk)) loginHeaders[phk] = ph[phk];
        }
      }
    } else {
      loginHeaders['Authorization'] = basic;
    }
    // Unchanged scheduling: the request is issued during this call, exactly as
    // before the session branch existed.
    promise = this._startLogin(url, loginHeaders, loginCredentials);
  }
  // Track and clear on settle. Both success and failure clear, so a
  // failed login does not pin a poisoned promise on subsequent calls.
  this._loginInFlight = promise;
  promise.then(
    function () { self._loginInFlight = null; },
    function () { self._loginInFlight = null; }
  );
  return promise;
};

/**
 * Resolve with the current valid JWT, kicking off (or joining) a login
 * if no token is available. The synchronisation primitive that the
 * fetch interceptor uses to delay PMTiles range requests until login
 * completes — without making the MapLibreMap constructor itself async.
 *
 * @public
 * @since 2.0.0
 * @return {Promise<String|null>}
 */
bemap.TilesAuth.prototype.whenTokenReady = function () {
  if (this._token && this.isValid()) return Promise.resolve(this._token);
  if (this._loginInFlight) return this._loginInFlight;
  return this.login();
};

/** @return {String|null} */
bemap.TilesAuth.prototype.getToken = function () { return this._token; };

/** @return {Boolean} */
bemap.TilesAuth.prototype.isValid = function () {
  if (!this._token) return false;
  if (!this._exp) return true; // unparseable exp — assume valid until 401
  var nowSec = Date.now() / 1000;
  return this._exp > nowSec + 30;
};

/**
 * Returns the last-3 history of superseded tokens — only the `exp` claim,
 * never the raw JWT.
 * @return {Array<{exp:Number}>}
 */
bemap.TilesAuth.prototype.getHistory = function () { return this._history.slice(); };

/**
 * Proactive renewal — re-uses the original credentials. Returns the new
 * token. Subscribers see exactly one `_onToken({ reason: 'refresh' })`
 * event per logical refresh; the synthetic `'login'` event is suppressed
 * during refresh to avoid the 3-events-per-cycle problem.
 *
 * @public
 * @return {Promise<String>}
 */
bemap.TilesAuth.prototype.refresh = function () {
  if (this._refreshInFlight) return this._refreshInFlight;
  var self = this;
  // `_inRefresh` suppresses the synthetic 'login' event inside login()'s .then.
  // Only claim it when refresh actually STARTS the login — if a plain login() is
  // already in-flight we JOIN it, and must NOT suppress its 'login' event for
  // the original caller (the flag is read later, in that login's .then).
  var startsLogin = !this._loginInFlight;
  if (startsLogin) this._inRefresh = true;
  var p = this.login().then(function (t) {
    if (startsLogin) self._inRefresh = false;
    self._refreshInFlight = null;
    self._onToken({ reason: 'refresh', exp: self._exp });
    return t;
  }, function (err) {
    if (startsLogin) self._inRefresh = false;
    self._refreshInFlight = null;
    throw err;
  });
  this._refreshInFlight = p;
  return p;
};

bemap.TilesAuth.prototype._scheduleRenewal = function () {
  if (this._renewalTimer) {
    clearTimeout(this._renewalTimer);
    this._renewalTimer = null;
  }
  var ms;
  if (this._exp) {
    ms = (this._exp * 1000) - Date.now() - 5 * 60 * 1000;
  } else {
    ms = 55 * 60 * 1000;
  }
  ms = Math.max(0, Math.min(24 * 60 * 60 * 1000, ms));
  var self = this;
  this._renewalTimer = setTimeout(function () {
    self.refresh().catch(function () { /* error already surfaced */ });
  }, ms);
};

bemap.TilesAuth.prototype._installLifecycleHooks = function () {
  // Idempotency guard — re-entrant constructors / subclass init must
  // not stack duplicate listeners. `destroy()` removes whatever set
  // here, but only the first set; a second install would leak.
  if (this._lifecycleInstalled) return;
  this._lifecycleInstalled = true;
  var self = this;
  if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
    this._visibilityHandler = function () {
      if (document.visibilityState !== 'visible') return;
      if (!self._token) return;
      if (!self.isValid()) {
        self.refresh().catch(function () { /* surfaced via _onError */ });
      } else if (self._exp && (self._exp - Date.now() / 1000) < 5 * 60) {
        self.refresh().catch(function () { /* surfaced */ });
      }
    };
    document.addEventListener('visibilitychange', this._visibilityHandler);
  }
  if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
    this._onlineHandler = function () {
      if (self._queuedRenewal) {
        self._queuedRenewal = false;
        self.refresh().catch(function () { /* surfaced */ });
      }
    };
    window.addEventListener('online', this._onlineHandler);
  }
};

/**
 * Build the MapLibre `transformRequest` function. Injects
 * `X-Session-Token` on every request whose origin matches `ctx.tilesHost`.
 * The closure captures `this.getToken` so token renewal is transparent.
 *
 * Surface the result as `map.native = new maplibregl.Map({ transformRequest: auth.buildTransformRequest() })`.
 *
 * @public
 * @return {Function}
 */
bemap.TilesAuth.prototype.buildTransformRequest = function () {
  var self = this;
  var host = this.ctx && this.ctx.tilesHost;
  return function (url, resourceType) {
    if (!host) return { url: url };
    var matches = false;
    try {
      var parsed = new URL(url, (typeof window !== 'undefined' && window.location && window.location.href) || 'http://localhost/');
      matches = (parsed.host === host);
    } catch (e) {
      matches = (url.indexOf('://' + host) > -1) || (url.indexOf('//' + host) === 0);
    }
    if (!matches) return { url: url };   // other origin — no auth, no leak
    // On the tiles host, authenticate per the resolved config (MapLibre GL v5
    // RequestParameters honours `credentials` and `headers` for style/glyph/
    // sprite/XYZ). Default cookie mode sends `credentials` and NO header → no
    // CORS preflight. (pmtiles archive Range reads bypass transformRequest and
    // are handled by the global fetch interceptor.)
    var cfg = self._authConfig || { mode: 'header', credentials: 'include', tokenHeader: 'X-Session-Token' };
    if (cfg.mode === 'header') {
      var ht = (typeof self.getToken === 'function') ? self.getToken() : null;
      var headers = {};
      if (ht) headers[cfg.tokenHeader || 'X-Session-Token'] = ht;
      return { url: url, headers: headers };
    }
    if (cfg.mode === 'query') {
      var qt = (typeof self.getToken === 'function') ? self.getToken() : null;
      if (qt) {
        var sep = (url.indexOf('?') === -1) ? '?' : '&';
        return { url: url + sep + encodeURIComponent(cfg.tokenParam || 'token') + '=' + encodeURIComponent(qt) };
      }
      return { url: url };
    }
    return { url: url, credentials: cfg.credentials || 'include' };
  };
};

/**
 * Mark a tile / style request as failed with HTTP 401 — triggers a refresh
 * followed by a `setStyle` reload of the current style spec, so the renewed
 * token is picked up by every pending request.
 *
 * @public
 * @param {Object} nativeMap The map to reload — normally the bemap.MapLibreMap
 *   WRAPPER (so its overlay-replay listener fires and `{_internal:true}` is
 *   honoured), or a raw `map.native` instance (which ignores the option).
 * @param {Object} [currentStyleSpec] Style currently displayed, for the reload.
 * @return {Promise<String>}
 */
bemap.TilesAuth.prototype.handle401 = function (nativeMap, currentStyleSpec) {
  // N simultaneous 401 errors → one shared refresh + one shared
  // setStyle reload. Without this dedup, a 401-storm produces N
  // parallel `setStyle` calls and N overlay catalog replays.
  if (this._handle401InFlight) return this._handle401InFlight;
  var self = this;
  var p = this.refresh().then(function (token) {
    if (nativeMap && currentStyleSpec && typeof nativeMap.setStyle === 'function') {
      // _internal: a 401-driven reload re-applies the SAME current spec, so it
      // must not latch _userSetStyle on the wrapper (that would permanently
      // suppress the init-time server default-style refresh). The wrapper reads
      // the option; the native MapLibre map ignores the unknown key.
      try { nativeMap.setStyle(currentStyleSpec, { _internal: true }); } catch (e) { /* ignore */ }
    }
    self._handle401InFlight = null;
    return token;
  }, function (err) {
    self._handle401InFlight = null;
    throw err;
  });
  this._handle401InFlight = p;
  return p;
};

/**
 * Clear the token, the history, the storage, and the renewal timer.
 * Optionally POST to the Worker logout endpoint to revoke server-side.
 *
 * @public
 * @param {Object} [options]
 * @param {Boolean} [options.serverSide=false] When true, fire-and-forget POST `/api/logout`.
 * @return {Promise<void>}
 */
bemap.TilesAuth.prototype.logout = function (options) {
  var opts = options || {};
  var token = this._token;
  this._token = null;
  this._exp = 0;
  this._history.length = 0;
  this._clearStorage();
  if (this._renewalTimer) {
    clearTimeout(this._renewalTimer);
    this._renewalTimer = null;
  }
  if (opts.serverSide && this.ctx && typeof fetch === 'function') {
    var url = this.ctx.getTilesLogoutUrl();
    if (url && token) {
      // Revoke per the resolved auth config so the Worker identifies the right
      // session: cookie → send credentials (+ the token header as a harmless
      // fallback for a header-keyed Worker); header → the configured header;
      // query → the token on the URL.
      var acfg = this._authConfig || { mode: 'header', credentials: 'include', tokenHeader: 'X-Session-Token', tokenParam: 'token' };
      var lurl = url;
      var linit = { method: 'POST' };
      if (acfg.mode === 'query') {
        var sep = (lurl.indexOf('?') === -1) ? '?' : '&';
        lurl = lurl + sep + encodeURIComponent(acfg.tokenParam || 'token') + '=' + encodeURIComponent(token);
      } else if (acfg.mode === 'header') {
        linit.headers = {};
        linit.headers[acfg.tokenHeader || 'X-Session-Token'] = token;
      } else {
        linit.credentials = acfg.credentials || 'include';
        linit.headers = { 'X-Session-Token': token };
      }
      return fetch(lurl, linit)
        .then(function () {})
        .catch(function () {});
    }
  }
  return Promise.resolve();
};

/**
 * Detach lifecycle listeners. Call from `MapLibreMap.remove()`.
 * @public
 */
bemap.TilesAuth.prototype.destroy = function () {
  this._destroyed = true;
  if (this._renewalTimer) {
    clearTimeout(this._renewalTimer);
    this._renewalTimer = null;
  }
  if (typeof document !== 'undefined' && this._visibilityHandler) {
    document.removeEventListener('visibilitychange', this._visibilityHandler);
    this._visibilityHandler = null;
  }
  if (typeof window !== 'undefined' && this._onlineHandler) {
    window.removeEventListener('online', this._onlineHandler);
    this._onlineHandler = null;
  }
  this._lifecycleInstalled = false;
  // Remove our entry from the interceptor's registry — if we were the
  // last entry, the wrapper restores `window.fetch` to its original.
  var host = this.ctx && this.ctx.tilesHost;
  if (host && typeof bemap.TilesAuth.uninstallFetchInterceptor === 'function') {
    bemap.TilesAuth.uninstallFetchInterceptor(host);
  }
};