@agent-native/dispatch 0.29.2 → 0.29.4

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 (41) hide show
  1. package/dist/actions/list-workspace-apps.d.ts +1 -0
  2. package/dist/actions/list-workspace-apps.js +3 -0
  3. package/dist/actions/list-workspace-apps.js.map +1 -1
  4. package/dist/actions/provider-api-register.d.ts +14 -14
  5. package/dist/components/layout/Layout.d.ts.map +1 -1
  6. package/dist/components/layout/Layout.js +19 -4
  7. package/dist/components/layout/Layout.js.map +1 -1
  8. package/dist/components/workspace-app-card.d.ts.map +1 -1
  9. package/dist/components/workspace-app-card.js +9 -6
  10. package/dist/components/workspace-app-card.js.map +1 -1
  11. package/dist/components/workspace-app-host.d.ts.map +1 -1
  12. package/dist/components/workspace-app-host.js +65 -13
  13. package/dist/components/workspace-app-host.js.map +1 -1
  14. package/dist/lib/workspace-apps.d.ts +16 -0
  15. package/dist/lib/workspace-apps.d.ts.map +1 -1
  16. package/dist/lib/workspace-apps.js +71 -1
  17. package/dist/lib/workspace-apps.js.map +1 -1
  18. package/dist/server/lib/app-creation-store.d.ts +2 -0
  19. package/dist/server/lib/app-creation-store.d.ts.map +1 -1
  20. package/dist/server/lib/app-creation-store.js +9 -3
  21. package/dist/server/lib/app-creation-store.js.map +1 -1
  22. package/dist/server/lib/mcp-gateway.d.ts.map +1 -1
  23. package/dist/server/lib/mcp-gateway.js +5 -42
  24. package/dist/server/lib/mcp-gateway.js.map +1 -1
  25. package/dist/shared/workspace-sso.d.ts +12 -0
  26. package/dist/shared/workspace-sso.d.ts.map +1 -1
  27. package/dist/shared/workspace-sso.js +43 -0
  28. package/dist/shared/workspace-sso.js.map +1 -1
  29. package/package.json +2 -2
  30. package/src/actions/list-workspace-apps.ts +3 -0
  31. package/src/components/layout/Layout.tsx +27 -5
  32. package/src/components/workspace-app-card.spec.tsx +60 -0
  33. package/src/components/workspace-app-card.tsx +17 -4
  34. package/src/components/workspace-app-host.spec.tsx +208 -41
  35. package/src/components/workspace-app-host.tsx +85 -10
  36. package/src/lib/workspace-apps.spec.ts +80 -0
  37. package/src/lib/workspace-apps.ts +95 -1
  38. package/src/server/lib/app-creation-store.spec.ts +43 -0
  39. package/src/server/lib/app-creation-store.ts +11 -3
  40. package/src/server/lib/mcp-gateway.ts +5 -48
  41. package/src/shared/workspace-sso.ts +55 -0
@@ -31,6 +31,7 @@ import { Link } from "react-router";
31
31
  import { isEmbedSessionExpiredMessage } from "../lib/embed-session-recovery";
32
32
  import {
33
33
  mergeChatFirstWorkspaceApps,
34
+ isWorkspaceSsoApp,
34
35
  navigateToWorkspaceApp,
35
36
  shouldOpenWorkspaceAppInTopWindow,
36
37
  workspaceAppDirectHref,
@@ -287,6 +288,7 @@ export function WorkspaceAppFrame({
287
288
  if (isDirectFallback) setEmbedError(null);
288
289
  }, [isDirectFallback, postThemeToFrame]);
289
290
  const workspaceSsoEnabled = useFeatureFlag(DISPATCH_WORKSPACE_SSO_FLAG.key);
291
+ const useWorkspaceSso = workspaceSsoEnabled && isWorkspaceSsoApp(app);
290
292
  const createEmbedSession = useActionMutation<
291
293
  EmbedSessionResult,
292
294
  EmbedSessionInput
@@ -320,6 +322,8 @@ export function WorkspaceAppFrame({
320
322
  return target.url ?? target.path ?? null;
321
323
  }, [app.path, app.url, embedPath]);
322
324
  const openInTopWindow = shouldOpenWorkspaceAppInTopWindow();
325
+ const topWindowSsoAttemptKey = `${app.id}\u0000${app.path ?? ""}\u0000${app.url ?? ""}\u0000${embedPath ?? ""}\u0000${embedAttempt}`;
326
+ const topWindowSsoAttemptedRef = useRef<string | null>(null);
323
327
  const embedInput = useMemo<EmbedSessionInput | null>(() => {
324
328
  if (embedPath !== undefined) {
325
329
  return buildChatFirstEmbedSessionInput(app.id, embedPath);
@@ -333,6 +337,10 @@ export function WorkspaceAppFrame({
333
337
  }, [app.id, app.path, app.url, appHref, embedPath]);
334
338
 
335
339
  useEffect(() => {
340
+ if (openInTopWindow && useWorkspaceSso && embedInput) {
341
+ setTopWindowNavigationFailed(false);
342
+ return;
343
+ }
336
344
  if (!openInTopWindow) {
337
345
  setTopWindowNavigationFailed(false);
338
346
  return;
@@ -349,26 +357,59 @@ export function WorkspaceAppFrame({
349
357
  didNavigate = false;
350
358
  }
351
359
  setTopWindowNavigationFailed(!didNavigate);
352
- }, [navigateToTopWindow, openInTopWindow, topWindowHref]);
360
+ }, [
361
+ embedInput,
362
+ navigateToTopWindow,
363
+ openInTopWindow,
364
+ topWindowHref,
365
+ useWorkspaceSso,
366
+ ]);
353
367
 
354
368
  useEffect(() => {
355
- if (!embedInput || (openInTopWindow && !topWindowNavigationFailed)) return;
369
+ const useTopWindowSso = openInTopWindow && useWorkspaceSso && !!embedInput;
370
+ if (
371
+ !embedInput ||
372
+ (openInTopWindow && !useWorkspaceSso && !topWindowNavigationFailed)
373
+ ) {
374
+ return;
375
+ }
376
+ if (
377
+ useTopWindowSso &&
378
+ topWindowSsoAttemptedRef.current === topWindowSsoAttemptKey
379
+ ) {
380
+ return;
381
+ }
382
+ if (useTopWindowSso) {
383
+ topWindowSsoAttemptedRef.current = topWindowSsoAttemptKey;
384
+ }
356
385
  let cancelled = false;
357
386
  setEmbedUrl(null);
358
387
  setEmbedError(null);
359
388
  setIsDirectFallback(false);
360
- const createSession = workspaceSsoEnabled
389
+ const createSession = useWorkspaceSso
361
390
  ? createWorkspaceSsoEmbedSession
362
391
  : createEmbedSession;
363
392
  void createSession
364
393
  .mutateAsync(embedInput)
365
394
  .then((result) => {
366
- if (!cancelled) setEmbedUrl(result.startUrl);
395
+ if (cancelled) return;
396
+ if (useTopWindowSso) {
397
+ let didNavigate = false;
398
+ try {
399
+ didNavigate = navigateToTopWindow(result.startUrl) !== false;
400
+ } catch {
401
+ didNavigate = false;
402
+ }
403
+ setTopWindowNavigationFailed(!didNavigate);
404
+ setEmbedUrl(didNavigate ? null : result.startUrl);
405
+ return;
406
+ }
407
+ setEmbedUrl(result.startUrl);
367
408
  })
368
409
  .catch((cause: unknown) => {
369
410
  if (cancelled) return;
370
411
  const error = cause instanceof Error ? cause : new Error(String(cause));
371
- if (workspaceSsoEnabled) {
412
+ if (useWorkspaceSso) {
372
413
  // An SSO-enabled pane must never fall back to the child app's
373
414
  // unauthenticated shell. Keep the parent-owned retry surface in
374
415
  // place so a transient exchange failure cannot expose another
@@ -376,6 +417,7 @@ export function WorkspaceAppFrame({
376
417
  setIsDirectFallback(false);
377
418
  setEmbedUrl(null);
378
419
  setEmbedError(error);
420
+ if (useTopWindowSso) setTopWindowNavigationFailed(true);
379
421
  return;
380
422
  }
381
423
  setIsDirectFallback(true);
@@ -400,8 +442,10 @@ export function WorkspaceAppFrame({
400
442
  embedPath,
401
443
  embedAttempt,
402
444
  openInTopWindow,
445
+ navigateToTopWindow,
446
+ topWindowSsoAttemptKey,
403
447
  topWindowNavigationFailed,
404
- workspaceSsoEnabled,
448
+ useWorkspaceSso,
405
449
  ]);
406
450
 
407
451
  useEffect(() => {
@@ -476,21 +520,52 @@ export function WorkspaceAppHost({
476
520
  const t = useT();
477
521
  const workspaceAppsQuery = useActionQuery<WorkspaceAppSummary[]>(
478
522
  "list-workspace-apps",
479
- { includeAgentCards: false },
523
+ { includeAgentCards: false, includeArchived: true },
524
+ );
525
+ const workspaceApps = useMemo(
526
+ () => mergeChatFirstWorkspaceApps(workspaceAppsQuery.data),
527
+ [workspaceAppsQuery.data],
528
+ );
529
+ const visibleWorkspaceApps = useMemo(
530
+ () => workspaceApps.filter((item) => !item.archived),
531
+ [workspaceApps],
532
+ );
533
+ const workspaceAppIds = useMemo(
534
+ () => new Set(workspaceApps.map((item) => item.id.trim().toLowerCase())),
535
+ [workspaceApps],
536
+ );
537
+ const workspaceApp = useMemo(
538
+ () =>
539
+ visibleWorkspaceApps.find(
540
+ (item) => item.id.trim().toLowerCase() === appId?.trim().toLowerCase(),
541
+ ) ?? null,
542
+ [appId, visibleWorkspaceApps],
480
543
  );
481
544
  const grantedAppsQuery = useActionQuery<GrantedWorkspaceAppsResult>(
482
545
  "list_apps",
483
546
  {},
547
+ {
548
+ // Mounted workspace apps are already fully described by the workspace
549
+ // registry. Defer the broader MCP grant/discovery scan until that
550
+ // lookup misses; it is only needed for externally granted apps.
551
+ enabled: !workspaceAppsQuery.isLoading && !workspaceApp,
552
+ },
484
553
  );
485
554
  const apps = useMemo(() => {
486
555
  const merged = new Map<string, WorkspaceAppSummary>();
487
556
 
488
- for (const app of mergeChatFirstWorkspaceApps(workspaceAppsQuery.data)) {
557
+ for (const app of visibleWorkspaceApps) {
489
558
  merged.set(app.id.trim().toLowerCase(), app);
490
559
  }
491
560
  for (const app of grantedAppsQuery.data?.apps ?? []) {
492
561
  const id = app.id.trim();
493
- if (!id || merged.has(id.toLowerCase())) continue;
562
+ if (
563
+ !id ||
564
+ workspaceAppIds.has(id.toLowerCase()) ||
565
+ merged.has(id.toLowerCase())
566
+ ) {
567
+ continue;
568
+ }
494
569
  merged.set(id.toLowerCase(), {
495
570
  id,
496
571
  name: app.name.trim() || id,
@@ -501,7 +576,7 @@ export function WorkspaceAppHost({
501
576
  }
502
577
 
503
578
  return [...merged.values()];
504
- }, [grantedAppsQuery.data?.apps, workspaceAppsQuery.data]);
579
+ }, [grantedAppsQuery.data?.apps, visibleWorkspaceApps, workspaceAppIds]);
505
580
  const app = useMemo(
506
581
  () =>
507
582
  apps.find(
@@ -3,9 +3,12 @@ import { describe, expect, it } from "vitest";
3
3
  import {
4
4
  isDefaultWorkspaceAppHiddenId,
5
5
  isDispatchWorkspaceAppId,
6
+ isPathMountedWorkspaceApp,
6
7
  isWorkspaceAppVisibleInDefaultLaunchers,
8
+ isWorkspaceSsoApp,
7
9
  mergeChatFirstWorkspaceApps,
8
10
  workspaceAppIdFromRoute,
11
+ workspaceAppDirectHref,
9
12
  workspaceAppRoute,
10
13
  } from "./workspace-apps";
11
14
 
@@ -31,6 +34,83 @@ describe("workspace app routes", () => {
31
34
  expect(isDispatchWorkspaceAppId("dispatch-tools")).toBe(false);
32
35
  });
33
36
 
37
+ it("requires canonical metadata before enabling workspace SSO", () => {
38
+ expect(
39
+ isWorkspaceSsoApp({
40
+ id: " Mail ",
41
+ path: "/",
42
+ url: "https://mail.agent-native.com",
43
+ }),
44
+ ).toBe(true);
45
+ expect(
46
+ isWorkspaceSsoApp({
47
+ id: "mail",
48
+ path: "/mail",
49
+ url: "https://agent-workspace.builder.io/mail",
50
+ }),
51
+ ).toBe(false);
52
+ expect(
53
+ isWorkspaceSsoApp({
54
+ id: "feedback-leaderboard",
55
+ path: "/feedback-leaderboard",
56
+ url: "https://agent-workspace.builder.io/feedback-leaderboard",
57
+ }),
58
+ ).toBe(false);
59
+ });
60
+
61
+ it("accepts the server eligibility projection for registered custom apps", () => {
62
+ expect(
63
+ isWorkspaceSsoApp({
64
+ id: "workspace-reports",
65
+ path: "/workspace-reports",
66
+ url: "https://reports.example.com/workspace-reports",
67
+ workspaceSso: true,
68
+ }),
69
+ ).toBe(true);
70
+ expect(
71
+ isWorkspaceSsoApp({
72
+ id: "workspace-reports",
73
+ path: "/workspace-reports",
74
+ url: "javascript:alert(1)",
75
+ workspaceSso: true,
76
+ }),
77
+ ).toBe(false);
78
+ });
79
+
80
+ it("keeps canonical loopback apps eligible outside production", () => {
81
+ expect(
82
+ isWorkspaceSsoApp({
83
+ id: "mail",
84
+ path: "/mail",
85
+ url: "http://localhost:8084",
86
+ }),
87
+ ).toBe(true);
88
+ });
89
+
90
+ it("identifies mounted apps and resolves their direct workspace href", () => {
91
+ const app = {
92
+ path: "/feedback-leaderboard",
93
+ url: "https://agent-workspace.builder.io/feedback-leaderboard/leaderboard",
94
+ };
95
+
96
+ expect(isPathMountedWorkspaceApp(app)).toBe(true);
97
+ expect(workspaceAppDirectHref(app, "/")).toBe(
98
+ "https://agent-workspace.builder.io/feedback-leaderboard/leaderboard",
99
+ );
100
+ expect(
101
+ isPathMountedWorkspaceApp({
102
+ path: "/",
103
+ url: "https://mail.agent-native.com",
104
+ }),
105
+ ).toBe(false);
106
+ expect(
107
+ isPathMountedWorkspaceApp({
108
+ path: "/",
109
+ url: "https://feedback.example.com",
110
+ }),
111
+ ).toBe(true);
112
+ });
113
+
34
114
  it("hides the generic chat starter and Dispatch from default launchers", () => {
35
115
  expect(isDefaultWorkspaceAppHiddenId(" Chat ")).toBe(true);
36
116
  expect(isWorkspaceAppVisibleInDefaultLaunchers({ id: "chat" })).toBe(false);
@@ -2,7 +2,10 @@ import { CHAT_FIRST_DEFAULT_APP_IDS } from "@agent-native/core/client/chat-first
2
2
  import { isInBuilderFrame } from "@agent-native/core/client/host";
3
3
  import { withBuilderUtmTrackingParams } from "@agent-native/core/shared/builder-link-tracking";
4
4
 
5
- import { CANONICAL_WORKSPACE_SSO_APP_ORIGINS } from "../shared/workspace-sso";
5
+ import {
6
+ CANONICAL_WORKSPACE_SSO_APP_ORIGINS,
7
+ isWorkspaceSsoAppUrl,
8
+ } from "../shared/workspace-sso";
6
9
 
7
10
  export interface WorkspaceAppSummary {
8
11
  id: string;
@@ -28,17 +31,108 @@ export interface WorkspaceAppSummary {
28
31
  agentName?: string | null;
29
32
  agentSkillsCount?: number | null;
30
33
  archived?: boolean;
34
+ workspaceSso?: boolean;
31
35
  }
32
36
 
33
37
  interface WorkspaceAppHrefSource {
34
38
  path?: string | null;
35
39
  url?: string | null;
40
+ workspaceSso?: boolean;
36
41
  }
37
42
 
38
43
  export function isDispatchWorkspaceAppId(appId: string): boolean {
39
44
  return appId.trim().toLowerCase() === "dispatch";
40
45
  }
41
46
 
47
+ function clientRuntimeEnvironment(): "production" | "development" {
48
+ const importMetaEnv = (
49
+ import.meta as unknown as {
50
+ env?: Record<string, string | boolean | undefined>;
51
+ }
52
+ ).env;
53
+ const processEnv = (
54
+ globalThis as typeof globalThis & {
55
+ process?: { env?: Record<string, string | boolean | undefined> };
56
+ }
57
+ ).process?.env;
58
+ const env = { ...processEnv, ...importMetaEnv };
59
+ if (
60
+ env.PROD === true ||
61
+ env.MODE === "production" ||
62
+ env.NODE_ENV === "production"
63
+ ) {
64
+ return "production";
65
+ }
66
+ if (
67
+ env.DEV === true ||
68
+ env.MODE === "development" ||
69
+ env.MODE === "test" ||
70
+ env.NODE_ENV === "development" ||
71
+ env.NODE_ENV === "test"
72
+ ) {
73
+ return "development";
74
+ }
75
+ return "production";
76
+ }
77
+
78
+ /**
79
+ * The workspace SSO action only accepts an exact registered app identity and
80
+ * origin. The catalog's boolean is a server-derived projection for custom
81
+ * registrations; the URL check keeps malformed metadata out of the action.
82
+ */
83
+ export function isWorkspaceSsoApp(
84
+ app: WorkspaceAppHrefSource & { id: string },
85
+ ): boolean {
86
+ const rawUrl = app.url?.trim();
87
+ if (!rawUrl) return false;
88
+ try {
89
+ const url = new URL(rawUrl);
90
+ if (url.protocol !== "http:" && url.protocol !== "https:") return false;
91
+ if (app.workspaceSso === true) return true;
92
+ return isWorkspaceSsoAppUrl(
93
+ { id: app.id, url: rawUrl },
94
+ { nodeEnv: clientRuntimeEnvironment() },
95
+ );
96
+ // coercion-ok: malformed app metadata is not eligible for workspace SSO.
97
+ } catch {
98
+ return false;
99
+ }
100
+ }
101
+
102
+ function isCanonicalWorkspaceSsoOrigin(rawUrl: string): boolean {
103
+ try {
104
+ const origin = new URL(rawUrl).origin;
105
+ return Object.values(CANONICAL_WORKSPACE_SSO_APP_ORIGINS).some(
106
+ (canonicalOrigin) => canonicalOrigin === origin,
107
+ );
108
+ } catch {
109
+ // coercion-ok: malformed app metadata is not a canonical first-party app.
110
+ return false;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * A mounted app URL leaves Dispatch's `/apps/:id` host route. Canonical
116
+ * first-party origins can stay inline even at `/`, while external published
117
+ * apps must open at their own origin regardless of their path.
118
+ */
119
+ export function isPathMountedWorkspaceApp(
120
+ app: WorkspaceAppHrefSource,
121
+ ): boolean {
122
+ const rawUrl = app.url?.trim();
123
+ if (rawUrl) {
124
+ try {
125
+ const pathname = new URL(rawUrl).pathname.replace(/\/+$/, "") || "/";
126
+ return pathname !== "/" || !isCanonicalWorkspaceSsoOrigin(rawUrl);
127
+ // coercion-ok: invalid absolute URLs use the mounted path fallback.
128
+ } catch {
129
+ // Fall through to the mounted path for relative manifest values.
130
+ }
131
+ }
132
+ const path = app.path?.trim().replace(/\/+$/, "") || "/";
133
+ return path !== "/";
134
+ }
135
+
42
136
  export function isDefaultWorkspaceAppHiddenId(appId: string): boolean {
43
137
  const normalized = appId.trim().toLowerCase();
44
138
  return normalized === "chat" || isDispatchWorkspaceAppId(normalized);
@@ -345,6 +345,49 @@ describe("listWorkspaceApps", () => {
345
345
  expect(apps.map((app) => app.id)).toEqual(["dispatch"]);
346
346
  });
347
347
 
348
+ it("projects exact custom SSO eligibility without exposing registry details", async () => {
349
+ stubNoPendingContext();
350
+ stubManifest([
351
+ { id: "dispatch", name: "Dispatch", path: "/dispatch" },
352
+ {
353
+ id: "workspace-reports",
354
+ name: "Workspace Reports",
355
+ path: "/workspace-reports",
356
+ url: "https://reports.example.com/workspace-reports",
357
+ },
358
+ {
359
+ id: "unregistered",
360
+ name: "Unregistered",
361
+ path: "/unregistered",
362
+ url: "https://unregistered.example.com",
363
+ },
364
+ ]);
365
+ vi.stubEnv(
366
+ "IDENTITY_SSO_APP_REGISTRY_JSON",
367
+ JSON.stringify([
368
+ {
369
+ appId: "workspace-reports",
370
+ clientId: "workspace-reports-client",
371
+ origin: "https://reports.example.com",
372
+ callbackPath: "/_agent-native/identity/callback",
373
+ capabilities: ["identity-sso"],
374
+ },
375
+ ]),
376
+ );
377
+
378
+ const apps = await runWithRequestContext(
379
+ { userEmail: "dev@example.test" },
380
+ () => listWorkspaceApps({ includeAgentCards: false }),
381
+ );
382
+
383
+ expect(apps.find((app) => app.id === "workspace-reports")).toMatchObject({
384
+ workspaceSso: true,
385
+ });
386
+ expect(apps.find((app) => app.id === "unregistered")).toMatchObject({
387
+ workspaceSso: false,
388
+ });
389
+ });
390
+
348
391
  it("filters workspace apps by audience", async () => {
349
392
  stubNoPendingContext();
350
393
  vi.stubEnv(
@@ -17,6 +17,7 @@ import {
17
17
  import { getSetting, putSetting } from "@agent-native/core/settings";
18
18
  import { assertValidWorkspaceAppId } from "@agent-native/core/shared";
19
19
 
20
+ import { isWorkspaceSsoAppUrl } from "../../shared/workspace-sso.js";
20
21
  import { identityKeyForIncoming } from "./dispatch-integrations.js";
21
22
  import {
22
23
  currentOrgId,
@@ -103,6 +104,8 @@ export interface WorkspaceAppSummary {
103
104
  agentName?: string | null;
104
105
  agentSkillsCount?: number | null;
105
106
  archived?: boolean;
107
+ /** Safe server-side eligibility projection for the workspace SSO action. */
108
+ workspaceSso?: boolean;
106
109
  }
107
110
 
108
111
  export interface ListWorkspaceAppsOptions {
@@ -1326,9 +1329,14 @@ async function applyArchivedAndPending(
1326
1329
  app,
1327
1330
  metadataSettings,
1328
1331
  );
1329
- return archivedSet.has(app.id)
1330
- ? { ...withMetadata, archived: true }
1331
- : withMetadata;
1332
+ return {
1333
+ ...withMetadata,
1334
+ workspaceSso: isWorkspaceSsoAppUrl(withMetadata, {
1335
+ nodeEnv: process.env.NODE_ENV,
1336
+ registryRaw: process.env.IDENTITY_SSO_APP_REGISTRY_JSON,
1337
+ }),
1338
+ ...(archivedSet.has(app.id) ? { archived: true } : {}),
1339
+ };
1332
1340
  });
1333
1341
  return options.includeArchived
1334
1342
  ? filterAppsByAudience(annotated, options.audience)
@@ -25,9 +25,8 @@ import {
25
25
  } from "@agent-native/core/server/agent-discovery";
26
26
 
27
27
  import {
28
- CANONICAL_WORKSPACE_SSO_APP_ORIGINS,
29
28
  DISPATCH_WORKSPACE_SSO_FLAG,
30
- parseWorkspaceSsoAppRegistrations,
29
+ isWorkspaceSsoAppUrl,
31
30
  } from "../../shared/workspace-sso.js";
32
31
  import {
33
32
  listWorkspaceApps,
@@ -465,17 +464,6 @@ function safeAppOrigin(app: DispatchMcpAccessibleApp): string | null {
465
464
  }
466
465
  }
467
466
 
468
- function isLoopbackOrigin(origin: string): boolean {
469
- try {
470
- const hostname = new URL(origin).hostname;
471
- return ["localhost", "127.0.0.1", "::1"].includes(hostname);
472
- } catch {
473
- // coercion-ok: only a previously validated app origin reaches this helper;
474
- // malformed values are ineligible for the loopback development exception.
475
- return false;
476
- }
477
- }
478
-
479
467
  function appBaseUrl(app: DispatchMcpAccessibleApp): string {
480
468
  return app.url.replace(/\/+$/, "");
481
469
  }
@@ -643,44 +631,13 @@ export async function resolveGrantedDispatchMcpApp(
643
631
  return match;
644
632
  }
645
633
 
646
- function workspaceSsoOriginForApp(appId: string): string | null {
647
- const canonical =
648
- CANONICAL_WORKSPACE_SSO_APP_ORIGINS[
649
- appId as keyof typeof CANONICAL_WORKSPACE_SSO_APP_ORIGINS
650
- ];
651
- if (canonical) return canonical;
652
- const custom = parseWorkspaceSsoAppRegistrations(
653
- process.env.IDENTITY_SSO_APP_REGISTRY_JSON,
654
- ).find((registration) => registration.appId === appId);
655
- return custom?.origin ?? null;
656
- }
657
-
658
- function isWorkspaceSsoCanonicalApp(app: DispatchMcpAccessibleApp): boolean {
659
- const origin = safeAppOrigin(app);
660
- if (!origin) return false;
661
- const registeredOrigin = workspaceSsoOriginForApp(app.id);
662
- if (origin === registeredOrigin) return true;
663
- // The built-in discovery table intentionally points at local dev ports when
664
- // running outside production. Keep local development usable without ever
665
- // weakening the production exact-origin check.
666
- return process.env.NODE_ENV !== "production" && isLoopbackOrigin(origin)
667
- ? Object.prototype.hasOwnProperty.call(
668
- CANONICAL_WORKSPACE_SSO_APP_ORIGINS,
669
- app.id,
670
- )
671
- : false;
672
- }
673
-
674
634
  async function isEligibleWorkspaceSsoApp(
675
635
  candidate: DispatchMcpAccessibleApp,
676
636
  ): Promise<boolean> {
677
- const origin = safeAppOrigin(candidate);
678
- if (!origin) return false;
679
- if (isWorkspaceSsoCanonicalApp(candidate)) return true;
680
-
681
- const customOrigin = workspaceSsoOriginForApp(candidate.id);
682
- if (customOrigin === origin) return true;
683
- return false;
637
+ return isWorkspaceSsoAppUrl(candidate, {
638
+ nodeEnv: process.env.NODE_ENV,
639
+ registryRaw: process.env.IDENTITY_SSO_APP_REGISTRY_JSON,
640
+ });
684
641
  }
685
642
 
686
643
  async function listWorkspaceSsoApps(): Promise<DispatchMcpAccessibleApp[]> {
@@ -116,3 +116,58 @@ export function parseWorkspaceSsoAppRegistrations(
116
116
  }
117
117
  return registrations;
118
118
  }
119
+
120
+ function appOrigin(rawUrl: unknown): string | null {
121
+ if (typeof rawUrl !== "string" || !rawUrl.trim()) return null;
122
+ try {
123
+ const url = new URL(rawUrl.trim());
124
+ return url.protocol === "http:" || url.protocol === "https:"
125
+ ? url.origin
126
+ : null;
127
+ } catch {
128
+ // coercion-ok: malformed app metadata is not eligible for workspace SSO.
129
+ return null;
130
+ }
131
+ }
132
+
133
+ function isLoopbackOrigin(origin: string): boolean {
134
+ try {
135
+ return LOCALHOST_HOSTS.has(new URL(origin).hostname);
136
+ } catch {
137
+ // coercion-ok: only a previously parsed origin reaches this helper.
138
+ return false;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Keep the server-side app catalog and the browser's action choice on the
144
+ * same exact-origin rules. `registryRaw` is deliberately supplied by the
145
+ * caller so this shared module never reads process or browser environment.
146
+ */
147
+ export function isWorkspaceSsoAppUrl(
148
+ app: { id: string; url?: unknown },
149
+ options: { nodeEnv?: string; registryRaw?: unknown } = {},
150
+ ): boolean {
151
+ const origin = appOrigin(app.url);
152
+ if (!origin) return false;
153
+
154
+ const appId = app.id.trim().toLowerCase();
155
+ const canonicalOrigin =
156
+ CANONICAL_WORKSPACE_SSO_APP_ORIGINS[
157
+ appId as keyof typeof CANONICAL_WORKSPACE_SSO_APP_ORIGINS
158
+ ];
159
+ if (canonicalOrigin === origin) return true;
160
+
161
+ if (
162
+ options.nodeEnv !== "production" &&
163
+ canonicalOrigin &&
164
+ isLoopbackOrigin(origin)
165
+ ) {
166
+ return true;
167
+ }
168
+
169
+ return parseWorkspaceSsoAppRegistrations(options.registryRaw).some(
170
+ (registration) =>
171
+ registration.appId === appId && registration.origin === origin,
172
+ );
173
+ }