@ethlete/query 6.0.0-next.42 → 6.0.0-next.44

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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # @ethlete/query
2
2
 
3
+ ## 6.0.0-next.44
4
+
5
+ ### Patch Changes
6
+
7
+ - [#3077](https://github.com/ethlete-io/ethdk/pull/3077) [`8c2cb3a`](https://github.com/ethlete-io/ethdk/commit/8c2cb3a7b8a311363255d66b0f3c07fcfc4f6bdb) Thanks [@github-actions](https://github.com/apps/github-actions)! - Auth: `onRefreshFailure` and the `logout()` it triggers now run outside the reactive context of the effect that detects the failure. A consumer reacting to the session ending could otherwise throw `NG0602` instead of handling the logout.
8
+
9
+ - [`f7d6bb1`](https://github.com/ethlete-io/ethdk/commit/f7d6bb12a799b680ce72c90cbdcfd96ae3ea2fc0) Thanks [@TomTomB](https://github.com/TomTomB)! - A secure query now waits for the token refresh instead of sending an access token that is already expired, so a session seeded by SSO no longer costs a `401` and a retry.
10
+
11
+ ## 6.0.0-next.43
12
+
13
+ ### Patch Changes
14
+
15
+ - [#3075](https://github.com/ethlete-io/ethdk/pull/3075) [`48488fb`](https://github.com/ethlete-io/ethdk/commit/48488fbdc0d61bafe5e8117844e5efa2c31c330a) Thanks [@github-actions](https://github.com/apps/github-actions)! - Auth: a rejected cookie auto-login now ends the session through `onRefreshFailure` with `sessionEndCause()` `'expired'`, and `setTokens()` supersedes an auto-login still in flight so its late `401` no longer overwrites the seeded session.
16
+
17
+ - [`a533863`](https://github.com/ethlete-io/ethdk/commit/a533863ce73cbb9e3454b6016be39487aa965c7a) Thanks [@TomTomB](https://github.com/TomTomB)! - Query devtools: a session that ends as expired is dropped from the auth vault, so a tab seeded with it no longer meets the same dead token on every reload.
18
+
19
+ - [#3075](https://github.com/ethlete-io/ethdk/pull/3075) [`1d38e7e`](https://github.com/ethlete-io/ethdk/commit/1d38e7e0a025769f065d8ca7d506cb75ad89139a) Thanks [@github-actions](https://github.com/apps/github-actions)! - `et update` moves the `@ethlete/*` packages to a newer version, runs the codemods those versions
20
+ declare in their own `migrations.json`, and reports what needs a decision or an agent.
21
+
22
+ - [#3075](https://github.com/ethlete-io/ethdk/pull/3075) [`f9fbd3a`](https://github.com/ethlete-io/ethdk/commit/f9fbd3a6959a99af7c6de60d7ceaf2781e8a58d2) Thanks [@github-actions](https://github.com/apps/github-actions)! - Re-export types explicitly with `export type` so the barrels type-check under `isolatedModules`.
23
+
3
24
  ## 6.0.0-next.42
4
25
 
5
26
  ### Minor Changes
@@ -1,7 +1,7 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { inject, Injector, effect, DestroyRef, InjectionToken, EnvironmentInjector, ElementRef, ErrorHandler, createEnvironmentInjector, untracked, signal, linkedSignal, computed, runInInjectionContext, isDevMode, PLATFORM_ID, resource, DOCUMENT, VERSION, makeEnvironmentProviders, provideEnvironmentInitializer, ApplicationRef, assertInInjectionContext, ChangeDetectorRef, ViewContainerRef, TemplateRef, Input, Directive, input, Pipe, ViewEncapsulation, Component, NgZone } from '@angular/core';
3
3
  import { UrlTree, Router, RedirectCommand, ActivatedRoute } from '@angular/router';
4
- import { Observable, defer, from, filter, Subject, Subscription, throwError, of, timer, switchMap, tap, retry, catchError, EMPTY as EMPTY$1, take, takeUntil, finalize, mergeMap, toArray, map, merge, takeWhile, firstValueFrom, race, concatMap, fromEvent, throttleTime, interval, startWith, pairwise, BehaviorSubject, combineLatest, ReplaySubject, shareReplay, skip, withLatestFrom, debounceTime } from 'rxjs';
4
+ import { Observable, defer, from, filter, Subject, Subscription, throwError, of, timer, switchMap, tap, retry, catchError, EMPTY as EMPTY$1, take, takeUntil, finalize, mergeMap, toArray, map, race, NEVER, merge, takeWhile, firstValueFrom, concatMap, fromEvent, throttleTime, interval, startWith, pairwise, BehaviorSubject, combineLatest, ReplaySubject, shareReplay, skip, withLatestFrom, debounceTime } from 'rxjs';
5
5
  import { defineStaticProvider, RuntimeError, clamp, randomId, defineRootProvider, isObject, injectUnsavedChangesCoordinator, injectRoute, getCookie, deleteCookie, setCookie, CORE_VERSION, createDestroy, computedTillTruthy, defineProvider, toProvideFn, toInjectFn, nextFrame, injectQueryParams, injectQueryParamChanges, clone, equal, ET_PROPERTY_REMOVED, previousSignalValue } from '@ethlete/core';
6
6
  import { HttpClient, HttpEventType, HttpErrorResponse, HttpResponse, HttpHeaders, HttpStatusCode } from '@angular/common/http';
7
7
  import { toObservable, takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
@@ -4389,6 +4389,12 @@ const createQueryClient = (options) => defineRootProvider(() => {
4389
4389
  });
4390
4390
 
4391
4391
  const AUTH_HEADER = 'Authorization';
4392
+ /**
4393
+ * How long a request waits for the refresh of an expired access token before it sends the expired one
4394
+ * anyway. Nothing guarantees the refresh arrives - the refresh token can be spent, and the request can
4395
+ * fail or never come back - and a request that 401s and retries beats one that never goes out.
4396
+ */
4397
+ const EXPIRED_TOKEN_WAIT_MS = 5000;
4392
4398
  const createSecureExecuteFactory = (options) => {
4393
4399
  const executeState = setupQueryExecuteState();
4394
4400
  const circularChecker = circularQueryDependencyChecker();
@@ -4479,16 +4485,25 @@ const createSecureExecuteFactory = (options) => {
4479
4485
  // Calling `authAndExec` purely because the auth query is "done" therefore races
4480
4486
  // the token and can run while it is still null. Gate on the token signal instead:
4481
4487
  // execute immediately if it is already available, otherwise wait for it.
4488
+ //
4489
+ // A token that is present but already expired is no better than a missing one: it can only earn a
4490
+ // 401, and the refresh that answers that 401 is the one already in flight (a token seed hands over
4491
+ // an expired access token, and the refresh query's schedule fires on it at once). Waiting for it
4492
+ // spends one round trip instead of two.
4482
4493
  const authAndExecWhenTokenReady = (executeArgs) => {
4483
- if (options.authProvider.accessToken()) {
4494
+ const tokenAtWait = options.authProvider.accessToken();
4495
+ if (tokenAtWait && !options.authProvider.isAccessTokenExpired()) {
4484
4496
  authAndExec(executeArgs);
4485
4497
  return;
4486
4498
  }
4499
+ // The query is on its way out, not idle - without this a consumer renders an empty state for as
4500
+ // long as the wait lasts.
4501
+ options.state.loading.set({ executeTime: Date.now(), progress: null });
4487
4502
  tokenWaitSubscription.unsubscribe();
4488
- tokenWaitSubscription = toObservable(options.authProvider.accessToken, {
4503
+ tokenWaitSubscription = race(toObservable(options.authProvider.accessToken, {
4489
4504
  injector: options.deps.injector,
4490
- })
4491
- .pipe(filter(Boolean), take(1), tap(() => authAndExec(executeArgs)))
4505
+ }).pipe(filter((token) => !!token && token !== tokenAtWait)), tokenAtWait ? timer(EXPIRED_TOKEN_WAIT_MS) : NEVER)
4506
+ .pipe(take(1), tap(() => authAndExec(executeArgs)))
4492
4507
  .subscribe();
4493
4508
  };
4494
4509
  const exec = (executeArgs) => {
@@ -5279,6 +5294,10 @@ const createBearerAuthProviderImpl = (config) => {
5279
5294
  // executionState (e.g. `{ type: 'login', state: 'success' }`) via setupBearerQueryRegistry's
5280
5295
  // execute(), so that internal path calls applyTokens directly to avoid stomping it with this.
5281
5296
  const setTokens = (access, refresh) => {
5297
+ // A seed supersedes what is in flight, the way a logout does: the tokens are here, so a cookie
5298
+ // auto-login or a refresh still out with an older token can only report a failure that no longer
5299
+ // means anything - or apply the pair it comes back with over this one.
5300
+ invalidateTokenIssuingExecutions();
5282
5301
  applyTokens(access, refresh);
5283
5302
  executionState.set({ type: 'tokenSeed', state: 'success' });
5284
5303
  };
@@ -5327,6 +5346,7 @@ const createBearerAuthProviderImpl = (config) => {
5327
5346
  sessionEndCause: sessionEndCause.asReadonly(),
5328
5347
  isTabLocalSession: isTabLocalSession.asReadonly(),
5329
5348
  });
5349
+ let readAccessTokenExpiry = null;
5330
5350
  const querySetupContext = {
5331
5351
  name: config.name,
5332
5352
  accessToken,
@@ -5339,8 +5359,12 @@ const createBearerAuthProviderImpl = (config) => {
5339
5359
  logout,
5340
5360
  afterTokenRefresh$,
5341
5361
  queries: queries,
5362
+ latestExecutedQuery: latestExecutedQuery.asReadonly(),
5342
5363
  executionState: executionState.asReadonly(),
5343
5364
  hasTokenIssuingExecutionInFlight,
5365
+ reportAccessTokenExpiry: (isExpired) => {
5366
+ readAccessTokenExpiry = isExpired;
5367
+ },
5344
5368
  };
5345
5369
  for (const builder of config.queries) {
5346
5370
  builder.setup?.(querySetupContext);
@@ -5391,6 +5415,7 @@ const createBearerAuthProviderImpl = (config) => {
5391
5415
  refreshToken: refreshToken.asReadonly(),
5392
5416
  bearerData,
5393
5417
  isAuthenticated,
5418
+ isAccessTokenExpired: () => readAccessTokenExpiry?.() ?? false,
5394
5419
  latestExecutedQuery: latestExecutedQuery.asReadonly(),
5395
5420
  latestNonInternalQuery: latestNonInternalQuery.asReadonly(),
5396
5421
  executionState: executionState.asReadonly(),
@@ -5746,6 +5771,24 @@ const withRefreshQuery = (key, config) => {
5746
5771
  const remainingMs = msUntilAccessTokenIsStale();
5747
5772
  return remainingMs !== null && remainingMs <= 0;
5748
5773
  };
5774
+ /**
5775
+ * Whether the access token this tab holds is already past its expiry, so sending it can only earn
5776
+ * a 401. Unlike {@link isAccessTokenStale} this has no grace period: a token with seconds of life
5777
+ * left is still worth sending.
5778
+ */
5779
+ const isAccessTokenExpired = () => {
5780
+ const token = context.accessToken();
5781
+ if (!token)
5782
+ return false;
5783
+ const expiresAt = tokenExpiresAt(token);
5784
+ return expiresAt !== null && expiresAt <= Date.now();
5785
+ };
5786
+ // Only reported when an expired token actually gets refreshed. With `refreshIfExpired: false` the
5787
+ // application asked for the request to go out and fail, so a secure query must not wait for a
5788
+ // refresh that never comes.
5789
+ if (refreshIfExpired) {
5790
+ context.reportAccessTokenExpiry(isAccessTokenExpired);
5791
+ }
5749
5792
  /**
5750
5793
  * The scheduled refresh, re-armed for as long as it keeps declining to spend the refresh token.
5751
5794
  * Without the re-arm a single declined attempt strands the session on a token nothing renews until
@@ -5804,9 +5847,23 @@ const withRefreshQuery = (key, config) => {
5804
5847
  });
5805
5848
  effect(() => {
5806
5849
  const state = context.executionState();
5807
- if (state?.type !== 'tokenRefresh' || state.state !== 'error')
5850
+ if (state?.state !== 'error')
5808
5851
  return;
5809
- onRefreshFailure({ error: state.error, logout: () => context.logout('expired') });
5852
+ // `withPersistentAuth` spends the cookie's refresh token through this very query, and that
5853
+ // execution reports as `autoLogin` rather than as `tokenRefresh`. Judged by its type alone it
5854
+ // would end nothing: the tab keeps a rejected cookie's error state for good, `sessionEndCause`
5855
+ // never says `expired`, and an app gated on either never leaves its startup screen. The token
5856
+ // check keeps a session another tab handed over mid-request out of it - that session is not
5857
+ // this restore's to end.
5858
+ const isRejectedRestore = state.type === 'autoLogin' &&
5859
+ untracked(context.latestExecutedQuery)?.key === key &&
5860
+ !untracked(context.accessToken);
5861
+ if (state.type !== 'tokenRefresh' && !isRejectedRestore)
5862
+ return;
5863
+ // `logout` tears down every secure cache entry and emits on `events$`, so a consumer that reacts
5864
+ // to a session ending runs inside this effect. Angular refuses `effect()` from a reactive context,
5865
+ // so a listener that creates a query would throw NG0602 instead of handling the logout.
5866
+ untracked(() => onRefreshFailure({ error: state.error, logout: () => context.logout('expired') }));
5810
5867
  });
5811
5868
  // Auto-retry on 401: Listen to repository events and trigger refresh on 401 errors
5812
5869
  const autoRetryOn401 = config.autoRetryOn401 ?? true;
@@ -8099,9 +8156,12 @@ const trackQueryDevtoolsAuthProvider = (registration) => {
8099
8156
  const stop = effect(() => {
8100
8157
  const accessToken = handle.accessToken();
8101
8158
  const refreshToken = handle.refreshToken();
8159
+ const endCause = handle.sessionEndCause();
8102
8160
  untracked(() => {
8103
8161
  if (accessToken && refreshToken)
8104
8162
  remember({ provider: name, accessToken, refreshToken });
8163
+ else if (endCause === 'expired')
8164
+ forgetExpired(name);
8105
8165
  else
8106
8166
  forgetActive(name);
8107
8167
  });
@@ -8212,6 +8272,26 @@ const forgetActive = (provider) => {
8212
8272
  persist();
8213
8273
  syncPill();
8214
8274
  };
8275
+ /**
8276
+ * Drops the session a provider was on because it expired, tokens and all - unlike every other way a
8277
+ * session ends, this pair is spent. Keeping it offers a switch back that can only fail, and a tab-local
8278
+ * seed would re-apply it on every reload, so the tab meets the same dead token until someone clears the
8279
+ * storage by hand.
8280
+ */
8281
+ const forgetExpired = (provider) => {
8282
+ const sessionId = active()[provider] ?? null;
8283
+ const { [provider]: _dropped, ...seeds } = readSeeds();
8284
+ writeSeeds(seeds);
8285
+ if (!sessionId)
8286
+ return;
8287
+ store.update((current) => ({
8288
+ ...current,
8289
+ sessions: current.sessions.filter((session) => session.id !== sessionId),
8290
+ }));
8291
+ setActive(provider, null);
8292
+ persist();
8293
+ syncPill();
8294
+ };
8215
8295
  /** The sessions offered for one provider, which are the ones this backend issued. */
8216
8296
  const queryDevtoolsAuthSessionsFor = (provider) => {
8217
8297
  const scope = scopeOf();
@@ -9582,7 +9662,7 @@ const armQueryDevtoolsOverrideTransfer = (recorder, ops) => {
9582
9662
 
9583
9663
  // Generated from package.json by tools/scripts/generate-version.js - do not edit.
9584
9664
  /** The version of `@ethlete/query` this build was cut from. */
9585
- const QUERY_VERSION = '6.0.0-next.42';
9665
+ const QUERY_VERSION = '6.0.0-next.44';
9586
9666
 
9587
9667
  /** How deep a schema is expanded before a nested branch is given up on. */
9588
9668
  const MAX_DEPTH = 12;