@iloveagents/foundry-agent 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +16 -0
  2. package/dist/client/agui-runner.d.ts +53 -0
  3. package/dist/client/agui-runner.js +320 -0
  4. package/dist/client/runner-events.d.ts +54 -0
  5. package/dist/client/runner-events.js +1 -0
  6. package/dist/client/service-fetch.d.ts +112 -0
  7. package/dist/client/service-fetch.js +244 -0
  8. package/dist/index.d.ts +7 -0
  9. package/dist/index.js +10 -0
  10. package/dist/msal/auth-config.d.ts +91 -0
  11. package/dist/msal/auth-config.js +70 -0
  12. package/dist/msal/auth-store.d.ts +95 -0
  13. package/dist/msal/auth-store.js +372 -0
  14. package/dist/msal/index.d.ts +3 -0
  15. package/dist/msal/index.js +3 -0
  16. package/dist/msal/token-fetch.d.ts +16 -0
  17. package/dist/msal/token-fetch.js +57 -0
  18. package/dist/store/citation-store.d.ts +42 -0
  19. package/dist/store/citation-store.js +14 -0
  20. package/dist/store/link-store.d.ts +29 -0
  21. package/dist/store/link-store.js +28 -0
  22. package/dist/store/streaming-status-store.d.ts +15 -0
  23. package/dist/store/streaming-status-store.js +9 -0
  24. package/dist/tools/registry.d.ts +48 -0
  25. package/dist/tools/registry.js +50 -0
  26. package/package.json +23 -9
  27. package/AGENTS.md +0 -91
  28. package/CHANGELOG.md +0 -182
  29. package/CLAUDE.md +0 -1
  30. package/src/__tests__/agui-runner.test.ts +0 -404
  31. package/src/__tests__/auth-store.test.ts +0 -596
  32. package/src/__tests__/citation-store.test.ts +0 -52
  33. package/src/__tests__/client-tool-registry.test.ts +0 -84
  34. package/src/__tests__/link-store.test.ts +0 -48
  35. package/src/__tests__/service-fetch.test.ts +0 -525
  36. package/src/__tests__/streaming-status-store.test.ts +0 -22
  37. package/src/__tests__/token-fetch.test.ts +0 -134
  38. package/src/client/agui-runner.ts +0 -382
  39. package/src/client/runner-events.ts +0 -27
  40. package/src/client/service-fetch.ts +0 -318
  41. package/src/index.ts +0 -27
  42. package/src/msal/auth-config.ts +0 -150
  43. package/src/msal/auth-store.ts +0 -517
  44. package/src/msal/index.ts +0 -14
  45. package/src/msal/token-fetch.ts +0 -68
  46. package/src/store/citation-store.ts +0 -52
  47. package/src/store/link-store.ts +0 -53
  48. package/src/store/streaming-status-store.ts +0 -21
  49. package/src/tools/registry.ts +0 -112
  50. package/tsconfig.json +0 -15
  51. package/vitest.config.ts +0 -8
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Per-service fetch factory.
3
+ *
4
+ * Returns an authenticated `fetch`-shaped function that:
5
+ * 1. Rewrites local-relative URLs to a configured router base in production
6
+ * (Vite proxy in dev → httpRouteConfigs FQDN in prod).
7
+ * 2. Acquires a Bearer token via the supplied `acquireToken` callback and
8
+ * attaches it as the `Authorization` header.
9
+ *
10
+ * `@iloveagents/foundry-agent` stays auth-mechanism-agnostic — `acquireToken` is
11
+ * supplied by the host (apps/web wires it to MSAL via the `/msal` subpath;
12
+ * future shells could plug in a different token source).
13
+ */
14
+ /**
15
+ * Wrapper thrown when ``acquireToken`` rejects inside the fetch
16
+ * interceptor. Lets the outer 401-retry layer distinguish
17
+ * token-acquisition failures (which warrant interactive recovery) from
18
+ * generic ``fetch`` rejections like network drops, aborts, or CORS
19
+ * preflight failures (which do NOT — those would needlessly bounce the
20
+ * user through ``loginRedirect`` on a transient transport error).
21
+ *
22
+ * Exported so hosts that wrap ``serviceFetch`` further can ``instanceof``
23
+ * against the same class without re-declaring it.
24
+ */
25
+ export class TokenAcquisitionError extends Error {
26
+ constructor(cause) {
27
+ super(cause instanceof Error ? cause.message : String(cause));
28
+ this.name = "TokenAcquisitionError";
29
+ this.cause = cause;
30
+ }
31
+ }
32
+ const defaultOriginResolver = () => typeof window !== "undefined" ? window.location.origin : "";
33
+ /**
34
+ * Build a service-fetch function. Returns a function with the same shape as
35
+ * `fetch` that rewrites URLs + attaches the Bearer token from `acquireToken`.
36
+ */
37
+ export function createServiceFetch(options) {
38
+ const routerBase = (options.baseUrl ?? "").replace(/\/+$/, "");
39
+ const resolveOrigin = options.originResolver ?? defaultOriginResolver;
40
+ function resolveUrl(url) {
41
+ if (!routerBase)
42
+ return url; // dev: Vite proxy handles routing
43
+ const origin = resolveOrigin();
44
+ const rel = origin && url.startsWith(origin) ? url.slice(origin.length) : url;
45
+ return `${routerBase}${rel}`;
46
+ }
47
+ return async function serviceFetch(input, init) {
48
+ const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
49
+ const resolvedUrl = resolveUrl(url);
50
+ // Merge headers from a `Request` input (if provided), then `init`. The
51
+ // Request-input branch must preserve method / body / credentials /
52
+ // signal / etc. — silently dropping them by reading only `.url` would
53
+ // turn POST/PUT into GET and drop required headers.
54
+ const baseHeaders = new Headers(input instanceof Request ? input.headers : undefined);
55
+ if (init?.headers) {
56
+ new Headers(init.headers).forEach((value, key) => baseHeaders.set(key, value));
57
+ }
58
+ const callerSuppliedAuth = baseHeaders.has("Authorization");
59
+ function buildRequestInitFrom(req) {
60
+ // ReadableStream bodies are single-consume. ``req.clone()`` returns a
61
+ // fresh Request whose body stream is independent — call it once per
62
+ // dispatch attempt so the 401 retry can replay POST/PUT bodies
63
+ // intact.
64
+ const fresh = req.clone();
65
+ const ri = {
66
+ method: fresh.method,
67
+ body: fresh.method === "GET" || fresh.method === "HEAD" ? undefined : fresh.body,
68
+ credentials: fresh.credentials,
69
+ mode: fresh.mode,
70
+ cache: fresh.cache,
71
+ redirect: fresh.redirect,
72
+ referrer: fresh.referrer,
73
+ integrity: fresh.integrity,
74
+ signal: fresh.signal,
75
+ };
76
+ // Streaming bodies need duplex: "half"; harmless when there's no body.
77
+ if (fresh.body !== null) {
78
+ ri.duplex = "half";
79
+ }
80
+ return ri;
81
+ }
82
+ async function dispatch(forceRefreshToken) {
83
+ const headers = new Headers(baseHeaders);
84
+ if (!callerSuppliedAuth) {
85
+ try {
86
+ const token = await options.acquireToken(forceRefreshToken ? { forceRefresh: true } : undefined);
87
+ if (token) {
88
+ headers.set("Authorization", `Bearer ${token}`);
89
+ }
90
+ }
91
+ catch (error) {
92
+ // A thrown token-acquisition error usually means the auth layer
93
+ // is starting an interactive recovery. Do not downgrade
94
+ // protected API calls to anonymous requests; that creates
95
+ // noisy 401s and stale UI. Wrap the error so the outer
96
+ // 401-retry layer can distinguish it from generic ``fetch``
97
+ // rejections (network drops, aborts, CORS preflight fails)
98
+ // and only escalate to ``recoverFromHardAuthFailure`` for
99
+ // genuine auth failures.
100
+ throw new TokenAcquisitionError(error);
101
+ }
102
+ }
103
+ if (input instanceof Request) {
104
+ // Re-clone for THIS attempt — the body stream of the original is
105
+ // either still pristine (first attempt) or already consumed
106
+ // (second attempt); ``clone()`` always returns a fresh, replayable
107
+ // copy.
108
+ const requestInit = buildRequestInitFrom(input);
109
+ return fetch(resolvedUrl, { ...requestInit, ...init, headers });
110
+ }
111
+ return fetch(resolvedUrl, { ...init, headers });
112
+ }
113
+ const response = await dispatch(false);
114
+ // Long-lived tab recovery: when the resource server returns 401 the
115
+ // local MSAL cache may still hold a token MSAL itself thinks is valid
116
+ // (claims challenge, conditional-access re-eval, audience drift, or
117
+ // the user simply left the tab open past the cached access token's
118
+ // server-side validity). Force-refresh the token via the refresh
119
+ // token grant and replay the request once. If the refresh-token is
120
+ // also gone (24h SPA cap), ``acquireToken`` will throw an
121
+ // interaction-required error and the auth layer kicks off
122
+ // ``loginRedirect`` — that's the only correct UX for a hard expiry.
123
+ //
124
+ // Conditions for retry: 401, no caller-supplied auth header (we own
125
+ // the token), and the server didn't already see a fresh token (we
126
+ // only retry once).
127
+ if (response.status !== 401 || callerSuppliedAuth) {
128
+ return response;
129
+ }
130
+ // Drain the failed response body — letting it sit unread keeps the
131
+ // underlying connection occupied on some runtimes.
132
+ response.body?.cancel().catch(() => undefined);
133
+ // Two failure shapes for the retry:
134
+ // (a) The force-refreshed token grant FAILS — refresh token gone
135
+ // (24h SPA cap), interaction-required claims challenge, etc.
136
+ // ``acquireToken`` throws inside ``dispatch`` BEFORE the fetch
137
+ // leaves the browser. ``dispatch`` re-throws wrapped in
138
+ // ``TokenAcquisitionError`` so we can distinguish this case
139
+ // from network errors below.
140
+ // (b) The grant succeeds but the resource server STILL rejects
141
+ // the token (audience drift, server-side policy change). The
142
+ // retried response status is 401.
143
+ // Both must funnel into ``recoverFromHardAuthFailure`` so the auth
144
+ // layer can kick off ``loginRedirect`` — the only correct UX for a
145
+ // hard expiry. We treat the two auth paths symmetrically.
146
+ //
147
+ // We deliberately do NOT trigger recovery on generic ``dispatch``
148
+ // throws (TypeError from network drops, AbortError from caller
149
+ // cancellation, CORS-preflight failures, etc.) — those are
150
+ // transient transport problems, not auth state corruption, and
151
+ // bouncing the user through ``loginRedirect`` for a flaky network
152
+ // would be a much worse UX than letting the error propagate.
153
+ let retried;
154
+ try {
155
+ retried = await dispatch(true);
156
+ }
157
+ catch (err) {
158
+ if (err instanceof TokenAcquisitionError && options.recoverFromHardAuthFailure) {
159
+ // ``recoverFromHardAuthFailure`` is expected to throw once the
160
+ // redirect is in flight; if it returns (e.g. test stub) we
161
+ // re-throw the wrapped original so the caller still sees the
162
+ // auth failure rather than a phantom recovery. We pass the
163
+ // unwrapped ``cause`` to recovery so it sees the real MSAL
164
+ // error (InteractionRequiredAuthError etc.) for telemetry.
165
+ await options.recoverFromHardAuthFailure(err.cause);
166
+ }
167
+ throw err;
168
+ }
169
+ // Hard auth failure path (b): even the force-refreshed token got
170
+ // rejected. A token minted from the cached refresh token carries
171
+ // the same identity claims as the original, so it'll keep getting
172
+ // rejected for the same reason. Only a brand-new ``loginRedirect``
173
+ // produces a token bound to the current server policy. Without
174
+ // this branch the user sees an endless 401 loop until they
175
+ // manually log out.
176
+ if (retried.status === 401 && options.recoverFromHardAuthFailure) {
177
+ // Continuous Access Evaluation / Conditional-Access claims
178
+ // challenge passthrough. When the resource server requires a
179
+ // step-up (MFA, device compliance, revocation invalidation),
180
+ // it returns 401 with ``WWW-Authenticate: Bearer ... claims="…"``.
181
+ // We forward the payload through the recovery callback so the
182
+ // auth layer's redirect carries it to Entra ID and the
183
+ // re-minted token satisfies the exact challenge. Without it,
184
+ // Entra re-issues the same already-rejected claims set and the
185
+ // user loops back into the broken state.
186
+ const claims = parseClaimsChallengeFromWwwAuthenticate(retried.headers.get("www-authenticate"));
187
+ retried.body?.cancel().catch(() => undefined);
188
+ const reason = new AuthInteractionRequiredError("API returned 401 after force-refresh retry", claims ? { claims } : undefined);
189
+ // ``recoverFromHardAuthFailure`` throws once the redirect is in
190
+ // flight; the throw stops the calling pipeline so we don't
191
+ // return a stale 401 the caller might handle as a real failure.
192
+ await options.recoverFromHardAuthFailure(reason);
193
+ }
194
+ return retried;
195
+ };
196
+ }
197
+ /**
198
+ * Error class the fetch interceptor raises (and forwards through
199
+ * ``recoverFromHardAuthFailure``) when a 401 needs interactive
200
+ * recovery. Exposed so the auth-layer recovery can pick up a
201
+ * ``claims`` field if one was extracted from the
202
+ * ``WWW-Authenticate`` header.
203
+ */
204
+ export class AuthInteractionRequiredError extends Error {
205
+ constructor(message, options) {
206
+ super(message);
207
+ this.name = "AuthInteractionRequiredError";
208
+ this.claims = options?.claims;
209
+ }
210
+ }
211
+ /**
212
+ * Extract the claims challenge string from a resource server's
213
+ * ``WWW-Authenticate: Bearer ... claims="…"`` header. Returns
214
+ * ``undefined`` when the header is missing, malformed, or carries
215
+ * no claims directive.
216
+ *
217
+ * The value is forwarded VERBATIM to MSAL's
218
+ * ``acquireTokenRedirect({ claims })``; MSAL handles the
219
+ * base64-url decode + JSON parse itself. We don't try to validate
220
+ * the inner shape — letting Entra speak for itself avoids drift if
221
+ * the schema evolves.
222
+ *
223
+ * Spec: RFC 6750 ``WWW-Authenticate`` + CAE claims-challenge
224
+ * supplement (Microsoft Identity Platform docs).
225
+ */
226
+ export function parseClaimsChallengeFromWwwAuthenticate(header) {
227
+ if (!header)
228
+ return undefined;
229
+ // ``WWW-Authenticate`` can carry multiple challenges separated by
230
+ // commas (e.g. ``Basic realm="x", Bearer ...``). We only care
231
+ // about the Bearer challenge.
232
+ const bearer = header.match(/Bearer\s+([^,]+(?:,(?!\s*[A-Za-z]+\s)[^,]*)*)/i);
233
+ if (!bearer)
234
+ return undefined;
235
+ // Inside the Bearer params, find ``claims="..."`` (quoted) or
236
+ // ``claims=token68`` (unquoted, base64url).
237
+ const quoted = bearer[1].match(/claims\s*=\s*"([^"]*)"/i);
238
+ if (quoted)
239
+ return quoted[1].length > 0 ? quoted[1] : undefined;
240
+ const unquoted = bearer[1].match(/claims\s*=\s*([A-Za-z0-9_\-+/=]+)/i);
241
+ if (unquoted)
242
+ return unquoted[1].length > 0 ? unquoted[1] : undefined;
243
+ return undefined;
244
+ }
@@ -0,0 +1,7 @@
1
+ export { AGUIRunner, type AGUIRunnerOptions, type AGUIRunInput } from "./client/agui-runner.js";
2
+ export type { RunnerEvent } from "./client/runner-events.js";
3
+ export { createServiceFetch, type ServiceFetch, type ServiceFetchOptions, } from "./client/service-fetch.js";
4
+ export { clientToolRegistry, type ClientToolEntry, type ToolRegistry } from "./tools/registry.js";
5
+ export { streamingStatusStore, type StreamingStatus } from "./store/streaming-status-store.js";
6
+ export { citationStore, type CitationResult, type CitationHandler, } from "./store/citation-store.js";
7
+ export { linkStore, resolveLinkHandler, type LinkHandler, type ResolvedLinkHandler, } from "./store/link-store.js";
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ // --- AG-UI runner ---
2
+ export { AGUIRunner } from "./client/agui-runner.js";
3
+ // --- Service fetch factory ---
4
+ export { createServiceFetch, } from "./client/service-fetch.js";
5
+ // --- Tool registry ---
6
+ export { clientToolRegistry } from "./tools/registry.js";
7
+ // --- Stores (vanilla) ---
8
+ export { streamingStatusStore } from "./store/streaming-status-store.js";
9
+ export { citationStore, } from "./store/citation-store.js";
10
+ export { linkStore, resolveLinkHandler, } from "./store/link-store.js";
@@ -0,0 +1,91 @@
1
+ /**
2
+ * MSAL configuration types and PublicClientApplication singleton.
3
+ *
4
+ * `initializeMsal()` creates and caches the singleton PublicClientApplication.
5
+ * `getMsalInstance()` returns null until initialization has completed.
6
+ *
7
+ * `@azure/msal-browser` is loaded via dynamic import so `@iloveagents/foundry-agent`
8
+ * does not pull MSAL into bundles that don't need it.
9
+ */
10
+ export interface MsalAuthConfig {
11
+ clientId: string;
12
+ authority: string;
13
+ redirectUri: string;
14
+ apiScope: string;
15
+ postLogoutRedirectUri?: string;
16
+ }
17
+ export interface MsalAccountInfo {
18
+ username: string;
19
+ localAccountId: string;
20
+ name?: string | null;
21
+ }
22
+ export interface MsalClientApplication {
23
+ initialize(): Promise<void>;
24
+ handleRedirectPromise(): Promise<unknown>;
25
+ clearCache(request?: {
26
+ account?: MsalAccountInfo;
27
+ }): Promise<void>;
28
+ getAllAccounts(): MsalAccountInfo[];
29
+ loginRedirect(request: {
30
+ scopes: string[];
31
+ prompt?: string;
32
+ /** Same claims-challenge passthrough as ``acquireTokenRedirect``. */
33
+ claims?: string;
34
+ }): Promise<void>;
35
+ logoutRedirect(): Promise<void>;
36
+ setActiveAccount(account: MsalAccountInfo | null): void;
37
+ acquireTokenSilent(request: {
38
+ scopes: string[];
39
+ account: MsalAccountInfo;
40
+ /**
41
+ * Skip MSAL's local cache and force a round-trip to the token
42
+ * endpoint using the cached refresh token. The fetch interceptor
43
+ * uses this on a 401 retry — the first attempt may have served a
44
+ * cached access token MSAL still thought valid (within
45
+ * ``tokenRenewalOffsetSeconds``) that the resource server has
46
+ * since rejected (claims challenge, conditional access re-eval,
47
+ * audience drift).
48
+ */
49
+ forceRefresh?: boolean;
50
+ }): Promise<{
51
+ accessToken: string;
52
+ }>;
53
+ acquireTokenRedirect(request: {
54
+ scopes: string[];
55
+ account: MsalAccountInfo;
56
+ /**
57
+ * Force a fresh interactive login regardless of Entra SSO state.
58
+ * Without ``prompt: "login"``, when the user still has a live
59
+ * Entra session, the redirect silently round-trips and returns
60
+ * THE SAME stale token / claims — leaving the SPA back in the
61
+ * exact broken state we tried to recover from. See
62
+ * https://learn.microsoft.com/entra/identity-platform/msal-error-handling-js
63
+ * "Hard expiry / silent redirect loop" pattern.
64
+ */
65
+ prompt?: string;
66
+ /**
67
+ * Optional Continuous Access Evaluation / Conditional Access
68
+ * claims challenge payload, parsed from a resource-server 401
69
+ * response's ``WWW-Authenticate: Bearer ... claims="..."``
70
+ * header. When present, MSAL passes it through to Entra so the
71
+ * issued token explicitly satisfies the challenge (MFA step-up,
72
+ * device-compliance refresh, revocation invalidation, etc.).
73
+ */
74
+ claims?: string;
75
+ }): Promise<void>;
76
+ acquireTokenPopup(request: {
77
+ scopes: string[];
78
+ account: MsalAccountInfo;
79
+ }): Promise<{
80
+ accessToken: string;
81
+ }>;
82
+ }
83
+ /**
84
+ * Initialize the MSAL singleton. Must be called once before rendering.
85
+ * Dynamically imports `@azure/msal-browser` so the dependency stays optional.
86
+ */
87
+ export declare function initializeMsal(config: MsalAuthConfig): Promise<MsalClientApplication>;
88
+ /** Get the MSAL instance (null when not configured). */
89
+ export declare function getMsalInstance(): MsalClientApplication | null;
90
+ /** Get the MSAL auth config (null when not configured). */
91
+ export declare function getMsalConfig(): MsalAuthConfig | null;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * MSAL configuration types and PublicClientApplication singleton.
3
+ *
4
+ * `initializeMsal()` creates and caches the singleton PublicClientApplication.
5
+ * `getMsalInstance()` returns null until initialization has completed.
6
+ *
7
+ * `@azure/msal-browser` is loaded via dynamic import so `@iloveagents/foundry-agent`
8
+ * does not pull MSAL into bundles that don't need it.
9
+ */
10
+ let msalInstance = null;
11
+ let msalConfig = null;
12
+ /**
13
+ * Initialize the MSAL singleton. Must be called once before rendering.
14
+ * Dynamically imports `@azure/msal-browser` so the dependency stays optional.
15
+ */
16
+ export async function initializeMsal(config) {
17
+ if (msalInstance)
18
+ return msalInstance;
19
+ const { PublicClientApplication: PCA } = await import("@azure/msal-browser");
20
+ // Token-renewal config — see Microsoft Learn:
21
+ // https://learn.microsoft.com/entra/msal/javascript/browser/errors
22
+ //
23
+ // SPA refresh tokens are 24 h, non-sliding, non-renewable. After that
24
+ // window the user MUST re-auth; nothing the SPA can do silently saves
25
+ // it. Goal of these knobs is to make the unavoidable interactive
26
+ // bounce predictable and to keep the silent path healthy in between.
27
+ const msalConfiguration = {
28
+ auth: {
29
+ clientId: config.clientId,
30
+ authority: config.authority,
31
+ redirectUri: config.redirectUri,
32
+ postLogoutRedirectUri: config.postLogoutRedirectUri ?? config.redirectUri,
33
+ },
34
+ cache: {
35
+ // localStorage is required for Playwright E2E tests — sessionStorage
36
+ // is not preserved across page navigations in the Playwright context.
37
+ cacheLocation: "localStorage",
38
+ // Cache key includes a hash of any `claims` parameter. Without
39
+ // this, MSAL serves the same cached access token even after a
40
+ // claims-challenge / token revocation / role change. The MSAL
41
+ // team has signalled this will become the default; opt in early.
42
+ claimsBasedCachingEnabled: true,
43
+ },
44
+ system: {
45
+ // Treat access tokens as "expired" 10 min before the actual exp
46
+ // claim instead of MSAL's default 5 min. Eliminates the race
47
+ // where the SPA's clock thinks the token is still valid but the
48
+ // resource server rejects it as expired (clock skew, slow request
49
+ // queueing, etc.).
50
+ tokenRenewalOffsetSeconds: 600,
51
+ // Default 6 s is too tight on modern browsers — third-party
52
+ // storage partitioning + slower CPUs in the silent iframe can
53
+ // push the round-trip past it. 10 s is the value MSAL Angular
54
+ // and React samples ship with.
55
+ iframeHashTimeout: 10000,
56
+ },
57
+ };
58
+ msalInstance = new PCA(msalConfiguration);
59
+ await msalInstance.initialize();
60
+ msalConfig = config;
61
+ return msalInstance;
62
+ }
63
+ /** Get the MSAL instance (null when not configured). */
64
+ export function getMsalInstance() {
65
+ return msalInstance;
66
+ }
67
+ /** Get the MSAL auth config (null when not configured). */
68
+ export function getMsalConfig() {
69
+ return msalConfig;
70
+ }
@@ -0,0 +1,95 @@
1
+ export interface AuthUser {
2
+ name: string;
3
+ email: string;
4
+ avatar?: string;
5
+ oid?: string;
6
+ }
7
+ export declare class AuthInteractionRequiredError extends Error {
8
+ readonly code?: string;
9
+ readonly cause?: unknown;
10
+ constructor(message: string, options?: {
11
+ code?: string;
12
+ cause?: unknown;
13
+ });
14
+ }
15
+ interface AuthState {
16
+ user: AuthUser | null;
17
+ isAuthenticated: boolean;
18
+ /** Sign in with the given user. Called by AuthProvider after MSAL login. */
19
+ signIn: (user: AuthUser) => void;
20
+ /** Sign out and clear user state. Triggers MSAL logout when configured. */
21
+ signOut: () => void;
22
+ /**
23
+ * Acquire an access token for the given audience.
24
+ * Returns null when MSAL is not configured (local dev).
25
+ *
26
+ * Recovery semantics — follows the canonical MSAL.js pattern documented
27
+ * at https://learn.microsoft.com/entra/msal/javascript/browser/errors:
28
+ *
29
+ * 1. ``acquireTokenSilent`` first. Pass-through on success.
30
+ * 2. On a recoverable error (``InteractionRequiredAuthError``,
31
+ * ``monitor_window_timeout`` from blocked silent-SSO iframes,
32
+ * ``login_required`` / ``consent_required``), call
33
+ * :func:`startInteractiveRecovery` to navigate the user through a
34
+ * fresh interactive auth.
35
+ *
36
+ * Recovery uses ``acquireTokenRedirect`` (NOT ``loginRedirect``) as
37
+ * the first step — that's the documented primitive for refreshing a
38
+ * known account's tokens without forcing a full re-login. Falls back
39
+ * to ``loginRedirect`` only when ``acquireTokenRedirect`` itself
40
+ * fails to navigate (browser policy, popup blocker, etc.).
41
+ *
42
+ * Critically: cache is NEVER cleared before the redirect. The
43
+ * previous version cleared the account record before calling
44
+ * ``loginRedirect``; when the redirect failed silently (browser
45
+ * blocking the navigation), the account record was gone but the
46
+ * tokens lingered, leaving every subsequent API call to find
47
+ * ``getAllAccounts() === []``, return null, and ship anonymously
48
+ * to the API — endless 401s, only manual ``localStorage.clear()``
49
+ * recovers. This bug is documented in
50
+ * AzureAD/microsoft-authentication-library-for-js#7551.
51
+ *
52
+ * Recoverable auth failures reject with ``AuthInteractionRequiredError``
53
+ * after recovery has been started. The error exposes ``code`` and
54
+ * ``cause`` so callers can distinguish a normal interaction-required
55
+ * redirect from a blocked/failed redirect attempt.
56
+ *
57
+ * Other ``BrowserAuthError`` codes (``hash_empty_error``,
58
+ * ``hash_does_not_contain_known_properties``, ``block_iframe_reload``)
59
+ * are config / race-condition bugs that another redirect won't fix —
60
+ * propagate them as null without navigating.
61
+ *
62
+ * The "no account in cache" case is handled by detecting ORPHANED
63
+ * STATE (no accounts but localStorage has MSAL token entries) and
64
+ * cleaning the half-corrupted cache before redirecting. Without this,
65
+ * the page renders authenticated UI but every API call goes anonymous.
66
+ *
67
+ * Errors are matched by ``name`` / ``errorCode`` rather than
68
+ * ``instanceof`` because ``@azure/msal-browser`` is loaded via
69
+ * dynamic import; the error class identity isn't shared across
70
+ * module boundaries.
71
+ *
72
+ * ``forceRefresh: true`` skips MSAL's local cache and goes back to the
73
+ * token endpoint with the cached refresh token. Use it from the
74
+ * fetch interceptor when a protected API returns 401 — the first
75
+ * attempt may have used a stale cached access token (claims
76
+ * challenge, conditional-access re-eval, audience drift, etc.) that
77
+ * MSAL still considered valid against its own clock.
78
+ */
79
+ getAccessToken: (audience?: "api" | "spaces", options?: {
80
+ forceRefresh?: boolean;
81
+ }) => Promise<string | null>;
82
+ /**
83
+ * Force interactive recovery: start an ``acquireTokenRedirect`` (or
84
+ * ``loginRedirect`` fallback). Use this from the fetch interceptor
85
+ * when even a force-refreshed access token still gets rejected by
86
+ * the resource server (the silent path can't tell us "this is
87
+ * fundamentally the wrong token" — it has to come from the protected
88
+ * API saying 401 after we already retried). Throws
89
+ * ``AuthInteractionRequiredError`` once the redirect has been kicked
90
+ * off so the caller can stop processing.
91
+ */
92
+ recoverFromHardAuthFailure: (reason: unknown) => Promise<never>;
93
+ }
94
+ export declare const authStore: import("zustand/vanilla").StoreApi<AuthState>;
95
+ export {};