diff --git a/.mise-tasks/zero/hosts.sh b/.mise-tasks/zero/hosts.sh
new file mode 100755
index 0000000000..b1aaa08ffe
--- /dev/null
+++ b/.mise-tasks/zero/hosts.sh
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# MISE description='Ensure setup.localhost is in /etc/hosts for enterprise onboarding dev.'
+# MISE hide=true
+
+ENTRY="127.0.0.1 setup.localhost"
+
+if grep -q 'setup\.localhost' /etc/hosts 2>/dev/null; then
+ echo "✅ setup.localhost already in /etc/hosts"
+ exit 0
+fi
+
+echo "Adding setup.localhost to /etc/hosts (requires sudo)"
+sudo sh -c "echo '$ENTRY' >> /etc/hosts"
+echo "✅ Added setup.localhost to /etc/hosts"
diff --git a/.mise-tasks/zero/tls.sh b/.mise-tasks/zero/tls.sh
index 853a113cce..cfa0ef1281 100755
--- a/.mise-tasks/zero/tls.sh
+++ b/.mise-tasks/zero/tls.sh
@@ -117,6 +117,7 @@ cert_names() {
"$server_host" \
"$assistant_host" \
"localhost" \
+ "setup.localhost" \
"127.0.0.1" \
"::1" \
"gram.local" \
diff --git a/client/dashboard/src/App.tsx b/client/dashboard/src/App.tsx
index bc6aacb23d..0cf482a653 100644
--- a/client/dashboard/src/App.tsx
+++ b/client/dashboard/src/App.tsx
@@ -31,6 +31,8 @@ import { usePageTitle } from "./hooks/use-page-title";
import CliCallback from "./pages/cli/CliCallback";
import SlackRegister from "./pages/slackapp/SlackRegister";
import { AppRoute, useRoutes, useOrgRoutes } from "./routes";
+import SetupPage from "./pages/setup/Setup";
+import { isSetupDomain } from "./lib/utils";
export default function App() {
const [theme, setTheme] = useState<"light" | "dark">("light");
@@ -246,6 +248,8 @@ const RouteProvider = () => {
} />
}>
+ {/* On the setup subdomain, render the wizard at "/" so the org slug stays hidden */}
+ {isSetupDomain() && } />}
{routesWithSubroutes(outsideStructureRoutes)}
@@ -257,6 +261,7 @@ const RouteProvider = () => {
/>
{routesWithSubroutes(authenticatedRoutes)}
+ } />
}>
{orgHomeRoute?.component && (
} />
diff --git a/client/dashboard/src/components/app-layout.tsx b/client/dashboard/src/components/app-layout.tsx
index 70c4e2f824..ea9e248058 100644
--- a/client/dashboard/src/components/app-layout.tsx
+++ b/client/dashboard/src/components/app-layout.tsx
@@ -2,10 +2,12 @@ import { useIsAdmin, useOrganization, useSession } from "@/contexts/Auth.tsx";
import { useSdkClient } from "@/contexts/Sdk.tsx";
import { useRBAC } from "@/hooks/useRBAC";
import { useObservabilityMcpConfig } from "@/hooks/useObservabilityMcpConfig";
+import { isSetupDomain } from "@/lib/utils";
import { Icon, Modal, ModalProvider } from "@speakeasy-api/moonshine";
import { ShieldAlert } from "lucide-react";
import { useCallback, useMemo } from "react";
import { Navigate, Outlet, useLocation } from "react-router";
+import Login from "@/pages/login/Login";
import { AppSidebar } from "./app-sidebar.tsx";
import { BrandGradientLine } from "./brand-gradient-line.tsx";
import { InsightsProvider } from "./insights-sidebar.tsx";
@@ -19,6 +21,10 @@ export const LoginCheck = () => {
const location = useLocation();
if (session.session === "") {
+ // On setup domain, render login inline — no redirect, no URL change
+ if (isSetupDomain()) {
+ return ;
+ }
const redirectTo = encodeURIComponent(location.pathname + location.search);
return ;
}
diff --git a/client/dashboard/src/contexts/AuthProvider.tsx b/client/dashboard/src/contexts/AuthProvider.tsx
index af4aa6f0a8..97523b14fa 100644
--- a/client/dashboard/src/contexts/AuthProvider.tsx
+++ b/client/dashboard/src/contexts/AuthProvider.tsx
@@ -29,6 +29,7 @@ import {
useSearchParams,
} from "react-router";
import { orgRoutePaths } from "@/routes";
+import { isSetupDomain } from "@/lib/utils";
import { useSlugs } from "./Sdk";
import {
useCaptureUserAuthorizationEvent,
@@ -85,7 +86,9 @@ const AuthHandler = ({ children }: { children: React.ReactNode }) => {
// skeleton flash for logged-out users before the redirect to /login fires.
if (
location.pathname === "/" ||
- UNAUTHENTICATED_PATHS.some((p) => location.pathname.startsWith(p))
+ UNAUTHENTICATED_PATHS.some((p) => location.pathname.startsWith(p)) ||
+ location.pathname.endsWith("/setup") ||
+ isSetupDomain()
) {
return null;
}
@@ -148,6 +151,22 @@ const AuthHandler = ({ children }: { children: React.ReactNode }) => {
// Handle initial navigation
const redirectParam = searchParams.get("redirect");
+
+ // On the setup subdomain, always funnel authenticated users to the setup wizard.
+ // The setup domain renders the wizard at "/" so the org slug stays out of the URL.
+ // Early-return after the redirect so we never hit the slug-based navigation below
+ // (which would redirect to /:orgSlug and cause a loop).
+ if (isSetupDomain() && session.organization) {
+ if (location.pathname !== "/") {
+ return ;
+ }
+ return (
+
+ {children}
+
+ );
+ }
+
if (redirectParam) {
return ;
} else if (isSlugExempt) {
diff --git a/client/dashboard/src/lib/utils.ts b/client/dashboard/src/lib/utils.ts
index 0061a3e6a6..e5e1ecc983 100644
--- a/client/dashboard/src/lib/utils.ts
+++ b/client/dashboard/src/lib/utils.ts
@@ -11,6 +11,12 @@ export function cn(...inputs: ClassValue[]) {
// Use everywhere except the playground — MCP configs, callback URL
// displays, anything operator-facing.
export function getServerURL(): string {
+ // On the setup subdomain, route API calls through the current origin
+ // (Vite proxy in dev, same-origin in prod) so that session cookies —
+ // which are scoped to the setup host — are included by the browser.
+ if (isSetupDomain()) {
+ return window.location.origin;
+ }
return __GRAM_SERVER_URL__ ?? window.location.origin;
}
@@ -23,7 +29,10 @@ export function getPlaygroundMcpBaseURL(): string {
}
export function buildLoginRedirectURL(redirectTo: string | null): string {
- let href = `${getServerURL()}/rpc/auth.login`;
+ // On the setup subdomain, route auth through the current origin so the
+ // session cookie is scoped to setup.* (Vite proxies /rpc to the server).
+ const base = isSetupDomain() ? window.location.origin : getServerURL();
+ let href = `${base}/rpc/auth.login`;
if (redirectTo) href += `?redirect=${encodeURIComponent(redirectTo)}`;
return href;
}
@@ -42,6 +51,16 @@ export function assert(condition: unknown, message: string): asserts condition {
}
}
+/**
+ * Returns true when the dashboard is served from the setup subdomain
+ * (setup.getgram.ai in prod, setup.dev.getgram.ai in dev,
+ * setup.localhost in local dev, setup-pr-*.dev.getgram.ai in preview deploys).
+ */
+export function isSetupDomain(): boolean {
+ const hostname = window.location.hostname;
+ return hostname.startsWith("setup.") || hostname.startsWith("setup-");
+}
+
export function getCustomDomainCNAME(): string {
try {
const url = new URL(getServerURL());
diff --git a/client/dashboard/src/pages/login/Login.tsx b/client/dashboard/src/pages/login/Login.tsx
index badddee444..362c2e737f 100644
--- a/client/dashboard/src/pages/login/Login.tsx
+++ b/client/dashboard/src/pages/login/Login.tsx
@@ -1,6 +1,6 @@
import { useSession } from "@/contexts/Auth";
import { useRoutes } from "@/routes";
-import { buildLoginRedirectURL } from "@/lib/utils";
+import { buildLoginRedirectURL, isSetupDomain } from "@/lib/utils";
import { JourneyDemo } from "./components/journey-demo";
import { LoginSection } from "./components/login-section";
import { useSearchParams, useNavigate } from "react-router";
@@ -14,9 +14,14 @@ export default function Login() {
const explicitRedirect = searchParams.get("redirect");
const disposition = searchParams.get("disposition");
- const redirectTo =
- explicitRedirect ??
- (disposition ? `/?disposition=${encodeURIComponent(disposition)}` : null);
+ // On setup domain, redirect back to the setup origin after auth so the
+ // server doesn't send us to the default GRAM_SITE_URL (app domain).
+ const redirectTo = isSetupDomain()
+ ? window.location.origin
+ : (explicitRedirect ??
+ (disposition
+ ? `/?disposition=${encodeURIComponent(disposition)}`
+ : null));
useEffect(() => {
if (session.session !== "") {
// Disposition signups must reach the server-side Callback so
@@ -26,6 +31,11 @@ export default function Login() {
window.location.href = buildLoginRedirectURL(redirectTo);
return;
}
+ // On setup domain, always navigate to the setup wizard after auth
+ if (isSetupDomain()) {
+ navigate("/", { replace: true });
+ return;
+ }
if (redirectTo) {
navigate(redirectTo, { replace: true });
} else {
diff --git a/client/dashboard/src/pages/setup/Setup.tsx b/client/dashboard/src/pages/setup/Setup.tsx
new file mode 100644
index 0000000000..e0d9ab7b93
--- /dev/null
+++ b/client/dashboard/src/pages/setup/Setup.tsx
@@ -0,0 +1,5 @@
+import { EnterpriseSetupWizard } from "./components/onboarding-wizard";
+
+export default function SetupPage() {
+ return ;
+}
diff --git a/client/dashboard/src/pages/setup/components/onboarding-footer.tsx b/client/dashboard/src/pages/setup/components/onboarding-footer.tsx
new file mode 100644
index 0000000000..2d6c892da0
--- /dev/null
+++ b/client/dashboard/src/pages/setup/components/onboarding-footer.tsx
@@ -0,0 +1,33 @@
+export function OnboardingFooter() {
+ return (
+
+ );
+}
diff --git a/client/dashboard/src/pages/setup/components/onboarding-header.tsx b/client/dashboard/src/pages/setup/components/onboarding-header.tsx
new file mode 100644
index 0000000000..fcb2985f20
--- /dev/null
+++ b/client/dashboard/src/pages/setup/components/onboarding-header.tsx
@@ -0,0 +1,47 @@
+import { RotateCcw, X } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import { Logo } from "@speakeasy-api/moonshine";
+
+interface OnboardingHeaderProps {
+ onRestart?: () => void;
+ onLeave?: () => void;
+}
+
+export function OnboardingHeader({
+ onRestart,
+ onLeave,
+}: OnboardingHeaderProps) {
+ return (
+
+ );
+}
diff --git a/client/dashboard/src/pages/setup/components/onboarding-stepper.tsx b/client/dashboard/src/pages/setup/components/onboarding-stepper.tsx
new file mode 100644
index 0000000000..3a5ae2ac7c
--- /dev/null
+++ b/client/dashboard/src/pages/setup/components/onboarding-stepper.tsx
@@ -0,0 +1,90 @@
+import { Check } from "lucide-react";
+import { cn } from "@/lib/utils";
+
+export interface Step {
+ id: string;
+ title: string;
+ description: string;
+}
+
+interface OnboardingStepperProps {
+ steps: Step[];
+ currentStep: number;
+ onStepClick?: (index: number) => void;
+}
+
+export function OnboardingStepper({
+ steps,
+ currentStep,
+ onStepClick,
+}: OnboardingStepperProps) {
+ return (
+
+ );
+}
diff --git a/client/dashboard/src/pages/setup/components/onboarding-wizard.tsx b/client/dashboard/src/pages/setup/components/onboarding-wizard.tsx
new file mode 100644
index 0000000000..81c5afc6db
--- /dev/null
+++ b/client/dashboard/src/pages/setup/components/onboarding-wizard.tsx
@@ -0,0 +1,136 @@
+import { useState, useCallback } from "react";
+import { useNavigate } from "react-router";
+import { OnboardingHeader } from "./onboarding-header";
+import { OnboardingFooter } from "./onboarding-footer";
+import { OnboardingStepper, type Step } from "./onboarding-stepper";
+import {
+ ConnectIdpStep,
+ DirectorySyncStep,
+ InstrumentAgentsStep,
+ AddSourcesStep,
+ ConfirmTrafficStep,
+} from "./steps";
+
+const STEPS: Step[] = [
+ {
+ id: "connect-idp",
+ title: "Connect identity provider",
+ description: "Link SSO for authentication",
+ },
+ {
+ id: "directory-sync",
+ title: "Directory sync",
+ description: "Confirm users and roles",
+ },
+ {
+ id: "instrument-agents",
+ title: "Instrument agents",
+ description: "Connect AI coding assistants",
+ },
+ {
+ id: "add-sources",
+ title: "Add MCP sources",
+ description: "Configure tools and data access",
+ },
+ {
+ id: "confirm-traffic",
+ title: "Confirm traffic",
+ description: "Verify connectivity and compliance",
+ },
+];
+
+export function EnterpriseSetupWizard() {
+ const navigate = useNavigate();
+ const [currentStep, setCurrentStep] = useState(0);
+
+ const goToStep = useCallback(
+ (index: number) => {
+ if (index < currentStep) {
+ setCurrentStep(index);
+ }
+ },
+ [currentStep],
+ );
+
+ const completeCurrentStep = useCallback(() => {
+ const nextIndex = currentStep + 1;
+ if (nextIndex < STEPS.length) {
+ setCurrentStep(nextIndex);
+ } else {
+ // All steps completed - redirect to org home
+ navigate("..");
+ }
+ }, [currentStep, navigate]);
+
+ const goBack = useCallback(() => {
+ if (currentStep > 0) {
+ setCurrentStep(currentStep - 1);
+ }
+ }, [currentStep]);
+
+ const handleRestart = () => {
+ setCurrentStep(0);
+ };
+
+ const handleLeave = () => {
+ navigate("..");
+ };
+
+ const renderStep = () => {
+ switch (currentStep) {
+ case 0:
+ return ;
+ case 1:
+ return (
+
+ );
+ case 2:
+ return (
+
+ );
+ case 3:
+ return (
+
+ );
+ case 4:
+ return (
+
+ );
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+ {/* Header */}
+
+
+ {/* Main content */}
+
+
+ {/* Left: Stepper */}
+
+
+
+
+ {/* Right: Step content */}
+
{renderStep()}
+
+
+
+ {/* Footer */}
+
+
+ );
+}
diff --git a/client/dashboard/src/pages/setup/components/step-container.tsx b/client/dashboard/src/pages/setup/components/step-container.tsx
new file mode 100644
index 0000000000..4200ba2c0c
--- /dev/null
+++ b/client/dashboard/src/pages/setup/components/step-container.tsx
@@ -0,0 +1,72 @@
+import type { ReactNode } from "react";
+import { ArrowRight, ArrowLeft } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+interface StepContainerProps {
+ icon: ReactNode;
+ title: string;
+ description: string;
+ children: ReactNode;
+ onContinue?: () => void;
+ onBack?: () => void;
+ continueLabel?: string;
+ showBack?: boolean;
+ isLoading?: boolean;
+ canContinue?: boolean;
+}
+
+export function StepContainer({
+ icon,
+ title,
+ description,
+ children,
+ onContinue,
+ onBack,
+ continueLabel = "Continue",
+ showBack = false,
+ isLoading = false,
+ canContinue = true,
+}: StepContainerProps) {
+ return (
+
+ {/* Icon */}
+
{icon}
+
+ {/* Header */}
+
+ {title}
+
+
{description}
+
+ {/* Content */}
+
{children}
+
+ {/* Divider */}
+
+
+ {/* Actions */}
+
+
+ {showBack && (
+
+ )}
+
+
+
+
+ );
+}
diff --git a/client/dashboard/src/pages/setup/components/steps/add-sources-step.tsx b/client/dashboard/src/pages/setup/components/steps/add-sources-step.tsx
new file mode 100644
index 0000000000..51bbc24bf8
--- /dev/null
+++ b/client/dashboard/src/pages/setup/components/steps/add-sources-step.tsx
@@ -0,0 +1,166 @@
+import { useState } from "react";
+import { Database, Search } from "lucide-react";
+import { StepContainer } from "../step-container";
+import { MCP_SOURCES } from "../../mock-data";
+import type { McpSource } from "../../types";
+import { Switch } from "@/components/ui/switch";
+import { Input } from "@/components/ui/input";
+import { cn } from "@/lib/utils";
+
+interface AddSourcesStepProps {
+ onComplete: () => void;
+ onBack: () => void;
+}
+
+export function AddSourcesStep({ onComplete, onBack }: AddSourcesStepProps) {
+ const [sources, setSources] = useState(MCP_SOURCES);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [activeTab, setActiveTab] = useState<"all" | "1st-party" | "3rd-party">(
+ "all",
+ );
+
+ const toggleSource = (sourceId: string) => {
+ setSources((prev) =>
+ prev.map((s) => (s.id === sourceId ? { ...s, enabled: !s.enabled } : s)),
+ );
+ };
+
+ const filteredSources = sources.filter((source) => {
+ const matchesSearch = source.name
+ .toLowerCase()
+ .includes(searchQuery.toLowerCase());
+ const matchesTab = activeTab === "all" || source.type === activeTab;
+ return matchesSearch && matchesTab;
+ });
+
+ const enabledCount = sources.filter((s) => s.enabled).length;
+ const firstPartyEnabled = sources.filter(
+ (s) => s.type === "1st-party" && s.enabled,
+ ).length;
+ const thirdPartyEnabled = sources.filter(
+ (s) => s.type === "3rd-party" && s.enabled,
+ ).length;
+
+ return (
+
+
+
+ }
+ title="Add MCP sources"
+ description="Configure which tools and data sources your agents can access. Sanctioned servers will be distributed to your team."
+ onContinue={onComplete}
+ continueLabel="Continue"
+ showBack
+ onBack={onBack}
+ >
+
+ {/* Stats */}
+
+
+
+ {enabledCount}
+
+
Total enabled
+
+
+
+ {firstPartyEnabled}
+
+
1st party
+
+
+
+ {thirdPartyEnabled}
+
+
3rd party
+
+
+
+ {/* Search and filter */}
+
+
+
+ setSearchQuery(value)}
+ className="pl-9"
+ />
+
+
+ {(["all", "1st-party", "3rd-party"] as const).map((tab) => (
+
+ ))}
+
+
+
+ {/* Sources grid */}
+
+ {filteredSources.map((source) => (
+
+
+
+ {source.name.charAt(0)}
+
+
+
+
+
+ {source.name}
+
+
+ {source.type === "1st-party" ? "1st" : "3rd"}
+
+
+
+ {source.description}
+
+
+
toggleSource(source.id)}
+ />
+
+ ))}
+
+
+
+ );
+}
diff --git a/client/dashboard/src/pages/setup/components/steps/confirm-traffic-step.tsx b/client/dashboard/src/pages/setup/components/steps/confirm-traffic-step.tsx
new file mode 100644
index 0000000000..d26fbf0e82
--- /dev/null
+++ b/client/dashboard/src/pages/setup/components/steps/confirm-traffic-step.tsx
@@ -0,0 +1,230 @@
+import { useState, useEffect } from "react";
+import {
+ Activity,
+ Check,
+ Loader2,
+ TrendingUp,
+ TrendingDown,
+ Minus,
+ PartyPopper,
+} from "lucide-react";
+import { StepContainer } from "../step-container";
+import { MOCK_TRAFFIC_METRICS } from "../../mock-data";
+import type { TrafficMetric } from "../../types";
+import { cn } from "@/lib/utils";
+
+interface ConfirmTrafficStepProps {
+ onComplete: () => void;
+ onBack: () => void;
+}
+
+export function ConfirmTrafficStep({
+ onComplete,
+ onBack,
+}: ConfirmTrafficStepProps) {
+ const [checking, setChecking] = useState(true);
+ const [metrics, setMetrics] = useState([]);
+ const [allHealthy, setAllHealthy] = useState(false);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setMetrics(MOCK_TRAFFIC_METRICS);
+ setChecking(false);
+ setAllHealthy(MOCK_TRAFFIC_METRICS.every((m) => m.healthy));
+ }, 2500);
+ return () => clearTimeout(timer);
+ }, []);
+
+ const TrendIcon = ({ trend }: { trend: TrafficMetric["trend"] }) => {
+ if (trend === "up") return ;
+ if (trend === "down")
+ return ;
+ return ;
+ };
+
+ if (checking) {
+ return (
+
+
+
+ }
+ title="Verifying traffic"
+ description="Checking that everything is connected and working properly..."
+ onContinue={() => {}}
+ showBack
+ onBack={onBack}
+ canContinue={false}
+ isLoading
+ >
+
+
+
+ Verifying connectivity and compliance
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ }
+ title="Confirm traffic"
+ description="Verify that traffic is flowing and your team is compliant with configured policies."
+ onContinue={onComplete}
+ continueLabel="Go to Dashboard"
+ showBack
+ onBack={onBack}
+ >
+
+ {/* Health status banner */}
+
+
+
+
+
+
+
+ {allHealthy ? "All systems healthy" : "Attention required"}
+
+
+ {allHealthy
+ ? "Traffic is flowing and compliance is verified"
+ : "Some metrics require your attention"}
+
+
+
+
+
+ {/* Metrics grid */}
+
+ {metrics.map((metric, index) => (
+
+
+
+ {metric.label}
+
+
+
+
+ {metric.value}
+
+
+ ))}
+
+
+ {/* Live activity */}
+
+
+
+ Recent activity
+
+
+
+ Live
+
+
+
+ {[
+ {
+ user: "sarah.chen@acme.com",
+ action: "Accessed GitHub MCP",
+ time: "2s ago",
+ status: "allowed",
+ },
+ {
+ user: "marcus.j@acme.com",
+ action: "Tool call: read_file",
+ time: "5s ago",
+ status: "allowed",
+ },
+ {
+ user: "e.rodriguez@acme.com",
+ action: "Requested: npm_install",
+ time: "12s ago",
+ status: "pending",
+ },
+ {
+ user: "d.kim@acme.com",
+ action: "Accessed Slack MCP",
+ time: "18s ago",
+ status: "allowed",
+ },
+ ].map((event, i) => (
+
+
+
+ {event.user}
+
+ {" "}
+ - {event.action}
+
+
+
+ {event.time}
+
+
+ ))}
+
+
+
+ {/* Success message */}
+ {allHealthy && (
+
+
+
+
+
+ Setup complete!
+
+
+ Your organization is ready to use Speakeasy.
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/client/dashboard/src/pages/setup/components/steps/connect-idp-step.tsx b/client/dashboard/src/pages/setup/components/steps/connect-idp-step.tsx
new file mode 100644
index 0000000000..12bb6c05c2
--- /dev/null
+++ b/client/dashboard/src/pages/setup/components/steps/connect-idp-step.tsx
@@ -0,0 +1,139 @@
+import { useState } from "react";
+import { KeyRound, Check, ExternalLink } from "lucide-react";
+import { StepContainer } from "../step-container";
+import { IDP_PROVIDERS } from "../../mock-data";
+import { cn } from "@/lib/utils";
+
+interface ConnectIdpStepProps {
+ onComplete: () => void;
+}
+
+export function ConnectIdpStep({ onComplete }: ConnectIdpStepProps) {
+ const [selectedProvider, setSelectedProvider] = useState(null);
+ const [isConnecting, setIsConnecting] = useState(false);
+ const [isConnected, setIsConnected] = useState(false);
+
+ const handleConnect = async () => {
+ if (!selectedProvider) return;
+
+ setIsConnecting(true);
+ // Simulate connection
+ await new Promise((resolve) => setTimeout(resolve, 1500));
+ setIsConnecting(false);
+ setIsConnected(true);
+ };
+
+ const handleContinue = () => {
+ if (isConnected) {
+ onComplete();
+ } else {
+ handleConnect();
+ }
+ };
+
+ return (
+
+
+
+ }
+ title="Connect identity provider"
+ description="Connect your SSO provider to enable secure authentication for your team. This allows employees to sign in with their existing credentials."
+ onContinue={handleContinue}
+ continueLabel={
+ isConnected ? "Continue" : isConnecting ? "Connecting..." : "Connect"
+ }
+ isLoading={isConnecting}
+ canContinue={!!selectedProvider}
+ >
+
+
+
+
+ {IDP_PROVIDERS.map((provider) => (
+
+ ))}
+
+
+
+ {selectedProvider && !isConnected && (
+
+
+
+
+
+
+
+ {"You'll"} be redirected to complete setup
+
+
+ After clicking Connect, {"you'll"} be taken to your identity
+ provider to authorize the connection.
+
+
+
+
+ )}
+
+ {isConnected && (
+
+
+
+
+
+
+
+ Successfully connected
+
+
+ Your identity provider is now linked. Directory sync will
+ begin automatically.
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/client/dashboard/src/pages/setup/components/steps/directory-sync-step.tsx b/client/dashboard/src/pages/setup/components/steps/directory-sync-step.tsx
new file mode 100644
index 0000000000..eb929d1726
--- /dev/null
+++ b/client/dashboard/src/pages/setup/components/steps/directory-sync-step.tsx
@@ -0,0 +1,209 @@
+import { useState, useEffect } from "react";
+import { Users, Check, ChevronDown, ChevronUp, Loader2 } from "lucide-react";
+import { StepContainer } from "../step-container";
+import { MOCK_DIRECTORY_USERS } from "../../mock-data";
+import type { DirectoryUser } from "../../types";
+import { Badge } from "@/components/ui/badge";
+import { cn } from "@/lib/utils";
+
+interface DirectorySyncStepProps {
+ onComplete: () => void;
+ onBack: () => void;
+}
+
+export function DirectorySyncStep({
+ onComplete,
+ onBack,
+}: DirectorySyncStepProps) {
+ const [syncing, setSyncing] = useState(true);
+ const [users, setUsers] = useState([]);
+ const [showAllMembers, setShowAllMembers] = useState(false);
+
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setUsers(MOCK_DIRECTORY_USERS);
+ setSyncing(false);
+ }, 2000);
+ return () => clearTimeout(timer);
+ }, []);
+
+ const admins = users.filter((u) => u.role === "admin");
+ const members = users.filter((u) => u.role === "member");
+ const displayedMembers = showAllMembers ? members : members.slice(0, 3);
+
+ const toggleUserRole = (userId: string) => {
+ setUsers((prev) =>
+ prev.map((u) =>
+ u.id === userId
+ ? { ...u, role: u.role === "admin" ? "member" : "admin" }
+ : u,
+ ),
+ );
+ };
+
+ if (syncing) {
+ return (
+
+
+
+ }
+ title="Syncing directory"
+ description="Fetching users and groups from your identity provider..."
+ onContinue={() => {}}
+ showBack
+ onBack={onBack}
+ canContinue={false}
+ >
+
+
+
+ This may take a moment
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ }
+ title="Confirm directory sync"
+ description="Review the users synced from your identity provider. Toggle roles as needed - admins can manage policies and settings."
+ onContinue={onComplete}
+ continueLabel="Continue"
+ showBack
+ onBack={onBack}
+ >
+
+ {/* Summary stats */}
+
+
+
+ {admins.length}
+
+
Administrators
+
+
+
+ {members.length}
+
+
Members
+
+
+
+ {/* Admins */}
+
+
+
+ {admins.map((user) => (
+ toggleUserRole(user.id)}
+ />
+ ))}
+
+
+
+ {/* Members */}
+
+
+
+ {displayedMembers.map((user) => (
+ toggleUserRole(user.id)}
+ />
+ ))}
+
+ {members.length > 3 && (
+
+ )}
+
+
+ {/* Sync complete notice */}
+
+
+
+
+
+
+
+ Directory sync complete
+
+
+ {users.length} users imported. Roles will sync automatically
+ going forward.
+
+
+
+
+
+
+ );
+}
+
+function UserRow({
+ user,
+ onToggleRole,
+}: {
+ user: DirectoryUser;
+ onToggleRole: () => void;
+}) {
+ return (
+
+
+
+ {user.name
+ .split(" ")
+ .map((n) => n[0])
+ .join("")}
+
+
+
+
+ {user.name}
+
+
{user.email}
+
+
+
+ );
+}
diff --git a/client/dashboard/src/pages/setup/components/steps/index.ts b/client/dashboard/src/pages/setup/components/steps/index.ts
new file mode 100644
index 0000000000..82981ad0c4
--- /dev/null
+++ b/client/dashboard/src/pages/setup/components/steps/index.ts
@@ -0,0 +1,5 @@
+export { ConnectIdpStep } from "./connect-idp-step";
+export { DirectorySyncStep } from "./directory-sync-step";
+export { InstrumentAgentsStep } from "./instrument-agents-step";
+export { AddSourcesStep } from "./add-sources-step";
+export { ConfirmTrafficStep } from "./confirm-traffic-step";
diff --git a/client/dashboard/src/pages/setup/components/steps/instrument-agents-step.tsx b/client/dashboard/src/pages/setup/components/steps/instrument-agents-step.tsx
new file mode 100644
index 0000000000..286af34fac
--- /dev/null
+++ b/client/dashboard/src/pages/setup/components/steps/instrument-agents-step.tsx
@@ -0,0 +1,168 @@
+import { useState } from "react";
+import { Terminal, Check, Copy, ExternalLink } from "lucide-react";
+import { StepContainer } from "../step-container";
+import { AGENT_PLATFORMS } from "../../mock-data";
+import type { AgentPlatform } from "../../types";
+import { Switch } from "@/components/ui/switch";
+import { Button } from "@/components/ui/button";
+import { cn } from "@/lib/utils";
+
+interface InstrumentAgentsStepProps {
+ onComplete: () => void;
+ onBack: () => void;
+}
+
+const MOCK_WEBHOOK_URL = "https://api.speakeasy.com/hooks/acme-corp/agents";
+const MOCK_API_KEY = "sk_live_speakeasy_xxxxxxxxxxxxx";
+
+export function InstrumentAgentsStep({
+ onComplete,
+ onBack,
+}: InstrumentAgentsStepProps) {
+ const [platforms, setPlatforms] = useState(AGENT_PLATFORMS);
+ const [copiedField, setCopiedField] = useState(null);
+
+ const connectedCount = platforms.filter((p) => p.connected).length;
+
+ const togglePlatform = (platformId: string) => {
+ setPlatforms((prev) =>
+ prev.map((p) =>
+ p.id === platformId ? { ...p, connected: !p.connected } : p,
+ ),
+ );
+ };
+
+ const copyToClipboard = async (text: string, field: string) => {
+ await navigator.clipboard.writeText(text);
+ setCopiedField(field);
+ setTimeout(() => setCopiedField(null), 2000);
+ };
+
+ return (
+
+
+
+ }
+ title="Instrument agent platforms"
+ description="Connect the AI coding assistants used by your team. This enables traffic monitoring and policy enforcement."
+ onContinue={onComplete}
+ continueLabel="Continue"
+ showBack
+ onBack={onBack}
+ >
+
+ {/* Credentials */}
+
+
+
+ Use these credentials to configure your agent platforms.
+
+
+
+
+
+
+ {MOCK_WEBHOOK_URL}
+
+
+
+
+
+
+
+
+ {MOCK_API_KEY}
+
+
+
+
+
+
+
+ {/* Platforms */}
+
+
+
+
+ {connectedCount} of {platforms.length} enabled
+
+
+
+ {platforms.map((platform) => (
+
+
+
+ {platform.name.charAt(0)}
+
+
+
+
+ {platform.name}
+
+
+ {platform.description}
+
+
+
+
togglePlatform(platform.id)}
+ />
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/client/dashboard/src/pages/setup/mock-data.ts b/client/dashboard/src/pages/setup/mock-data.ts
new file mode 100644
index 0000000000..4ccfa28288
--- /dev/null
+++ b/client/dashboard/src/pages/setup/mock-data.ts
@@ -0,0 +1,180 @@
+import type {
+ IdpProvider,
+ DirectoryUser,
+ AgentPlatform,
+ McpSource,
+ TrafficMetric,
+} from "./types";
+
+export const IDP_PROVIDERS: IdpProvider[] = [
+ {
+ id: "okta",
+ name: "Okta",
+ icon: "O",
+ type: "SAML 2.0 / OIDC",
+ connected: false,
+ },
+ {
+ id: "azure",
+ name: "Microsoft Entra ID",
+ icon: "M",
+ type: "SAML 2.0 / OIDC",
+ connected: false,
+ },
+ {
+ id: "google",
+ name: "Google Workspace",
+ icon: "G",
+ type: "OIDC",
+ connected: false,
+ },
+ {
+ id: "onelogin",
+ name: "OneLogin",
+ icon: "1",
+ type: "SAML 2.0",
+ connected: false,
+ },
+ {
+ id: "jumpcloud",
+ name: "JumpCloud",
+ icon: "J",
+ type: "SAML 2.0 / OIDC",
+ connected: false,
+ },
+];
+
+export const MOCK_DIRECTORY_USERS: DirectoryUser[] = [
+ { id: "1", name: "Sarah Chen", email: "sarah.chen@acme.com", role: "admin" },
+ {
+ id: "2",
+ name: "Marcus Johnson",
+ email: "marcus.j@acme.com",
+ role: "admin",
+ },
+ {
+ id: "3",
+ name: "Emily Rodriguez",
+ email: "e.rodriguez@acme.com",
+ role: "member",
+ },
+ { id: "4", name: "David Kim", email: "d.kim@acme.com", role: "member" },
+ {
+ id: "5",
+ name: "Lisa Thompson",
+ email: "l.thompson@acme.com",
+ role: "member",
+ },
+ { id: "6", name: "James Wilson", email: "j.wilson@acme.com", role: "member" },
+ {
+ id: "7",
+ name: "Anna Martinez",
+ email: "a.martinez@acme.com",
+ role: "member",
+ },
+ { id: "8", name: "Michael Brown", email: "m.brown@acme.com", role: "member" },
+];
+
+export const AGENT_PLATFORMS: AgentPlatform[] = [
+ {
+ id: "cursor",
+ name: "Cursor",
+ description: "AI-powered code editor",
+ icon: "cursor",
+ connected: false,
+ },
+ {
+ id: "windsurf",
+ name: "Windsurf",
+ description: "Codeium IDE",
+ icon: "windsurf",
+ connected: false,
+ },
+ {
+ id: "claude-desktop",
+ name: "Claude Desktop",
+ description: "Anthropic desktop app",
+ icon: "claude",
+ connected: false,
+ },
+ {
+ id: "vscode",
+ name: "VS Code + Copilot",
+ description: "GitHub Copilot integration",
+ icon: "vscode",
+ connected: false,
+ },
+ {
+ id: "jetbrains",
+ name: "JetBrains IDEs",
+ description: "IntelliJ, PyCharm, etc.",
+ icon: "jetbrains",
+ connected: false,
+ },
+];
+
+export const MCP_SOURCES: McpSource[] = [
+ {
+ id: "github",
+ name: "GitHub",
+ type: "1st-party",
+ description: "Code repositories and issues",
+ enabled: false,
+ },
+ {
+ id: "slack",
+ name: "Slack",
+ type: "1st-party",
+ description: "Team messaging and channels",
+ enabled: false,
+ },
+ {
+ id: "linear",
+ name: "Linear",
+ type: "1st-party",
+ description: "Issue tracking and projects",
+ enabled: false,
+ },
+ {
+ id: "notion",
+ name: "Notion",
+ type: "1st-party",
+ description: "Documents and wikis",
+ enabled: false,
+ },
+ {
+ id: "jira",
+ name: "Jira",
+ type: "3rd-party",
+ description: "Project management",
+ enabled: false,
+ },
+ {
+ id: "confluence",
+ name: "Confluence",
+ type: "3rd-party",
+ description: "Documentation platform",
+ enabled: false,
+ },
+ {
+ id: "datadog",
+ name: "Datadog",
+ type: "3rd-party",
+ description: "Monitoring and analytics",
+ enabled: false,
+ },
+ {
+ id: "sentry",
+ name: "Sentry",
+ type: "3rd-party",
+ description: "Error tracking",
+ enabled: false,
+ },
+];
+
+export const MOCK_TRAFFIC_METRICS: TrafficMetric[] = [
+ { label: "Active Users", value: "24", trend: "up", healthy: true },
+ { label: "Tool Requests", value: "1,247", trend: "up", healthy: true },
+ { label: "Blocked Calls", value: "38", trend: "down", healthy: true },
+ { label: "Compliance Rate", value: "96.8%", trend: "stable", healthy: true },
+];
diff --git a/client/dashboard/src/pages/setup/types.ts b/client/dashboard/src/pages/setup/types.ts
new file mode 100644
index 0000000000..658ce8abdc
--- /dev/null
+++ b/client/dashboard/src/pages/setup/types.ts
@@ -0,0 +1,86 @@
+export type OnboardingStep =
+ | "connect-idp"
+ | "directory-sync"
+ | "instrument-agents"
+ | "add-sources"
+ | "confirm-traffic";
+
+export interface StepConfig {
+ id: OnboardingStep;
+ title: string;
+ description: string;
+ icon: string;
+}
+
+export const ONBOARDING_STEPS: StepConfig[] = [
+ {
+ id: "connect-idp",
+ title: "Connect Identity Provider",
+ description: "Link your SSO provider",
+ icon: "shield",
+ },
+ {
+ id: "directory-sync",
+ title: "Directory Sync",
+ description: "Confirm roles and admins",
+ icon: "users",
+ },
+ {
+ id: "instrument-agents",
+ title: "Instrument Agents",
+ description: "Setup agent platform hooks",
+ icon: "cpu",
+ },
+ {
+ id: "add-sources",
+ title: "Add MCP Sources",
+ description: "Configure 1st & 3rd party sources",
+ icon: "database",
+ },
+ {
+ id: "confirm-traffic",
+ title: "Confirm Traffic",
+ description: "Verify compliance",
+ icon: "activity",
+ },
+];
+
+// Mock data types
+export interface IdpProvider {
+ id: string;
+ name: string;
+ icon: string;
+ type: string;
+ connected: boolean;
+}
+
+export interface DirectoryUser {
+ id: string;
+ name: string;
+ email: string;
+ role: "admin" | "member";
+ avatarUrl?: string;
+}
+
+export interface AgentPlatform {
+ id: string;
+ name: string;
+ description: string;
+ icon: string;
+ connected: boolean;
+}
+
+export interface McpSource {
+ id: string;
+ name: string;
+ type: "1st-party" | "3rd-party";
+ description: string;
+ enabled: boolean;
+}
+
+export interface TrafficMetric {
+ label: string;
+ value: string;
+ trend: "up" | "down" | "stable";
+ healthy: boolean;
+}
diff --git a/client/dashboard/vite.config.ts b/client/dashboard/vite.config.ts
index f9a740cb9d..cd2d88f8ce 100644
--- a/client/dashboard/vite.config.ts
+++ b/client/dashboard/vite.config.ts
@@ -92,7 +92,7 @@ export default defineConfig(({ command }) => {
},
server: {
host: true,
- allowedHosts: ["localhost", "127.0.0.1", "devbox"],
+ allowedHosts: ["localhost", "127.0.0.1", "devbox", "setup.localhost"],
https: key && cert ? { key, cert } : void 0,
// Setting these up to side-step cors issues experienced during
// development. Specifically, the Vercel AI SDK does not forward cookies
diff --git a/mise.toml b/mise.toml
index 32b886e359..ac6e3352a3 100644
--- a/mise.toml
+++ b/mise.toml
@@ -106,6 +106,11 @@ GRAM_ENVIRONMENT = "local"
GRAM_HOST = "localhost" # Only needed for local development. In production, you should set GRAM_SERVER_URL and GRAM_SITE_URL directly.
GRAM_SITE_PORT = "5173"
GRAM_SITE_URL = "https://{{env.GRAM_HOST}}:{{env.GRAM_SITE_PORT}}"
+GRAM_SETUP_SITE_URL = "https://setup.{{env.GRAM_HOST}}:{{env.GRAM_SITE_PORT}}"
+## In production set this to the apex domain (e.g. getgram.ai) so session
+## cookies are shared across subdomains (app.*, setup.*). Leave blank for
+## local dev — "localhost" is a reserved TLD and Chrome rejects Domain=localhost.
+# GRAM_COOKIE_DOMAIN = ""
GRAM_SERVER_URL = "https://{{env.GRAM_HOST}}:{{env.GRAM_SERVER_PORT}}"
GRAM_ASSETS_BACKEND = "fs"
GRAM_ASSETS_URI = ".assets"
diff --git a/server/cmd/gram/start.go b/server/cmd/gram/start.go
index c7319e1ef8..84e5a6c8b6 100644
--- a/server/cmd/gram/start.go
+++ b/server/cmd/gram/start.go
@@ -154,6 +154,16 @@ func newStartCommand() *cli.Command {
EnvVars: []string{"GRAM_SITE_URL"},
Required: true,
},
+ &cli.StringFlag{
+ Name: "setup-site-url",
+ Usage: "Origin of the enterprise setup subdomain (e.g. https://setup.getgram.ai)",
+ EnvVars: []string{"GRAM_SETUP_SITE_URL"},
+ },
+ &cli.StringFlag{
+ Name: "cookie-domain",
+ Usage: "Domain attribute for session cookies (e.g. getgram.ai, localhost)",
+ EnvVars: []string{"GRAM_COOKIE_DOMAIN"},
+ },
&cli.StringFlag{
Name: "database-url",
Usage: "Database URL",
@@ -916,7 +926,13 @@ func newStartCommand() *cli.Command {
mux.Use(middleware.NewHTTPLoggingMiddleware(logger))
mux.Use(middleware.NewRecovery(logger))
mux.Use(middleware.CORSMiddleware(c.String("environment"), c.String("server-url"), chatSessionsManager))
- mux.Use(customdomains.Middleware(logger, db, c.String("environment"), serverURL))
+ var setupSiteURL *url.URL
+ if raw := c.String("setup-site-url"); raw != "" {
+ if parsed, err := url.Parse(raw); err == nil {
+ setupSiteURL = parsed
+ }
+ }
+ mux.Use(customdomains.Middleware(logger, db, c.String("environment"), serverURL, setupSiteURL))
mux.Use(middleware.SessionMiddleware)
mux.Use(middleware.AdminOverrideMiddleware)
mux.Use(middleware.RBACOverrideMiddleware())
@@ -968,6 +984,8 @@ func newStartCommand() *cli.Command {
IDPBaseURL: c.String("idp-base-url"),
GramServerURL: c.String("server-url"),
SignInRedirectURL: auth.FormSignInRedirectURL(c.String("site-url")),
+ SetupSiteURL: c.String("setup-site-url"),
+ CookieDomain: c.String("cookie-domain"),
Environment: c.String("environment"),
},
authzEngine,
diff --git a/server/internal/auth/impl.go b/server/internal/auth/impl.go
index a1ca824041..22eab9e0ec 100644
--- a/server/internal/auth/impl.go
+++ b/server/internal/auth/impl.go
@@ -88,6 +88,8 @@ type AuthConfigurations struct {
IDPBaseURL string
GramServerURL string
SignInRedirectURL string
+ SetupSiteURL string // Origin of the enterprise setup subdomain (e.g. https://setup.getgram.ai)
+ CookieDomain string // Domain attribute for session cookies (e.g. "getgram.ai", "localhost")
Environment string
}
@@ -168,9 +170,53 @@ func Attach(mux goahttp.Muxer, service *Service) {
// context so validateAuthNonce() can verify it.
server.Callback = callbackNonceBindingMiddleware(server.Callback)
+ // If a cookie domain is configured, rewrite gram_session cookies to
+ // include Domain= so they're shared across subdomains (e.g. app.getgram.ai
+ // and setup.getgram.ai both under getgram.ai).
+ if service.cfg.CookieDomain != "" {
+ server.Login = cookieDomainMiddleware(service.cfg.CookieDomain)(server.Login)
+ server.Callback = cookieDomainMiddleware(service.cfg.CookieDomain)(server.Callback)
+ server.SwitchScopes = cookieDomainMiddleware(service.cfg.CookieDomain)(server.SwitchScopes)
+ server.Logout = cookieDomainMiddleware(service.cfg.CookieDomain)(server.Logout)
+ server.Info = cookieDomainMiddleware(service.cfg.CookieDomain)(server.Info)
+ }
+
srv.Mount(mux, server)
}
+// cookieDomainMiddleware rewrites Set-Cookie headers for auth cookies
+// (gram_session, gram_auth_nonce) to include the specified Domain attribute.
+// This is needed because the Goa generated code and nonce middleware set
+// cookies without a Domain, making them host-only.
+func cookieDomainMiddleware(domain string) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ wrapped := &cookieDomainWriter{ResponseWriter: w, domain: domain}
+ next.ServeHTTP(wrapped, r)
+ })
+ }
+}
+
+type cookieDomainWriter struct {
+ http.ResponseWriter
+ domain string
+}
+
+func (w *cookieDomainWriter) WriteHeader(statusCode int) {
+ headers := w.Header()
+ cookies := headers.Values("Set-Cookie")
+ if len(cookies) > 0 {
+ headers.Del("Set-Cookie")
+ for _, c := range cookies {
+ if strings.HasPrefix(c, "gram_session=") || strings.HasPrefix(c, nonceBindingCookie+"=") {
+ c += "; Domain=" + w.domain
+ }
+ headers.Add("Set-Cookie", c)
+ }
+ }
+ w.ResponseWriter.WriteHeader(statusCode)
+}
+
// loginNonceBindingMiddleware generates a random nonce-binding token, sets it
// as an HttpOnly/SameSite cookie, and stores it in the request context.
func loginNonceBindingMiddleware(env string) func(http.Handler) http.Handler {
@@ -1008,6 +1054,27 @@ func (s *Service) buildCallbackURL(ctx context.Context) string {
returnAddress = "https://" + requestCtx.Host
}
+ // If the login request came from the setup subdomain, route the IDP
+ // callback through the setup origin so the nonce-binding cookie is
+ // scoped correctly.
+ if s.cfg.SetupSiteURL != "" {
+ if requestCtx, ok := contextvalues.GetRequestContext(ctx); ok && requestCtx != nil {
+ setupURL, err := url.Parse(s.cfg.SetupSiteURL)
+ if err == nil {
+ // In local dev the Vite proxy rewrites Host, so check
+ // Referer. In deployed envs (dev/prod) the Host header
+ // arrives unmodified.
+ host := requestCtx.Host
+ if s.cfg.Environment == "local" {
+ host = requestCtx.RefererHost
+ }
+ if host == setupURL.Host {
+ returnAddress = strings.TrimRight(s.cfg.SetupSiteURL, "/")
+ }
+ }
+ }
+ }
+
return returnAddress + "/rpc/auth.callback"
}
@@ -1015,28 +1082,43 @@ func (s *Service) buildCallbackURL(ctx context.Context) string {
var validOrgNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\s-_]+$`)
// callbackRedirectURL determines the redirect location after authentication. It
-// only allows relative URLs to prevent open redirect attacks (see relativeURL).
+// only allows relative URLs to prevent open redirect attacks (see relativeURL),
+// with the exception of the trusted setup subdomain origin (SetupSiteURL).
// If no redirect is found, fall back to SignInRedirectURL.
func (s *Service) callbackRedirectURL(
ctx context.Context,
payload *gen.CallbackPayload,
) string {
- var location string
-
if state := decodeStateParam(payload); state != nil {
- location = relativeURL(state.FinalDestinationURL)
- }
-
- if location != "" {
- msg := fmt.Sprintf("Found destination URL in state: '%s'", location)
- s.logger.InfoContext(ctx, msg)
+ // Allow absolute redirects to the trusted setup subdomain.
+ if s.cfg.SetupSiteURL != "" && isTrustedSetupRedirect(state.FinalDestinationURL, s.cfg.SetupSiteURL) {
+ s.logger.InfoContext(ctx, fmt.Sprintf("Redirecting to setup domain: '%s'", state.FinalDestinationURL))
+ return state.FinalDestinationURL
+ }
- return location
+ if location := relativeURL(state.FinalDestinationURL); location != "" {
+ s.logger.InfoContext(ctx, fmt.Sprintf("Found destination URL in state: '%s'", location))
+ return location
+ }
}
return s.cfg.SignInRedirectURL
}
+// isTrustedSetupRedirect returns true if destURL is an absolute URL whose
+// origin (scheme + host) matches the trusted setup site URL.
+func isTrustedSetupRedirect(destURL, setupSiteURL string) bool {
+ dest, err := url.Parse(destURL)
+ if err != nil || dest.Host == "" {
+ return false
+ }
+ trusted, err := url.Parse(setupSiteURL)
+ if err != nil || trusted.Host == "" {
+ return false
+ }
+ return dest.Scheme == trusted.Scheme && dest.Host == trusted.Host
+}
+
// relativeURL converts any URL to a safe relative URL by extracting only the
// path, query, and fragment components.
//
diff --git a/server/internal/customdomains/middleware.go b/server/internal/customdomains/middleware.go
index fb5d4145d8..3333e0b38e 100644
--- a/server/internal/customdomains/middleware.go
+++ b/server/internal/customdomains/middleware.go
@@ -15,7 +15,7 @@ import (
"github.com/speakeasy-api/gram/server/internal/oops"
)
-func Middleware(logger *slog.Logger, db *pgxpool.Pool, env string, serverURL *url.URL) func(next http.Handler) http.Handler {
+func Middleware(logger *slog.Logger, db *pgxpool.Pool, env string, serverURL *url.URL, setupSiteURL *url.URL) func(next http.Handler) http.Handler {
domainsRepo := domainsRepo.New(db)
logger = logger.With(attr.SlogComponent("custom_domains_middleware"))
@@ -50,6 +50,11 @@ func Middleware(logger *slog.Logger, db *pgxpool.Pool, env string, serverURL *ur
return
}
+ if setupSiteURL != nil && host == setupSiteURL.Host {
+ next.ServeHTTP(w, r)
+ return
+ }
+
domain, err := domainsRepo.GetCustomDomainByDomain(ctx, host)
switch {
case errors.Is(err, pgx.ErrNoRows):
diff --git a/server/internal/customdomains/middleware_test.go b/server/internal/customdomains/middleware_test.go
index 098d43f0ad..6a6e91617e 100644
--- a/server/internal/customdomains/middleware_test.go
+++ b/server/internal/customdomains/middleware_test.go
@@ -214,7 +214,7 @@ func TestCustomDomainsMiddleware(t *testing.T) {
}
// Create the middleware
- middlewareFunc := customdomains.Middleware(logger, instance.conn, tt.env, serverURL)
+ middlewareFunc := customdomains.Middleware(logger, instance.conn, tt.env, serverURL, nil)
// Create a test handler that captures context and responds
var capturedCtx context.Context
@@ -278,7 +278,7 @@ func TestCustomDomainsMiddleware_DeletedDomain(t *testing.T) {
err = instance.domainsRepo.DeleteCustomDomain(ctx, "org-deleted")
require.NoError(t, err)
- middlewareFunc := customdomains.Middleware(logger, instance.conn, "prod", serverURL)
+ middlewareFunc := customdomains.Middleware(logger, instance.conn, "prod", serverURL, nil)
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("Handler should not be called for deleted domain")
@@ -310,7 +310,7 @@ func TestCustomDomainsMiddleware_DatabaseErrors(t *testing.T) {
require.NoError(t, err)
closedConn.Close()
- middlewareFunc := customdomains.Middleware(logger, closedConn, "prod", serverURL)
+ middlewareFunc := customdomains.Middleware(logger, closedConn, "prod", serverURL, nil)
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("Handler should not be called when database error occurs")
@@ -339,7 +339,7 @@ func TestCustomDomainsMiddleware_MissingHost(t *testing.T) {
serverURL, err := url.Parse("https://api.speakeasyapi.dev")
require.NoError(t, err)
- middlewareFunc := customdomains.Middleware(logger, instance.conn, "prod", serverURL)
+ middlewareFunc := customdomains.Middleware(logger, instance.conn, "prod", serverURL, nil)
testHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Error("Handler should not be called with empty host")
diff --git a/zero b/zero
index 07675954a7..4a1d48ccad 100755
--- a/zero
+++ b/zero
@@ -114,6 +114,8 @@ interactive_only mise run zero:melange
interactive_only mise run zero:fly
mise run zero:encryption
mise run zero:tls
+mise run zero:hosts
+
assistant_runtime_provider="$(mise env GRAM_ASSISTANT_RUNTIME_PROVIDER 2>/dev/null | sed -n 's/^export GRAM_ASSISTANT_RUNTIME_PROVIDER=//p')"
assistant_runtime_provider="${assistant_runtime_provider:-local}"
if [[ "$assistant_runtime_provider" == "local" ]]; then