From e4d79246edb2f53fbe0aec27d19665587f7d2da6 Mon Sep 17 00:00:00 2001 From: Andrey Koltsov Date: Sun, 31 May 2026 13:12:36 +0200 Subject: [PATCH 1/5] feat(session): token refresh on 401 at the session layer (LIB-9) Session-created child clients transparently refresh the Synergia bearer token and retry once on a 401, instead of surfacing the raw error. Concurrent 401s for the same child collapse to a single portal round- trip (stampede protection). A 401 that persists after the fresh token throws LibrusAuthenticationError with code AUTH_REFRESH_FAILED, with the original 401 preserved as cause. Standalone SynergiaApiClient with no callback is unchanged. Adds LibrusSession.refreshBearerToken(childId) as a public refresh primitive. Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 9 + src/sdk/LibrusSession.ts | 61 ++++++- src/sdk/models/errors.ts | 32 +++- src/sdk/synergia/SynergiaApiClient.ts | 95 ++++++++-- src/sdk/synergia/request.ts | 6 + test/librus-session.test.ts | 248 ++++++++++++++++++++++++++ 6 files changed, 427 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd5bdb2..35e9d74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Added +- Automatic session-layer bearer-token refresh for child-scoped clients. A + `SynergiaApiClient` obtained from `LibrusSession.forChild()` now transparently + refreshes its bearer token and retries the request once on a `401`, instead of + surfacing the expired-token error. Concurrent `401`s for the same child + collapse to a single refresh, and a `401` that persists after refresh throws + `LibrusAuthenticationError` with code `AUTH_REFRESH_FAILED` (the original + `401` preserved as `cause`). `LibrusSession.refreshBearerToken(childId)` is + also available directly. Standalone `new SynergiaApiClient(token)` is + unchanged and still throws on `401`. - Automatic retry with exponential backoff and jitter for transient Synergia failures (`408, 425, 429, 500, 502, 503, 504`). Idempotent methods (`GET`/`HEAD`) only by default, honors `Retry-After` on `429`, and respects an diff --git a/src/sdk/LibrusSession.ts b/src/sdk/LibrusSession.ts index 3e2410d..4fef7f5 100644 --- a/src/sdk/LibrusSession.ts +++ b/src/sdk/LibrusSession.ts @@ -16,6 +16,7 @@ import { type WiadomosciMessagesClientOptions, } from "./wiadomosci/WiadomosciMessagesClient.js"; import { + LibrusApiError, LibrusConfigurationError, LibrusSdkError, type ChildAccount, @@ -122,6 +123,12 @@ export class LibrusSession { > | undefined; private accountsCache?: SynergiaAccountsResponse; + /** + * In-flight bearer-token refreshes keyed by child id. Collapses concurrent + * refreshes for the same child to a single portal round-trip (stampede + * protection), so parallel `401`s trigger exactly one refresh. + */ + private readonly refreshInFlight = new Map>(); constructor(options: LibrusSessionOptions) { if (options.authMode !== undefined) { @@ -384,7 +391,55 @@ export class LibrusSession { ? await this.resolveChild(selectorOrChild) : selectorOrChild; - return new SynergiaApiClient(child.accessToken, this.synergiaClientOptions); + return new SynergiaApiClient(child.accessToken, { + ...this.synergiaClientOptions, + onAuthInvalidated: () => this.refreshBearerToken(child.id), + }); + } + + /** + * Re-fetch a fresh Synergia bearer token for a known child, reusing the + * cached portal login (and re-logging in if the portal session itself is + * dead). Concurrent calls for the same child share one in-flight promise. + */ + async refreshBearerToken(childId: string | number): Promise { + this.assertApiV3Backend("refreshBearerToken"); + + const key = String(childId); + const existing = this.refreshInFlight.get(key); + + if (existing) { + return existing; + } + + const promise = this.performBearerTokenRefresh(key).finally(() => { + this.refreshInFlight.delete(key); + }); + + this.refreshInFlight.set(key, promise); + + return promise; + } + + private async performBearerTokenRefresh(childId: string): Promise { + const child = await this.resolveChild(childId); + const portalClient = this.getPortalClient(); + + await this.login(); + + try { + const fresh = await portalClient.getFreshSynergiaAccount(child.login); + return fresh.accessToken; + } catch (error) { + if (!isUnauthorized(error)) { + throw error; + } + + // The cached portal session is dead. Force a fresh login and retry once. + await portalClient.login(this.getPortalCredentials()); + const fresh = await portalClient.getFreshSynergiaAccount(child.login); + return fresh.accessToken; + } } async forChildWiadomosci( @@ -485,6 +540,10 @@ export class LibrusSession { } } +function isUnauthorized(error: unknown): boolean { + return error instanceof LibrusApiError && error.details?.status === 401; +} + function resolveApiBackend(env: NodeJS.ProcessEnv): ApiBackendEnvValue { const rawApiBackend = env.LIBRUS_API_BACKEND; const rawAuthMode = env.LIBRUS_AUTH_MODE; diff --git a/src/sdk/models/errors.ts b/src/sdk/models/errors.ts index efccf60..bf8318e 100644 --- a/src/sdk/models/errors.ts +++ b/src/sdk/models/errors.ts @@ -1,3 +1,10 @@ +/** + * Error code used when a session-layer bearer-token refresh still yields a 401. + * Surfaced on {@link LibrusAuthenticationError} so callers can distinguish a + * failed refresh from an initial authentication failure. + */ +export const CODE_AUTH_REFRESH_FAILED = "AUTH_REFRESH_FAILED"; + export interface LibrusErrorDetails { endpoint?: string; issues?: string[]; @@ -7,12 +14,21 @@ export interface LibrusErrorDetails { [key: string]: unknown; } +export interface LibrusSdkErrorOptions { + cause?: unknown; +} + export class LibrusSdkError extends Error { readonly code: string; readonly details: LibrusErrorDetails | undefined; - constructor(code: string, message: string, details?: LibrusErrorDetails) { - super(message); + constructor( + code: string, + message: string, + details?: LibrusErrorDetails, + options?: LibrusSdkErrorOptions, + ) { + super(message, options); this.name = "LibrusSdkError"; this.code = code; this.details = details; @@ -30,9 +46,17 @@ export class LibrusApiError extends LibrusSdkError { } } +export interface LibrusAuthenticationErrorOptions extends LibrusSdkErrorOptions { + code?: string; +} + export class LibrusAuthenticationError extends LibrusSdkError { - constructor(message = "Portal authentication failed") { - super("AUTHENTICATION_FAILED", message); + constructor( + message = "Portal authentication failed", + options: LibrusAuthenticationErrorOptions = {}, + ) { + const { code = "AUTHENTICATION_FAILED", ...sdkOptions } = options; + super(code, message, undefined, sdkOptions); this.name = "LibrusAuthenticationError"; } } diff --git a/src/sdk/synergia/SynergiaApiClient.ts b/src/sdk/synergia/SynergiaApiClient.ts index 835ce91..74dd68f 100644 --- a/src/sdk/synergia/SynergiaApiClient.ts +++ b/src/sdk/synergia/SynergiaApiClient.ts @@ -1,5 +1,9 @@ import type { FetchLike } from "../models/common.js"; -import { LibrusApiError } from "../models/errors.js"; +import { + CODE_AUTH_REFRESH_FAILED, + LibrusApiError, + LibrusAuthenticationError, +} from "../models/errors.js"; import type { SchoolNoticeResponse, SchoolNoticesResponse, @@ -219,6 +223,14 @@ export interface SynergiaApiClientOptions { requestTimeoutMs?: number; retry?: RetryOption; logger?: Logger; + /** + * @internal Session-only hook. {@link LibrusSession.forChild} wires this in + * so an expired bearer token can be refreshed and the request retried once on + * a `401`. Not part of the public API — standalone clients omit it and keep + * the historical "throw on 401" behavior. Deliberately an inline type so the + * coupling surface stays internal. + */ + onAuthInvalidated?: () => Promise; } type SynergiaId = string | number; @@ -230,15 +242,17 @@ const MESSAGES_SCOPE_HINT = export class SynergiaApiClient { private readonly fetchImpl: FetchLike; private readonly apiBaseUrl: string; - private readonly accessToken: string; + private accessToken: string; private readonly authMode: SynergiaAuthMode; private readonly messageBackend: MessageReadBackend | undefined; private readonly requestTimeoutMs: number; private readonly logger: Logger; + private readonly onAuthInvalidated: (() => Promise) | undefined; constructor(accessToken: string, options: SynergiaApiClientOptions = {}) { this.accessToken = accessToken; this.authMode = options.authMode ?? "bearer"; + this.onAuthInvalidated = options.onAuthInvalidated; this.logger = options.logger ?? noopLogger; this.requestTimeoutMs = resolveRequestTimeoutMs(options.requestTimeoutMs); const timeoutFetch = wrapFetchWithTimeout( @@ -264,15 +278,17 @@ export class SynergiaApiClient { schema: TSchema, options: SynergiaRequestOptions = {}, ): Promise> { - return requestGetJson( - this.fetchImpl, - this.accessToken, - this.apiBaseUrl, - path, - schema, - options, - this.authMode, - this.logger, + return this.withAuthRefresh(() => + requestGetJson( + this.fetchImpl, + this.accessToken, + this.apiBaseUrl, + path, + schema, + options, + this.authMode, + this.logger, + ), ); } @@ -280,17 +296,54 @@ export class SynergiaApiClient { path: string, options: SynergiaRequestOptions = {}, ): Promise { - return requestGetBinary( - this.fetchImpl, - this.accessToken, - this.apiBaseUrl, - path, - options, - this.authMode, - this.logger, + return this.withAuthRefresh(() => + requestGetBinary( + this.fetchImpl, + this.accessToken, + this.apiBaseUrl, + path, + options, + this.authMode, + this.logger, + ), ); } + /** + * Run a GET request, refreshing the bearer token and retrying **once** if the + * session supplied an `onAuthInvalidated` callback and the request fails with + * a `401`. `run` reads `this.accessToken` at call-time, so the retry uses the + * freshly refreshed token. Without a callback the original error propagates + * unchanged, preserving the standalone-client contract. + * + * Safe only for idempotent (GET) requests — see the invariant note in + * `request.ts`. + */ + private async withAuthRefresh(run: () => Promise): Promise { + try { + return await run(); + } catch (error) { + if (!this.onAuthInvalidated || !isUnauthorized(error)) { + throw error; + } + + this.accessToken = await this.onAuthInvalidated(); + + try { + return await run(); + } catch (retryError) { + if (isUnauthorized(retryError)) { + throw new LibrusAuthenticationError( + "Synergia request failed with 401 after refreshing the bearer token.", + { code: CODE_AUTH_REFRESH_FAILED, cause: retryError }, + ); + } + + throw retryError; + } + } + } + private async withMessageAccessDiagnostics( request: () => Promise, ): Promise { @@ -887,3 +940,7 @@ export class SynergiaApiClient { ); } } + +function isUnauthorized(error: unknown): boolean { + return error instanceof LibrusApiError && error.details?.status === 401; +} diff --git a/src/sdk/synergia/request.ts b/src/sdk/synergia/request.ts index 09af59c..40db457 100644 --- a/src/sdk/synergia/request.ts +++ b/src/sdk/synergia/request.ts @@ -20,6 +20,12 @@ export interface SynergiaRequestOptions { export type SynergiaAuthMode = "bearer" | "cookie"; +// Invariant: every request helper here issues a GET. This keeps them safe to +// replay — both the transient-failure retry (`wrapFetchWithRetry`) and the +// session-layer 401 auth-refresh retry (`SynergiaApiClient.withAuthRefresh`) +// assume idempotency. A future mutating helper (POST/PUT/DELETE) must NOT +// inherit either retry path; add it with its own non-retrying request flow. + const LIBRUS_MOBILE_ORIGIN = "app://librus"; const LIBRUS_MOBILE_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148 LibrusMobileApp"; diff --git a/test/librus-session.test.ts b/test/librus-session.test.ts index cf84004..5f1cae2 100644 --- a/test/librus-session.test.ts +++ b/test/librus-session.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it, vi } from "vitest"; import { + CODE_AUTH_REFRESH_FAILED, + LibrusApiError, + LibrusAuthenticationError, LibrusConfigurationError, LibrusSession, + SynergiaApiClient, type ChildAccount, } from "../src/sdk/index.js"; import { PortalClient } from "../src/sdk/portal/PortalClient.js"; @@ -1017,3 +1021,247 @@ describe("LibrusSession.resolveChild", () => { expect(readSynergiaClientRequestTimeoutMs(childClient)).toBe(123); }); }); + +const REFRESH_SECRETS = { + email: "parent@example.com", + password: "super-secret-password", + staleToken: "stale-bearer-AAA", + freshToken: "fresh-bearer-BBB", +} as const; + +function authHeaderOf(init: RequestInit | undefined): string | undefined { + return (init?.headers as Record | undefined)?.authorization; +} + +function unauthorizedResponse(): Response { + return new Response(JSON.stringify({ error: "expired" }), { + status: 401, + headers: { "content-type": "application/json" }, + }); +} + +function luckyNumberResponse(): Response { + return new Response( + JSON.stringify({ + LuckyNumber: { LuckyNumber: 13 }, + Resources: {}, + Url: "https://api.librus.pl/3.0/LuckyNumbers", + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); +} + +describe("LibrusSession token refresh", () => { + it("refreshes the bearer token once on a 401 and retries with the fresh token", async () => { + const child = createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.staleToken, + }); + const { getFreshSynergiaAccount, portalClient } = createPortalClientStub({ + accounts: [child], + }); + getFreshSynergiaAccount.mockResolvedValue( + createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.freshToken, + }), + ); + + const seenAuth: Array = []; + const fetchMock = vi.fn(async (_input, init) => { + seenAuth.push(authHeaderOf(init)); + + return seenAuth.length === 1 + ? unauthorizedResponse() + : luckyNumberResponse(); + }); + + const session = new LibrusSession({ + credentials: { + email: REFRESH_SECRETS.email, + password: REFRESH_SECRETS.password, + }, + portalClient, + synergiaClientOptions: { fetch: fetchMock, retry: false }, + }); + + const client = await session.forChild(child); + const lucky = await client.getLuckyNumber(); + + expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); + expect(getFreshSynergiaAccount).toHaveBeenCalledWith("child-login"); + expect(seenAuth).toEqual([ + `Bearer ${REFRESH_SECRETS.staleToken}`, + `Bearer ${REFRESH_SECRETS.freshToken}`, + ]); + expect(lucky.LuckyNumber.LuckyNumber).toBe(13); + }); + + it("collapses concurrent refreshes for the same child into a single portal call", async () => { + const child = createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.staleToken, + }); + const { getFreshSynergiaAccount, portalClient } = createPortalClientStub({ + accounts: [child], + }); + let resolveFresh!: (account: ChildAccount) => void; + const pendingRefresh = new Promise((resolve) => { + resolveFresh = resolve; + }); + getFreshSynergiaAccount.mockReturnValue(pendingRefresh); + + const session = new LibrusSession({ + credentials: { + email: REFRESH_SECRETS.email, + password: REFRESH_SECRETS.password, + }, + portalClient, + }); + + // Invoked synchronously in sequence: the first registers the in-flight + // refresh, the rest must reuse it rather than starting their own. + const refreshes = Promise.all([ + session.refreshBearerToken(101), + session.refreshBearerToken(101), + session.refreshBearerToken("101"), + ]); + + await vi.waitFor(() => { + expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); + }); + resolveFresh( + createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.freshToken, + }), + ); + + await expect(refreshes).resolves.toEqual([ + REFRESH_SECRETS.freshToken, + REFRESH_SECRETS.freshToken, + REFRESH_SECRETS.freshToken, + ]); + expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); + }); + + it("throws AUTH_REFRESH_FAILED preserving the original 401 when a 401 persists after refresh", async () => { + const child = createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.staleToken, + }); + const { getFreshSynergiaAccount, portalClient } = createPortalClientStub({ + accounts: [child], + }); + getFreshSynergiaAccount.mockResolvedValue( + createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.freshToken, + }), + ); + const fetchMock = vi.fn(async () => unauthorizedResponse()); + + const session = new LibrusSession({ + credentials: { + email: REFRESH_SECRETS.email, + password: REFRESH_SECRETS.password, + }, + portalClient, + synergiaClientOptions: { fetch: fetchMock, retry: false }, + }); + + const client = await session.forChild(child); + const error = await client + .getLuckyNumber() + .catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(LibrusAuthenticationError); + expect((error as LibrusAuthenticationError).code).toBe( + CODE_AUTH_REFRESH_FAILED, + ); + const cause = (error as LibrusAuthenticationError).cause; + expect(cause).toBeInstanceOf(LibrusApiError); + expect((cause as LibrusApiError).details?.status).toBe(401); + expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("leaves a standalone SynergiaApiClient unchanged on 401 with no refresh", async () => { + const fetchMock = vi.fn(async () => unauthorizedResponse()); + const client = new SynergiaApiClient(REFRESH_SECRETS.staleToken, { + fetch: fetchMock, + retry: false, + }); + + const error = await client + .getLuckyNumber() + .catch((reason: unknown) => reason); + + expect(error).toBeInstanceOf(LibrusApiError); + expect((error as LibrusApiError).code).toBe("API_REQUEST_FAILED"); + expect((error as LibrusApiError).details?.status).toBe(401); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("never leaks the token or password in logs or errors during a failed refresh", async () => { + const logs: string[] = []; + const logger = { + log: (level: string, event: string, fields?: Record) => { + logs.push(JSON.stringify({ level, event, fields })); + }, + }; + const child = createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.staleToken, + }); + const { getFreshSynergiaAccount, portalClient } = createPortalClientStub({ + accounts: [child], + }); + getFreshSynergiaAccount.mockResolvedValue( + createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.freshToken, + }), + ); + const fetchMock = vi.fn(async () => unauthorizedResponse()); + + const session = new LibrusSession({ + credentials: { + email: REFRESH_SECRETS.email, + password: REFRESH_SECRETS.password, + }, + portalClient, + logger, + synergiaClientOptions: { fetch: fetchMock, retry: false }, + }); + + const client = await session.forChild(child); + const error = await client + .getLuckyNumber() + .catch((reason: unknown) => reason); + + const haystack = [ + ...logs, + String(error), + (error as Error)?.message ?? "", + (error as Error)?.stack ?? "", + JSON.stringify((error as LibrusApiError)?.details ?? {}), + ].join("\n"); + + for (const secret of [ + REFRESH_SECRETS.staleToken, + REFRESH_SECRETS.freshToken, + REFRESH_SECRETS.password, + ]) { + expect(haystack).not.toContain(secret); + } + }); +}); From 9ec7b06940e22274f9746a98d25989bf9f1d4b6a Mon Sep 17 00:00:00 2001 From: Andrey Koltsov Date: Sun, 31 May 2026 13:22:13 +0200 Subject: [PATCH 2/5] fix(session): address review feedback on LIB-9 token refresh - forChildWiadomosci() now receives the onAuthInvalidated callback so API 3.0 methods on those clients also benefit from token refresh - performBearerTokenRefresh() updates accountsCache after a successful refresh so a subsequent forChild() starts with the fresh token instead of the now-stale one stored at login time - withAuthRefresh: use cause: error (first/original 401) instead of cause: retryError (second 401), consistent with CHANGELOG and docs - onAuthInvalidated moved from exported SynergiaApiClientOptions to an internal SynergiaApiClientInternalOptions interface so it no longer appears in the public .d.ts as a settable option - refreshBearerToken() documented in README session methods list - Test: identity-checks that cause was the pre-refresh 401 (stale token) not the post-refresh one (fresh token) Co-Authored-By: Claude Sonnet 4.6 --- README.md | 1 + src/sdk/LibrusSession.ts | 26 ++++++++++++++++++++------ src/sdk/synergia/SynergiaApiClient.ts | 25 ++++++++++++++++--------- test/librus-session.test.ts | 13 ++++++++++++- 4 files changed, 49 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 50e0404..bc440dc 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,7 @@ Current high-level methods on `LibrusSession`: - `forChild(selectorOrChild)` - `forChildWiadomosci(selectorOrChild)` - `forChildBff(selectorOrChild)` +- `refreshBearerToken(childId)` — manually refresh the Synergia bearer for a child (normally called automatically on 401) Current methods on `PortalClient`: diff --git a/src/sdk/LibrusSession.ts b/src/sdk/LibrusSession.ts index 4fef7f5..af363f3 100644 --- a/src/sdk/LibrusSession.ts +++ b/src/sdk/LibrusSession.ts @@ -394,7 +394,7 @@ export class LibrusSession { return new SynergiaApiClient(child.accessToken, { ...this.synergiaClientOptions, onAuthInvalidated: () => this.refreshBearerToken(child.id), - }); + } as SynergiaApiClientOptions); } /** @@ -427,9 +427,10 @@ export class LibrusSession { await this.login(); + let fresh: ChildAccount; + try { - const fresh = await portalClient.getFreshSynergiaAccount(child.login); - return fresh.accessToken; + fresh = await portalClient.getFreshSynergiaAccount(child.login); } catch (error) { if (!isUnauthorized(error)) { throw error; @@ -437,9 +438,21 @@ export class LibrusSession { // The cached portal session is dead. Force a fresh login and retry once. await portalClient.login(this.getPortalCredentials()); - const fresh = await portalClient.getFreshSynergiaAccount(child.login); - return fresh.accessToken; + fresh = await portalClient.getFreshSynergiaAccount(child.login); + } + + // Keep accountsCache in sync so a subsequent forChild() call starts with + // the refreshed token instead of the now-stale one stored at login time. + if (this.accountsCache) { + this.accountsCache = { + ...this.accountsCache, + accounts: this.accountsCache.accounts.map((a) => + a.id === fresh.id ? fresh : a, + ), + }; } + + return fresh.accessToken; } async forChildWiadomosci( @@ -459,7 +472,8 @@ export class LibrusSession { return new SynergiaApiClient(child.accessToken, { ...this.synergiaClientOptions, messageBackend, - }); + onAuthInvalidated: () => this.refreshBearerToken(child.id), + } as SynergiaApiClientOptions); } async forChildBff( diff --git a/src/sdk/synergia/SynergiaApiClient.ts b/src/sdk/synergia/SynergiaApiClient.ts index 74dd68f..a0af408 100644 --- a/src/sdk/synergia/SynergiaApiClient.ts +++ b/src/sdk/synergia/SynergiaApiClient.ts @@ -223,13 +223,16 @@ export interface SynergiaApiClientOptions { requestTimeoutMs?: number; retry?: RetryOption; logger?: Logger; - /** - * @internal Session-only hook. {@link LibrusSession.forChild} wires this in - * so an expired bearer token can be refreshed and the request retried once on - * a `401`. Not part of the public API — standalone clients omit it and keep - * the historical "throw on 401" behavior. Deliberately an inline type so the - * coupling surface stays internal. - */ +} + +/** + * @internal Not exported. {@link LibrusSession.forChild} and + * {@link LibrusSession.forChildWiadomosci} extend the public options with this + * callback so the session can refresh an expired bearer token transparently. + * Keeping it off the exported interface prevents it from appearing in the + * generated `.d.ts`. + */ +interface SynergiaApiClientInternalOptions extends SynergiaApiClientOptions { onAuthInvalidated?: () => Promise; } @@ -252,7 +255,9 @@ export class SynergiaApiClient { constructor(accessToken: string, options: SynergiaApiClientOptions = {}) { this.accessToken = accessToken; this.authMode = options.authMode ?? "bearer"; - this.onAuthInvalidated = options.onAuthInvalidated; + this.onAuthInvalidated = ( + options as SynergiaApiClientInternalOptions + ).onAuthInvalidated; this.logger = options.logger ?? noopLogger; this.requestTimeoutMs = resolveRequestTimeoutMs(options.requestTimeoutMs); const timeoutFetch = wrapFetchWithTimeout( @@ -333,9 +338,11 @@ export class SynergiaApiClient { return await run(); } catch (retryError) { if (isUnauthorized(retryError)) { + // Preserve the original (pre-refresh) 401 as cause so callers can + // inspect the initial expired-token response. throw new LibrusAuthenticationError( "Synergia request failed with 401 after refreshing the bearer token.", - { code: CODE_AUTH_REFRESH_FAILED, cause: retryError }, + { code: CODE_AUTH_REFRESH_FAILED, cause: error }, ); } diff --git a/test/librus-session.test.ts b/test/librus-session.test.ts index 5f1cae2..153dbbb 100644 --- a/test/librus-session.test.ts +++ b/test/librus-session.test.ts @@ -1165,7 +1165,12 @@ describe("LibrusSession token refresh", () => { accessToken: REFRESH_SECRETS.freshToken, }), ); - const fetchMock = vi.fn(async () => unauthorizedResponse()); + // Track which Authorization header was in use when each 401 occurred. + const authAtCall: Array = []; + const fetchMock = vi.fn(async (_input, init) => { + authAtCall.push(authHeaderOf(init)); + return unauthorizedResponse(); + }); const session = new LibrusSession({ credentials: { @@ -1188,6 +1193,12 @@ describe("LibrusSession token refresh", () => { const cause = (error as LibrusAuthenticationError).cause; expect(cause).toBeInstanceOf(LibrusApiError); expect((cause as LibrusApiError).details?.status).toBe(401); + + // cause must be the *first* (pre-refresh) 401 — the one that fired while + // the stale token was in use — not the retry 401. + expect(authAtCall).toHaveLength(2); + expect(authAtCall[0]).toBe(`Bearer ${REFRESH_SECRETS.staleToken}`); + expect(authAtCall[1]).toBe(`Bearer ${REFRESH_SECRETS.freshToken}`); expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); expect(fetchMock).toHaveBeenCalledTimes(2); }); From e6e311ca85366e2ce60ce893525ee00b3a03fab6 Mon Sep 17 00:00:00 2001 From: Andrey Koltsov Date: Sun, 31 May 2026 13:30:10 +0200 Subject: [PATCH 3/5] fix(session): eliminate redundant portal refresh for delayed stale 401s Add latestTokenPerChild map to LibrusSession so that: - A delayed 401 that fires after the in-flight refresh has already cleared can detect the superseded token (staleToken arg passed by onAuthInvalidated) and return the cached fresh token without a second portal round-trip. Previously the in-flight dedup only helped while the promise was still pending. - forChild(childObject) always starts with the freshest known token, even when the caller holds a stale ChildAccount reference from before a manual refreshBearerToken() call. onAuthInvalidated now receives the client's stale token as an argument so LibrusSession.refreshBearerToken() can compare it against the latest known value. The public refreshBearerToken(childId) signature is unchanged for external callers (staleToken is an optional second arg). Two new tests cover both scenarios. Co-Authored-By: Claude Sonnet 4.6 --- src/sdk/LibrusSession.ts | 56 +++++++++++++++--- src/sdk/synergia/SynergiaApiClient.ts | 9 ++- test/librus-session.test.ts | 84 +++++++++++++++++++++++++++ 3 files changed, 138 insertions(+), 11 deletions(-) diff --git a/src/sdk/LibrusSession.ts b/src/sdk/LibrusSession.ts index af363f3..6d620b1 100644 --- a/src/sdk/LibrusSession.ts +++ b/src/sdk/LibrusSession.ts @@ -123,10 +123,20 @@ export class LibrusSession { > | undefined; private accountsCache?: SynergiaAccountsResponse; + /** + * Latest known bearer token per child id (keyed by `String(id)`). Updated + * after every successful portal refresh so that: + * - `forChild(childObject)` always starts with the freshest token even when + * the caller holds a stale `ChildAccount` reference. + * - A delayed `401` (one that fires after an in-flight refresh has already + * cleared `refreshInFlight`) can detect the superseded token and return + * the already-refreshed value without a second portal round-trip. + */ + private readonly latestTokenPerChild = new Map(); /** * In-flight bearer-token refreshes keyed by child id. Collapses concurrent * refreshes for the same child to a single portal round-trip (stampede - * protection), so parallel `401`s trigger exactly one refresh. + * protection) while the refresh is pending. */ private readonly refreshInFlight = new Map>(); @@ -391,9 +401,13 @@ export class LibrusSession { ? await this.resolveChild(selectorOrChild) : selectorOrChild; - return new SynergiaApiClient(child.accessToken, { + const token = + this.latestTokenPerChild.get(String(child.id)) ?? child.accessToken; + + return new SynergiaApiClient(token, { ...this.synergiaClientOptions, - onAuthInvalidated: () => this.refreshBearerToken(child.id), + onAuthInvalidated: (staleToken: string) => + this.refreshBearerToken(child.id, staleToken), } as SynergiaApiClientOptions); } @@ -401,11 +415,30 @@ export class LibrusSession { * Re-fetch a fresh Synergia bearer token for a known child, reusing the * cached portal login (and re-logging in if the portal session itself is * dead). Concurrent calls for the same child share one in-flight promise. + * + * The optional `staleToken` argument is supplied by the internal + * `onAuthInvalidated` callback. When present, the method first checks + * `latestTokenPerChild`: if a previous refresh already produced a different + * token the caller can skip a portal round-trip entirely and reuse it. This + * prevents a delayed `401` (one that fires after the in-flight map has been + * cleared) from triggering a redundant second refresh. */ - async refreshBearerToken(childId: string | number): Promise { + async refreshBearerToken( + childId: string | number, + staleToken?: string, + ): Promise { this.assertApiV3Backend("refreshBearerToken"); const key = String(childId); + + if (staleToken !== undefined) { + const latest = this.latestTokenPerChild.get(key); + + if (latest !== undefined && latest !== staleToken) { + return latest; + } + } + const existing = this.refreshInFlight.get(key); if (existing) { @@ -441,8 +474,11 @@ export class LibrusSession { fresh = await portalClient.getFreshSynergiaAccount(child.login); } - // Keep accountsCache in sync so a subsequent forChild() call starts with - // the refreshed token instead of the now-stale one stored at login time. + // Record the latest token so delayed 401s and forChild(childObject) calls + // can pick up the fresh value without another portal round-trip. + this.latestTokenPerChild.set(String(fresh.id), fresh.accessToken); + + // Keep accountsCache in sync so resolveChild() returns the fresh account. if (this.accountsCache) { this.accountsCache = { ...this.accountsCache, @@ -469,10 +505,14 @@ export class LibrusSession { portalClient: this.getPortalClient(), }); - return new SynergiaApiClient(child.accessToken, { + const token = + this.latestTokenPerChild.get(String(child.id)) ?? child.accessToken; + + return new SynergiaApiClient(token, { ...this.synergiaClientOptions, messageBackend, - onAuthInvalidated: () => this.refreshBearerToken(child.id), + onAuthInvalidated: (staleToken: string) => + this.refreshBearerToken(child.id, staleToken), } as SynergiaApiClientOptions); } diff --git a/src/sdk/synergia/SynergiaApiClient.ts b/src/sdk/synergia/SynergiaApiClient.ts index a0af408..3317d51 100644 --- a/src/sdk/synergia/SynergiaApiClient.ts +++ b/src/sdk/synergia/SynergiaApiClient.ts @@ -233,7 +233,7 @@ export interface SynergiaApiClientOptions { * generated `.d.ts`. */ interface SynergiaApiClientInternalOptions extends SynergiaApiClientOptions { - onAuthInvalidated?: () => Promise; + onAuthInvalidated?: (staleToken: string) => Promise; } type SynergiaId = string | number; @@ -250,7 +250,9 @@ export class SynergiaApiClient { private readonly messageBackend: MessageReadBackend | undefined; private readonly requestTimeoutMs: number; private readonly logger: Logger; - private readonly onAuthInvalidated: (() => Promise) | undefined; + private readonly onAuthInvalidated: + | ((staleToken: string) => Promise) + | undefined; constructor(accessToken: string, options: SynergiaApiClientOptions = {}) { this.accessToken = accessToken; @@ -332,7 +334,8 @@ export class SynergiaApiClient { throw error; } - this.accessToken = await this.onAuthInvalidated(); + const staleToken = this.accessToken; + this.accessToken = await this.onAuthInvalidated(staleToken); try { return await run(); diff --git a/test/librus-session.test.ts b/test/librus-session.test.ts index 153dbbb..746792d 100644 --- a/test/librus-session.test.ts +++ b/test/librus-session.test.ts @@ -1149,6 +1149,90 @@ describe("LibrusSession token refresh", () => { expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); }); + it("reuses the already-refreshed token when a delayed 401 fires after the in-flight refresh cleared", async () => { + const child = createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.staleToken, + }); + const { getFreshSynergiaAccount, portalClient } = createPortalClientStub({ + accounts: [child], + }); + getFreshSynergiaAccount.mockResolvedValue( + createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.freshToken, + }), + ); + const session = new LibrusSession({ + credentials: { + email: REFRESH_SECRETS.email, + password: REFRESH_SECRETS.password, + }, + portalClient, + }); + + // First refresh completes and clears the in-flight map. + const first = await session.refreshBearerToken(101); + expect(first).toBe(REFRESH_SECRETS.freshToken); + expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); + + // A second call that supplies the stale token (simulating a delayed 401 + // from a client that was already using the old token) must reuse the + // cached result without a second portal round-trip. + const second = await session.refreshBearerToken( + 101, + REFRESH_SECRETS.staleToken, + ); + expect(second).toBe(REFRESH_SECRETS.freshToken); + expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); + }); + + it("forChild with a stale ChildAccount object starts with the refreshed token after a refresh", async () => { + const child = createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.staleToken, + }); + const { getFreshSynergiaAccount, portalClient } = createPortalClientStub({ + accounts: [child], + }); + getFreshSynergiaAccount.mockResolvedValue( + createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.freshToken, + }), + ); + + const seenAuth: Array = []; + const fetchMock = vi.fn(async (_input, init) => { + seenAuth.push(authHeaderOf(init)); + return luckyNumberResponse(); + }); + + const session = new LibrusSession({ + credentials: { + email: REFRESH_SECRETS.email, + password: REFRESH_SECRETS.password, + }, + portalClient, + synergiaClientOptions: { fetch: fetchMock, retry: false }, + }); + + // Refresh outside of a client call; the session now holds the fresh token. + await session.refreshBearerToken(child.id); + + // Pass the *original* stale child object — forChild must still use the + // fresh token that was produced by the earlier refresh. + const client = await session.forChild(child); + await client.getLuckyNumber(); + + expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); + expect(seenAuth).toEqual([`Bearer ${REFRESH_SECRETS.freshToken}`]); + }); + it("throws AUTH_REFRESH_FAILED preserving the original 401 when a 401 persists after refresh", async () => { const child = createChild({ id: 101, From 0144512fb24a0f67ce4d8f3307be13e6279f42aa Mon Sep 17 00:00:00 2001 From: Andrey Koltsov Date: Sun, 31 May 2026 13:59:14 +0200 Subject: [PATCH 4/5] fix(session): fix same-client parallel 401 race and clean up public API SynergiaApiClient.withAuthRefresh now captures this.accessToken before run() so that concurrent sibling requests each report the token they actually used, not the value a racing handler may have already updated. This lets acquireFreshToken() correctly detect superseded stale tokens. LibrusSession: split refreshBearerToken (public, no stale-token arg) from acquireFreshToken (private helper used by onAuthInvalidated). The stale-token parameter no longer appears in the public .d.ts. forChildBff(childObject) now reads latestTokenPerChild so a previously refreshed token is used instead of child.accessToken. Test: replace the direct-refreshBearerToken stale-token dedup test with a same-client parallel-request test that exercises the real code path; add assertion that explicit double-refresh does two portal calls. Co-Authored-By: Claude Sonnet 4.6 --- src/sdk/LibrusSession.ts | 37 +++++++------- src/sdk/synergia/SynergiaApiClient.ts | 11 ++++- test/librus-session.test.ts | 69 ++++++++++++++++++++++++--- 3 files changed, 91 insertions(+), 26 deletions(-) diff --git a/src/sdk/LibrusSession.ts b/src/sdk/LibrusSession.ts index 6d620b1..8f390a9 100644 --- a/src/sdk/LibrusSession.ts +++ b/src/sdk/LibrusSession.ts @@ -407,7 +407,7 @@ export class LibrusSession { return new SynergiaApiClient(token, { ...this.synergiaClientOptions, onAuthInvalidated: (staleToken: string) => - this.refreshBearerToken(child.id, staleToken), + this.acquireFreshToken(String(child.id), staleToken), } as SynergiaApiClientOptions); } @@ -415,27 +415,27 @@ export class LibrusSession { * Re-fetch a fresh Synergia bearer token for a known child, reusing the * cached portal login (and re-logging in if the portal session itself is * dead). Concurrent calls for the same child share one in-flight promise. - * - * The optional `staleToken` argument is supplied by the internal - * `onAuthInvalidated` callback. When present, the method first checks - * `latestTokenPerChild`: if a previous refresh already produced a different - * token the caller can skip a portal round-trip entirely and reuse it. This - * prevents a delayed `401` (one that fires after the in-flight map has been - * cleared) from triggering a redundant second refresh. */ - async refreshBearerToken( - childId: string | number, - staleToken?: string, - ): Promise { + async refreshBearerToken(childId: string | number): Promise { this.assertApiV3Backend("refreshBearerToken"); + return this.acquireFreshToken(String(childId), undefined); + } - const key = String(childId); - + /** + * Internal refresh path used by the `onAuthInvalidated` callback. Accepts + * the token the client actually used for the failed request so it can detect + * when a sibling request already refreshed to a newer token and skip the + * portal round-trip. + */ + private acquireFreshToken( + key: string, + staleToken: string | undefined, + ): Promise { if (staleToken !== undefined) { const latest = this.latestTokenPerChild.get(key); if (latest !== undefined && latest !== staleToken) { - return latest; + return Promise.resolve(latest); } } @@ -512,7 +512,7 @@ export class LibrusSession { ...this.synergiaClientOptions, messageBackend, onAuthInvalidated: (staleToken: string) => - this.refreshBearerToken(child.id, staleToken), + this.acquireFreshToken(String(child.id), staleToken), } as SynergiaApiClientOptions); } @@ -524,7 +524,10 @@ export class LibrusSession { typeof selectorOrChild === "string" ? await this.resolveChild(selectorOrChild) : selectorOrChild; - return new BffApiClient(child.accessToken, this.bffClientOptions); + const token = + this.latestTokenPerChild.get(String(child.id)) ?? child.accessToken; + + return new BffApiClient(token, this.bffClientOptions); } private getPortalCredentials(): PortalCredentials { diff --git a/src/sdk/synergia/SynergiaApiClient.ts b/src/sdk/synergia/SynergiaApiClient.ts index 3317d51..86afc6e 100644 --- a/src/sdk/synergia/SynergiaApiClient.ts +++ b/src/sdk/synergia/SynergiaApiClient.ts @@ -323,10 +323,18 @@ export class SynergiaApiClient { * freshly refreshed token. Without a callback the original error propagates * unchanged, preserving the standalone-client contract. * + * The token is captured before `run()` so that parallel requests on the same + * client each report the token they actually used, even if a sibling handler + * has already updated `this.accessToken` by the time the catch block fires. + * * Safe only for idempotent (GET) requests — see the invariant note in * `request.ts`. */ private async withAuthRefresh(run: () => Promise): Promise { + // Capture before run() so a concurrent sibling that already refreshed + // this.accessToken does not pollute our stale-token value. + const tokenUsed = this.accessToken; + try { return await run(); } catch (error) { @@ -334,8 +342,7 @@ export class SynergiaApiClient { throw error; } - const staleToken = this.accessToken; - this.accessToken = await this.onAuthInvalidated(staleToken); + this.accessToken = await this.onAuthInvalidated(tokenUsed); try { return await run(); diff --git a/test/librus-session.test.ts b/test/librus-session.test.ts index 746792d..9aadb9a 100644 --- a/test/librus-session.test.ts +++ b/test/librus-session.test.ts @@ -1178,14 +1178,69 @@ describe("LibrusSession token refresh", () => { expect(first).toBe(REFRESH_SECRETS.freshToken); expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); - // A second call that supplies the stale token (simulating a delayed 401 - // from a client that was already using the old token) must reuse the - // cached result without a second portal round-trip. - const second = await session.refreshBearerToken( - 101, - REFRESH_SECRETS.staleToken, + // A second explicit refresh (no stale-token context) always goes through + // to the portal — the stale-token dedup is internal to onAuthInvalidated. + getFreshSynergiaAccount.mockResolvedValue( + createChild({ + id: 101, + login: "child-login", + accessToken: "fresh-bearer-CCC", + }), ); - expect(second).toBe(REFRESH_SECRETS.freshToken); + const second = await session.refreshBearerToken(101); + expect(second).toBe("fresh-bearer-CCC"); + expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(2); + }); + + it("single-client parallel requests reuse the first refresh when both get a stale-token 401", async () => { + const child = createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.staleToken, + }); + const { getFreshSynergiaAccount, portalClient } = createPortalClientStub({ + accounts: [child], + }); + getFreshSynergiaAccount.mockResolvedValue( + createChild({ + id: 101, + login: "child-login", + accessToken: REFRESH_SECRETS.freshToken, + }), + ); + + const seenAuth: Array = []; + // Stale token → 401; fresh token → 200. + const fetchMock = vi.fn(async (_input, init) => { + const auth = authHeaderOf(init); + seenAuth.push(auth); + return auth === `Bearer ${REFRESH_SECRETS.staleToken}` + ? unauthorizedResponse() + : luckyNumberResponse(); + }); + + const session = new LibrusSession({ + credentials: { + email: REFRESH_SECRETS.email, + password: REFRESH_SECRETS.password, + }, + portalClient, + synergiaClientOptions: { fetch: fetchMock, retry: false }, + }); + + const client = await session.forChild(child); + + // Fire two requests in parallel. Each captures the stale token before + // run() executes, so the second delayed 401 correctly identifies its token + // as stale and reuses the fresh-1 produced by the first refresh. + const [a, b] = await Promise.all([ + client.getLuckyNumber(), + client.getLuckyNumber(), + ]); + + expect(a.LuckyNumber.LuckyNumber).toBe(13); + expect(b.LuckyNumber.LuckyNumber).toBe(13); + // Exactly one portal refresh, not two. expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); }); From 89180b46836efef9fc6a6a0b15fbe2a2497c7ef2 Mon Sep 17 00:00:00 2001 From: Andrey Koltsov Date: Sun, 31 May 2026 14:59:48 +0200 Subject: [PATCH 5/5] test(session): rename test and make parallel-401 race deterministic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename "reuses the already-refreshed token when a delayed 401 fires after the in-flight refresh cleared" → "refreshBearerToken always triggers a portal call regardless of the latest-token cache" to match what the test now verifies (explicit double-refresh semantics). Make the same-client parallel-401 test deterministic: gate the second stale-token 401 response with a Promise, release it via vi.waitFor after getFreshSynergiaAccount has been called (macrotask boundary guarantees latestTokenPerChild is set). This explicitly exercises the "delayed 401 after in-flight cleared" path rather than relying on Promise.all scheduling. Also update PR description test count from 405 to 408. Co-Authored-By: Claude Sonnet 4.6 --- test/librus-session.test.ts | 54 ++++++++++++++++++++++++++----------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/test/librus-session.test.ts b/test/librus-session.test.ts index 9aadb9a..c133708 100644 --- a/test/librus-session.test.ts +++ b/test/librus-session.test.ts @@ -1149,7 +1149,7 @@ describe("LibrusSession token refresh", () => { expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); }); - it("reuses the already-refreshed token when a delayed 401 fires after the in-flight refresh cleared", async () => { + it("refreshBearerToken always triggers a portal call regardless of the latest-token cache", async () => { const child = createChild({ id: 101, login: "child-login", @@ -1173,13 +1173,12 @@ describe("LibrusSession token refresh", () => { portalClient, }); - // First refresh completes and clears the in-flight map. const first = await session.refreshBearerToken(101); expect(first).toBe(REFRESH_SECRETS.freshToken); expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); - // A second explicit refresh (no stale-token context) always goes through - // to the portal — the stale-token dedup is internal to onAuthInvalidated. + // A second explicit refresh (no stale-token context) always goes to the + // portal — the stale-token short-circuit is internal to onAuthInvalidated. getFreshSynergiaAccount.mockResolvedValue( createChild({ id: 101, @@ -1192,7 +1191,7 @@ describe("LibrusSession token refresh", () => { expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(2); }); - it("single-client parallel requests reuse the first refresh when both get a stale-token 401", async () => { + it("single-client parallel 401s collapse to one portal refresh even when the second 401 arrives after the first refresh clears", async () => { const child = createChild({ id: 101, login: "child-login", @@ -1209,14 +1208,27 @@ describe("LibrusSession token refresh", () => { }), ); - const seenAuth: Array = []; - // Stale token → 401; fresh token → 200. + // Gate: hold the second stale-token 401 response so it arrives only after + // the first refresh has fully completed. Released via vi.waitFor below so + // that a macrotask boundary guarantees latestTokenPerChild is set. + let releaseGate!: () => void; + const gate = new Promise((resolve) => { + releaseGate = resolve; + }); + + let callCount = 0; const fetchMock = vi.fn(async (_input, init) => { + callCount++; const auth = authHeaderOf(init); - seenAuth.push(auth); - return auth === `Bearer ${REFRESH_SECRETS.staleToken}` - ? unauthorizedResponse() - : luckyNumberResponse(); + const isStale = auth === `Bearer ${REFRESH_SECRETS.staleToken}`; + + if (callCount === 2 && isStale) { + // Deterministically delay this second stale 401 until after + // performBearerTokenRefresh has set latestTokenPerChild. + await gate; + } + + return isStale ? unauthorizedResponse() : luckyNumberResponse(); }); const session = new LibrusSession({ @@ -1230,18 +1242,30 @@ describe("LibrusSession token refresh", () => { const client = await session.forChild(child); - // Fire two requests in parallel. Each captures the stale token before - // run() executes, so the second delayed 401 correctly identifies its token - // as stale and reuses the fresh-1 produced by the first refresh. - const [a, b] = await Promise.all([ + // Fire both requests. The second stale 401 is held by the gate. + const allPromise = Promise.all([ client.getLuckyNumber(), client.getLuckyNumber(), ]); + // A macrotask boundary guarantees all pending microtasks (including + // latestTokenPerChild.set inside performBearerTokenRefresh) have run. + await vi.waitFor(() => { + expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); + }); + + // Release the gate. The second stale 401 now arrives after the refresh, + // exercising the latestTokenPerChild short-circuit path in acquireFreshToken. + releaseGate(); + + const [a, b] = await allPromise; + expect(a.LuckyNumber.LuckyNumber).toBe(13); expect(b.LuckyNumber.LuckyNumber).toBe(13); // Exactly one portal refresh, not two. expect(getFreshSynergiaAccount).toHaveBeenCalledTimes(1); + // Four fetch calls: stale×2 (initial requests), fresh×2 (retries). + expect(fetchMock).toHaveBeenCalledTimes(4); }); it("forChild with a stale ChildAccount object starts with the refreshed token after a refresh", async () => {