@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 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@iloveagents/foundry-agent",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "type": "module",
6
6
  "types": "./src/index.ts",
@@ -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";
@@ -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
 
@@ -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) return null;
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
- // Silent acquisition failed — trigger interactive redirect
61
- try {
62
- await msal.acquireTokenRedirect({
63
- scopes: [scope],
64
- account: accounts[0],
65
- });
66
- } catch {
67
- // Redirect will navigate away; nothing to return
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
+ }