diff --git a/AGENTS.md b/AGENTS.md index 6120c02..8708634 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,6 +11,14 @@ This repository is open-source and must remain provider- and organization-agnost - Never use TextCortex hostnames in test fixtures. - New or changed data structures, schemas, DTOs, CRDs, and API models must use `type` as the field name, never `kind`. If an external payload already sends `kind`, convert it at the boundary with an alias and keep the internal field named `type`. +## UI Ownership Rules (Mandatory) + +- Add user-facing UI inside the Spritz React app under `ui/`. +- Do not add new server-rendered HTML pages in gateway, API, operator, or integration services. +- Integration and gateway services should expose redirects, callbacks, event ingestion, and JSON APIs for the React UI to consume. +- If a temporary server-rendered page is unavoidable for an external protocol callback, document why it cannot be served by the React app and keep it minimal. +- When touching existing server-rendered integration pages, prefer migrating the surface to React over extending Go templates. + Exception: - Documentation `author` front matter may use a real maintainer identity, including a real email address, when the document is intentionally attributed to that maintainer. diff --git a/docs/2026-04-24-channel-install-message-policy-config.md b/docs/2026-04-24-channel-install-message-policy-config.md index c852a82..c5173d9 100644 --- a/docs/2026-04-24-channel-install-message-policy-config.md +++ b/docs/2026-04-24-channel-install-message-policy-config.md @@ -150,11 +150,16 @@ Recommended operation: - `channel.installation.config.update` +`installationId` is a deployment-backend-assigned opaque ID. Spritz should +receive it from the installation-management API, render it in UI URLs when +needed, and send it back unchanged. Spritz must not generate it, parse it, or +assume any specific prefix or ID-generation scheme. + Recommended request shape: ```json { - "installationId": "inst_123", + "installationId": "opaque-installation-id", "installationConfig": { "channelPolicies": [ { @@ -171,7 +176,7 @@ Recommended response shape: ```json { "installation": { - "id": "inst_123", + "id": "opaque-installation-id", "provider": "slack", "externalScopeType": "workspace", "externalTenantId": "T1234567890", diff --git a/docs/2026-04-24-channel-installation-data-model.md b/docs/2026-04-24-channel-installation-data-model.md index 3a90154..3b07b00 100644 --- a/docs/2026-04-24-channel-installation-data-model.md +++ b/docs/2026-04-24-channel-installation-data-model.md @@ -104,7 +104,7 @@ Spritz should define the generic channel-installation contract. That includes: - provider route identity fields -- stable installation, connection, and route IDs +- stable opaque installation, connection, and route IDs - management API shapes - UI routing and rendering expectations - gateway behavior for routing and mention policy @@ -121,6 +121,33 @@ The entity names in this document are canonical logical names. The SQL below is an illustrative relational implementation shape, not a requirement that Spritz core ships or runs these migrations. +## ID Ownership And Opaqueness + +The deployment-owned backend assigns channel-installation IDs. + +Spritz must treat these IDs as opaque strings. Spritz should list them, render +them into URLs, and send the same values back on management requests. Spritz +must not generate them, parse them, derive meaning from prefixes, or assume +that they encode database rows, Slack workspace IDs, Slack channel IDs, agent +IDs, users, organizations, or runtime instances. + +The required ID properties are: + +- stable for the lifetime of the logical record +- unique within the deployment-owned backend +- safe to use in URLs and logs +- non-sequential from Spritz's perspective +- not derived from external provider IDs or internal database row numbers + +The Spritz contract intentionally does not require TypeID, UUID, ULID, or any +specific prefix format. A deployment may use typed IDs internally, but that is +an implementation detail of the backend that owns the storage and uniqueness +constraints. + +Examples in this document use placeholder IDs such as +`opaque-installation-id`, `opaque-connection-id`, and `opaque-route-id`. They +are placeholders, not required literal formats. + ## Naming Decisions ### `provider` @@ -220,7 +247,7 @@ A relational implementation might use this shape: ```sql CREATE TABLE channel_installation ( - id VARCHAR(32) PRIMARY KEY, + id VARCHAR(64) PRIMARY KEY, provider VARCHAR(32) NOT NULL, app_principal_id VARCHAR(128) NOT NULL, external_installation_key VARCHAR(256) NOT NULL, @@ -250,7 +277,7 @@ Column justifications: | Column | Decision | Justification | |---|---|---| -| `id` | keep | Stable internal ID for APIs and UI. External provider IDs should not be the primary product URL. | +| `id` | keep | Stable opaque backend-assigned ID for APIs and UI. External provider IDs and database row numbers should not be the primary product URL. | | `provider` | keep | Identifies the messaging provider, such as `slack`, `discord`, or `msteams`. Routing and provider adapters need this. | | `app_principal_id` | keep | Identifies which shared app or gateway identity receives events for this route. This is not the product owner. The old `principal_id` name is too vague. | | `external_installation_key` | keep | Provider-adapter-generated stable key for the external install surface. This avoids a core `scopeType` enum while still giving routing a deterministic key. | @@ -266,7 +293,7 @@ Example Slack row: ```json { - "id": "ci_01k...", + "id": "opaque-installation-id", "provider": "slack", "appPrincipalId": "shared-slack-gateway", "externalInstallationKey": "workspace:T021GRS5F4P", @@ -298,8 +325,8 @@ A relational implementation might use this shape: ```sql CREATE TABLE channel_connection ( - id VARCHAR(32) PRIMARY KEY, - installation_id VARCHAR(32) NOT NULL, + id VARCHAR(64) PRIMARY KEY, + installation_id VARCHAR(64) NOT NULL, display_name VARCHAR(512) NULL, is_default BOOLEAN NOT NULL DEFAULT FALSE, status VARCHAR(32) NOT NULL, @@ -322,7 +349,7 @@ Column justifications: | Column | Decision | Justification | |---|---|---| -| `id` | keep | Stable internal connection ID for APIs and UI. | +| `id` | keep | Stable opaque backend-assigned connection ID for APIs and UI. | | `installation_id` | keep | Parent external installation. A connection cannot exist without an installed provider app. | | `display_name` | keep | Human-facing label in settings. The UI should not need Spritz runtime details to list connections. | | `is_default` | keep | Defines fallback routing when a channel has no explicit route. Without this, workspace-mode behavior is ambiguous. | @@ -352,7 +379,7 @@ A relational implementation might use this shape: ```sql CREATE TABLE spritz_channel_connection ( - connection_id VARCHAR(32) PRIMARY KEY, + connection_id VARCHAR(64) PRIMARY KEY, preset_id VARCHAR(128) NOT NULL, preset_inputs JSON NULL, spritz_binding_key VARCHAR(256) NULL, @@ -408,9 +435,9 @@ A relational implementation might use this shape: ```sql CREATE TABLE channel_route ( - id VARCHAR(32) PRIMARY KEY, - installation_id VARCHAR(32) NOT NULL, - connection_id VARCHAR(32) NOT NULL, + id VARCHAR(64) PRIMARY KEY, + installation_id VARCHAR(64) NOT NULL, + connection_id VARCHAR(64) NOT NULL, external_channel_id VARCHAR(256) NOT NULL, require_mention BOOLEAN NOT NULL DEFAULT TRUE, enabled BOOLEAN NOT NULL DEFAULT TRUE, @@ -438,7 +465,7 @@ Column justifications: | Column | Decision | Justification | |---|---|---| -| `id` | keep | Stable internal route ID for update, delete, and audit. | +| `id` | keep | Stable opaque backend-assigned route ID for update, delete, and audit. | | `installation_id` | keep | Scopes the route to one external app installation. Provider channel IDs are not globally unique across installs. | | `connection_id` | keep | Destination for messages from this external channel. | | `external_channel_id` | keep | Provider channel ID from incoming events. This is the actual routing selector. | @@ -585,7 +612,7 @@ Example request: ```json { - "installationId": "ci_123", + "installationId": "opaque-installation-id", "query": "support" } ``` @@ -614,13 +641,13 @@ Examples: ```http GET /channel/installations -GET /channel/installations/ci_123 -GET /channel/installations/ci_123/connections -POST /channel/installations/ci_123/connections -PATCH /channel/connections/cc_456 -GET /channel/connections/cc_456/routes -PUT /channel/connections/cc_456/routes/C0ANJGDB4Q5 -DELETE /channel/routes/cr_789 +GET /channel/installations/{installationId} +GET /channel/installations/{installationId}/connections +POST /channel/installations/{installationId}/connections +PATCH /channel/connections/{connectionId} +GET /channel/connections/{connectionId}/routes +PUT /channel/connections/{connectionId}/routes/C0ANJGDB4Q5 +DELETE /channel/routes/{routeId} ``` The list and detail responses should be server-driven. Spritz should use @@ -632,7 +659,7 @@ Example installation list response: { "installations": [ { - "id": "ci_123", + "id": "opaque-installation-id", "provider": "slack", "displayName": "Example Workspace", "status": "active", @@ -672,8 +699,8 @@ set for one connection: ```json { - "installationId": "ci_123", - "connectionId": "cc_456", + "installationId": "opaque-installation-id", + "connectionId": "opaque-connection-id", "channelPolicies": [ { "externalChannelId": "C0ANJGDB4Q5", @@ -808,6 +835,8 @@ Minimum validation for this model: - Keep `provider`; for Slack the value is `slack`. - Use internal IDs for URLs and management APIs. +- The deployment-owned backend assigns stable opaque IDs. Spritz only + round-trips them and must not generate or parse them. - Model provider installation, internal connection, Spritz runtime backing, and channel routes as separate concepts. - Do not add core `targetType`, `preset`, or `runtime` fields. diff --git a/docs/2026-04-25-react-management-ui-migration-plan.md b/docs/2026-04-25-react-management-ui-migration-plan.md new file mode 100644 index 0000000..787eb16 --- /dev/null +++ b/docs/2026-04-25-react-management-ui-migration-plan.md @@ -0,0 +1,322 @@ +--- +date: 2026-04-25 +author: Onur Solmaz +title: React Management UI Migration Plan +tags: [spritz, ui, react, slack-gateway, settings, migration] +--- + +## Overview + +Spritz user-facing management pages should be served by the first-party React +UI under `ui/`. + +The Slack gateway currently renders several HTML pages directly from Go +templates. That was useful for a fast admin path, but it creates a second UI +stack with different navigation, styling, routing, form behavior, and +confirmation patterns. It also makes future provider settings harder because +Teams, Discord, and Slack would each be tempted to add their own integration +templates. + +This plan migrates the rendered gateway pages to the React app and leaves the +gateway responsible for protocol work: Slack OAuth redirects and callbacks, +Slack event ingestion, health checks, and JSON APIs consumed by the React UI. + +## Decision + +All new user-facing UI belongs in the Spritz React app. + +Gateway and integration services may expose: + +- external protocol redirects +- external protocol callbacks +- event ingestion endpoints +- health endpoints +- JSON APIs for the React app + +Gateway and integration services should not own product settings pages, +admin pages, channel settings pages, install result pages, or test-message +forms. + +## Existing Chat UI Boundary + +The existing chat view should change as little as possible. + +The migration should add only one settings entry point to the existing chat UI, +such as a settings button or link in the current header or navigation area. +That entry point should go through the gateway management URL +(`/slack-gateway/slack/workspaces`, or the configured absolute gateway URL). +Same-origin/proxied deployments will redirect into the new `/settings/*` route +group. Cross-origin deployments without a same-origin gateway proxy will remain +on the gateway fallback pages instead of opening a React page that cannot reach +the gateway APIs. + +The settings experience should be its own React view. It should not redesign +the chat page, add a settings sidebar inside the chat surface, or make Slack +management part of the active chat layout. Chat remains focused on agent +conversation. Settings owns integration management. + +## Current Server-Rendered Surfaces + +The current Slack gateway owns these rendered pages: + +| Current gateway route | Current purpose | React destination | +|---|---|---| +| `/slack-gateway/slack/install/select` | Select install target after Slack OAuth | `/settings/slack/install/select` | +| `/slack-gateway/slack/install/result` | Show install success or failure | `/settings/slack/install/result` | +| `/slack-gateway/slack/workspaces` | List manageable Slack workspaces | `/settings/slack/workspaces` | +| `/slack-gateway/slack/workspaces/target` | Change a workspace target | `/settings/slack/workspaces/target?teamId=...` | +| `/slack-gateway/slack/workspaces/test` | Send a test message | `/settings/slack/workspaces/test?teamId=...` | +| `/slack-gateway/settings/channels` | List channel-installation settings | `/settings/slack/channels` | +| `/slack-gateway/settings/channels/installations/:installationId` | List connections for one installation | `/settings/slack/channels/installations/:installationId` | +| `/slack-gateway/settings/channels/installations/:installationId/connections/:connectionId` | Edit channel route policies | `/settings/slack/channels/installations/:installationId/connections/:connectionId` | + +The gateway should keep these non-UI protocol routes: + +| Gateway route | Reason it remains in gateway | +|---|---| +| `/slack-gateway/slack/install` | Starts Slack OAuth and builds provider-specific state. | +| `/slack-gateway/slack/oauth/callback` | Receives Slack OAuth callback and exchanges provider code. | +| `/slack-gateway/slack/events` | Receives Slack signed events. | +| `/slack-gateway/healthz` | Service health. | + +## Target React Route Shape + +Add a settings section to the React app: + +```text +/settings +/settings/slack +/settings/slack/install/select +/settings/slack/install/result +/settings/slack/workspaces +/settings/slack/workspaces/target?teamId=... +/settings/slack/workspaces/test?teamId=... +/settings/slack/channels +/settings/slack/channels/installations/:installationId +/settings/slack/channels/installations/:installationId/connections/:connectionId +``` + +The settings section should use shared React layout components: + +- settings sidebar +- page header +- status and notice banners +- destructive-action confirmation dialogs +- loading and empty states +- shared table/list and form controls + +Slack-specific content can live under a Slack integration section, but the +shell should be generic enough for future providers. + +## JSON API Shape + +The React UI needs JSON endpoints with the same browser authentication model +as the current gateway pages. The endpoints may live behind the Slack gateway +while the backend contract settles. + +Recommended gateway JSON endpoints: + +```text +GET /slack-gateway/api/slack/install/selection +POST /slack-gateway/api/slack/install/selection +GET /slack-gateway/api/slack/install/result?... + +GET /slack-gateway/api/slack/workspaces +GET /slack-gateway/api/slack/workspaces/target?teamId=... +POST /slack-gateway/api/slack/workspaces/target +POST /slack-gateway/api/slack/workspaces/test +POST /slack-gateway/api/slack/workspaces/disconnect + +GET /slack-gateway/api/settings/channels +GET /slack-gateway/api/settings/channels/installations/:installationId +GET /slack-gateway/api/settings/channels/installations/:installationId/connections/:connectionId +PUT /slack-gateway/api/settings/channels/installations/:installationId/connections/:connectionId +``` + +API rules: + +- Return JSON only. +- Require the same browser principal checks as the current pages. +- Use opaque backend-assigned IDs exactly as returned by the backend. +- Do not expose Slack tokens or credential material. +- Return `allowedActions` so React can enable or hide controls. +- Return provider display labels from the backend or provider adapter. +- Use structured errors with user-safe messages and request IDs. + +## OAuth Flow + +The OAuth entry, callback, and cross-origin protocol fallbacks remain +gateway-owned. + +Target flow: + +1. User starts at React: `/settings/slack`. +2. React links to gateway: `/slack-gateway/slack/install`. +3. Gateway redirects to Slack OAuth. +4. Slack calls gateway callback: `/slack-gateway/slack/oauth/callback`. +5. If target selection is needed and gateway APIs are same-origin/proxied with + React, gateway redirects to React: `/settings/slack/install/select`. +6. React fetches selection data from gateway JSON and submits the selected + opaque target payload back to gateway JSON. +7. If target selection is needed but the gateway and React are different + origins, gateway renders the minimal target picker itself so its HTTP-only + pending-state cookie remains readable by the gateway. +8. Gateway upserts the install and redirects to React result: + `/settings/slack/install/result?...`. +9. React renders the result page. + +The opaque OAuth state stays gateway-owned. The callback stores pending +selection state in a short-lived, HTTP-only gateway cookie scoped to the +selection API. React must not receive, parse, or forward Slack token material +or encrypted OAuth state in the URL. + +Exception: when the gateway public URL and the Spritz React URL are different +origins and there is no same-origin `/slack-gateway` proxy, that HTTP-only +gateway cookie cannot be sent by browser calls from the React origin. In that +case the gateway must keep the target picker and legacy management pages on the +gateway origin as a minimal protocol fallback. Same-origin/proxied deployments +should use the React pages. + +## Migration Phases + +### Phase 1: JSON API parity + +Add JSON endpoints that mirror the current rendered page capabilities. + +Required work: + +- Add response DTOs for workspace list, target picker, test message result, + channel installation list, connection detail, and route update result. +- Reuse the existing backend client calls where possible. +- Add tests for browser auth, allowed actions, validation errors, and backend + error mapping. +- Keep existing rendered pages working during this phase. + +Exit criteria: + +- Every rendered page can be rebuilt from JSON endpoint data. +- The existing Go template tests still pass. +- New JSON handler tests cover success and failure paths. + +### Phase 2: React settings shell + +Add the shared settings routes and layout in `ui/`. + +Required work: + +- Add `/settings` route group. +- Add one minimal settings button or link to the existing chat view. +- Add a settings sidebar with integration navigation. +- Add Slack integration landing and workspace list pages. +- Add API client helpers under `ui/src/lib`. +- Add loading, empty, notice, and error states. +- Add React tests for route rendering and API failure states. + +Exit criteria: + +- The chat view has no layout redesign beyond the settings entry point. +- React can show the Slack workspace list using JSON endpoints. +- The page uses the shared Spritz UI components and design tokens. +- `pnpm build`, `pnpm typecheck`, and UI tests pass. + +### Phase 3: Management actions + +Move workspace and channel management actions to React. + +Required work: + +- Move change-target flow into React. +- Move channel route policy editing into React. +- Move test-message form into React. +- Add confirmation dialogs for disconnect and destructive route removals. +- Add optimistic or explicit reload behavior after successful saves. +- Preserve old URL redirects to the new React routes. + +Exit criteria: + +- A user can manage a Slack workspace without visiting a Go-rendered page. +- A user can edit `requireMention` channel policy from React. +- A user can send a test message from React. +- Disconnect requires confirmation. + +### Phase 4: Install result and target selection + +Move install completion pages into React. + +Required work: + +- Redirect OAuth callback outcomes to React result routes. +- Render all known install result codes in React. +- Move target selection rendering and submission into React. +- Keep OAuth state validation in the gateway. + +Exit criteria: + +- Slack OAuth install flow starts at React and returns to React. +- Gateway only handles provider OAuth mechanics and JSON submission. + +### Phase 5: Remove rendered templates + +Retire the Go templates after the React routes are deployed. + +Required work: + +- Replace old rendered page routes with redirects to React equivalents. +- Keep redirects for bookmarks and stale links. +- Delete template constants and template-only tests after one stable release. +- Keep protocol routes and JSON endpoints. + +Exit criteria: + +- No user-facing Slack management page is rendered from Go. +- `integrations/slack-gateway` has no product UI templates. +- Existing old URLs land on React pages. + +## Acceptance Criteria + +- All user-facing Slack management UI is implemented in `ui/`. +- Slack gateway no longer owns product settings pages. +- Existing chat UI changes are limited to a settings entry point. +- React pages share a settings sidebar and common layout. +- Browser auth behavior stays the same or becomes stricter. +- Existing OAuth, event, and routing behavior remains unchanged. +- Old gateway page URLs redirect to React equivalents. +- UI tests cover the main Slack management routes. +- Gateway tests cover JSON endpoint auth, validation, and backend mapping. + +## Non-Goals + +- Rewriting Slack event handling. +- Moving Slack OAuth code into React. +- Making React handle Slack signed events. +- Redesigning the existing chat UI. +- Changing the provider-agnostic channel installation data model. +- Changing how deployment backends assign opaque installation IDs. +- Removing gateway JSON endpoints before a stable backend product API exists. + +## Risks + +The main risk is breaking the Slack OAuth callback path while moving result +and target-selection UI. Keep the callback handler gateway-owned and migrate +only the rendered page after JSON parity exists. + +The second risk is routing ambiguity between `/slack-gateway/*` and React +routes. Avoid that by putting React settings pages under `/settings/*` and +using explicit redirects from the old gateway page URLs. + +The third risk is duplicating backend calls between gateway and React. Keep +React on gateway JSON endpoints at first. Move to a direct product API only +after that API is stable and authenticated for browser use. + +## Implementation Checklist + +- [ ] Add gateway JSON endpoints for each current rendered page. +- [ ] Add React settings shell and sidebar. +- [ ] Add Slack workspace list page in React. +- [ ] Add Slack workspace target page in React. +- [ ] Add Slack test message page in React. +- [ ] Add Slack channel settings list/detail pages in React. +- [ ] Add install target selection page in React. +- [ ] Add install result page in React. +- [ ] Redirect old gateway page URLs to React routes. +- [ ] Delete rendered templates after one stable release. diff --git a/integrations/slack-gateway/backend_client.go b/integrations/slack-gateway/backend_client.go index 6ac092a..f37a751 100644 --- a/integrations/slack-gateway/backend_client.go +++ b/integrations/slack-gateway/backend_client.go @@ -87,10 +87,11 @@ type backendManagedInstallationRoute struct { } type backendManagedChannelRoute struct { - ID string `json:"id"` - ExternalChannelID string `json:"externalChannelId"` - RequireMention *bool `json:"requireMention"` - Enabled *bool `json:"enabled"` + ID string `json:"id"` + ExternalChannelID string `json:"externalChannelId"` + ExternalChannelType string `json:"externalChannelType,omitempty"` + RequireMention *bool `json:"requireMention"` + Enabled *bool `json:"enabled"` } type backendManagedConnection struct { diff --git a/integrations/slack-gateway/backend_client_test.go b/integrations/slack-gateway/backend_client_test.go index 9d1727f..8a2d607 100644 --- a/integrations/slack-gateway/backend_client_test.go +++ b/integrations/slack-gateway/backend_client_test.go @@ -225,7 +225,7 @@ func TestUpdateManagedInstallationConfigPostsExpectedPayload(t *testing.T) { func TestManagedChannelRoutesDefaultMissingBooleansSafely(t *testing.T) { var connection backendManagedConnection if err := json.Unmarshal( - []byte(`{"id":"cc_1","routes":[{"externalChannelId":"C_default"}]}`), + []byte(`{"id":"chconn_default","routes":[{"externalChannelId":"D_default","externalChannelType":"im"}]}`), &connection, ); err != nil { t.Fatalf("decode connection: %v", err) @@ -238,6 +238,9 @@ func TestManagedChannelRoutesDefaultMissingBooleansSafely(t *testing.T) { if policies[0].RequireMention == nil || !*policies[0].RequireMention { t.Fatalf("expected omitted requireMention to default to true, got %#v", policies[0]) } + if policies[0].ExternalChannelType != "im" { + t.Fatalf("expected channel type to be preserved, got %#v", policies[0]) + } rows := channelRouteSettingsRows(connection) if len(rows) != 1 || rows[0].ModeLabel != "Mentions required" { t.Fatalf("expected settings row to render as mention-required, got %#v", rows) diff --git a/integrations/slack-gateway/channel_settings.go b/integrations/slack-gateway/channel_settings.go index ad05200..b2c0c30 100644 --- a/integrations/slack-gateway/channel_settings.go +++ b/integrations/slack-gateway/channel_settings.go @@ -565,9 +565,10 @@ func routesFromInstallationConfig(config installationConfig) []backendManagedCha } enabled := true routes = append(routes, backendManagedChannelRoute{ - ExternalChannelID: strings.TrimSpace(policy.ExternalChannelID), - RequireMention: &requireMention, - Enabled: &enabled, + ExternalChannelID: strings.TrimSpace(policy.ExternalChannelID), + ExternalChannelType: strings.TrimSpace(policy.ExternalChannelType), + RequireMention: &requireMention, + Enabled: &enabled, }) } sort.Slice(routes, func(i, j int) bool { @@ -596,8 +597,9 @@ func channelPoliciesFromConnection(connection backendManagedConnection) []instal } requireMention := managedRouteRequireMention(route) policies = append(policies, installationChannelPolicy{ - ExternalChannelID: channelID, - RequireMention: &requireMention, + ExternalChannelID: channelID, + ExternalChannelType: strings.TrimSpace(route.ExternalChannelType), + RequireMention: &requireMention, }) } sort.Slice(policies, func(i, j int) bool { diff --git a/integrations/slack-gateway/channel_settings_handlers.go b/integrations/slack-gateway/channel_settings_handlers.go index bedfc77..8401eb4 100644 --- a/integrations/slack-gateway/channel_settings_handlers.go +++ b/integrations/slack-gateway/channel_settings_handlers.go @@ -11,6 +11,14 @@ func (g *slackGateway) handleChannelSettings(w http.ResponseWriter, r *http.Requ if !ok { return } + if r.Method == http.MethodGet && g.reactRoutesShareGatewayOrigin() { + g.redirectToReactRoute( + w, + r, + reactSlackChannelSettingsPath(g.relativeGatewayPath(r.URL.Path), r.URL.Query()), + ) + return + } installations, err := g.listManagedInstallations(r.Context(), principal.ID) if err != nil { g.logger.ErrorContext( @@ -79,11 +87,25 @@ func (g *slackGateway) matchChannelSettingsConnectionPath( r *http.Request, installations []backendManagedInstallation, relativePath string, +) (backendManagedInstallation, backendManagedConnection, bool) { + installation, connection, ok := matchManagedChannelSettingsConnection( + installations, + relativePath, + ) + if !ok { + http.NotFound(w, r) + return backendManagedInstallation{}, backendManagedConnection{}, false + } + return installation, connection, true +} + +func matchManagedChannelSettingsConnection( + installations []backendManagedInstallation, + relativePath string, ) (backendManagedInstallation, backendManagedConnection, bool) { trimmed := strings.Trim(strings.TrimPrefix(relativePath, "/settings/channels/"), "/") parts := strings.Split(trimmed, "/") if len(parts) != 4 || parts[0] != "installations" || parts[2] != "connections" { - http.NotFound(w, r) return backendManagedInstallation{}, backendManagedConnection{}, false } installationID := strings.TrimSpace(parts[1]) @@ -94,12 +116,10 @@ func (g *slackGateway) matchChannelSettingsConnectionPath( } connection, found := managedConnectionByID(installation, connectionID) if !found { - http.NotFound(w, r) return backendManagedInstallation{}, backendManagedConnection{}, false } return installation, connection, true } - http.NotFound(w, r) return backendManagedInstallation{}, backendManagedConnection{}, false } @@ -130,16 +150,16 @@ func (g *slackGateway) handleChannelSettingsUpdate( delete(policiesByChannel, channelID) case "toggle": requireMention := strings.EqualFold(strings.TrimSpace(r.FormValue("requireMention")), "true") - policiesByChannel[channelID] = installationChannelPolicy{ - ExternalChannelID: channelID, - RequireMention: &requireMention, - } + policy := policiesByChannel[channelID] + policy.ExternalChannelID = channelID + policy.RequireMention = &requireMention + policiesByChannel[channelID] = policy default: requireMention := r.FormValue("requireMention") == "on" - policiesByChannel[channelID] = installationChannelPolicy{ - ExternalChannelID: channelID, - RequireMention: &requireMention, - } + policy := policiesByChannel[channelID] + policy.ExternalChannelID = channelID + policy.RequireMention = &requireMention + policiesByChannel[channelID] = policy } policies := make([]installationChannelPolicy, 0, len(policiesByChannel)) diff --git a/integrations/slack-gateway/gateway.go b/integrations/slack-gateway/gateway.go index 13dce2d..e53245a 100644 --- a/integrations/slack-gateway/gateway.go +++ b/integrations/slack-gateway/gateway.go @@ -92,6 +92,14 @@ func (g *slackGateway) routes() http.Handler { g.registerRoute(mux, "/slack/workspaces/disconnect", g.handleWorkspaceDisconnect) g.registerRoute(mux, "/settings/channels", g.handleChannelSettings) g.registerRoute(mux, "/settings/channels/", g.handleChannelSettings) + g.registerRoute(mux, "/api/slack/install/selection", g.handleInstallTargetSelectionAPI) + g.registerRoute(mux, "/api/slack/install/result", g.handleInstallResultAPI) + g.registerRoute(mux, "/api/slack/workspaces", g.handleWorkspaceManagementAPI) + g.registerRoute(mux, "/api/slack/workspaces/target", g.handleWorkspaceTargetAPI) + g.registerRoute(mux, "/api/slack/workspaces/test", g.handleWorkspaceTestAPI) + g.registerRoute(mux, "/api/slack/workspaces/disconnect", g.handleWorkspaceDisconnectAPI) + g.registerRoute(mux, "/api/settings/channels", g.handleChannelSettingsAPI) + g.registerRoute(mux, "/api/settings/channels/", g.handleChannelSettingsAPI) g.registerRoute(mux, "/slack/oauth/callback", g.handleOAuthCallback) g.registerRoute(mux, "/slack/events", g.handleSlackEvents) return mux diff --git a/integrations/slack-gateway/gateway_test.go b/integrations/slack-gateway/gateway_test.go index e4e82a1..4fa0a6b 100644 --- a/integrations/slack-gateway/gateway_test.go +++ b/integrations/slack-gateway/gateway_test.go @@ -88,7 +88,7 @@ func TestOAuthCallbackAutoSelectsSingleInstallTargetAndUpsertsRegistry(t *testin SlackBotScopes: []string{"chat:write"}, BackendBaseURL: backend.URL, BackendInternalToken: "backend-internal-token", - SpritzBaseURL: "https://spritz.example.test", + SpritzBaseURL: publicURL, SpritzServiceToken: "spritz-service-token", PrincipalID: "shared-slack-gateway", HTTPTimeout: 5 * time.Second, @@ -184,22 +184,50 @@ func TestOAuthCallbackAutoSelectsSingleInstallTargetAndUpsertsRegistry(t *testin resultRec := httptest.NewRecorder() gateway.routes().ServeHTTP(resultRec, resultReq) - if resultRec.Code != http.StatusOK { - t.Fatalf("expected result page 200, got %d: %s", resultRec.Code, resultRec.Body.String()) + if resultRec.Code != http.StatusSeeOther { + t.Fatalf("expected result redirect, got %d: %s", resultRec.Code, resultRec.Body.String()) + } + resultLocation, err := url.Parse(resultRec.Header().Get("Location")) + if err != nil { + t.Fatalf("parse result redirect: %v", err) } - if contentType := resultRec.Header().Get("Content-Type"); !strings.Contains(contentType, "text/html") { - t.Fatalf("expected html content type, got %q", contentType) + if resultLocation.Scheme != "https" || resultLocation.Host != "gateway.example.test" { + t.Fatalf("expected result redirect to use Spritz host, got %s", resultLocation.String()) } - body := resultRec.Body.String() - if !strings.Contains(body, "Slack workspace connected") { - t.Fatalf("expected success title in result page, got %q", body) + if resultLocation.Path != "/settings/slack/install/result" { + t.Fatalf("expected React result route, got %q", resultLocation.Path) } - if !strings.Contains(body, requestID) { - t.Fatalf("expected request id in result page, got %q", body) + if resultLocation.Query().Get("requestId") != requestID { + t.Fatalf("expected request id in result redirect, got %q", resultLocation.RawQuery) } } -func TestOAuthCallbackRendersInstallTargetPickerWhenMultipleTargetsAvailable(t *testing.T) { +func TestInstallResultKeepsLegacyPageWhenReactGatewayIsCrossOrigin(t *testing.T) { + gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", + SpritzBaseURL: "https://spritz.example.test", + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + + req := httptest.NewRequest( + http.MethodGet, + "/slack/install/result?status=success&code=installed&requestId=req_123", + nil, + ) + rec := httptest.NewRecorder() + gateway.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected legacy result page, got %d: %s", rec.Code, rec.Body.String()) + } + if location := rec.Header().Get("Location"); location != "" { + t.Fatalf("expected no React redirect, got %q", location) + } + if body := rec.Body.String(); !strings.Contains(body, "Slack workspace connected") || !strings.Contains(body, "req_123") { + t.Fatalf("expected legacy install result page, got %q", body) + } +} + +func TestOAuthCallbackRedirectsToReactInstallTargetPickerWhenSameOrigin(t *testing.T) { backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/internal/v2/spritz/channel-install-targets/list": @@ -249,6 +277,145 @@ func TestOAuthCallbackRendersInstallTargetPickerWhenMultipleTargetsAvailable(t * })) defer slackAPI.Close() + gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", + SlackClientID: "client-id", + SlackClientSecret: "client-secret", + SlackSigningSecret: "signing-secret", + OAuthStateSecret: "oauth-state-secret", + SlackAPIBaseURL: slackAPI.URL, + SlackBotScopes: []string{"chat:write"}, + BackendBaseURL: backend.URL, + BackendInternalToken: "backend-internal-token", + SpritzBaseURL: "https://gateway.example.test", + SpritzServiceToken: "spritz-service-token", + PrincipalID: "shared-slack-gateway", + HTTPTimeout: 5 * time.Second, + DedupeTTL: time.Minute, + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + state, err := gateway.state.generate() + if err != nil { + t.Fatalf("state generate failed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/slack/oauth/callback?code=test-code&state="+url.QueryEscape(state), nil) + rec := httptest.NewRecorder() + gateway.handleOAuthCallback(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected picker redirect, got %d: %s", rec.Code, rec.Body.String()) + } + redirectURL, err := url.Parse(rec.Header().Get("Location")) + if err != nil { + t.Fatalf("parse picker redirect: %v", err) + } + if redirectURL.Scheme != "https" || redirectURL.Host != "gateway.example.test" { + t.Fatalf("expected picker redirect to use Spritz host, got %s", redirectURL.String()) + } + if redirectURL.Path != "/settings/slack/install/select" { + t.Fatalf("expected React picker route, got %q", redirectURL.Path) + } + requestID := redirectURL.Query().Get("requestId") + if requestID == "" { + t.Fatalf("expected picker redirect to include requestId, got %q", redirectURL.RawQuery) + } + if strings.Contains(redirectURL.RawQuery, "state=") { + t.Fatalf("expected picker redirect without pending state query, got %q", redirectURL.RawQuery) + } + var pendingCookie *http.Cookie + for _, cookie := range rec.Result().Cookies() { + if cookie.Name == pendingInstallCookieNameForRequest(requestID) { + pendingCookie = cookie + break + } + } + if pendingCookie == nil || pendingCookie.Value == "" { + t.Fatalf("expected pending install state cookie") + } + if !pendingCookie.HttpOnly || !pendingCookie.Secure { + t.Fatalf("expected secure http-only pending install cookie, got %#v", pendingCookie) + } + if strings.Contains(pendingCookie.Value, "xoxb-installed") { + t.Fatalf("expected pending install cookie to keep bot token encrypted") + } + if strings.Contains(rec.Body.String(), "xoxb-installed") { + t.Fatalf("expected picker redirect to keep bot token encrypted, got %q", rec.Body.String()) + } + + selectionReq := httptest.NewRequest( + http.MethodGet, + "/api/slack/install/selection?requestId="+url.QueryEscape(requestID), + nil, + ) + selectionReq.AddCookie(pendingCookie) + selectionRec := httptest.NewRecorder() + gateway.routes().ServeHTTP(selectionRec, selectionReq) + + if selectionRec.Code != http.StatusOK { + t.Fatalf("expected selection API response, got %d: %s", selectionRec.Code, selectionRec.Body.String()) + } + if strings.Contains(selectionRec.Body.String(), "xoxb-installed") || strings.Contains(selectionRec.Body.String(), "botAccessToken") { + t.Fatalf("selection API leaked bot token material: %s", selectionRec.Body.String()) + } + var selectionPayload map[string]any + if err := json.NewDecoder(selectionRec.Body).Decode(&selectionPayload); err != nil { + t.Fatalf("decode selection API payload: %v", err) + } + if _, ok := selectionPayload["installation"]; ok { + t.Fatalf("selection API should not expose pending installation payload: %#v", selectionPayload["installation"]) + } +} + +func TestOAuthCallbackRendersGatewayInstallTargetPickerWhenCrossOrigin(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/internal/v2/spritz/channel-install-targets/list": + writeJSON(w, http.StatusOK, map[string]any{ + "status": "resolved", + "targets": []map[string]any{ + { + "id": "ag_123", + "profile": map[string]any{ + "name": "Personal Helper", + }, + "ownerLabel": "Personal", + "presetInputs": map[string]any{ + "agentId": "ag_123", + }, + }, + { + "id": "ag_456", + "profile": map[string]any{ + "name": "Workspace Helper", + }, + "ownerLabel": "Workspace", + "presetInputs": map[string]any{ + "agentId": "ag_456", + }, + }, + }, + }) + case "/internal/v1/spritz/channel-installations/upsert": + t.Fatal("upsert should not happen before the picker selection") + default: + t.Fatalf("unexpected backend path %s", r.URL.Path) + } + })) + defer backend.Close() + + slackAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "app_id": "A_app_1", + "scope": "chat:write", + "access_token": "xoxb-installed", + "bot_user_id": "U_bot", + "team": map[string]any{"id": "T_workspace_1"}, + "authed_user": map[string]any{"id": "U_installer"}, + }) + })) + defer slackAPI.Close() + gateway := newSlackGateway(config{ PublicURL: "https://gateway.example.test", SlackClientID: "client-id", @@ -275,13 +442,13 @@ func TestOAuthCallbackRendersInstallTargetPickerWhenMultipleTargetsAvailable(t * gateway.handleOAuthCallback(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("expected picker page 200, got %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("expected gateway picker page, got %d: %s", rec.Code, rec.Body.String()) } body := rec.Body.String() if !strings.Contains(body, "Choose an install target") { t.Fatalf("expected picker title, got %q", body) } - if !strings.Contains(body, "Personal Helper") || !strings.Contains(body, "Org Helper") { + if !strings.Contains(body, "Personal Helper") || !strings.Contains(body, "Workspace Helper") { t.Fatalf("expected picker targets, got %q", body) } if !strings.Contains(body, `/slack/install/select`) { @@ -290,6 +457,95 @@ func TestOAuthCallbackRendersInstallTargetPickerWhenMultipleTargetsAvailable(t * if strings.Contains(body, "xoxb-installed") { t.Fatalf("expected picker state to keep bot token encrypted, got %q", body) } + for _, cookie := range rec.Result().Cookies() { + if strings.HasPrefix(cookie.Name, pendingInstallCookieName) { + t.Fatalf("expected cross-origin picker to avoid pending install cookie, got %#v", cookie) + } + } +} + +func TestInstallTargetSelectionAPIUsesRequestScopedPendingCookie(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/internal/v2/spritz/channel-install-targets/list" { + t.Fatalf("unexpected backend path %s", r.URL.Path) + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "resolved", + "targets": []map[string]any{ + { + "id": "ag_workspace", + "profile": map[string]any{ + "name": "Workspace Helper", + }, + "presetInputs": map[string]any{ + "agentId": "ag_workspace", + }, + }, + }, + }) + })) + defer backend.Close() + + gateway := newSlackGateway(config{ + BackendBaseURL: backend.URL, + BackendFastAPIBaseURL: backend.URL, + BackendInternalToken: "backend-internal-token", + OAuthStateSecret: "oauth-state-secret", + PrincipalID: "shared-slack-gateway", + HTTPTimeout: 5 * time.Second, + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + + pendingStateA, err := gateway.state.generatePendingInstall(pendingInstallState{ + RequestID: "install-request-a", + Installation: slackInstallation{ + TeamID: "T_workspace_a", + InstallingUserID: "U_installer", + BotAccessToken: "xoxb-installed-a", + BotUserID: "U_bot", + APIAppID: "A_app_1", + }, + }) + if err != nil { + t.Fatalf("generate first pending install state failed: %v", err) + } + pendingStateB, err := gateway.state.generatePendingInstall(pendingInstallState{ + RequestID: "install-request-b", + Installation: slackInstallation{ + TeamID: "T_workspace_b", + InstallingUserID: "U_installer", + BotAccessToken: "xoxb-installed-b", + BotUserID: "U_bot", + APIAppID: "A_app_1", + }, + }) + if err != nil { + t.Fatalf("generate second pending install state failed: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/slack/install/selection?requestId=install-request-a", nil) + req.AddCookie(&http.Cookie{ + Name: pendingInstallCookieNameForRequest("install-request-a"), + Value: pendingStateA, + Path: "/api/slack/install/selection", + }) + req.AddCookie(&http.Cookie{ + Name: pendingInstallCookieNameForRequest("install-request-b"), + Value: pendingStateB, + Path: "/api/slack/install/selection", + }) + 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()) + } + var payload map[string]any + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode selection payload: %v", err) + } + if payload["requestId"] != "install-request-a" || payload["teamId"] != "T_workspace_a" { + t.Fatalf("expected first pending install, got %#v", payload) + } } func TestWorkspaceManagementRequiresBrowserPrincipal(t *testing.T) { @@ -340,8 +596,70 @@ func TestWorkspaceManagementRendersManagedInstallations(t *testing.T) { defer backend.Close() gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", BackendFastAPIBaseURL: backend.URL, BackendInternalToken: "backend-internal-token", + SpritzBaseURL: "https://gateway.example.test", + 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-Spritz-User-Id", "user-1") + rec := httptest.NewRecorder() + gateway.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected React redirect, got %d: %s", rec.Code, rec.Body.String()) + } + if location := rec.Header().Get("Location"); location != "https://gateway.example.test/settings/slack/workspaces" { + t.Fatalf("expected workspace React route, got %q", location) + } +} + +func TestWorkspaceManagementKeepsLegacyPageWhenReactGatewayIsCrossOrigin(t *testing.T) { + 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{ + { + "id": "opaque-installation-id", + "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", + }, + "connections": []map[string]any{ + { + "id": "opaque-connection-id", + "isDefault": true, + "state": "ready", + }, + }, + "allowedActions": []string{"changeTarget", "disconnect"}, + }, + }, + }) + })) + defer backend.Close() + + gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", + BackendFastAPIBaseURL: backend.URL, + BackendInternalToken: "backend-internal-token", + SpritzBaseURL: "https://spritz.example.test", PrincipalID: "shared-slack-gateway", HTTPTimeout: 5 * time.Second, }, slog.New(slog.NewTextHandler(io.Discard, nil))) @@ -352,20 +670,48 @@ func TestWorkspaceManagementRendersManagedInstallations(t *testing.T) { gateway.routes().ServeHTTP(rec, req) if rec.Code != http.StatusOK { - t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String()) + t.Fatalf("expected legacy page, got %d: %s", rec.Code, rec.Body.String()) } - body := rec.Body.String() - if !strings.Contains(body, "Slack workspaces") { - t.Fatalf("expected workspace page title, got %q", body) + if location := rec.Header().Get("Location"); location != "" { + t.Fatalf("expected no React redirect, got %q", location) } - if !strings.Contains(body, "T_workspace_1") { - t.Fatalf("expected workspace id on page, got %q", body) + body := rec.Body.String() + if !strings.Contains(body, "Slack workspaces") || !strings.Contains(body, "Workspace Helper") { + t.Fatalf("expected legacy workspace page, got %q", body) } - if !strings.Contains(body, "Workspace Helper") { - t.Fatalf("expected current target on page, got %q", body) +} + +func TestWorkspaceManagementAPIWritesNoStore(t *testing.T) { + 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{}, + }) + })) + defer backend.Close() + + gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", + BackendFastAPIBaseURL: backend.URL, + BackendInternalToken: "backend-internal-token", + SpritzBaseURL: "https://gateway.example.test", + PrincipalID: "shared-slack-gateway", + HTTPTimeout: 5 * time.Second, + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + + req := httptest.NewRequest(http.MethodGet, "/api/slack/workspaces", nil) + req.Header.Set("X-Spritz-User-Id", "user-1") + 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()) } - if !strings.Contains(body, "Send test") { - t.Fatalf("expected workspace test action on page, got %q", body) + if cacheControl := rec.Header().Get("Cache-Control"); cacheControl != "no-store" { + t.Fatalf("expected no-store cache policy, got %q", cacheControl) } } @@ -378,7 +724,7 @@ func TestChannelSettingsRendersManagedConnections(t *testing.T) { "status": "resolved", "installations": []map[string]any{ { - "id": "ci_1", + "id": "chinst_workspace_1", "route": map[string]any{ "principalId": "shared-slack-gateway", "provider": "slack", @@ -394,12 +740,12 @@ func TestChannelSettingsRendersManagedConnections(t *testing.T) { }, "connections": []map[string]any{ { - "id": "cc_1", + "id": "chconn_default", "isDefault": true, "state": "ready", "routes": []map[string]any{ { - "id": "cr_1", + "id": "chroute_channel_1", "externalChannelId": "C_channel_1", "requireMention": false, "enabled": true, @@ -407,11 +753,11 @@ func TestChannelSettingsRendersManagedConnections(t *testing.T) { }, }, { - "id": "cc_2", + "id": "chconn_secondary", "state": "ready", "routes": []map[string]any{ { - "id": "cr_2", + "id": "chroute_channel_2", "externalChannelId": "C_channel_2", "requireMention": true, "enabled": true, @@ -427,40 +773,111 @@ func TestChannelSettingsRendersManagedConnections(t *testing.T) { defer backend.Close() gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", BackendFastAPIBaseURL: backend.URL, BackendInternalToken: "backend-internal-token", + SpritzBaseURL: "https://gateway.example.test", PrincipalID: "shared-slack-gateway", HTTPTimeout: 5 * time.Second, }, slog.New(slog.NewTextHandler(io.Discard, nil))) - installationReq := httptest.NewRequest(http.MethodGet, "/settings/channels/installations/ci_1", nil) + installationReq := httptest.NewRequest(http.MethodGet, "/settings/channels/installations/chinst_workspace_1", nil) installationReq.Header.Set("X-Spritz-User-Id", "user-1") installationRec := httptest.NewRecorder() gateway.routes().ServeHTTP(installationRec, installationReq) - if installationRec.Code != http.StatusOK { - t.Fatalf("expected installation page 200, got %d: %s", installationRec.Code, installationRec.Body.String()) + if installationRec.Code != http.StatusSeeOther { + t.Fatalf("expected installation redirect, got %d: %s", installationRec.Code, installationRec.Body.String()) } - installationBody := installationRec.Body.String() - if !strings.Contains(installationBody, "/settings/channels/installations/ci_1/connections/cc_1") || - !strings.Contains(installationBody, "/settings/channels/installations/ci_1/connections/cc_2") { - t.Fatalf("expected installation page to link every connection, got %q", installationBody) + if location := installationRec.Header().Get("Location"); location != "https://gateway.example.test/settings/slack/channels/installations/chinst_workspace_1" { + t.Fatalf("expected installation React route, got %q", location) } - req := httptest.NewRequest(http.MethodGet, "/settings/channels/installations/ci_1/connections/cc_2", nil) + req := httptest.NewRequest(http.MethodGet, "/settings/channels/installations/chinst_workspace_1/connections/chconn_secondary", nil) + req.Header.Set("X-Spritz-User-Id", "user-1") + rec := httptest.NewRecorder() + gateway.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected connection redirect, got %d: %s", rec.Code, rec.Body.String()) + } + if location := rec.Header().Get("Location"); location != "https://gateway.example.test/settings/slack/channels/installations/chinst_workspace_1/connections/chconn_secondary" { + t.Fatalf("expected connection React route, got %q", location) + } +} + +func TestChannelSettingsKeepsLegacyPageWhenReactGatewayIsCrossOrigin(t *testing.T) { + 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{ + { + "id": "opaque-installation-id", + "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", + }, + }, + "connections": []map[string]any{ + { + "id": "opaque-connection-id", + "isDefault": true, + "state": "ready", + "routes": []map[string]any{ + { + "id": "opaque-route-id", + "externalChannelId": "C_channel_1", + "requireMention": false, + "enabled": true, + }, + }, + }, + }, + "allowedActions": []string{"changeTarget", "disconnect"}, + }, + }, + }) + })) + defer backend.Close() + + gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", + BackendFastAPIBaseURL: backend.URL, + BackendInternalToken: "backend-internal-token", + SpritzBaseURL: "https://spritz.example.test", + PrincipalID: "shared-slack-gateway", + HTTPTimeout: 5 * time.Second, + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + + req := httptest.NewRequest( + http.MethodGet, + "/settings/channels/installations/opaque-installation-id/connections/opaque-connection-id", + nil, + ) req.Header.Set("X-Spritz-User-Id", "user-1") 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()) + t.Fatalf("expected legacy page, got %d: %s", rec.Code, rec.Body.String()) } - body := rec.Body.String() - if !strings.Contains(body, "T_workspace_1") || !strings.Contains(body, "Workspace Helper") { - t.Fatalf("expected channel settings context on page, got %q", body) + if location := rec.Header().Get("Location"); location != "" { + t.Fatalf("expected no React redirect, got %q", location) } - if !strings.Contains(body, "C_channel_2") || !strings.Contains(body, "Mentions required") { - t.Fatalf("expected configured channel route on page, got %q", body) + body := rec.Body.String() + if !strings.Contains(body, "Channel settings") || !strings.Contains(body, "C_channel_1") { + t.Fatalf("expected legacy channel settings page, got %q", body) } } @@ -474,7 +891,7 @@ func TestChannelSettingsListDoesNotInventLegacyConnectionIDs(t *testing.T) { "status": "resolved", "installations": []map[string]any{ { - "id": "ci_1", + "id": "chinst_workspace_1", "route": map[string]any{ "principalId": "shared-slack-gateway", "provider": "slack", @@ -495,8 +912,10 @@ func TestChannelSettingsListDoesNotInventLegacyConnectionIDs(t *testing.T) { defer backend.Close() gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", BackendFastAPIBaseURL: backend.URL, BackendInternalToken: "backend-internal-token", + SpritzBaseURL: "https://gateway.example.test", PrincipalID: "shared-slack-gateway", HTTPTimeout: 5 * time.Second, }, slog.New(slog.NewTextHandler(io.Discard, nil))) @@ -506,15 +925,11 @@ func TestChannelSettingsListDoesNotInventLegacyConnectionIDs(t *testing.T) { 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()) - } - body := rec.Body.String() - if strings.Contains(body, "/connections/cc_1") || strings.Contains(body, "Open settings") { - t.Fatalf("expected legacy response without connection id to be read-only, got %q", body) + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected React redirect, got %d: %s", rec.Code, rec.Body.String()) } - if !strings.Contains(body, "Settings unavailable") { - t.Fatalf("expected unavailable settings marker, got %q", body) + if location := rec.Header().Get("Location"); location != "https://gateway.example.test/settings/slack/channels" { + t.Fatalf("expected channel settings React route, got %q", location) } } @@ -527,7 +942,7 @@ func TestChannelSettingsUpdatePostsRoutePolicies(t *testing.T) { "status": "resolved", "installations": []map[string]any{ { - "id": "ci_1", + "id": "chinst_workspace_1", "route": map[string]any{ "principalId": "shared-slack-gateway", "provider": "slack", @@ -537,12 +952,12 @@ func TestChannelSettingsUpdatePostsRoutePolicies(t *testing.T) { "state": "ready", "connections": []map[string]any{ { - "id": "cc_1", + "id": "chconn_default", "isDefault": true, "state": "ready", "routes": []map[string]any{ { - "id": "cr_1", + "id": "chroute_existing", "externalChannelId": "C_existing", "requireMention": true, "enabled": true, @@ -550,11 +965,11 @@ func TestChannelSettingsUpdatePostsRoutePolicies(t *testing.T) { }, }, { - "id": "cc_2", + "id": "chconn_secondary", "state": "ready", "routes": []map[string]any{ { - "id": "cr_2", + "id": "chroute_channel_2", "externalChannelId": "C_channel_2", "requireMention": true, "enabled": true, @@ -562,73 +977,261 @@ func TestChannelSettingsUpdatePostsRoutePolicies(t *testing.T) { }, }, }, - "allowedActions": []string{"changeTarget", "disconnect"}, + "allowedActions": []string{"changeTarget", "disconnect"}, + }, + }, + }) + case "/internal/v2/spritz/channel-installations/routes/update": + if err := json.NewDecoder(r.Body).Decode(&updatePayload); err != nil { + t.Fatalf("decode update payload: %v", err) + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "resolved", + "needsProvisioning": false, + }) + default: + t.Fatalf("unexpected backend path %s", r.URL.Path) + } + })) + defer backend.Close() + + gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", + BackendFastAPIBaseURL: backend.URL, + BackendInternalToken: "backend-internal-token", + SpritzBaseURL: "https://gateway.example.test", + PrincipalID: "shared-slack-gateway", + HTTPTimeout: 5 * time.Second, + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + gateway.policies.remember("T_workspace_1", installationPolicySnapshot{ + config: installationConfig{}, + botUserID: "U_bot", + }) + + req := httptest.NewRequest( + http.MethodPost, + "/settings/channels/installations/chinst_workspace_1/connections/chconn_default", + strings.NewReader("action=upsert&externalChannelId=C_new"), + ) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("X-Spritz-User-Id", "user-1") + rec := httptest.NewRecorder() + gateway.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected 303, got %d: %s", rec.Code, rec.Body.String()) + } + if updatePayload["callerAuthId"] != "user-1" { + t.Fatalf("expected caller auth id, got %#v", updatePayload["callerAuthId"]) + } + if updatePayload["installationId"] != "chinst_workspace_1" || updatePayload["connectionId"] != "chconn_default" { + t.Fatalf("expected installation and connection ids, got %#v", updatePayload) + } + policies, ok := updatePayload["channelPolicies"].([]any) + if !ok || len(policies) != 2 { + t.Fatalf("expected two channel policies, got %#v", updatePayload["channelPolicies"]) + } + var newPolicy map[string]any + for _, rawPolicy := range policies { + policy, ok := rawPolicy.(map[string]any) + if !ok { + t.Fatalf("expected policy object, got %#v", rawPolicy) + } + if policy["externalChannelId"] == "C_new" { + newPolicy = policy + } + } + if newPolicy == nil || newPolicy["requireMention"] != false { + t.Fatalf("expected no-mention policy for C_new, got %#v", policies) + } + if _, ok := gateway.policies.lookup("T_workspace_1"); ok { + t.Fatalf("expected channel settings update to evict cached policy") + } +} + +func TestChannelSettingsAPIUpdatePostsRoutePolicies(t *testing.T) { + var updatePayload map[string]any + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/internal/v2/spritz/channel-installations/list": + writeJSON(w, http.StatusOK, map[string]any{ + "status": "resolved", + "installations": []map[string]any{ + { + "id": "chinst_example", + "route": map[string]any{ + "principalId": "shared-slack-gateway", + "provider": "slack", + "externalScopeType": "workspace", + "externalTenantId": "T_workspace_1", + }, + "state": "ready", + "connections": []map[string]any{ + { + "id": "chconn_example", + "isDefault": true, + "state": "ready", + "routes": []map[string]any{ + { + "id": "chroute_existing", + "externalChannelId": "C_existing", + "requireMention": true, + "enabled": true, + }, + }, + }, + }, + "allowedActions": []string{"changeTarget", "disconnect"}, + }, + }, + }) + case "/internal/v2/spritz/channel-installations/routes/update": + if err := json.NewDecoder(r.Body).Decode(&updatePayload); err != nil { + t.Fatalf("decode update payload: %v", err) + } + writeJSON(w, http.StatusOK, map[string]any{ + "status": "resolved", + "needsProvisioning": false, + }) + default: + t.Fatalf("unexpected backend path %s", r.URL.Path) + } + })) + defer backend.Close() + + gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", + BackendFastAPIBaseURL: backend.URL, + BackendInternalToken: "backend-internal-token", + SpritzBaseURL: "https://gateway.example.test", + PrincipalID: "shared-slack-gateway", + HTTPTimeout: 5 * time.Second, + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + gateway.policies.remember("T_workspace_1", installationPolicySnapshot{ + config: installationConfig{}, + botUserID: "U_bot", + }) + + req := httptest.NewRequest( + http.MethodPut, + "/api/settings/channels/installations/chinst_example/connections/chconn_example", + strings.NewReader(`{"channelPolicies":[{"externalChannelId":"C_new","externalChannelType":"channel","requireMention":false}]}`), + ) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Spritz-User-Id", "user-1") + 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()) + } + if updatePayload["callerAuthId"] != "user-1" { + t.Fatalf("expected caller auth id, got %#v", updatePayload["callerAuthId"]) + } + if updatePayload["installationId"] != "chinst_example" || updatePayload["connectionId"] != "chconn_example" { + t.Fatalf("expected opaque installation and connection ids, got %#v", updatePayload) + } + policies, ok := updatePayload["channelPolicies"].([]any) + if !ok || len(policies) != 1 { + t.Fatalf("expected one channel policy, got %#v", updatePayload["channelPolicies"]) + } + policy, ok := policies[0].(map[string]any) + if !ok { + t.Fatalf("expected policy object, got %#v", policies[0]) + } + if policy["externalChannelId"] != "C_new" || policy["requireMention"] != false || policy["externalChannelType"] != "channel" { + t.Fatalf("expected no-mention policy for C_new, got %#v", policy) + } + if _, ok := gateway.policies.lookup("T_workspace_1"); ok { + t.Fatalf("expected channel settings update to evict cached policy") + } +} + +func TestChannelSettingsAPIMissingConnectionReturnsJSON(t *testing.T) { + 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{ + { + "id": "chinst_example", + "route": map[string]any{ + "principalId": "shared-slack-gateway", + "provider": "slack", + "externalScopeType": "workspace", + "externalTenantId": "T_workspace_1", + }, + "state": "ready", + "connections": []map[string]any{ + { + "id": "chconn_example", + "isDefault": true, + "state": "ready", + }, }, }, - }) - case "/internal/v2/spritz/channel-installations/routes/update": - if err := json.NewDecoder(r.Body).Decode(&updatePayload); err != nil { - t.Fatalf("decode update payload: %v", err) - } - writeJSON(w, http.StatusOK, map[string]any{ - "status": "resolved", - "needsProvisioning": false, - }) - default: - t.Fatalf("unexpected backend path %s", r.URL.Path) - } + }, + }) })) defer backend.Close() gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", BackendFastAPIBaseURL: backend.URL, BackendInternalToken: "backend-internal-token", + SpritzBaseURL: "https://gateway.example.test", PrincipalID: "shared-slack-gateway", HTTPTimeout: 5 * time.Second, }, slog.New(slog.NewTextHandler(io.Discard, nil))) - gateway.policies.remember("T_workspace_1", installationPolicySnapshot{ - config: installationConfig{}, - botUserID: "U_bot", - }) req := httptest.NewRequest( - http.MethodPost, - "/settings/channels/installations/ci_1/connections/cc_1", - strings.NewReader("action=upsert&externalChannelId=C_new"), + http.MethodGet, + "/api/settings/channels/installations/chinst_example/connections/missing", + nil, ) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("X-Spritz-User-Id", "user-1") rec := httptest.NewRecorder() gateway.routes().ServeHTTP(rec, req) - if rec.Code != http.StatusSeeOther { - t.Fatalf("expected 303, got %d: %s", rec.Code, rec.Body.String()) + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404, got %d: %s", rec.Code, rec.Body.String()) } - if updatePayload["callerAuthId"] != "user-1" { - t.Fatalf("expected caller auth id, got %#v", updatePayload["callerAuthId"]) + if contentType := rec.Header().Get("Content-Type"); !strings.Contains(contentType, "application/json") { + t.Fatalf("expected JSON content type, got %q", contentType) } - if updatePayload["installationId"] != "ci_1" || updatePayload["connectionId"] != "cc_1" { - t.Fatalf("expected installation and connection ids, got %#v", updatePayload) + var payload map[string]any + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode error payload: %v", err) } - policies, ok := updatePayload["channelPolicies"].([]any) - if !ok || len(policies) != 2 { - t.Fatalf("expected two channel policies, got %#v", updatePayload["channelPolicies"]) + if payload["status"] != "error" || payload["message"] != "connection not found" { + t.Fatalf("expected structured missing connection error, got %#v", payload) } - var newPolicy map[string]any - for _, rawPolicy := range policies { - policy, ok := rawPolicy.(map[string]any) - if !ok { - t.Fatalf("expected policy object, got %#v", rawPolicy) - } - if policy["externalChannelId"] == "C_new" { - newPolicy = policy - } +} + +func TestManagementAPIReturnsJSONAuthErrors(t *testing.T) { + gateway := newSlackGateway(config{}, slog.New(slog.NewTextHandler(io.Discard, nil))) + + req := httptest.NewRequest(http.MethodGet, "/api/slack/workspaces", nil) + rec := httptest.NewRecorder() + gateway.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401, got %d: %s", rec.Code, rec.Body.String()) } - if newPolicy == nil || newPolicy["requireMention"] != false { - t.Fatalf("expected no-mention policy for C_new, got %#v", policies) + if contentType := rec.Header().Get("Content-Type"); !strings.Contains(contentType, "application/json") { + t.Fatalf("expected JSON content type, got %q", contentType) } - if _, ok := gateway.policies.lookup("T_workspace_1"); ok { - t.Fatalf("expected channel settings update to evict cached policy") + if cacheControl := rec.Header().Get("Cache-Control"); cacheControl != "no-store" { + t.Fatalf("expected no-store cache control, got %q", cacheControl) + } + var payload map[string]any + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode auth error payload: %v", err) + } + if payload["status"] != "error" || payload["message"] != "unauthorized" { + t.Fatalf("expected structured unauthorized error, got %#v", payload) } } @@ -666,8 +1269,10 @@ func TestWorkspaceManagementAcceptsConfiguredBrowserAuthHeaders(t *testing.T) { defer backend.Close() gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", BackendFastAPIBaseURL: backend.URL, BackendInternalToken: "backend-internal-token", + SpritzBaseURL: "https://gateway.example.test", PrincipalID: "shared-slack-gateway", HTTPTimeout: 5 * time.Second, }, slog.New(slog.NewTextHandler(io.Discard, nil))) @@ -678,8 +1283,11 @@ func TestWorkspaceManagementAcceptsConfiguredBrowserAuthHeaders(t *testing.T) { 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()) + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected React redirect, got %d: %s", rec.Code, rec.Body.String()) + } + if location := rec.Header().Get("Location"); location != "https://gateway.example.test/settings/slack/workspaces" { + t.Fatalf("expected workspace React route, got %q", location) } } @@ -711,13 +1319,15 @@ func TestWorkspaceTargetPickerUsesCurrentBrowserPrincipal(t *testing.T) { defer backend.Close() gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", BackendFastAPIBaseURL: backend.URL, BackendInternalToken: "backend-internal-token", + SpritzBaseURL: "https://gateway.example.test", PrincipalID: "shared-slack-gateway", HTTPTimeout: 5 * time.Second, }, slog.New(slog.NewTextHandler(io.Discard, nil))) - req := httptest.NewRequest(http.MethodGet, "/slack/workspaces/target?teamId=T_workspace_1", nil) + req := httptest.NewRequest(http.MethodGet, "/api/slack/workspaces/target?teamId=T_workspace_1", nil) req.Header.Set("X-Spritz-User-Id", "user-1") rec := httptest.NewRecorder() gateway.routes().ServeHTTP(rec, req) @@ -728,8 +1338,8 @@ func TestWorkspaceTargetPickerUsesCurrentBrowserPrincipal(t *testing.T) { if listPayload["ownerAuthId"] != "user-1" { t.Fatalf("expected ownerAuthId to come from browser principal, got %#v", listPayload["ownerAuthId"]) } - if !strings.Contains(rec.Body.String(), "Change workspace target") { - t.Fatalf("expected target picker title, got %q", rec.Body.String()) + if !strings.Contains(rec.Body.String(), "Workspace Helper") { + t.Fatalf("expected target payload, got %q", rec.Body.String()) } } @@ -750,8 +1360,10 @@ func TestWorkspaceTargetUpdateRedirectsOnSuccess(t *testing.T) { defer backend.Close() gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", BackendFastAPIBaseURL: backend.URL, BackendInternalToken: "backend-internal-token", + SpritzBaseURL: "https://gateway.example.test", PrincipalID: "shared-slack-gateway", HTTPTimeout: 5 * time.Second, }, slog.New(slog.NewTextHandler(io.Discard, nil))) @@ -792,8 +1404,10 @@ func TestWorkspaceDisconnectRedirectsOnSuccess(t *testing.T) { defer backend.Close() gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", BackendFastAPIBaseURL: backend.URL, BackendInternalToken: "backend-internal-token", + SpritzBaseURL: "https://gateway.example.test", PrincipalID: "shared-slack-gateway", HTTPTimeout: 5 * time.Second, }, slog.New(slog.NewTextHandler(io.Discard, nil))) @@ -867,8 +1481,10 @@ func TestWorkspaceTestFormRendersForManageableWorkspace(t *testing.T) { defer backend.Close() gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", BackendFastAPIBaseURL: backend.URL, BackendInternalToken: "backend-internal-token", + SpritzBaseURL: "https://gateway.example.test", PrincipalID: "shared-slack-gateway", HTTPTimeout: 5 * time.Second, }, slog.New(slog.NewTextHandler(io.Discard, nil))) @@ -878,15 +1494,11 @@ func TestWorkspaceTestFormRendersForManageableWorkspace(t *testing.T) { 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()) - } - body := rec.Body.String() - if !strings.Contains(body, "Send a synthetic Slack test message") { - t.Fatalf("expected synthetic test title, got %q", body) + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected React redirect, got %d: %s", rec.Code, rec.Body.String()) } - if !strings.Contains(body, "Workspace Helper") { - t.Fatalf("expected current target name on form, got %q", body) + if location := rec.Header().Get("Location"); location != "https://gateway.example.test/settings/slack/workspaces/test?teamId=T_workspace_1" { + t.Fatalf("expected workspace test React route, got %q", location) } } @@ -1252,7 +1864,7 @@ func TestInstallTargetSelectionUsesSelectedPresetInputs(t *testing.T) { SlackBotScopes: []string{"chat:write"}, BackendBaseURL: backend.URL, BackendInternalToken: "backend-internal-token", - SpritzBaseURL: "https://spritz.example.test", + SpritzBaseURL: "https://gateway.example.test", SpritzServiceToken: "spritz-service-token", PrincipalID: "shared-slack-gateway", HTTPTimeout: 5 * time.Second, @@ -1312,6 +1924,258 @@ func TestInstallTargetSelectionUsesSelectedPresetInputs(t *testing.T) { } } +func TestInstallTargetSelectionAPIPreservesClassifiedUpsertFailure(t *testing.T) { + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/internal/v1/spritz/channel-installations/upsert" { + t.Fatalf("unexpected backend path %s", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"status":"unresolved","field":"ownerRef","error":"external_identity_unresolved"}`)) + })) + defer backend.Close() + + gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", + SlackClientID: "client-id", + SlackClientSecret: "client-secret", + SlackSigningSecret: "signing-secret", + OAuthStateSecret: "oauth-state-secret", + SlackAPIBaseURL: "https://slack.example.test/api", + SlackBotScopes: []string{"chat:write"}, + BackendBaseURL: backend.URL, + BackendInternalToken: "backend-internal-token", + SpritzBaseURL: "https://gateway.example.test", + SpritzServiceToken: "spritz-service-token", + PrincipalID: "shared-slack-gateway", + HTTPTimeout: 5 * time.Second, + DedupeTTL: time.Minute, + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + + pendingState, err := gateway.state.generatePendingInstall(pendingInstallState{ + RequestID: "install-request-1", + Installation: slackInstallation{ + TeamID: "T_workspace_1", + InstallingUserID: "U_installer", + BotAccessToken: "xoxb-installed", + BotUserID: "U_bot", + APIAppID: "A_app_1", + }, + }) + if err != nil { + t.Fatalf("generate pending install state failed: %v", err) + } + requestBody, err := json.Marshal(map[string]any{ + "requestId": "install-request-1", + "presetInputs": map[string]any{ + "agentId": "ag_456", + }, + }) + if err != nil { + t.Fatalf("marshal request body: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/slack/install/selection", bytes.NewReader(requestBody)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(&http.Cookie{ + Name: pendingInstallCookieNameForRequest("install-request-1"), + Value: pendingState, + Path: "/api/slack/install/selection", + }) + rec := httptest.NewRecorder() + gateway.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected typed install result response, got %d: %s", rec.Code, rec.Body.String()) + } + var payload map[string]any + if err := json.NewDecoder(rec.Body).Decode(&payload); err != nil { + t.Fatalf("decode API payload: %v", err) + } + if payload["status"] != "error" { + t.Fatalf("expected error status, got %#v", payload["status"]) + } + if payload["code"] != "identity.unresolved" { + t.Fatalf("expected identity.unresolved code, got %#v", payload["code"]) + } + if payload["requestId"] != "install-request-1" { + t.Fatalf("expected request id to round-trip, got %#v", payload["requestId"]) + } + if payload["teamId"] != "T_workspace_1" { + t.Fatalf("expected team id to round-trip, got %#v", payload["teamId"]) + } + if strings.Contains(rec.Body.String(), "ownerRef") { + t.Fatalf("expected API payload to hide backend field names, got %q", rec.Body.String()) + } + if strings.Contains(rec.Body.String(), "xoxb-installed") { + t.Fatalf("expected API payload to hide bot token material, got %q", rec.Body.String()) + } +} + +func TestInstallTargetSelectionAPIReturnsTypedStateFailures(t *testing.T) { + backendCalled := false + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + backendCalled = true + t.Fatalf("backend should not be called for invalid install selection state") + })) + defer backend.Close() + + gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", + SlackClientID: "client-id", + SlackClientSecret: "client-secret", + SlackSigningSecret: "signing-secret", + OAuthStateSecret: "oauth-state-secret", + SlackAPIBaseURL: "https://slack.example.test/api", + SlackBotScopes: []string{"chat:write"}, + BackendBaseURL: backend.URL, + BackendInternalToken: "backend-internal-token", + SpritzBaseURL: "https://gateway.example.test", + SpritzServiceToken: "spritz-service-token", + PrincipalID: "shared-slack-gateway", + HTTPTimeout: 5 * time.Second, + DedupeTTL: time.Minute, + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + + issuedAt := time.Date(2026, 4, 25, 12, 0, 0, 0, time.UTC) + gateway.state.now = func() time.Time { return issuedAt } + pendingState, err := gateway.state.generatePendingInstall(pendingInstallState{ + RequestID: "install-request-expired", + Installation: slackInstallation{ + TeamID: "T_workspace_1", + InstallingUserID: "U_installer", + BotAccessToken: "xoxb-installed", + BotUserID: "U_bot", + APIAppID: "A_app_1", + }, + }) + if err != nil { + t.Fatalf("generate pending install state failed: %v", err) + } + gateway.state.now = func() time.Time { return issuedAt.Add(16 * time.Minute) } + + getReq := httptest.NewRequest(http.MethodGet, "/api/slack/install/selection?requestId=install-request-expired", nil) + getReq.AddCookie(&http.Cookie{ + Name: pendingInstallCookieNameForRequest("install-request-expired"), + Value: pendingState, + Path: "/api/slack/install/selection", + }) + getRec := httptest.NewRecorder() + gateway.routes().ServeHTTP(getRec, getReq) + + if getRec.Code != http.StatusOK { + t.Fatalf("expected typed expired state response, got %d: %s", getRec.Code, getRec.Body.String()) + } + var getPayload map[string]any + if err := json.NewDecoder(getRec.Body).Decode(&getPayload); err != nil { + t.Fatalf("decode expired state payload: %v", err) + } + if getPayload["status"] != "error" || getPayload["code"] != "state.expired" { + t.Fatalf("expected state.expired install result, got %#v", getPayload) + } + if getPayload["requestId"] != "install-request-expired" { + t.Fatalf("expected request id to round-trip, got %#v", getPayload["requestId"]) + } + if getPayload["actionHref"] == "" || getPayload["actionLabel"] == "" { + t.Fatalf("expected retry action in expired state payload, got %#v", getPayload) + } + if strings.Contains(getRec.Body.String(), "xoxb-installed") { + t.Fatalf("expected expired state payload to hide bot token material, got %q", getRec.Body.String()) + } + + postBody, err := json.Marshal(map[string]any{ + "requestId": "install-request-invalid", + "state": "not-a-state-token", + "presetInputs": map[string]any{"agentId": "ag_456"}, + }) + if err != nil { + t.Fatalf("marshal post body: %v", err) + } + postReq := httptest.NewRequest(http.MethodPost, "/api/slack/install/selection", bytes.NewReader(postBody)) + postReq.Header.Set("Content-Type", "application/json") + postRec := httptest.NewRecorder() + gateway.routes().ServeHTTP(postRec, postReq) + + if postRec.Code != http.StatusOK { + t.Fatalf("expected typed invalid state response, got %d: %s", postRec.Code, postRec.Body.String()) + } + var postPayload map[string]any + if err := json.NewDecoder(postRec.Body).Decode(&postPayload); err != nil { + t.Fatalf("decode invalid state payload: %v", err) + } + if postPayload["status"] != "error" || postPayload["code"] != "state.invalid" { + t.Fatalf("expected state.invalid install result, got %#v", postPayload) + } + if postPayload["requestId"] != "install-request-invalid" { + t.Fatalf("expected request id to round-trip, got %#v", postPayload["requestId"]) + } + if backendCalled { + t.Fatalf("backend was called for invalid install selection state") + } +} + +func TestInstallTargetSelectionAPIRejectsStaleRequestID(t *testing.T) { + backendCalled := false + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + backendCalled = true + t.Fatalf("backend should not be called for stale install selection") + })) + defer backend.Close() + + gateway := newSlackGateway(config{ + PublicURL: "https://gateway.example.test", + SlackClientID: "client-id", + SlackClientSecret: "client-secret", + SlackSigningSecret: "signing-secret", + OAuthStateSecret: "oauth-state-secret", + SlackAPIBaseURL: "https://slack.example.test/api", + SlackBotScopes: []string{"chat:write"}, + BackendBaseURL: backend.URL, + BackendInternalToken: "backend-internal-token", + SpritzBaseURL: "https://spritz.example.test", + SpritzServiceToken: "spritz-service-token", + PrincipalID: "shared-slack-gateway", + HTTPTimeout: 5 * time.Second, + DedupeTTL: time.Minute, + }, slog.New(slog.NewTextHandler(io.Discard, nil))) + + pendingState, err := gateway.state.generatePendingInstall(pendingInstallState{ + RequestID: "install-request-current", + Installation: slackInstallation{ + TeamID: "T_workspace_current", + InstallingUserID: "U_installer", + BotAccessToken: "xoxb-installed", + BotUserID: "U_bot", + APIAppID: "A_app_1", + }, + }) + if err != nil { + t.Fatalf("generate pending install state failed: %v", err) + } + requestBody, err := json.Marshal(map[string]any{ + "requestId": "install-request-stale", + "state": pendingState, + "presetInputs": map[string]any{ + "agentId": "ag_456", + }, + }) + if err != nil { + t.Fatalf("marshal request body: %v", err) + } + + req := httptest.NewRequest(http.MethodPost, "/api/slack/install/selection", bytes.NewReader(requestBody)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + gateway.routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("expected 400 for stale request id, got %d: %s", rec.Code, rec.Body.String()) + } + if backendCalled { + t.Fatalf("backend was called for stale install selection") + } +} + func TestInstallRedirectUsesConfiguredSlackHost(t *testing.T) { cfg := config{ PublicURL: "https://gateway.example.test", @@ -1435,7 +2299,7 @@ func TestOAuthCallbackRedirectsToControlledRetryableErrorWhenBackendUpsertFails( SlackBotScopes: []string{"chat:write"}, BackendBaseURL: backend.URL, BackendInternalToken: "backend-internal-token", - SpritzBaseURL: "https://spritz.example.test", + SpritzBaseURL: "https://gateway.example.test", SpritzServiceToken: "spritz-service-token", PrincipalID: "shared-slack-gateway", HTTPTimeout: 5 * time.Second, @@ -1473,11 +2337,11 @@ func TestOAuthCallbackRedirectsToControlledRetryableErrorWhenBackendUpsertFails( resultRec := httptest.NewRecorder() gateway.routes().ServeHTTP(resultRec, resultReq) - if resultRec.Code != http.StatusOK { - t.Fatalf("expected result page 200, got %d: %s", resultRec.Code, resultRec.Body.String()) + if resultRec.Code != http.StatusSeeOther { + t.Fatalf("expected result redirect, got %d: %s", resultRec.Code, resultRec.Body.String()) } - if strings.Contains(resultRec.Body.String(), "backend unavailable") { - t.Fatalf("expected retryable result page to hide backend body, got %q", resultRec.Body.String()) + if strings.Contains(resultRec.Header().Get("Location"), "backend unavailable") { + t.Fatalf("expected retryable result redirect to hide backend body, got %q", resultRec.Header().Get("Location")) } logOutput := logBuffer.String() if !strings.Contains(logOutput, "slack oauth callback installation upsert failed") { @@ -1542,7 +2406,7 @@ func TestOAuthCallbackRedirectsToControlledOwnerResolutionError(t *testing.T) { SlackBotScopes: []string{"chat:write"}, BackendBaseURL: backend.URL, BackendInternalToken: "backend-internal-token", - SpritzBaseURL: "https://spritz.example.test", + SpritzBaseURL: "https://gateway.example.test", SpritzServiceToken: "spritz-service-token", PrincipalID: "shared-slack-gateway", HTTPTimeout: 5 * time.Second, @@ -1573,15 +2437,15 @@ func TestOAuthCallbackRedirectsToControlledOwnerResolutionError(t *testing.T) { resultRec := httptest.NewRecorder() gateway.routes().ServeHTTP(resultRec, resultReq) - if resultRec.Code != http.StatusOK { - t.Fatalf("expected result page 200, got %d: %s", resultRec.Code, resultRec.Body.String()) + if resultRec.Code != http.StatusSeeOther { + t.Fatalf("expected result redirect, got %d: %s", resultRec.Code, resultRec.Body.String()) } - body := resultRec.Body.String() - if !strings.Contains(body, "could not be linked") { - t.Fatalf("expected owner resolution guidance in result page, got %q", body) + location := resultRec.Header().Get("Location") + if !strings.Contains(location, "identity.unresolved") { + t.Fatalf("expected owner resolution code in result redirect, got %q", location) } - if strings.Contains(body, "ownerRef") { - t.Fatalf("expected result page to avoid leaking backend field names, got %q", body) + if strings.Contains(location, "ownerRef") { + t.Fatalf("expected result redirect to avoid leaking backend field names, got %q", location) } } @@ -7151,6 +8015,52 @@ func TestSpritzWebSocketURLPreservesBasePath(t *testing.T) { } } +func TestReactRouteURLUsesSpritzBaseURL(t *testing.T) { + gateway := newSlackGateway( + config{SpritzBaseURL: "https://spritz.example.test/app"}, + slog.New(slog.NewTextHandler(io.Discard, nil)), + ) + + target := gateway.reactRouteURL("/settings/slack/workspaces/test?teamId=T_workspace_1") + parsed, err := url.Parse(target) + if err != nil { + t.Fatalf("parse react route url: %v", err) + } + if parsed.Scheme != "https" || parsed.Host != "spritz.example.test" { + t.Fatalf("expected Spritz host, got %s", target) + } + if parsed.Path != "/app/settings/slack/workspaces/test" { + t.Fatalf("expected Spritz base path to be preserved, got %q", parsed.Path) + } + if parsed.Query().Get("teamId") != "T_workspace_1" { + t.Fatalf("expected query to be preserved, got %q", parsed.RawQuery) + } +} + +func TestReactRoutesShareGatewayOrigin(t *testing.T) { + sameOrigin := newSlackGateway( + config{ + PublicURL: "https://spritz.example.test/slack-gateway", + SpritzBaseURL: "https://spritz.example.test", + }, + slog.New(slog.NewTextHandler(io.Discard, nil)), + ) + if !sameOrigin.reactRoutesShareGatewayOrigin() { + t.Fatal("expected same host and scheme to share gateway origin") + } + + crossOrigin := newSlackGateway( + config{ + PublicURL: "https://gateway.example.test", + SpritzBaseURL: "https://spritz.example.test", + }, + slog.New(slog.NewTextHandler(io.Discard, nil)), + ) + if crossOrigin.reactRoutesShareGatewayOrigin() { + t.Fatal("expected different hosts not to share gateway origin") + } +} + func signSlackRequest(header http.Header, signingSecret string, body []byte, now time.Time) { timestamp := fmt.Sprintf("%d", now.Unix()) base := "v0:" + timestamp + ":" + string(body) diff --git a/integrations/slack-gateway/install_result.go b/integrations/slack-gateway/install_result.go index b132088..dd9c64d 100644 --- a/integrations/slack-gateway/install_result.go +++ b/integrations/slack-gateway/install_result.go @@ -76,6 +76,13 @@ type backendInstallErrorPayload struct { RequestID string `json:"requestId,omitempty"` } +func classifyInstallStateError(err error) installResultCode { + if err != nil && strings.Contains(strings.ToLower(err.Error()), "expired") { + return installResultCodeStateExpired + } + return installResultCodeStateInvalid +} + var installResultPageTemplate = template.Must(template.New("install-result").Parse(`
@@ -173,6 +180,20 @@ func (g *slackGateway) installRedirectPath() string { return g.publicPathPrefix() + "/slack/install" } +func (g *slackGateway) installRedirectURL() string { + base, err := url.Parse(strings.TrimRight(strings.TrimSpace(g.cfg.PublicURL), "/")) + if err != nil || base.Scheme == "" || base.Host == "" { + return g.installRedirectPath() + } + basePath := strings.TrimRight(base.Path, "/") + routePath := "/slack/install" + base.RawPath = "" + base.Path = basePath + routePath + base.RawQuery = "" + base.Fragment = "" + return base.String() +} + func (g *slackGateway) redirectToInstallResult(w http.ResponseWriter, r *http.Request, result installResult) { target := url.URL{Path: g.installResultPath()} query := target.Query() @@ -421,6 +442,15 @@ func classifyInstallUpsertError(err error) installResultCode { } func (g *slackGateway) handleInstallResult(w http.ResponseWriter, r *http.Request) { + if !g.reactRoutesShareGatewayOrigin() { + g.renderInstallResultPage(w, r) + return + } + target := url.URL{Path: reactSlackInstallResultPath(), RawQuery: r.URL.RawQuery} + g.redirectToReactRoute(w, r, target.String()) +} + +func (g *slackGateway) renderInstallResultPage(w http.ResponseWriter, r *http.Request) { result := installResult{ Status: installResultStatus(firstNonEmpty(r.URL.Query().Get("status"), string(installResultStatusError))), Code: normalizeInstallResultCode(firstNonEmpty(r.URL.Query().Get("code"), string(installResultCodeInternalError))), diff --git a/integrations/slack-gateway/management_api.go b/integrations/slack-gateway/management_api.go new file mode 100644 index 0000000..a779a09 --- /dev/null +++ b/integrations/slack-gateway/management_api.go @@ -0,0 +1,580 @@ +package main + +import ( + "encoding/json" + "net/http" + "sort" + "strings" +) + +type slackGatewayErrorResponse struct { + Status string `json:"status"` + Message string `json:"message"` +} + +type installSelectionResponse struct { + Status string `json:"status"` + RequestID string `json:"requestId"` + TeamID string `json:"teamId"` + Targets []backendInstallTarget `json:"targets"` +} + +type installSelectionRequest struct { + State string `json:"state"` + RequestID string `json:"requestId"` + PresetInputs map[string]any `json:"presetInputs"` +} + +type workspaceTargetRequest struct { + TeamID string `json:"teamId"` + RequestID string `json:"requestId"` + PresetInputs map[string]any `json:"presetInputs"` +} + +type workspaceDisconnectRequest struct { + TeamID string `json:"teamId"` +} + +type workspaceTestRequest struct { + TeamID string `json:"teamId"` + ChannelID string `json:"channelId"` + ThreadTS string `json:"threadTs"` + Prompt string `json:"prompt"` + Mode string `json:"mode"` +} + +type workspaceTestResponse struct { + Status string `json:"status"` + Outcome string `json:"outcome,omitempty"` + Reply string `json:"reply,omitempty"` + ConversationID string `json:"conversationId,omitempty"` + PostedMessageTS string `json:"postedMessageTs,omitempty"` +} + +type channelRoutesUpdateRequest struct { + RequestID string `json:"requestId"` + ChannelPolicies []installationChannelPolicy `json:"channelPolicies"` +} + +func writeAPIError(w http.ResponseWriter, status int, message string) { + writeAPIJSON(w, status, slackGatewayErrorResponse{ + Status: "error", + Message: strings.TrimSpace(message), + }) +} + +func writeAPIJSON(w http.ResponseWriter, status int, payload any) { + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, status, payload) +} + +func decodeJSONRequest(r *http.Request, target any) error { + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + return decoder.Decode(target) +} + +func (g *slackGateway) writeInstallStateErrorAPI(w http.ResponseWriter, status int, err error, requestID string) { + g.writeInstallResultAPI(w, status, installResult{ + Status: installResultStatusError, + Code: classifyInstallStateError(err), + Operation: installResultOperationChannelInstall, + Provider: slackProvider, + RequestID: strings.TrimSpace(requestID), + }) +} + +func requireAPIBrowserPrincipal(cfg config, w http.ResponseWriter, r *http.Request) (browserPrincipal, bool) { + id := strings.TrimSpace(r.Header.Get(cfg.BrowserAuthHeaderID)) + if id == "" { + writeAPIError(w, http.StatusUnauthorized, "unauthorized") + return browserPrincipal{}, false + } + return browserPrincipal{ + ID: id, + Email: strings.TrimSpace(r.Header.Get(cfg.BrowserAuthHeaderEmail)), + }, true +} + +func (g *slackGateway) handleInstallTargetSelectionAPI(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + g.handleInstallTargetSelectionAPIGet(w, r) + case http.MethodPost: + g.handleInstallTargetSelectionAPIPost(w, r) + default: + writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (g *slackGateway) handleInstallTargetSelectionAPIGet(w http.ResponseWriter, r *http.Request) { + requestID := strings.TrimSpace(r.URL.Query().Get("requestId")) + state := g.pendingInstallStateFromRequest(r, requestID, "") + pendingInstall, err := g.state.parsePendingInstall(state) + if err != nil { + g.clearPendingInstallCookie(w, r, requestID) + g.writeInstallStateErrorAPI(w, http.StatusOK, err, requestID) + return + } + targets, err := g.listInstallTargets(r.Context(), &pendingInstall.Installation, pendingInstall.RequestID) + if err != nil { + g.logger.ErrorContext( + r.Context(), + "install target selection api lookup failed", + "err", err, + "team_id", pendingInstall.Installation.TeamID, + "request_id", pendingInstall.RequestID, + ) + writeAPIError(w, http.StatusBadGateway, "install targets unavailable") + return + } + writeAPIJSON(w, http.StatusOK, installSelectionResponse{ + Status: "resolved", + RequestID: pendingInstall.RequestID, + TeamID: pendingInstall.Installation.TeamID, + Targets: targets, + }) +} + +func (g *slackGateway) handleInstallTargetSelectionAPIPost(w http.ResponseWriter, r *http.Request) { + var body installSelectionRequest + if err := decodeJSONRequest(r, &body); err != nil { + writeAPIError(w, http.StatusBadRequest, "invalid request body") + return + } + bodyRequestID := strings.TrimSpace(body.RequestID) + state := g.pendingInstallStateFromRequest(r, bodyRequestID, body.State) + pendingInstall, err := g.state.parsePendingInstall(state) + if err != nil { + g.clearPendingInstallCookie(w, r, bodyRequestID) + g.writeInstallStateErrorAPI(w, http.StatusOK, err, bodyRequestID) + return + } + if bodyRequestID != "" && bodyRequestID != pendingInstall.RequestID { + writeAPIError(w, http.StatusBadRequest, "install request is stale") + return + } + requestID := pendingInstall.RequestID + if len(body.PresetInputs) == 0 { + writeAPIError(w, http.StatusBadRequest, "presetInputs is required") + return + } + installation := pendingInstall.Installation + if err := g.upsertInstallation(r.Context(), &installation, requestID, body.PresetInputs); err != nil { + g.logger.ErrorContext( + r.Context(), + "install target selection api upsert failed", + "err", err, + "team_id", installation.TeamID, + "request_id", requestID, + ) + g.clearPendingInstallCookie(w, r, requestID) + g.writeInstallResultAPI(w, http.StatusOK, installResult{ + Status: installResultStatusError, + Code: classifyInstallUpsertError(err), + Operation: installResultOperationChannelInstall, + Provider: slackProvider, + RequestID: requestID, + TeamID: installation.TeamID, + }) + return + } + g.clearPendingInstallCookie(w, r, requestID) + writeAPIJSON(w, http.StatusOK, map[string]any{ + "status": "installed", + "requestId": requestID, + "teamId": installation.TeamID, + }) +} + +func (g *slackGateway) handleInstallResultAPI(w http.ResponseWriter, r *http.Request) { + result := installResult{ + Status: installResultStatus(firstNonEmpty(r.URL.Query().Get("status"), string(installResultStatusError))), + Code: normalizeInstallResultCode(firstNonEmpty(r.URL.Query().Get("code"), string(installResultCodeInternalError))), + Operation: strings.TrimSpace(r.URL.Query().Get("operation")), + Retryable: strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("retryable")), "true"), + Provider: firstNonEmpty(r.URL.Query().Get("provider"), slackProvider), + RequestID: strings.TrimSpace(r.URL.Query().Get("requestId")), + TeamID: strings.TrimSpace(r.URL.Query().Get("teamId")), + } + g.writeInstallResultAPI(w, http.StatusOK, result) +} + +func (g *slackGateway) writeInstallResultAPI(w http.ResponseWriter, status int, result installResult) { + descriptor := installResultDescriptorFor(result.Code, g.installRedirectURL()) + if result.Status == installResultStatusSuccess && result.Code == installResultCodeInternalError { + descriptor = installResultDescriptorFor(installResultCodeInstalled, g.installRedirectURL()) + } + writeAPIJSON(w, status, map[string]any{ + "status": result.Status, + "code": result.Code, + "operation": result.Operation, + "provider": result.Provider, + "requestId": result.RequestID, + "teamId": result.TeamID, + "title": descriptor.Title, + "message": descriptor.Message, + "retryable": descriptor.Retryable, + "actionLabel": descriptor.ActionLabel, + "actionHref": descriptor.ActionHref, + }) +} + +func (g *slackGateway) handleWorkspaceManagementAPI(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + principal, ok := requireAPIBrowserPrincipal(g.cfg, w, r) + if !ok { + return + } + installations, err := g.listManagedInstallations(r.Context(), principal.ID) + if err != nil { + g.logger.ErrorContext( + r.Context(), + "workspace management api list failed", + "err", err, + "caller_auth_id", principal.ID, + ) + writeAPIError(w, http.StatusBadGateway, "workspace list unavailable") + return + } + writeAPIJSON(w, http.StatusOK, map[string]any{ + "status": "resolved", + "installations": installations, + }) +} + +func (g *slackGateway) handleWorkspaceTargetAPI(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + g.handleWorkspaceTargetAPIGet(w, r) + case http.MethodPost: + g.handleWorkspaceTargetAPIPost(w, r) + default: + writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (g *slackGateway) handleWorkspaceTargetAPIGet(w http.ResponseWriter, r *http.Request) { + principal, ok := requireAPIBrowserPrincipal(g.cfg, w, r) + if !ok { + return + } + teamID := strings.TrimSpace(r.URL.Query().Get("teamId")) + if teamID == "" { + writeAPIError(w, http.StatusBadRequest, "teamId is required") + return + } + requestID := newInstallRequestID() + targets, err := g.listInstallTargetsForOwnerAuthID(r.Context(), teamID, principal.ID, requestID, nil) + if err != nil { + g.logger.ErrorContext( + r.Context(), + "workspace target api lookup failed", + "err", err, + "caller_auth_id", principal.ID, + "team_id", teamID, + "request_id", requestID, + ) + writeAPIError(w, http.StatusBadGateway, "workspace targets unavailable") + return + } + writeAPIJSON(w, http.StatusOK, map[string]any{ + "status": "resolved", + "teamId": teamID, + "requestId": requestID, + "targets": targets, + }) +} + +func (g *slackGateway) handleWorkspaceTargetAPIPost(w http.ResponseWriter, r *http.Request) { + principal, ok := requireAPIBrowserPrincipal(g.cfg, w, r) + if !ok { + return + } + var body workspaceTargetRequest + if err := decodeJSONRequest(r, &body); err != nil { + writeAPIError(w, http.StatusBadRequest, "invalid request body") + return + } + teamID := strings.TrimSpace(body.TeamID) + if teamID == "" { + writeAPIError(w, http.StatusBadRequest, "teamId is required") + return + } + if len(body.PresetInputs) == 0 { + writeAPIError(w, http.StatusBadRequest, "presetInputs is required") + return + } + requestID := firstNonEmpty(body.RequestID, newInstallRequestID()) + if err := g.updateManagedInstallationTarget(r.Context(), principal.ID, teamID, requestID, body.PresetInputs); err != nil { + g.logger.ErrorContext( + r.Context(), + "workspace target api update failed", + "err", err, + "caller_auth_id", principal.ID, + "team_id", teamID, + "request_id", requestID, + ) + writeAPIError(w, http.StatusBadGateway, "workspace target update failed") + return + } + writeAPIJSON(w, http.StatusOK, map[string]any{ + "status": "updated", + "teamId": teamID, + "requestId": requestID, + }) +} + +func (g *slackGateway) handleWorkspaceDisconnectAPI(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + principal, ok := requireAPIBrowserPrincipal(g.cfg, w, r) + if !ok { + return + } + var body workspaceDisconnectRequest + if err := decodeJSONRequest(r, &body); err != nil { + writeAPIError(w, http.StatusBadRequest, "invalid request body") + return + } + teamID := strings.TrimSpace(body.TeamID) + if teamID == "" { + writeAPIError(w, http.StatusBadRequest, "teamId is required") + return + } + if err := g.disconnectManagedInstallation(r.Context(), principal.ID, teamID); err != nil { + g.logger.ErrorContext( + r.Context(), + "workspace disconnect api failed", + "err", err, + "caller_auth_id", principal.ID, + "team_id", teamID, + ) + writeAPIError(w, http.StatusBadGateway, "workspace disconnect failed") + return + } + writeAPIJSON(w, http.StatusOK, map[string]any{ + "status": "disconnected", + "teamId": teamID, + }) +} + +func (g *slackGateway) handleWorkspaceTestAPI(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + principal, ok := requireAPIBrowserPrincipal(g.cfg, w, r) + if !ok { + return + } + var body workspaceTestRequest + if err := decodeJSONRequest(r, &body); err != nil { + writeAPIError(w, http.StatusBadRequest, "invalid request body") + return + } + teamID := strings.TrimSpace(body.TeamID) + if teamID == "" { + writeAPIError(w, http.StatusBadRequest, "teamId is required") + return + } + installation, err := g.lookupManagedWorkspace(r, principal.ID, teamID) + if err != nil { + g.logger.ErrorContext( + r.Context(), + "workspace test api lookup failed", + "err", err, + "caller_auth_id", principal.ID, + "team_id", teamID, + ) + writeAPIError(w, http.StatusBadGateway, "workspace lookup unavailable") + return + } + if installation == nil { + writeAPIError(w, http.StatusForbidden, "workspace not manageable") + return + } + envelope, err := inferSyntheticSlackEvent(body.ChannelID, body.Prompt, body.ThreadTS) + if err != nil { + writeAPIError(w, http.StatusBadRequest, err.Error()) + return + } + envelope.TeamID = teamID + delivery, process, err := g.beginMessageEventDelivery(envelope) + if err != nil { + writeAPIError(w, http.StatusConflict, err.Error()) + return + } + if !process { + writeAPIJSON(w, http.StatusOK, workspaceTestResponse{ + Status: "resolved", + Outcome: messageEventOutcomeIgnored, + }) + return + } + mode := strings.TrimSpace(body.Mode) + if mode == "" { + mode = "real" + } + result, err := g.processMessageEventWithDeliveryOptions( + r.Context(), + envelope, + delivery, + messageEventProcessOptions{ + Synthetic: true, + DryRun: mode == "dry-run", + }, + ) + if err != nil { + writeAPIError(w, http.StatusBadGateway, err.Error()) + return + } + writeAPIJSON(w, http.StatusOK, workspaceTestResponse{ + Status: "resolved", + Outcome: result.Outcome, + Reply: result.Reply, + ConversationID: result.ConversationID, + PostedMessageTS: result.PostedMessageTS, + }) +} + +func (g *slackGateway) handleChannelSettingsAPI(w http.ResponseWriter, r *http.Request) { + principal, ok := requireAPIBrowserPrincipal(g.cfg, w, r) + if !ok { + return + } + installations, err := g.listManagedInstallations(r.Context(), principal.ID) + if err != nil { + g.logger.ErrorContext( + r.Context(), + "channel settings api list failed", + "err", err, + "caller_auth_id", principal.ID, + ) + writeAPIError(w, http.StatusBadGateway, "channel settings unavailable") + return + } + + relativePath := strings.TrimRight(g.relativeGatewayPath(r.URL.Path), "/") + relativePath = strings.TrimPrefix(relativePath, "/api") + if relativePath == "/settings/channels" { + if r.Method != http.MethodGet { + writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + writeAPIJSON(w, http.StatusOK, map[string]any{ + "status": "resolved", + "installations": installations, + }) + return + } + + if installationID, ok := channelSettingsInstallationPathID(relativePath); ok { + if r.Method != http.MethodGet { + writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + for _, installation := range installations { + if strings.TrimSpace(installation.ID) == installationID { + writeAPIJSON(w, http.StatusOK, map[string]any{ + "status": "resolved", + "installation": installation, + }) + return + } + } + writeAPIError(w, http.StatusNotFound, "installation not found") + return + } + + installation, connection, found := matchManagedChannelSettingsConnection( + installations, + relativePath, + ) + if !found { + writeAPIError(w, http.StatusNotFound, "connection not found") + return + } + switch r.Method { + case http.MethodGet: + writeAPIJSON(w, http.StatusOK, map[string]any{ + "status": "resolved", + "installation": installation, + "connection": connection, + }) + case http.MethodPut: + g.handleChannelSettingsAPIUpdate(w, r, principal.ID, installation, connection) + default: + writeAPIError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func (g *slackGateway) handleChannelSettingsAPIUpdate( + w http.ResponseWriter, + r *http.Request, + callerAuthID string, + installation backendManagedInstallation, + connection backendManagedConnection, +) { + var body channelRoutesUpdateRequest + if err := decodeJSONRequest(r, &body); err != nil { + writeAPIError(w, http.StatusBadRequest, "invalid request body") + return + } + policiesByChannel := map[string]installationChannelPolicy{} + for _, policy := range body.ChannelPolicies { + channelID := strings.TrimSpace(policy.ExternalChannelID) + if channelID == "" { + continue + } + requireMention := true + if policy.RequireMention != nil { + requireMention = *policy.RequireMention + } + policiesByChannel[channelID] = installationChannelPolicy{ + ExternalChannelID: channelID, + ExternalChannelType: strings.TrimSpace(policy.ExternalChannelType), + RequireMention: &requireMention, + } + } + policies := make([]installationChannelPolicy, 0, len(policiesByChannel)) + for _, policy := range policiesByChannel { + policies = append(policies, policy) + } + sort.Slice(policies, func(i, j int) bool { + return policies[i].ExternalChannelID < policies[j].ExternalChannelID + }) + requestID := firstNonEmpty(body.RequestID, newInstallRequestID()) + if err := g.updateManagedInstallationRoutes( + r.Context(), + callerAuthID, + installation.ID, + connection.ID, + requestID, + policies, + ); err != nil { + g.logger.ErrorContext( + r.Context(), + "channel settings api update failed", + "err", err, + "caller_auth_id", callerAuthID, + "installation_id", installation.ID, + "connection_id", connection.ID, + "request_id", requestID, + ) + writeAPIError(w, http.StatusBadGateway, "channel routes update failed") + return + } + g.policies.forget(installation.Route.ExternalTenantID) + writeAPIJSON(w, http.StatusOK, map[string]any{ + "status": "updated", + "installation": installation.ID, + "connection": connection.ID, + "requestId": requestID, + }) +} diff --git a/integrations/slack-gateway/pending_install_cookie.go b/integrations/slack-gateway/pending_install_cookie.go new file mode 100644 index 0000000..0d9847d --- /dev/null +++ b/integrations/slack-gateway/pending_install_cookie.go @@ -0,0 +1,85 @@ +package main + +import ( + "net/http" + "strings" + "time" +) + +const pendingInstallCookieName = "spritz_slack_pending_install" + +func pendingInstallCookieNameForRequest(requestID string) string { + requestID = strings.TrimSpace(requestID) + if requestID == "" { + return pendingInstallCookieName + } + var suffix strings.Builder + for _, char := range requestID { + if char >= 'a' && char <= 'z' || + char >= 'A' && char <= 'Z' || + char >= '0' && char <= '9' || + char == '-' || char == '_' { + suffix.WriteRune(char) + } + } + if suffix.Len() == 0 { + return pendingInstallCookieName + } + return pendingInstallCookieName + "_" + suffix.String() +} + +func (g *slackGateway) pendingInstallCookiePath() string { + return g.publicPathPrefix() + "/api/slack/install/selection" +} + +func (g *slackGateway) pendingInstallCookieSecure(r *http.Request) bool { + if r != nil { + if r.TLS != nil { + return true + } + if strings.EqualFold(strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")), "https") { + return true + } + } + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(g.cfg.PublicURL)), "https://") +} + +func (g *slackGateway) setPendingInstallCookie(w http.ResponseWriter, r *http.Request, requestID, state string) { + http.SetCookie(w, &http.Cookie{ + Name: pendingInstallCookieNameForRequest(requestID), + Value: strings.TrimSpace(state), + Path: g.pendingInstallCookiePath(), + Expires: time.Now().UTC().Add(g.state.ttl), + MaxAge: int(g.state.ttl.Seconds()), + HttpOnly: true, + Secure: g.pendingInstallCookieSecure(r), + SameSite: http.SameSiteLaxMode, + }) +} + +func (g *slackGateway) clearPendingInstallCookie(w http.ResponseWriter, r *http.Request, requestID string) { + http.SetCookie(w, &http.Cookie{ + Name: pendingInstallCookieNameForRequest(requestID), + Value: "", + Path: g.pendingInstallCookiePath(), + Expires: time.Unix(0, 0).UTC(), + MaxAge: -1, + HttpOnly: true, + Secure: g.pendingInstallCookieSecure(r), + SameSite: http.SameSiteLaxMode, + }) +} + +func (g *slackGateway) pendingInstallStateFromRequest(r *http.Request, requestID, explicitState string) string { + if state := strings.TrimSpace(explicitState); state != "" { + return state + } + if r == nil { + return "" + } + cookie, err := r.Cookie(pendingInstallCookieNameForRequest(requestID)) + if err != nil { + return "" + } + return strings.TrimSpace(cookie.Value) +} diff --git a/integrations/slack-gateway/react_routes.go b/integrations/slack-gateway/react_routes.go new file mode 100644 index 0000000..4352a6d --- /dev/null +++ b/integrations/slack-gateway/react_routes.go @@ -0,0 +1,85 @@ +package main + +import ( + "net/http" + "net/url" + "strings" +) + +func reactSlackInstallResultPath() string { + return "/settings/slack/install/result" +} + +func reactSlackInstallSelectPath(requestID string) string { + target := url.URL{Path: "/settings/slack/install/select"} + if requestID := strings.TrimSpace(requestID); requestID != "" { + query := target.Query() + query.Set("requestId", requestID) + target.RawQuery = query.Encode() + } + return target.String() +} + +func reactSlackWorkspacesPath(query url.Values) string { + target := url.URL{Path: "/settings/slack/workspaces"} + target.RawQuery = query.Encode() + return target.String() +} + +func reactSlackWorkspaceTargetPath(query url.Values) string { + target := url.URL{Path: "/settings/slack/workspaces/target"} + target.RawQuery = query.Encode() + return target.String() +} + +func reactSlackWorkspaceTestPath(query url.Values) string { + target := url.URL{Path: "/settings/slack/workspaces/test"} + target.RawQuery = query.Encode() + return target.String() +} + +func reactSlackChannelSettingsPath(relativeGatewayPath string, query url.Values) string { + relative := strings.TrimPrefix(strings.TrimSpace(relativeGatewayPath), "/settings/channels") + target := url.URL{Path: "/settings/slack/channels" + relative} + target.RawQuery = query.Encode() + return target.String() +} + +func (g *slackGateway) reactRouteURL(target string) string { + route, err := url.Parse(strings.TrimSpace(target)) + if err != nil { + return target + } + if route.IsAbs() { + return route.String() + } + + base, err := url.Parse(strings.TrimRight(strings.TrimSpace(g.cfg.SpritzBaseURL), "/")) + if err != nil || base.Scheme == "" || base.Host == "" { + return route.String() + } + basePath := strings.TrimRight(base.Path, "/") + routePath := "/" + strings.TrimLeft(route.Path, "/") + base.RawPath = "" + base.Path = basePath + routePath + base.RawQuery = route.RawQuery + base.Fragment = route.Fragment + return base.String() +} + +func (g *slackGateway) reactRoutesShareGatewayOrigin() bool { + gatewayURL, err := url.Parse(strings.TrimSpace(g.cfg.PublicURL)) + if err != nil || gatewayURL.Scheme == "" || gatewayURL.Host == "" { + return false + } + reactURL, err := url.Parse(strings.TrimSpace(g.cfg.SpritzBaseURL)) + if err != nil || reactURL.Scheme == "" || reactURL.Host == "" { + return false + } + return strings.EqualFold(gatewayURL.Scheme, reactURL.Scheme) && + strings.EqualFold(gatewayURL.Host, reactURL.Host) +} + +func (g *slackGateway) redirectToReactRoute(w http.ResponseWriter, r *http.Request, target string) { + http.Redirect(w, r, g.reactRouteURL(target), http.StatusSeeOther) +} diff --git a/integrations/slack-gateway/slack_oauth.go b/integrations/slack-gateway/slack_oauth.go index f9b7b9e..8628259 100644 --- a/integrations/slack-gateway/slack_oauth.go +++ b/integrations/slack-gateway/slack_oauth.go @@ -215,7 +215,12 @@ func (g *slackGateway) handleOAuthCallback(w http.ResponseWriter, r *http.Reques }) return } - g.renderInstallTargetPicker(w, pendingState, requestID, targets) + if !g.reactRoutesShareGatewayOrigin() { + g.renderInstallTargetPicker(w, pendingState, requestID, targets) + return + } + g.setPendingInstallCookie(w, r, requestID, pendingState) + g.redirectToReactRoute(w, r, reactSlackInstallSelectPath(requestID)) return } if err := g.upsertInstallation(r.Context(), &installation, requestID, targets[0].PresetInputs); err != nil { @@ -323,13 +328,9 @@ func (g *slackGateway) handleInstallTargetSelection(w http.ResponseWriter, r *ht pendingInstall, err := g.state.parsePendingInstall(strings.TrimSpace(r.FormValue("state"))) if err != nil { - resultCode := installResultCodeStateInvalid - if strings.Contains(strings.ToLower(err.Error()), "expired") { - resultCode = installResultCodeStateExpired - } g.redirectToInstallResult(w, r, installResult{ Status: installResultStatusError, - Code: resultCode, + Code: classifyInstallStateError(err), Operation: installResultOperationChannelInstall, Provider: slackProvider, }) diff --git a/integrations/slack-gateway/workspace_handlers.go b/integrations/slack-gateway/workspace_handlers.go index 54a510a..41cc6ab 100644 --- a/integrations/slack-gateway/workspace_handlers.go +++ b/integrations/slack-gateway/workspace_handlers.go @@ -6,6 +6,19 @@ import ( ) func (g *slackGateway) handleWorkspaceManagement(w http.ResponseWriter, r *http.Request) { + if !g.reactRoutesShareGatewayOrigin() { + g.renderLegacyWorkspaceManagement(w, r) + return + } + principal, ok := requireBrowserPrincipal(g.cfg, w, r) + if !ok { + return + } + g.redirectToReactRoute(w, r, reactSlackWorkspacesPath(r.URL.Query())) + _ = principal +} + +func (g *slackGateway) renderLegacyWorkspaceManagement(w http.ResponseWriter, r *http.Request) { principal, ok := requireBrowserPrincipal(g.cfg, w, r) if !ok { return @@ -38,6 +51,19 @@ func (g *slackGateway) handleWorkspaceTarget(w http.ResponseWriter, r *http.Requ } func (g *slackGateway) handleWorkspaceTargetPicker(w http.ResponseWriter, r *http.Request) { + if !g.reactRoutesShareGatewayOrigin() { + g.renderLegacyWorkspaceTargetPicker(w, r) + return + } + principal, ok := requireBrowserPrincipal(g.cfg, w, r) + if !ok { + return + } + g.redirectToReactRoute(w, r, reactSlackWorkspaceTargetPath(r.URL.Query())) + _ = principal +} + +func (g *slackGateway) renderLegacyWorkspaceTargetPicker(w http.ResponseWriter, r *http.Request) { principal, ok := requireBrowserPrincipal(g.cfg, w, r) if !ok { return diff --git a/integrations/slack-gateway/workspace_test_messages.go b/integrations/slack-gateway/workspace_test_messages.go index 091fe44..f5333b0 100644 --- a/integrations/slack-gateway/workspace_test_messages.go +++ b/integrations/slack-gateway/workspace_test_messages.go @@ -287,6 +287,19 @@ func (g *slackGateway) handleWorkspaceTest(w http.ResponseWriter, r *http.Reques } func (g *slackGateway) handleWorkspaceTestForm(w http.ResponseWriter, r *http.Request) { + if !g.reactRoutesShareGatewayOrigin() { + g.renderLegacyWorkspaceTestForm(w, r) + return + } + principal, ok := requireBrowserPrincipal(g.cfg, w, r) + if !ok { + return + } + g.redirectToReactRoute(w, r, reactSlackWorkspaceTestPath(r.URL.Query())) + _ = principal +} + +func (g *slackGateway) renderLegacyWorkspaceTestForm(w http.ResponseWriter, r *http.Request) { principal, ok := requireBrowserPrincipal(g.cfg, w, r) if !ok { return diff --git a/ui/entrypoint.sh b/ui/entrypoint.sh index 43922bb..79bd6ac 100755 --- a/ui/entrypoint.sh +++ b/ui/entrypoint.sh @@ -4,6 +4,7 @@ set -eu API_BASE_URL="${SPRITZ_API_BASE_URL:-}" WEBSOCKET_BASE_URL="${SPRITZ_UI_WEBSOCKET_BASE_URL:-}" CHAT_PATH_PREFIX="${SPRITZ_UI_CHAT_PATH_PREFIX:-}" +SLACK_GATEWAY_BASE_PATH="${SPRITZ_UI_SLACK_GATEWAY_BASE_PATH:-}" OWNER_ID="${SPRITZ_UI_OWNER_ID:-}" AUTH_MODE="${SPRITZ_UI_AUTH_MODE:-}" AUTH_TOKEN_STORAGE="${SPRITZ_UI_AUTH_TOKEN_STORAGE:-}" @@ -38,6 +39,9 @@ fi if [ -z "$CHAT_PATH_PREFIX" ]; then CHAT_PATH_PREFIX="/c" fi +if [ -z "$SLACK_GATEWAY_BASE_PATH" ]; then + SLACK_GATEWAY_BASE_PATH="/slack-gateway" +fi if [ -z "$ASSET_VERSION" ]; then ASSET_VERSION="$(date +%s)" fi @@ -49,6 +53,7 @@ escape_sed() { API_BASE_URL_ESCAPED="$(escape_sed "$API_BASE_URL")" WEBSOCKET_BASE_URL_ESCAPED="$(escape_sed "$WEBSOCKET_BASE_URL")" CHAT_PATH_PREFIX_ESCAPED="$(escape_sed "$CHAT_PATH_PREFIX")" +SLACK_GATEWAY_BASE_PATH_ESCAPED="$(escape_sed "$SLACK_GATEWAY_BASE_PATH")" OWNER_ID_ESCAPED="$(escape_sed "$OWNER_ID")" AUTH_MODE_ESCAPED="$(escape_sed "$AUTH_MODE")" AUTH_TOKEN_STORAGE_ESCAPED="$(escape_sed "$AUTH_TOKEN_STORAGE")" @@ -80,6 +85,7 @@ ASSET_VERSION_ESCAPED="$(escape_sed "$ASSET_VERSION")" sed "s|__SPRITZ_API_BASE_URL__|${API_BASE_URL_ESCAPED}|g" "${HTML_DIR}/config.js" \ | sed "s|__SPRITZ_UI_WEBSOCKET_BASE_URL__|${WEBSOCKET_BASE_URL_ESCAPED}|g" \ | sed "s|__SPRITZ_UI_CHAT_PATH_PREFIX__|${CHAT_PATH_PREFIX_ESCAPED}|g" \ + | sed "s|__SPRITZ_UI_SLACK_GATEWAY_BASE_PATH__|${SLACK_GATEWAY_BASE_PATH_ESCAPED}|g" \ | sed "s|__SPRITZ_OWNER_ID__|${OWNER_ID_ESCAPED}|g" \ | sed "s|__SPRITZ_UI_AUTH_MODE__|${AUTH_MODE_ESCAPED}|g" \ | sed "s|__SPRITZ_UI_AUTH_TOKEN_STORAGE__|${AUTH_TOKEN_STORAGE_ESCAPED}|g" \ diff --git a/ui/public/config.js b/ui/public/config.js index a06ac5d..be6fb2d 100644 --- a/ui/public/config.js +++ b/ui/public/config.js @@ -2,6 +2,7 @@ window.SPRITZ_CONFIG = { apiBaseUrl: '__SPRITZ_API_BASE_URL__', websocketBaseUrl: '__SPRITZ_UI_WEBSOCKET_BASE_URL__', chatPathPrefix: '__SPRITZ_UI_CHAT_PATH_PREFIX__', + slackGatewayBasePath: '__SPRITZ_UI_SLACK_GATEWAY_BASE_PATH__', ownerId: '__SPRITZ_OWNER_ID__', presets: __SPRITZ_UI_PRESETS__, repoDefaults: { diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index 0546efd..184d584 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -1,10 +1,10 @@ -import { describe, it, expect, vi } from 'vite-plus/test'; +import { afterEach, describe, it, expect, vi } from 'vite-plus/test'; 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'; +import { buildLegacySlackGatewayRedirectURL, inferBrowserRouterBasename } from '@/App'; // Mock the page components to keep tests simple vi.mock('@/pages/chat', () => ({ @@ -16,6 +16,9 @@ vi.mock('@/pages/create', () => ({ vi.mock('@/pages/terminal', () => ({ TerminalPage: () =>{description}
+ )} ++ Target: {targetName(installation)} + {installation.currentTarget?.ownerLabel ? ` · ${installation.currentTarget.ownerLabel}` : ''} +
+{route.externalChannelType || 'channel'}
+{result.reply}
} + {result.conversationId && ( +Conversation: {result.conversationId}
+ )} +{result.message}
+ {result.requestId && ( +Request ID: {result.requestId}
+ )} + {result.actionHref && ( + + {result.actionLabel || 'Continue'} + + )} +