@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
@@ -1,517 +0,0 @@
1
- import { createStore } from "zustand/vanilla";
2
- import { getMsalInstance, getMsalConfig } from "./auth-config.ts";
3
- import type { MsalAccountInfo, MsalClientApplication } from "./auth-config.ts";
4
-
5
- export interface AuthUser {
6
- name: string;
7
- email: string;
8
- avatar?: string;
9
- oid?: string;
10
- }
11
-
12
- export class AuthInteractionRequiredError extends Error {
13
- readonly code?: string;
14
- readonly cause?: unknown;
15
-
16
- constructor(message: string, options: { code?: string; cause?: unknown } = {}) {
17
- super(message);
18
- this.name = "AuthInteractionRequiredError";
19
- this.code = options.code;
20
- this.cause = options.cause;
21
- }
22
- }
23
-
24
- interface AuthState {
25
- user: AuthUser | null;
26
- isAuthenticated: boolean;
27
-
28
- /** Sign in with the given user. Called by AuthProvider after MSAL login. */
29
- signIn: (user: AuthUser) => void;
30
-
31
- /** Sign out and clear user state. Triggers MSAL logout when configured. */
32
- signOut: () => void;
33
-
34
- /**
35
- * Acquire an access token for the given audience.
36
- * Returns null when MSAL is not configured (local dev).
37
- *
38
- * Recovery semantics — follows the canonical MSAL.js pattern documented
39
- * at https://learn.microsoft.com/entra/msal/javascript/browser/errors:
40
- *
41
- * 1. ``acquireTokenSilent`` first. Pass-through on success.
42
- * 2. On a recoverable error (``InteractionRequiredAuthError``,
43
- * ``monitor_window_timeout`` from blocked silent-SSO iframes,
44
- * ``login_required`` / ``consent_required``), call
45
- * :func:`startInteractiveRecovery` to navigate the user through a
46
- * fresh interactive auth.
47
- *
48
- * Recovery uses ``acquireTokenRedirect`` (NOT ``loginRedirect``) as
49
- * the first step — that's the documented primitive for refreshing a
50
- * known account's tokens without forcing a full re-login. Falls back
51
- * to ``loginRedirect`` only when ``acquireTokenRedirect`` itself
52
- * fails to navigate (browser policy, popup blocker, etc.).
53
- *
54
- * Critically: cache is NEVER cleared before the redirect. The
55
- * previous version cleared the account record before calling
56
- * ``loginRedirect``; when the redirect failed silently (browser
57
- * blocking the navigation), the account record was gone but the
58
- * tokens lingered, leaving every subsequent API call to find
59
- * ``getAllAccounts() === []``, return null, and ship anonymously
60
- * to the API — endless 401s, only manual ``localStorage.clear()``
61
- * recovers. This bug is documented in
62
- * AzureAD/microsoft-authentication-library-for-js#7551.
63
- *
64
- * Recoverable auth failures reject with ``AuthInteractionRequiredError``
65
- * after recovery has been started. The error exposes ``code`` and
66
- * ``cause`` so callers can distinguish a normal interaction-required
67
- * redirect from a blocked/failed redirect attempt.
68
- *
69
- * Other ``BrowserAuthError`` codes (``hash_empty_error``,
70
- * ``hash_does_not_contain_known_properties``, ``block_iframe_reload``)
71
- * are config / race-condition bugs that another redirect won't fix —
72
- * propagate them as null without navigating.
73
- *
74
- * The "no account in cache" case is handled by detecting ORPHANED
75
- * STATE (no accounts but localStorage has MSAL token entries) and
76
- * cleaning the half-corrupted cache before redirecting. Without this,
77
- * the page renders authenticated UI but every API call goes anonymous.
78
- *
79
- * Errors are matched by ``name`` / ``errorCode`` rather than
80
- * ``instanceof`` because ``@azure/msal-browser`` is loaded via
81
- * dynamic import; the error class identity isn't shared across
82
- * module boundaries.
83
- *
84
- * ``forceRefresh: true`` skips MSAL's local cache and goes back to the
85
- * token endpoint with the cached refresh token. Use it from the
86
- * fetch interceptor when a protected API returns 401 — the first
87
- * attempt may have used a stale cached access token (claims
88
- * challenge, conditional-access re-eval, audience drift, etc.) that
89
- * MSAL still considered valid against its own clock.
90
- */
91
- getAccessToken: (
92
- audience?: "api" | "spaces",
93
- options?: { forceRefresh?: boolean },
94
- ) => Promise<string | null>;
95
-
96
- /**
97
- * Force interactive recovery: start an ``acquireTokenRedirect`` (or
98
- * ``loginRedirect`` fallback). Use this from the fetch interceptor
99
- * when even a force-refreshed access token still gets rejected by
100
- * the resource server (the silent path can't tell us "this is
101
- * fundamentally the wrong token" — it has to come from the protected
102
- * API saying 401 after we already retried). Throws
103
- * ``AuthInteractionRequiredError`` once the redirect has been kicked
104
- * off so the caller can stop processing.
105
- */
106
- recoverFromHardAuthFailure: (reason: unknown) => Promise<never>;
107
- }
108
-
109
- /** Recoverable error codes per MSAL.js docs — every one of these has the
110
- * documented remedy "invoke an interactive API". */
111
- const RECOVERABLE_ERROR_CODES = new Set([
112
- // InteractionRequiredAuthError — canonical fallback case.
113
- "interaction_required",
114
- "login_required",
115
- "consent_required",
116
- // BrowserAuthError: monitor_window_timeout. Documented remedy includes
117
- // "Invoke an interactive API" (Microsoft Learn → "Common errors in
118
- // MSAL JS" → monitor_window_timeout → "Throttling" + "X-Frame-Options
119
- // Deny"). Real-world trigger on Chrome 120+ is third-party-iframe
120
- // storage partitioning blocking the silent SSO frame.
121
- "monitor_window_timeout",
122
- ]);
123
-
124
- /** `InteractionRequiredAuthError` always triggers the redirect — match by
125
- * class name as a fallback when the error code isn't set. */
126
- const INTERACTION_REQUIRED_NAME = "InteractionRequiredAuthError";
127
-
128
- /** Module-level deduplication: when N parallel API calls all trip the
129
- * recovery path simultaneously, the second-onwards must reuse the first
130
- * one's promise rather than each issuing their own redirect. Without
131
- * this, MSAL throws ``interaction_in_progress`` on N-1 of them and the
132
- * cascading errors mask the actual recovery state. Mirrors the pattern
133
- * in the msal-react ``useMsalAuthentication`` hook + the production
134
- * patterns linked from Microsoft Learn's "Common errors" guide. */
135
- let activeRecoveryPromise: Promise<never> | null = null;
136
-
137
- function isRecoverableAuthError(err: unknown): boolean {
138
- if (!err || typeof err !== "object") return false;
139
- const name = (err as { name?: string }).name ?? "";
140
- const code = (err as { errorCode?: string }).errorCode ?? "";
141
- return name === INTERACTION_REQUIRED_NAME || RECOVERABLE_ERROR_CODES.has(code);
142
- }
143
-
144
- function authErrorName(err: unknown): string | undefined {
145
- return err && typeof err === "object" ? (err as { name?: string }).name : undefined;
146
- }
147
-
148
- function authErrorCode(err: unknown): string | undefined {
149
- return err && typeof err === "object" ? (err as { errorCode?: string }).errorCode : undefined;
150
- }
151
-
152
- function authErrorMessage(err: unknown): string | undefined {
153
- if (err instanceof Error) return err.message;
154
- return err && typeof err === "object" ? (err as { message?: string }).message : undefined;
155
- }
156
-
157
- function describeAuthError(err: unknown): string {
158
- const parts = [
159
- authErrorName(err) ? `name=${authErrorName(err)}` : undefined,
160
- authErrorCode(err) ? `code=${authErrorCode(err)}` : undefined,
161
- authErrorMessage(err) ? `message=${authErrorMessage(err)}` : undefined,
162
- ].filter(Boolean);
163
- return parts.join(", ");
164
- }
165
-
166
- function createInteractionRequiredError(
167
- tokenError: unknown,
168
- redirectError?: unknown,
169
- ): AuthInteractionRequiredError {
170
- const tokenDetail = describeAuthError(tokenError) || "unknown token acquisition error";
171
- const redirectDetail = redirectError ? describeAuthError(redirectError) : "";
172
-
173
- if (redirectError) {
174
- return new AuthInteractionRequiredError(
175
- `Authentication interaction required, but redirect failed (${redirectDetail}). Original token error: ${tokenDetail}.`,
176
- {
177
- code: authErrorCode(redirectError) ?? authErrorCode(tokenError),
178
- cause: redirectError,
179
- },
180
- );
181
- }
182
-
183
- return new AuthInteractionRequiredError(
184
- `Authentication interaction required after token acquisition failed (${tokenDetail}).`,
185
- {
186
- code: authErrorCode(tokenError),
187
- cause: tokenError,
188
- },
189
- );
190
- }
191
-
192
- /** Storage probe: does the browser have orphaned MSAL token entries with
193
- * no matching account record? That state is the "stuck on 401 forever"
194
- * signature — getAllAccounts() returns [] but the SPA still has tokens
195
- * lingering from a previous session that recovery half-cleaned. Returns
196
- * the list of MSAL keys to nuke; empty array means cache is coherent. */
197
- function findOrphanedMsalKeys(): string[] {
198
- if (typeof window === "undefined" || !window.localStorage) return [];
199
- const allKeys: string[] = [];
200
- for (let i = 0; i < window.localStorage.length; i++) {
201
- const k = window.localStorage.key(i);
202
- if (k && k.startsWith("msal.")) allKeys.push(k);
203
- }
204
- const tokenKeys = allKeys.filter((k) =>
205
- /\|(?:access|refresh|id)token\|/.test(k),
206
- );
207
- // No tokens = clean (or never logged in). Tokens AND empty
208
- // getAllAccounts means orphaned — caller is the only one who knows
209
- // the accounts state, so caller decides whether to nuke.
210
- return tokenKeys.length > 0 ? allKeys : [];
211
- }
212
-
213
- function nukeMsalLocalStorage(): void {
214
- if (typeof window === "undefined") return;
215
- for (const store of [window.localStorage, window.sessionStorage]) {
216
- if (!store) continue;
217
- const toRemove: string[] = [];
218
- for (let i = 0; i < store.length; i++) {
219
- const k = store.key(i);
220
- if (k && k.startsWith("msal.")) toRemove.push(k);
221
- }
222
- toRemove.forEach((k) => store.removeItem(k));
223
- }
224
- }
225
-
226
- async function startInteractiveRecovery(
227
- msal: MsalClientApplication,
228
- account: MsalAccountInfo | null,
229
- scope: string,
230
- reason: unknown,
231
- ): Promise<never> {
232
- // Deduplicate concurrent recovery attempts. Multiple in-flight API
233
- // calls can simultaneously detect a stale token and trip recovery;
234
- // without this, MSAL throws ``interaction_in_progress`` on every
235
- // call after the first.
236
- if (activeRecoveryPromise) {
237
- return activeRecoveryPromise;
238
- }
239
-
240
- // Extract a claims challenge from the failure reason (if any).
241
- // Production scenarios that surface here with a payload:
242
- // * Conditional Access re-eval (MFA step-up required).
243
- // * Continuous Access Evaluation revocation.
244
- // * Device-compliance change mid-session.
245
- // The service-fetch interceptor parses WWW-Authenticate when it
246
- // can and stamps the payload onto the recovery reason — we pass
247
- // it through to MSAL so the next token explicitly satisfies the
248
- // challenge. Without this, Entra would silently re-issue the same
249
- // already-rejected claims set and we'd loop right back.
250
- const claims = extractClaimsChallenge(reason);
251
-
252
- activeRecoveryPromise = (async () => {
253
- let redirectError: unknown;
254
- authStore.setState({ user: null, isAuthenticated: false });
255
-
256
- // Step 0: strip a lingering MSAL error hash from the URL before
257
- // attempting any redirect. MSAL.js's redirect primitives refuse
258
- // to navigate with ``BrowserAuthError: block_iframe_reload`` when
259
- // the current page URL still carries a previous silent-auth
260
- // failure in its fragment (e.g.
261
- // ``#error=interaction_required&error_description=AADSTS160021…``
262
- // from an earlier ``handleRedirectPromise`` that surfaced
263
- // "user session does not exist"). The SDK's own anti-loop guard
264
- // sees the unconsumed error and aborts — leaving the user
265
- // permanently stuck unless they manually clear browser state.
266
- //
267
- // We can defuse that guard cleanly by replacing the URL with a
268
- // fragment-less copy via ``history.replaceState`` before the
269
- // redirect call. MSAL then sees a "clean" page and proceeds.
270
- // The original page state (path + search) is preserved so the
271
- // user lands back where they started after re-auth.
272
- //
273
- // Reference:
274
- // https://aka.ms/msal.js.errors#block_iframe_reload
275
- // https://github.com/AzureAD/microsoft-authentication-library-for-js/issues/5623
276
- stripStaleMsalErrorHash();
277
-
278
- // Step 1: surgically evict the bad account record from MSAL's
279
- // own token cache BEFORE redirecting. Two reasons:
280
- //
281
- // (a) The bad refresh token / claims set is what's causing the
282
- // hard-auth failure. Leaving it in cache means MSAL's
283
- // acquireTokenSilent (called by every other in-flight or
284
- // post-redirect request) hands out the SAME poisoned token
285
- // until the entry naturally expires.
286
- // (b) Microsoft's "orphaned-state" anti-pattern is about
287
- // clearing AFTER initiating a redirect (i.e. clearing while
288
- // MSAL is mid-flight). Clearing the SPECIFIC account
289
- // BEFORE the redirect is the documented pattern — see
290
- // https://github.com/AzureAD/microsoft-authentication-library-for-js/issues/6840
291
- // and the SDK's own ``IPublicClientApplication.clearCache``
292
- // contract which accepts ``{ account }``.
293
- //
294
- // Wrapped in a try/catch so a cache-removal hiccup doesn't
295
- // strand the user without ever attempting the redirect.
296
- if (account) {
297
- try {
298
- await msal.clearCache({ account });
299
- } catch (err) {
300
- // Telemetry only — keep flowing into the redirect path.
301
- // eslint-disable-next-line no-console
302
- console.warn(
303
- "[msal-recovery] clearCache(account) failed; continuing with redirect",
304
- err,
305
- );
306
- }
307
- }
308
-
309
- // Step 2: ``acquireTokenRedirect`` with ``prompt: "login"`` —
310
- // forces a fresh interactive authentication even if Entra still
311
- // has a live SSO session for the user. Without ``prompt: "login"``
312
- // the redirect silently round-trips through Entra and returns a
313
- // token built from the SAME state we just tried to escape from
314
- // (this is the "silent redirect loop" users actually experience:
315
- // the page bounces to login.microsoftonline.com and back, still
316
- // broken). The full re-auth path produces a fresh refresh-token
317
- // + fresh claims bound to current server policy.
318
- if (account) {
319
- try {
320
- await msal.acquireTokenRedirect({
321
- scopes: [scope],
322
- account,
323
- prompt: "login",
324
- ...(claims ? { claims } : {}),
325
- });
326
- // If we reach here without navigating, MSAL settled the
327
- // promise before browser navigation kicked in (test envs,
328
- // blocked redirects, etc.). Fall through to loginRedirect.
329
- } catch (err) {
330
- redirectError = err;
331
- }
332
- }
333
-
334
- // Step 3: full ``loginRedirect`` fallback. Same ``prompt: "login"``
335
- // contract so even this path can't loop back into broken state.
336
- // Used when:
337
- // * No account was passed (orphaned-state caller already
338
- // wiped localStorage upstream).
339
- // * acquireTokenRedirect threw a non-navigation error.
340
- // * acquireTokenRedirect settled without navigating.
341
- try {
342
- await msal.loginRedirect({
343
- scopes: [scope],
344
- prompt: "login",
345
- ...(claims ? { claims } : {}),
346
- });
347
- } catch (err) {
348
- redirectError = err;
349
- }
350
-
351
- throw createInteractionRequiredError(reason, redirectError);
352
- })();
353
-
354
- try {
355
- return await activeRecoveryPromise;
356
- } finally {
357
- activeRecoveryPromise = null;
358
- }
359
- }
360
-
361
- /**
362
- * Pull a CAE / Conditional-Access claims-challenge payload out of a
363
- * recovery-trigger error. Two shapes are produced upstream:
364
- *
365
- * * The fetch interceptor (``service-fetch``) parses the
366
- * ``WWW-Authenticate`` header on a 401 response and constructs
367
- * an error whose ``claims`` (or ``cause.claims``) field carries
368
- * the base64 payload it found.
369
- * * MSAL.js's own ``InteractionRequiredAuthError`` instances expose
370
- * ``claims`` directly when Azure returned one.
371
- *
372
- * Return ``undefined`` when none is present — callers should NOT
373
- * pass an empty string to MSAL (the SDK treats that as "ignore" but
374
- * older versions choke on the empty key).
375
- */
376
- /**
377
- * Replace the page's URL fragment with an empty one when it carries
378
- * a leftover MSAL/Entra error response (``#error=…``,
379
- * ``#error_description=…``, ``#error_code=…``, or
380
- * ``#error_uri=…``). Preserves path + query string so the user
381
- * lands back on the same page after the imminent recovery redirect.
382
- *
383
- * Why this is necessary:
384
- * MSAL.js's redirect primitives include an anti-loop guard that
385
- * refuses to navigate when the current URL fragment encodes a
386
- * prior auth error — the SDK raises ``block_iframe_reload``
387
- * instead of redirecting. In production we hit this when an
388
- * earlier ``acquireTokenSilent`` had its hidden iframe receive an
389
- * ``AADSTS160021: Application requested a user session which does
390
- * not exist`` response, MSAL surfaced it via
391
- * ``handleRedirectPromise`` but left the fragment intact.
392
- * Subsequent recovery redirects all aborted with
393
- * ``block_iframe_reload`` and the user was stuck until they
394
- * manually cleared browser storage.
395
- *
396
- * This helper is a no-op on non-browser environments and a no-op
397
- * when no error fragment is present.
398
- */
399
- function stripStaleMsalErrorHash(): void {
400
- if (typeof window === "undefined" || typeof history === "undefined") return;
401
- const hash = window.location.hash || "";
402
- if (!hash) return;
403
- // ``hash`` is e.g. ``#error=interaction_required&error_description=…``.
404
- // We look for any of the error-shaped keys MSAL emits.
405
- if (!/(?:^|[#&])error(?:_description|_code|_uri)?=/.test(hash)) return;
406
- try {
407
- const cleanUrl = window.location.pathname + window.location.search;
408
- history.replaceState(null, "", cleanUrl);
409
- } catch {
410
- // Some embedded environments lock down history.replaceState
411
- // (sandboxed iframes etc.). If that happens we still try the
412
- // redirect — MSAL may or may not succeed; nothing we can do.
413
- }
414
- }
415
-
416
- function extractClaimsChallenge(reason: unknown): string | undefined {
417
- if (!reason || typeof reason !== "object") return undefined;
418
- const direct = (reason as { claims?: unknown }).claims;
419
- if (typeof direct === "string" && direct.length > 0) return direct;
420
- const cause = (reason as { cause?: unknown }).cause;
421
- if (cause && typeof cause === "object") {
422
- const fromCause = (cause as { claims?: unknown }).claims;
423
- if (typeof fromCause === "string" && fromCause.length > 0) {
424
- return fromCause;
425
- }
426
- }
427
- return undefined;
428
- }
429
-
430
- export const authStore = createStore<AuthState>((set) => ({
431
- user: null,
432
- isAuthenticated: false,
433
-
434
- signIn: (user) => set({ user, isAuthenticated: true }),
435
-
436
- signOut: () => {
437
- set({ user: null, isAuthenticated: false });
438
- const msal = getMsalInstance();
439
- if (msal) {
440
- msal.logoutRedirect();
441
- }
442
- },
443
-
444
- getAccessToken: async (_audience = "api", options) => {
445
- const msal = getMsalInstance();
446
- const config = getMsalConfig();
447
- if (!msal || !config) return null;
448
-
449
- const accounts = msal.getAllAccounts();
450
- const scope = config.apiScope;
451
-
452
- // Orphaned-state recovery: ``getAllAccounts()`` returns [] BUT
453
- // localStorage still holds MSAL token entries. That happens when a
454
- // previous recovery attempt cleared the account record but its
455
- // redirect didn't navigate (browser policy, popup blocker, async
456
- // race). The SPA renders "logged in" but every API call goes
457
- // anonymous. Nuke the orphans + force a fresh redirect so the
458
- // user gets unstuck without manually clearing browser storage.
459
- if (accounts.length === 0) {
460
- const orphans = findOrphanedMsalKeys();
461
- if (orphans.length > 0) {
462
- nukeMsalLocalStorage();
463
- return startInteractiveRecovery(
464
- msal,
465
- null,
466
- scope,
467
- new Error(
468
- `Detected orphaned MSAL state (${orphans.length} cache entries with no account record); cleaned up + redirecting`,
469
- ),
470
- );
471
- }
472
- // Truly logged out — ``AuthGuard`` will call ``loginRedirect`` on
473
- // its next render. Don't double-redirect from here.
474
- return null;
475
- }
476
-
477
- const forceRefresh = options?.forceRefresh === true;
478
-
479
- try {
480
- const result = await msal.acquireTokenSilent({
481
- scopes: [scope],
482
- account: accounts[0],
483
- forceRefresh,
484
- });
485
- return result.accessToken;
486
- } catch (err) {
487
- if (isRecoverableAuthError(err)) {
488
- return startInteractiveRecovery(msal, accounts[0], scope, err);
489
- }
490
- return null;
491
- }
492
- },
493
-
494
- recoverFromHardAuthFailure: async (reason) => {
495
- // Fetch interceptor's escape hatch: silent path produced a token
496
- // but the resource server rejected it (audience drift, conditional-
497
- // access re-eval, claims challenge, tenant-policy change, etc.).
498
- // Same recovery primitive as the silent-failure path —
499
- // acquireTokenRedirect → loginRedirect — invoked from the
500
- // resource-server signal rather than an MSAL exception.
501
- const msal = getMsalInstance();
502
- const config = getMsalConfig();
503
- if (!msal || !config) {
504
- throw new AuthInteractionRequiredError(
505
- "Authentication interaction required (no MSAL instance configured).",
506
- { cause: reason },
507
- );
508
- }
509
- const accounts = msal.getAllAccounts();
510
- return startInteractiveRecovery(
511
- msal,
512
- accounts[0] ?? null,
513
- config.apiScope,
514
- reason,
515
- );
516
- },
517
- }));
package/src/msal/index.ts DELETED
@@ -1,14 +0,0 @@
1
- export {
2
- AuthInteractionRequiredError,
3
- authStore,
4
- type AuthUser,
5
- } from "./auth-store.ts";
6
- export {
7
- type MsalAuthConfig,
8
- type MsalAccountInfo,
9
- type MsalClientApplication,
10
- initializeMsal,
11
- getMsalInstance,
12
- getMsalConfig,
13
- } from "./auth-config.ts";
14
- export { tokenFetch } from "./token-fetch.ts";
@@ -1,68 +0,0 @@
1
- /**
2
- * Token-aware fetch wrapper.
3
- *
4
- * Acquires a Bearer token from MSAL (if configured) and attaches it to
5
- * every outgoing request. All requests use the API scope — the API backend
6
- * acts as a gateway and exchanges tokens server-side via OBO when calling
7
- * downstream services (e.g., Spaces).
8
- *
9
- * When MSAL is not configured, behaves identically to native `fetch()`.
10
- *
11
- * Long-lived tab recovery: on 401 the wrapper retries once with
12
- * ``forceRefresh: true`` so the resource server doesn't keep seeing a
13
- * stale-but-cached access token. See ``service-fetch.ts`` for the same
14
- * pattern with URL rewriting + the deeper rationale.
15
- */
16
-
17
- import { authStore } from "./auth-store.ts";
18
-
19
- export async function tokenFetch(
20
- input: string | URL | Request,
21
- init?: RequestInit,
22
- ): Promise<Response> {
23
- async function dispatch(forceRefreshToken: boolean): Promise<Response> {
24
- const token = await authStore
25
- .getState()
26
- .getAccessToken("api", forceRefreshToken ? { forceRefresh: true } : undefined);
27
-
28
- // Clone Request inputs per-attempt: ReadableStream bodies are single-
29
- // consume, so the 401 retry would otherwise see an empty body.
30
- // ``input.clone()`` returns a fresh Request whose body stream is
31
- // independent of the original.
32
- const target = input instanceof Request ? input.clone() : input;
33
-
34
- if (!token) {
35
- return fetch(target, init);
36
- }
37
-
38
- const headers = new Headers(input instanceof Request ? input.headers : undefined);
39
- if (init?.headers) {
40
- new Headers(init.headers).forEach((value, key) => headers.set(key, value));
41
- }
42
- headers.set("Authorization", `Bearer ${token}`);
43
- return fetch(target, { ...init, headers });
44
- }
45
-
46
- const response = await dispatch(false);
47
- if (response.status !== 401) {
48
- return response;
49
- }
50
- response.body?.cancel().catch(() => undefined);
51
- const retried = await dispatch(true);
52
- if (retried.status === 401) {
53
- // Second 401 after a force-refresh retry — the silent token path
54
- // can't recover this (refresh-token grant produces the same
55
- // identity claims that just got rejected). Kick off interactive
56
- // recovery so a fresh ``loginRedirect`` mints a token bound to
57
- // current server policy. ``recoverFromHardAuthFailure`` throws
58
- // once the redirect is in flight so we don't return a stale 401
59
- // the caller might handle as a real failure.
60
- retried.body?.cancel().catch(() => undefined);
61
- await authStore
62
- .getState()
63
- .recoverFromHardAuthFailure(
64
- new Error("API returned 401 after force-refresh retry"),
65
- );
66
- }
67
- return retried;
68
- }
@@ -1,52 +0,0 @@
1
- import { createStore } from "zustand/vanilla";
2
-
3
- /** Citation result shape — matches the search result format. */
4
- export interface CitationResult {
5
- chunk_id: string;
6
- entity_id: string;
7
- entity_name: string;
8
- content: string;
9
- page_number: number;
10
- bounding_regions: string;
11
- /** Semantic reranker score (0-4) or RRF score (~0.03) */
12
- score: number;
13
- }
14
-
15
- /** Handler for citation clicks — registered by feature modules (e.g., SPACES). */
16
- export interface CitationHandler {
17
- openCitation: (result: CitationResult) => void;
18
- }
19
-
20
- interface CitationState {
21
- /** Current search results (from the most recent search tool call) */
22
- results: CitationResult[];
23
- /** Optional handler for opening citations (registered by feature modules) */
24
- handler: CitationHandler | null;
25
- /** Store search results for citation resolution */
26
- setResults: (results: CitationResult[]) => void;
27
- /** Register a handler for citation click actions */
28
- setHandler: (handler: CitationHandler) => void;
29
- /**
30
- * Reset per-conversation state. Clears ``results`` only — the handler
31
- * is module-level state registered once at app boot by feature modules
32
- * (e.g. ``registerSpacesCitationHandler``) and must persist across
33
- * conversations. Wiping it on "New Thread" produced inert ``[n]``
34
- * markers for the rest of the session, since the registration guard
35
- * (``registerOnce`` in spaces ``use-spaces-init``) suppresses re-binding.
36
- */
37
- clear: () => void;
38
- }
39
-
40
- /**
41
- * Vanilla store for the citation cache so `[n]` markers in chat messages
42
- * resolve to clickable deep links. Search tool calls populate results; the
43
- * markdown renderer looks them up by index. A `CitationHandler` (registered
44
- * by feature modules) handles the click action.
45
- */
46
- export const citationStore = createStore<CitationState>((set) => ({
47
- results: [],
48
- handler: null,
49
- setResults: (results) => set({ results }),
50
- setHandler: (handler) => set({ handler }),
51
- clear: () => set({ results: [] }),
52
- }));