diff --git a/integrations/slack-gateway/browser_auth.go b/integrations/slack-gateway/browser_auth.go
index 90e2c13..ea6d0ab 100644
--- a/integrations/slack-gateway/browser_auth.go
+++ b/integrations/slack-gateway/browser_auth.go
@@ -5,24 +5,19 @@ import (
"strings"
)
-const (
- slackGatewayPrincipalIDHeader = "X-Spritz-User-Id"
- slackGatewayPrincipalEmailHeader = "X-Spritz-User-Email"
-)
-
type browserPrincipal struct {
ID string
Email string
}
-func requireBrowserPrincipal(w http.ResponseWriter, r *http.Request) (browserPrincipal, bool) {
- id := strings.TrimSpace(r.Header.Get(slackGatewayPrincipalIDHeader))
+func requireBrowserPrincipal(cfg config, w http.ResponseWriter, r *http.Request) (browserPrincipal, bool) {
+ id := strings.TrimSpace(r.Header.Get(cfg.BrowserAuthHeaderID))
if id == "" {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return browserPrincipal{}, false
}
return browserPrincipal{
ID: id,
- Email: strings.TrimSpace(r.Header.Get(slackGatewayPrincipalEmailHeader)),
+ Email: strings.TrimSpace(r.Header.Get(cfg.BrowserAuthHeaderEmail)),
}, true
}
diff --git a/integrations/slack-gateway/config.go b/integrations/slack-gateway/config.go
index 5b4e138..e63aedd 100644
--- a/integrations/slack-gateway/config.go
+++ b/integrations/slack-gateway/config.go
@@ -9,58 +9,62 @@ import (
)
type config struct {
- Addr string
- PublicURL string
- SlackClientID string
- SlackClientSecret string
- SlackSigningSecret string
- OAuthStateSecret string
- SlackAPIBaseURL string
- SlackBotScopes []string
- PresetID string
- BackendBaseURL string
- BackendFastAPIBaseURL string
- BackendInternalToken string
- SpritzBaseURL string
- SpritzServiceToken string
- PrincipalID string
- HTTPTimeout time.Duration
- DedupeTTL time.Duration
- ProcessingTimeout time.Duration
- SessionRetryInterval time.Duration
- StatusMessageDelay time.Duration
- RecoveryTimeout time.Duration
- PromptRetryInitial time.Duration
- PromptRetryMax time.Duration
- PromptRetryTimeout time.Duration
+ Addr string
+ PublicURL string
+ BrowserAuthHeaderID string
+ BrowserAuthHeaderEmail string
+ SlackClientID string
+ SlackClientSecret string
+ SlackSigningSecret string
+ OAuthStateSecret string
+ SlackAPIBaseURL string
+ SlackBotScopes []string
+ PresetID string
+ BackendBaseURL string
+ BackendFastAPIBaseURL string
+ BackendInternalToken string
+ SpritzBaseURL string
+ SpritzServiceToken string
+ PrincipalID string
+ HTTPTimeout time.Duration
+ DedupeTTL time.Duration
+ ProcessingTimeout time.Duration
+ SessionRetryInterval time.Duration
+ StatusMessageDelay time.Duration
+ RecoveryTimeout time.Duration
+ PromptRetryInitial time.Duration
+ PromptRetryMax time.Duration
+ PromptRetryTimeout time.Duration
}
func loadConfig() (config, error) {
cfg := config{
- Addr: envOrDefault("SPRITZ_SLACK_GATEWAY_ADDR", ":8080"),
- PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_GATEWAY_PUBLIC_URL")), "/"),
- SlackClientID: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_CLIENT_ID")),
- SlackClientSecret: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_CLIENT_SECRET")),
- SlackSigningSecret: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SIGNING_SECRET")),
- OAuthStateSecret: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_OAUTH_STATE_SECRET")),
- SlackAPIBaseURL: strings.TrimRight(envOrDefault("SPRITZ_SLACK_API_BASE_URL", "https://slack.com/api"), "/"),
- SlackBotScopes: splitCSV(envOrDefault("SPRITZ_SLACK_BOT_SCOPES", "app_mentions:read,channels:history,chat:write,im:history,mpim:history")),
- PresetID: strings.TrimSpace(envOrDefault("SPRITZ_SLACK_PRESET_ID", defaultSlackPresetID)),
- BackendBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_BASE_URL")), "/"),
- BackendFastAPIBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_FASTAPI_BASE_URL")), "/"),
- BackendInternalToken: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_INTERNAL_TOKEN")),
- SpritzBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SPRITZ_BASE_URL")), "/"),
- SpritzServiceToken: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SPRITZ_SERVICE_TOKEN")),
- PrincipalID: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_PRINCIPAL_ID")),
- HTTPTimeout: parseDurationEnv("SPRITZ_SLACK_HTTP_TIMEOUT", 15*time.Second),
- DedupeTTL: parseDurationEnv("SPRITZ_SLACK_DEDUPE_TTL", 10*time.Minute),
- ProcessingTimeout: parseDurationEnv("SPRITZ_SLACK_PROCESSING_TIMEOUT", 120*time.Second),
- SessionRetryInterval: parseDurationEnv("SPRITZ_SLACK_SESSION_RETRY_INTERVAL", time.Second),
- StatusMessageDelay: parseDurationEnv("SPRITZ_SLACK_STATUS_MESSAGE_DELAY", 5*time.Second),
- RecoveryTimeout: parseDurationEnv("SPRITZ_SLACK_RECOVERY_TIMEOUT", 120*time.Second),
- PromptRetryInitial: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_INITIAL", 250*time.Millisecond),
- PromptRetryMax: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_MAX", 2*time.Second),
- PromptRetryTimeout: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_TIMEOUT", 8*time.Second),
+ Addr: envOrDefault("SPRITZ_SLACK_GATEWAY_ADDR", ":8080"),
+ PublicURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_GATEWAY_PUBLIC_URL")), "/"),
+ BrowserAuthHeaderID: envOrDefault("SPRITZ_AUTH_HEADER_ID", "X-Spritz-User-Id"),
+ BrowserAuthHeaderEmail: envOrDefault("SPRITZ_AUTH_HEADER_EMAIL", "X-Spritz-User-Email"),
+ SlackClientID: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_CLIENT_ID")),
+ SlackClientSecret: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_CLIENT_SECRET")),
+ SlackSigningSecret: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SIGNING_SECRET")),
+ OAuthStateSecret: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_OAUTH_STATE_SECRET")),
+ SlackAPIBaseURL: strings.TrimRight(envOrDefault("SPRITZ_SLACK_API_BASE_URL", "https://slack.com/api"), "/"),
+ SlackBotScopes: splitCSV(envOrDefault("SPRITZ_SLACK_BOT_SCOPES", "app_mentions:read,channels:history,chat:write,im:history,mpim:history")),
+ PresetID: strings.TrimSpace(envOrDefault("SPRITZ_SLACK_PRESET_ID", defaultSlackPresetID)),
+ BackendBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_BASE_URL")), "/"),
+ BackendFastAPIBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_FASTAPI_BASE_URL")), "/"),
+ BackendInternalToken: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_BACKEND_INTERNAL_TOKEN")),
+ SpritzBaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SPRITZ_BASE_URL")), "/"),
+ SpritzServiceToken: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_SPRITZ_SERVICE_TOKEN")),
+ PrincipalID: strings.TrimSpace(os.Getenv("SPRITZ_SLACK_PRINCIPAL_ID")),
+ HTTPTimeout: parseDurationEnv("SPRITZ_SLACK_HTTP_TIMEOUT", 15*time.Second),
+ DedupeTTL: parseDurationEnv("SPRITZ_SLACK_DEDUPE_TTL", 10*time.Minute),
+ ProcessingTimeout: parseDurationEnv("SPRITZ_SLACK_PROCESSING_TIMEOUT", 120*time.Second),
+ SessionRetryInterval: parseDurationEnv("SPRITZ_SLACK_SESSION_RETRY_INTERVAL", time.Second),
+ StatusMessageDelay: parseDurationEnv("SPRITZ_SLACK_STATUS_MESSAGE_DELAY", 5*time.Second),
+ RecoveryTimeout: parseDurationEnv("SPRITZ_SLACK_RECOVERY_TIMEOUT", 120*time.Second),
+ PromptRetryInitial: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_INITIAL", 250*time.Millisecond),
+ PromptRetryMax: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_MAX", 2*time.Second),
+ PromptRetryTimeout: parseDurationEnv("SPRITZ_SLACK_PROMPT_RETRY_TIMEOUT", 8*time.Second),
}
if cfg.PublicURL == "" {
diff --git a/integrations/slack-gateway/gateway.go b/integrations/slack-gateway/gateway.go
index 6cc5546..0a2a7ee 100644
--- a/integrations/slack-gateway/gateway.go
+++ b/integrations/slack-gateway/gateway.go
@@ -33,6 +33,12 @@ func newSlackGateway(cfg config, logger *slog.Logger) *slackGateway {
if cfg.HTTPTimeout <= 0 {
cfg.HTTPTimeout = 15 * time.Second
}
+ if strings.TrimSpace(cfg.BrowserAuthHeaderID) == "" {
+ cfg.BrowserAuthHeaderID = envOrDefault("SPRITZ_AUTH_HEADER_ID", "X-Spritz-User-Id")
+ }
+ if strings.TrimSpace(cfg.BrowserAuthHeaderEmail) == "" {
+ cfg.BrowserAuthHeaderEmail = envOrDefault("SPRITZ_AUTH_HEADER_EMAIL", "X-Spritz-User-Email")
+ }
if cfg.DedupeTTL <= 0 {
cfg.DedupeTTL = 10 * time.Minute
}
diff --git a/integrations/slack-gateway/gateway_test.go b/integrations/slack-gateway/gateway_test.go
index 70ba21c..31b76a5 100644
--- a/integrations/slack-gateway/gateway_test.go
+++ b/integrations/slack-gateway/gateway_test.go
@@ -366,6 +366,57 @@ func TestWorkspaceManagementRendersManagedInstallations(t *testing.T) {
}
}
+func TestWorkspaceManagementAcceptsConfiguredBrowserAuthHeaders(t *testing.T) {
+ t.Setenv("SPRITZ_AUTH_HEADER_ID", "X-Forwarded-User")
+ t.Setenv("SPRITZ_AUTH_HEADER_EMAIL", "X-Forwarded-Email")
+
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/internal/v2/spritz/channel-installations/list" {
+ t.Fatalf("unexpected backend path %s", r.URL.Path)
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "status": "resolved",
+ "installations": []map[string]any{
+ {
+ "route": map[string]any{
+ "principalId": "shared-slack-gateway",
+ "provider": "slack",
+ "externalScopeType": "workspace",
+ "externalTenantId": "T_workspace_1",
+ },
+ "state": "ready",
+ "currentTarget": map[string]any{
+ "id": "ag_workspace",
+ "profile": map[string]any{
+ "name": "Workspace Helper",
+ },
+ "ownerLabel": "Personal",
+ },
+ "allowedActions": []string{"changeTarget", "disconnect"},
+ },
+ },
+ })
+ }))
+ defer backend.Close()
+
+ gateway := newSlackGateway(config{
+ BackendFastAPIBaseURL: backend.URL,
+ BackendInternalToken: "backend-internal-token",
+ PrincipalID: "shared-slack-gateway",
+ HTTPTimeout: 5 * time.Second,
+ }, slog.New(slog.NewTextHandler(io.Discard, nil)))
+
+ req := httptest.NewRequest(http.MethodGet, "/slack/workspaces", nil)
+ req.Header.Set("X-Forwarded-User", "user-1")
+ req.Header.Set("X-Forwarded-Email", "user@example.com")
+ rec := httptest.NewRecorder()
+ gateway.routes().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
+ }
+}
+
func TestWorkspaceTargetPickerUsesCurrentBrowserPrincipal(t *testing.T) {
var listPayload map[string]any
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
diff --git a/integrations/slack-gateway/workspace_handlers.go b/integrations/slack-gateway/workspace_handlers.go
index 33555eb..54a510a 100644
--- a/integrations/slack-gateway/workspace_handlers.go
+++ b/integrations/slack-gateway/workspace_handlers.go
@@ -6,7 +6,7 @@ import (
)
func (g *slackGateway) handleWorkspaceManagement(w http.ResponseWriter, r *http.Request) {
- principal, ok := requireBrowserPrincipal(w, r)
+ principal, ok := requireBrowserPrincipal(g.cfg, w, r)
if !ok {
return
}
@@ -38,7 +38,7 @@ func (g *slackGateway) handleWorkspaceTarget(w http.ResponseWriter, r *http.Requ
}
func (g *slackGateway) handleWorkspaceTargetPicker(w http.ResponseWriter, r *http.Request) {
- principal, ok := requireBrowserPrincipal(w, r)
+ principal, ok := requireBrowserPrincipal(g.cfg, w, r)
if !ok {
return
}
@@ -89,7 +89,7 @@ func (g *slackGateway) handleWorkspaceTargetPicker(w http.ResponseWriter, r *htt
}
func (g *slackGateway) handleWorkspaceTargetUpdate(w http.ResponseWriter, r *http.Request) {
- principal, ok := requireBrowserPrincipal(w, r)
+ principal, ok := requireBrowserPrincipal(g.cfg, w, r)
if !ok {
return
}
@@ -151,7 +151,7 @@ func (g *slackGateway) handleWorkspaceDisconnect(w http.ResponseWriter, r *http.
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
- principal, ok := requireBrowserPrincipal(w, r)
+ principal, ok := requireBrowserPrincipal(g.cfg, w, r)
if !ok {
return
}
diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx
index c4fcd02..0546efd 100644
--- a/ui/src/App.test.tsx
+++ b/ui/src/App.test.tsx
@@ -3,6 +3,8 @@ import { render, screen } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
import { ConfigProvider, config } from '@/lib/config';
import { NoticeProvider } from '@/components/notice-banner';
+import * as AppModule from '@/App';
+import { buildLegacySlackGatewayRedirectURL } from '@/App';
// Mock the page components to keep tests simple
vi.mock('@/pages/chat', () => ({
@@ -76,4 +78,26 @@ describe('App routing', () => {
expect(screen.getByTestId('chat-page')).toBeDefined();
});
+
+ it('maps legacy Slack gateway SPA paths to the real gateway URL', () => {
+ expect(
+ buildLegacySlackGatewayRedirectURL(
+ '/spritz/slack-gateway/slack/workspaces',
+ '?teamId=T123',
+ '#details',
+ ),
+ ).toBe('/slack-gateway/slack/workspaces?teamId=T123#details');
+ });
+
+ it('redirects legacy Slack gateway routes instead of rendering a blank page', () => {
+ const replaceSpy = vi
+ .spyOn(AppModule.browserLocation, 'replace')
+ .mockImplementation(() => undefined);
+ window.history.pushState({}, '', '/spritz/slack-gateway/slack/workspaces');
+
+ render();
+
+ expect(replaceSpy).toHaveBeenCalledWith('/slack-gateway/slack/workspaces');
+ replaceSpy.mockRestore();
+ });
});
diff --git a/ui/src/App.tsx b/ui/src/App.tsx
index e7bf49a..a617d7b 100644
--- a/ui/src/App.tsx
+++ b/ui/src/App.tsx
@@ -1,4 +1,4 @@
-import { BrowserRouter, Routes, Route } from 'react-router-dom';
+import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom';
import { ConfigProvider, config } from '@/lib/config';
import { BrandingEffects } from '@/components/branding-effects';
import { NoticeProvider } from '@/components/notice-banner';
@@ -8,6 +8,40 @@ import { CreatePage } from '@/pages/create';
import { TerminalPage } from '@/pages/terminal';
import { chatCatchAllRoutePath } from '@/lib/urls';
+/**
+ * Maps legacy SPA-mounted Slack gateway paths to the real server-rendered
+ * gateway surface while preserving query and hash fragments.
+ */
+export function buildLegacySlackGatewayRedirectURL(
+ pathname: string,
+ search: string,
+ hash: string,
+): string {
+ const nextPath = pathname.startsWith('/spritz/') ? pathname.slice('/spritz'.length) : pathname;
+ return `${nextPath}${search}${hash}`;
+}
+
+/**
+ * Performs a full-page navigation to a non-SPA route.
+ */
+export const browserLocation = {
+ replace(url: string): void {
+ window.location.replace(url);
+ },
+};
+
+function LegacySlackGatewayRedirectPage() {
+ const location = useLocation();
+
+ if (typeof window !== 'undefined') {
+ browserLocation.replace(
+ buildLegacySlackGatewayRedirectURL(location.pathname, location.search, location.hash),
+ );
+ }
+
+ return null;
+}
+
export function App() {
return (
@@ -15,6 +49,10 @@ export function App() {
+ }
+ />
}>
} />
} />