@iloveagents/foundry-agent 0.1.0 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/package.json +1 -1
- package/src/__tests__/auth-store.test.ts +133 -1
- package/src/__tests__/link-store.test.ts +48 -0
- package/src/index.ts +8 -9
- package/src/msal/auth-config.ts +25 -0
- package/src/msal/auth-store.ts +75 -11
- package/src/store/link-store.ts +53 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# @iloveagents/foundry-agent
|
|
2
|
+
|
|
3
|
+
## 0.1.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 689d3e9: feat(sidebar): distinct file-drop affordance for nav containers
|
|
8
|
+
|
|
9
|
+
`useNavItemDnd` now exposes `isFileDragOver` separately from `isDragOver`
|
|
10
|
+
so consuming nav items can render a prominent file-drop visual (dashed
|
|
11
|
+
primary outline + soft primary background + Upload icon) when the user
|
|
12
|
+
is dragging native files over the container. Previously file drags
|
|
13
|
+
shared the same subtle ring as entity-move drags, so users couldn't
|
|
14
|
+
tell that a folder accepted external files. Applies to all five nav-
|
|
15
|
+
item shapes (action-row leaf, button leaf, `NestedFolderItem`,
|
|
16
|
+
`CollapsibleNavItem` with children/actions, `NavLink` fallthrough) and
|
|
17
|
+
updates `ContainerDropZone` for visual parity.
|
|
18
|
+
|
|
19
|
+
Also fixes a related regression: the capture-phase
|
|
20
|
+
`onDragEnterCapture` / `onDragOverCapture` handlers were calling
|
|
21
|
+
`e.stopPropagation()`, which short-circuits React's synthetic dispatch
|
|
22
|
+
and prevented the bubble-phase `onDragEnter` (where state actually
|
|
23
|
+
mutates) from running. Removed — `preventDefault()` alone is enough to
|
|
24
|
+
mark the element as droppable.
|
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import { authStore } from "../msal/auth-store.ts";
|
|
3
|
+
import * as authConfig from "../msal/auth-config.ts";
|
|
3
4
|
|
|
4
5
|
const store = () => authStore.getState();
|
|
5
6
|
|
|
@@ -35,3 +36,134 @@ describe("authStore", () => {
|
|
|
35
36
|
expect(store().user?.avatar).toBe("https://example.com/bob.png");
|
|
36
37
|
});
|
|
37
38
|
});
|
|
39
|
+
|
|
40
|
+
describe("authStore.getAccessToken — canonical MSAL.js fallback", () => {
|
|
41
|
+
// Reference: https://learn.microsoft.com/entra/msal/javascript/browser/errors
|
|
42
|
+
const config = {
|
|
43
|
+
clientId: "test-client",
|
|
44
|
+
authority: "https://login.microsoftonline.com/test-tenant",
|
|
45
|
+
redirectUri: "http://localhost:8010",
|
|
46
|
+
apiScope: "api://test-api/access_as_user",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
function mockMsal(overrides: Record<string, unknown> = {}) {
|
|
50
|
+
const account = { username: "alice@example.com", localAccountId: "alice-oid" };
|
|
51
|
+
const mock = {
|
|
52
|
+
initialize: vi.fn().mockResolvedValue(undefined),
|
|
53
|
+
handleRedirectPromise: vi.fn().mockResolvedValue(null),
|
|
54
|
+
getAllAccounts: vi.fn().mockReturnValue([account]),
|
|
55
|
+
loginRedirect: vi.fn().mockResolvedValue(undefined),
|
|
56
|
+
logoutRedirect: vi.fn().mockResolvedValue(undefined),
|
|
57
|
+
setActiveAccount: vi.fn(),
|
|
58
|
+
acquireTokenSilent: vi.fn().mockResolvedValue({ accessToken: "fresh-token" }),
|
|
59
|
+
acquireTokenRedirect: vi.fn().mockResolvedValue(undefined),
|
|
60
|
+
acquireTokenPopup: vi.fn().mockResolvedValue({ accessToken: "popup-token" }),
|
|
61
|
+
...overrides,
|
|
62
|
+
};
|
|
63
|
+
vi.spyOn(authConfig, "getMsalInstance").mockReturnValue(mock);
|
|
64
|
+
vi.spyOn(authConfig, "getMsalConfig").mockReturnValue(config);
|
|
65
|
+
return mock;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
vi.restoreAllMocks();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("returns null without navigating when MSAL isn't configured", async () => {
|
|
73
|
+
vi.spyOn(authConfig, "getMsalInstance").mockReturnValue(null);
|
|
74
|
+
vi.spyOn(authConfig, "getMsalConfig").mockReturnValue(null);
|
|
75
|
+
expect(await store().getAccessToken()).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns the access token on a normal silent acquire", async () => {
|
|
79
|
+
const msal = mockMsal();
|
|
80
|
+
expect(await store().getAccessToken()).toBe("fresh-token");
|
|
81
|
+
expect(msal.acquireTokenSilent).toHaveBeenCalledOnce();
|
|
82
|
+
expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
|
|
83
|
+
expect(msal.loginRedirect).not.toHaveBeenCalled();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("returns null without redirecting when no account is cached", async () => {
|
|
87
|
+
// AuthGuard calls loginRedirect when accounts.length === 0; double-
|
|
88
|
+
// redirecting from getAccessToken would race with AuthGuard.
|
|
89
|
+
const msal = mockMsal({ getAllAccounts: vi.fn().mockReturnValue([]) });
|
|
90
|
+
expect(await store().getAccessToken()).toBeNull();
|
|
91
|
+
expect(msal.acquireTokenSilent).not.toHaveBeenCalled();
|
|
92
|
+
expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
|
|
93
|
+
expect(msal.loginRedirect).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("triggers acquireTokenRedirect on InteractionRequiredAuthError (canonical pattern)", async () => {
|
|
97
|
+
const err = Object.assign(new Error("MFA required"), {
|
|
98
|
+
name: "InteractionRequiredAuthError",
|
|
99
|
+
errorCode: "interaction_required",
|
|
100
|
+
});
|
|
101
|
+
const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
|
|
102
|
+
expect(await store().getAccessToken()).toBeNull();
|
|
103
|
+
expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
|
|
104
|
+
expect(msal.acquireTokenRedirect).toHaveBeenCalledWith({
|
|
105
|
+
scopes: [config.apiScope],
|
|
106
|
+
account: { username: "alice@example.com", localAccountId: "alice-oid" },
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("triggers acquireTokenRedirect on consent_required", async () => {
|
|
111
|
+
const err = Object.assign(new Error("consent required"), {
|
|
112
|
+
name: "InteractionRequiredAuthError",
|
|
113
|
+
errorCode: "consent_required",
|
|
114
|
+
});
|
|
115
|
+
const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
|
|
116
|
+
expect(await store().getAccessToken()).toBeNull();
|
|
117
|
+
expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("triggers acquireTokenRedirect on monitor_window_timeout (third-party-iframe block)", async () => {
|
|
121
|
+
// Microsoft Learn → "Common errors in MSAL JS" → monitor_window_timeout
|
|
122
|
+
// → documented remedy includes "Invoke an interactive API".
|
|
123
|
+
const err = Object.assign(new Error("monitor_window_timeout"), {
|
|
124
|
+
name: "BrowserAuthError",
|
|
125
|
+
errorCode: "monitor_window_timeout",
|
|
126
|
+
});
|
|
127
|
+
const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
|
|
128
|
+
expect(await store().getAccessToken()).toBeNull();
|
|
129
|
+
expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("does NOT redirect on interaction_in_progress (would race with another flow)", async () => {
|
|
133
|
+
const err = Object.assign(new Error("interaction_in_progress"), {
|
|
134
|
+
name: "BrowserAuthError",
|
|
135
|
+
errorCode: "interaction_in_progress",
|
|
136
|
+
});
|
|
137
|
+
const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
|
|
138
|
+
expect(await store().getAccessToken()).toBeNull();
|
|
139
|
+
expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("does NOT redirect on hash_empty_error (config bug, redirect won't fix)", async () => {
|
|
143
|
+
const err = Object.assign(new Error("hash_empty_error"), {
|
|
144
|
+
name: "BrowserAuthError",
|
|
145
|
+
errorCode: "hash_empty_error",
|
|
146
|
+
});
|
|
147
|
+
const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
|
|
148
|
+
expect(await store().getAccessToken()).toBeNull();
|
|
149
|
+
expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("does NOT redirect on a transient/unknown error", async () => {
|
|
153
|
+
// A network blip shouldn't bounce the user through a login flow.
|
|
154
|
+
const err = new Error("connection reset");
|
|
155
|
+
const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
|
|
156
|
+
expect(await store().getAccessToken()).toBeNull();
|
|
157
|
+
expect(msal.acquireTokenRedirect).not.toHaveBeenCalled();
|
|
158
|
+
expect(msal.loginRedirect).not.toHaveBeenCalled();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("matches InteractionRequiredAuthError by class name when no errorCode is set", async () => {
|
|
162
|
+
const err = Object.assign(new Error("interaction"), {
|
|
163
|
+
name: "InteractionRequiredAuthError",
|
|
164
|
+
});
|
|
165
|
+
const msal = mockMsal({ acquireTokenSilent: vi.fn().mockRejectedValue(err) });
|
|
166
|
+
expect(await store().getAccessToken()).toBeNull();
|
|
167
|
+
expect(msal.acquireTokenRedirect).toHaveBeenCalledOnce();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, expect, it, beforeEach, vi } from "vitest";
|
|
2
|
+
import { linkStore, resolveLinkHandler, type LinkHandler } from "../store/link-store.ts";
|
|
3
|
+
|
|
4
|
+
describe("linkStore", () => {
|
|
5
|
+
beforeEach(() => {
|
|
6
|
+
linkStore.getState().clear();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("registers and clears a markdown link handler", () => {
|
|
10
|
+
const handler: LinkHandler = {
|
|
11
|
+
canHandle: (href) => href.startsWith("/spaces/"),
|
|
12
|
+
openLink: vi.fn(),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
linkStore.getState().setHandler(handler);
|
|
16
|
+
expect(linkStore.getState().handler).toBe(handler);
|
|
17
|
+
expect(linkStore.getState().handlers).toEqual([handler]);
|
|
18
|
+
|
|
19
|
+
linkStore.getState().clear();
|
|
20
|
+
expect(linkStore.getState().handler).toBeNull();
|
|
21
|
+
expect(linkStore.getState().handlers).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("keeps multiple handlers and resolves the matching namespace", () => {
|
|
25
|
+
const spacesHandler: LinkHandler = {
|
|
26
|
+
canHandle: (href) => href.startsWith("/spaces/"),
|
|
27
|
+
openLink: vi.fn(),
|
|
28
|
+
};
|
|
29
|
+
const docsHandler: LinkHandler = {
|
|
30
|
+
normalizeHref: (href) => (href.startsWith("docs:") ? `/docs/${href.slice(5)}` : href),
|
|
31
|
+
canHandle: (href) => href.startsWith("/docs/"),
|
|
32
|
+
openLink: vi.fn(),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
linkStore.getState().setHandler(spacesHandler);
|
|
36
|
+
linkStore.getState().setHandler(docsHandler);
|
|
37
|
+
|
|
38
|
+
expect(resolveLinkHandler("/spaces/page-1")).toEqual({
|
|
39
|
+
handler: spacesHandler,
|
|
40
|
+
href: "/spaces/page-1",
|
|
41
|
+
});
|
|
42
|
+
expect(resolveLinkHandler("docs:setup")).toEqual({
|
|
43
|
+
handler: docsHandler,
|
|
44
|
+
href: "/docs/setup",
|
|
45
|
+
});
|
|
46
|
+
expect(resolveLinkHandler("/unknown")).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -10,19 +10,18 @@ export {
|
|
|
10
10
|
} from "./client/service-fetch.ts";
|
|
11
11
|
|
|
12
12
|
// --- Tool registry ---
|
|
13
|
-
export {
|
|
14
|
-
clientToolRegistry,
|
|
15
|
-
type ClientToolEntry,
|
|
16
|
-
type ToolRegistry,
|
|
17
|
-
} from "./tools/registry.ts";
|
|
13
|
+
export { clientToolRegistry, type ClientToolEntry, type ToolRegistry } from "./tools/registry.ts";
|
|
18
14
|
|
|
19
15
|
// --- Stores (vanilla) ---
|
|
20
|
-
export {
|
|
21
|
-
streamingStatusStore,
|
|
22
|
-
type StreamingStatus,
|
|
23
|
-
} from "./store/streaming-status-store.ts";
|
|
16
|
+
export { streamingStatusStore, type StreamingStatus } from "./store/streaming-status-store.ts";
|
|
24
17
|
export {
|
|
25
18
|
citationStore,
|
|
26
19
|
type CitationResult,
|
|
27
20
|
type CitationHandler,
|
|
28
21
|
} from "./store/citation-store.ts";
|
|
22
|
+
export {
|
|
23
|
+
linkStore,
|
|
24
|
+
resolveLinkHandler,
|
|
25
|
+
type LinkHandler,
|
|
26
|
+
type ResolvedLinkHandler,
|
|
27
|
+
} from "./store/link-store.ts";
|
package/src/msal/auth-config.ts
CHANGED
|
@@ -57,6 +57,13 @@ export async function initializeMsal(
|
|
|
57
57
|
|
|
58
58
|
const { PublicClientApplication: PCA } = await import("@azure/msal-browser");
|
|
59
59
|
|
|
60
|
+
// Token-renewal config — see Microsoft Learn:
|
|
61
|
+
// https://learn.microsoft.com/entra/msal/javascript/browser/errors
|
|
62
|
+
//
|
|
63
|
+
// SPA refresh tokens are 24 h, non-sliding, non-renewable. After that
|
|
64
|
+
// window the user MUST re-auth; nothing the SPA can do silently saves
|
|
65
|
+
// it. Goal of these knobs is to make the unavoidable interactive
|
|
66
|
+
// bounce predictable and to keep the silent path healthy in between.
|
|
60
67
|
const msalConfiguration = {
|
|
61
68
|
auth: {
|
|
62
69
|
clientId: config.clientId,
|
|
@@ -68,6 +75,24 @@ export async function initializeMsal(
|
|
|
68
75
|
// localStorage is required for Playwright E2E tests — sessionStorage
|
|
69
76
|
// is not preserved across page navigations in the Playwright context.
|
|
70
77
|
cacheLocation: "localStorage",
|
|
78
|
+
// Cache key includes a hash of any `claims` parameter. Without
|
|
79
|
+
// this, MSAL serves the same cached access token even after a
|
|
80
|
+
// claims-challenge / token revocation / role change. The MSAL
|
|
81
|
+
// team has signalled this will become the default; opt in early.
|
|
82
|
+
claimsBasedCachingEnabled: true,
|
|
83
|
+
},
|
|
84
|
+
system: {
|
|
85
|
+
// Treat access tokens as "expired" 10 min before the actual exp
|
|
86
|
+
// claim instead of MSAL's default 5 min. Eliminates the race
|
|
87
|
+
// where the SPA's clock thinks the token is still valid but the
|
|
88
|
+
// resource server rejects it as expired (clock skew, slow request
|
|
89
|
+
// queueing, etc.).
|
|
90
|
+
tokenRenewalOffsetSeconds: 600,
|
|
91
|
+
// Default 6 s is too tight on modern browsers — third-party
|
|
92
|
+
// storage partitioning + slower CPUs in the silent iframe can
|
|
93
|
+
// push the round-trip past it. 10 s is the value MSAL Angular
|
|
94
|
+
// and React samples ship with.
|
|
95
|
+
iframeHashTimeout: 10000,
|
|
71
96
|
},
|
|
72
97
|
};
|
|
73
98
|
|
package/src/msal/auth-store.ts
CHANGED
|
@@ -21,10 +21,69 @@ interface AuthState {
|
|
|
21
21
|
/**
|
|
22
22
|
* Acquire an access token for the given audience.
|
|
23
23
|
* Returns null when MSAL is not configured (local dev).
|
|
24
|
+
*
|
|
25
|
+
* Recovery semantics — follows the canonical MSAL.js pattern documented
|
|
26
|
+
* at https://learn.microsoft.com/entra/msal/javascript/browser/errors:
|
|
27
|
+
*
|
|
28
|
+
* try {
|
|
29
|
+
* await msal.acquireTokenSilent(req);
|
|
30
|
+
* } catch (error) {
|
|
31
|
+
* if (error instanceof InteractionRequiredAuthError) {
|
|
32
|
+
* await msal.acquireTokenRedirect(req);
|
|
33
|
+
* }
|
|
34
|
+
* }
|
|
35
|
+
*
|
|
36
|
+
* Plus one extra recoverable case explicitly called out in those docs:
|
|
37
|
+
* `BrowserAuthError: monitor_window_timeout`. Microsoft's recommendation
|
|
38
|
+
* is to either backoff, fix the redirectUri page, or "invoke an
|
|
39
|
+
* interactive API such as acquireTokenPopup or acquireTokenRedirect" —
|
|
40
|
+
* we take the last option, which matches third-party-iframe storage
|
|
41
|
+
* blocking on Chrome 120+ / Edge / Safari (the silent iframe times
|
|
42
|
+
* out because the cross-site cookie is partitioned).
|
|
43
|
+
*
|
|
44
|
+
* Other `BrowserAuthError` codes (`interaction_in_progress`,
|
|
45
|
+
* `hash_empty_error`, `hash_does_not_contain_known_properties`,
|
|
46
|
+
* `block_iframe_reload`) are config / race-condition bugs that another
|
|
47
|
+
* redirect won't fix — propagate them as null without navigating.
|
|
48
|
+
*
|
|
49
|
+
* The "no account in cache" case is handled at boot by `AuthGuard`,
|
|
50
|
+
* which calls `loginRedirect` when `accounts.length === 0`. If we
|
|
51
|
+
* still reach this method without an account, it's an unusual state;
|
|
52
|
+
* return null and let the next AuthGuard render recover.
|
|
53
|
+
*
|
|
54
|
+
* Errors are matched by `name` / `errorCode` rather than `instanceof`
|
|
55
|
+
* because `@azure/msal-browser` is loaded via dynamic import; the
|
|
56
|
+
* error class identity isn't shared across module boundaries.
|
|
24
57
|
*/
|
|
25
58
|
getAccessToken: (audience?: "api" | "spaces") => Promise<string | null>;
|
|
26
59
|
}
|
|
27
60
|
|
|
61
|
+
/** Recoverable error codes per MSAL.js docs — every one of these has the
|
|
62
|
+
* documented remedy "invoke an interactive API". */
|
|
63
|
+
const RECOVERABLE_ERROR_CODES = new Set([
|
|
64
|
+
// InteractionRequiredAuthError — canonical fallback case.
|
|
65
|
+
"interaction_required",
|
|
66
|
+
"login_required",
|
|
67
|
+
"consent_required",
|
|
68
|
+
// BrowserAuthError: monitor_window_timeout. Documented remedy includes
|
|
69
|
+
// "Invoke an interactive API" (Microsoft Learn → "Common errors in
|
|
70
|
+
// MSAL JS" → monitor_window_timeout → "Throttling" + "X-Frame-Options
|
|
71
|
+
// Deny"). Real-world trigger on Chrome 120+ is third-party-iframe
|
|
72
|
+
// storage partitioning blocking the silent SSO frame.
|
|
73
|
+
"monitor_window_timeout",
|
|
74
|
+
]);
|
|
75
|
+
|
|
76
|
+
/** `InteractionRequiredAuthError` always triggers the redirect — match by
|
|
77
|
+
* class name as a fallback when the error code isn't set. */
|
|
78
|
+
const INTERACTION_REQUIRED_NAME = "InteractionRequiredAuthError";
|
|
79
|
+
|
|
80
|
+
function isRecoverableAuthError(err: unknown): boolean {
|
|
81
|
+
if (!err || typeof err !== "object") return false;
|
|
82
|
+
const name = (err as { name?: string }).name ?? "";
|
|
83
|
+
const code = (err as { errorCode?: string }).errorCode ?? "";
|
|
84
|
+
return name === INTERACTION_REQUIRED_NAME || RECOVERABLE_ERROR_CODES.has(code);
|
|
85
|
+
}
|
|
86
|
+
|
|
28
87
|
export const authStore = createStore<AuthState>((set) => ({
|
|
29
88
|
user: null,
|
|
30
89
|
isAuthenticated: false,
|
|
@@ -45,9 +104,13 @@ export const authStore = createStore<AuthState>((set) => ({
|
|
|
45
104
|
if (!msal || !config) return null;
|
|
46
105
|
|
|
47
106
|
const accounts = msal.getAllAccounts();
|
|
48
|
-
if (accounts.length === 0)
|
|
107
|
+
if (accounts.length === 0) {
|
|
108
|
+
// No cached account — `AuthGuard` will call `loginRedirect` on
|
|
109
|
+
// its next render. Don't double-redirect from here.
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
49
112
|
|
|
50
|
-
// Single API-scoped token — Spaces accepts both audiences (multi-audience JWT)
|
|
113
|
+
// Single API-scoped token — Spaces accepts both audiences (multi-audience JWT).
|
|
51
114
|
const scope = config.apiScope;
|
|
52
115
|
|
|
53
116
|
try {
|
|
@@ -56,15 +119,16 @@ export const authStore = createStore<AuthState>((set) => ({
|
|
|
56
119
|
account: accounts[0],
|
|
57
120
|
});
|
|
58
121
|
return result.accessToken;
|
|
59
|
-
} catch {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (isRecoverableAuthError(err)) {
|
|
124
|
+
try {
|
|
125
|
+
await msal.acquireTokenRedirect({
|
|
126
|
+
scopes: [scope],
|
|
127
|
+
account: accounts[0],
|
|
128
|
+
});
|
|
129
|
+
} catch {
|
|
130
|
+
// Redirect navigates away; nothing to return.
|
|
131
|
+
}
|
|
68
132
|
}
|
|
69
133
|
return null;
|
|
70
134
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { createStore } from "zustand/vanilla";
|
|
2
|
+
|
|
3
|
+
export interface LinkHandler {
|
|
4
|
+
canHandle: (href: string) => boolean;
|
|
5
|
+
normalizeHref?: (href: string) => string | null;
|
|
6
|
+
openLink: (href: string) => void;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ResolvedLinkHandler {
|
|
10
|
+
handler: LinkHandler;
|
|
11
|
+
href: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface LinkState {
|
|
15
|
+
/**
|
|
16
|
+
* Latest registered handler kept for backwards-compatible consumers.
|
|
17
|
+
* New code should use `handlers` or `resolveLinkHandler`.
|
|
18
|
+
*/
|
|
19
|
+
handler: LinkHandler | null;
|
|
20
|
+
handlers: LinkHandler[];
|
|
21
|
+
setHandler: (handler: LinkHandler) => void;
|
|
22
|
+
clear: () => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Generic markdown-link extension point.
|
|
27
|
+
*
|
|
28
|
+
* Feature modules register a handler for their own deep-link namespace
|
|
29
|
+
* (for example Spaces handles `/spaces/...`). The chat renderer stays
|
|
30
|
+
* module-agnostic while still letting app-local links navigate in-place.
|
|
31
|
+
*/
|
|
32
|
+
export const linkStore = createStore<LinkState>((set) => ({
|
|
33
|
+
handler: null,
|
|
34
|
+
handlers: [],
|
|
35
|
+
setHandler: (handler) =>
|
|
36
|
+
set((state) => ({
|
|
37
|
+
handler,
|
|
38
|
+
handlers: [...state.handlers.filter((item) => item !== handler), handler],
|
|
39
|
+
})),
|
|
40
|
+
clear: () => set({ handler: null, handlers: [] }),
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
export function resolveLinkHandler(rawHref: string): ResolvedLinkHandler | null {
|
|
44
|
+
const handlers = linkStore.getState().handlers;
|
|
45
|
+
for (let i = handlers.length - 1; i >= 0; i -= 1) {
|
|
46
|
+
const handler = handlers[i];
|
|
47
|
+
const href = handler.normalizeHref ? handler.normalizeHref(rawHref) : rawHref;
|
|
48
|
+
if (href && handler.canHandle(href)) {
|
|
49
|
+
return { handler, href };
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return null;
|
|
53
|
+
}
|