feat(slack): helix-org Slack transport via ServiceConnection tiers + processor routing#2725
Open
philwinder wants to merge 39 commits into
Open
feat(slack): helix-org Slack transport via ServiceConnection tiers + processor routing#2725philwinder wants to merge 39 commits into
philwinder wants to merge 39 commits into
Conversation
bbabe8a to
995aa84
Compare
…uting
Add Slack as a first-class org-graph Topic transport. Replaces the
abandoned per-agent/configregistry approach with a three-tier
ServiceConnection model and routes inbound messages through the existing
processor/filter layer rather than a bespoke in-transport router.
- Global app: a global `slack_app` ServiceConnection (admin panel).
- Workspace install: org-scoped `slack_workspace` ServiceConnection(s),
created by the OAuth install flow; strict multi-tenancy.
- KindSlack Topic transport: {service_connection_id, channel}.
- Generic protocol layer in api/pkg/serviceconnection/slack (Events-API
verify/parse, Socket Mode, OAuth exchange, persona post) with zero org
concepts; org wiring in org/infrastructure/transports/slack.
- Inbound (REST Events API + Socket Mode) shares one ingest:
team_id -> workspace -> org -> matching Topics -> Publishing.Publish ->
dispatcher + processors -> Workers. Outbound posts as worker persona.
- REST + org-scoped routes; OAuth install + workspace list/delete.
- Admin slack_app form in ServiceConnectionsTable.
Verified e2e on localhost:8080 (signature, team->org->topic->publish,
drop path, org-scoped isolation). Unit tests for signature/dispatch and
ingest routing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Frontend wiring for the Slack org transport:
- Topic transport picker: add `slack` kind to the New Topic dialog;
choose a connected workspace + channel id; builds
transport.config {service_connection_id, channel}.
- Org Settings: SlackIntegrationsPanel — "Install to Slack" (OAuth
redirect) + list/disconnect connected workspaces (org-scoped).
- Hooks: useListSlackWorkspaces / useStartSlackInstall /
useDisconnectSlackWorkspace.
Backend: slackOAuthStart now returns the authorize URL as JSON (so the
token-authenticated frontend can redirect) instead of a 302; swagger
annotations on the org-scoped slack endpoints; regenerated OpenAPI
client. Routes verified live (start 503 no-app, workspaces 200).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…notice Make Slack channel-join part of topic creation, in the domain layer: - streaming.AutoInstaller: optional Inbound extension; a provisioner that returns true is run automatically by topics.Create. Slack opts in; GitHub (explicit webhook flow) does not. - streaming.InstallResult.Notice: non-fatal, human-facing message. - Slack Provisioner.Install self-joins PUBLIC channels via conversations.join. Private channels can't be joined remotely (Slack offers no such API) — returns a Notice telling the user to /invite the bot, not an error. The message text is produced in the domain. - topics.Create runs the provisioner best-effort (never fails create) and threads the notice out; REST returns it as TopicDTO.provisioning_notice, MCP as `notice`. processors.TopicWriter + callers updated for the new signature. - Frontend surfaces the notice (snackbar) after topic create; channel help text explains public auto-join vs private invite. Verified live: creating a slack topic runs the provisioner and returns provisioning_notice non-fatally (201). Unit tests cover the opt-in/notice/non-fatal-failure orchestration. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…igger Adopt the per-agent "View setup instructions" paradigm for the global Slack app config, and de-duplicate the two so they share one scaffold. - New components/slack/SlackSetupScaffold.tsx: shared StepNumber, SetupStepList, CopyableCodeBlock (expandable manifest + copy), CopyField. - TriggerSlackSetup refactored to consume the scaffold (keeps its screenshots, image modal, and token fields as per-step content) — no more duplicated step list / manifest block. - New dashboard/SlackAppSetup.tsx: global-app instructions dialog. Manifest is pre-filled with THIS deployment's OAuth redirect URL; REST vs Socket steps differ; copy fields for the redirect + Events request URLs. - ServiceConnectionsTable: replaced the cramped info Alert with a clear "One Slack app for the whole deployment" callout that explains org admins install THIS app into their workspaces, plus a "View setup instructions" button opening the dialog. Type-clean; Vite HMR applied without errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The global Slack app setup steps (manifest + which credentials to copy) genuinely differ between REST and Socket Mode, so they can't be unified. Move the Ingress Mode selector above the "View setup instructions" button and label the button with the chosen mode, so the user selects the mode first and always sees the matching steps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…global dialog
The setup screenshots live in Vite's publicDir (frontend/assets, served at
the site root). They were imported from JS — which Vite forbids for
publicDir assets ("Assets in public directory cannot be imported from
JavaScript"), silently breaking every <img>. Reference them by URL
(/img/slack/*.png) instead.
While here, move image rendering + the click-to-enlarge modal into the
shared SlackSetupScaffold (SetupStep.image), so both the per-agent trigger
setup and the global app setup get screenshots from one implementation —
and add the create/manifest/token screenshots to the global "Create the
global Helix Slack app" dialog, which had none.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…g workspace The "choose a workspace" step when creating the app from a manifest picks the workspace that MANAGES (configures) the app, not where it runs — the app is installable into other workspaces. Reword both REST and Socket Mode steps accordingly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… scope)
The generated manifest subscribed to the message.mpim bot event but the
app's scopes (and the backend's OAuth install scopes) don't include
mpim:history, so Slack rejected the manifest ("message.mpim is missing").
Remove the event so events match the requested scopes. Group-DM support
would mean adding mpim:history to both scope lists.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unify REST and Socket Mode so the bot token always belongs to a workspace
install (slack_workspace), resolved by team_id — never the global app.
Backend:
- Socket runner connects with the app-level token ALONE; per-workspace bot
tokens are resolved downstream by team_id (so one socket can serve many
workspaces). No global bot token required.
- New POST /orgs/{org}/slack/workspaces: connect a workspace from a pasted
bot token (Socket Mode / on-prem, where there's no OAuth). auth.tests the
token, derives team id/name/bot user, stores a slack_workspace — same row
the OAuth flow produces.
Frontend:
- Global app Socket Mode form drops the Bot Token field (app-level token
only); copy explains bot tokens are per-workspace.
- Socket setup instructions end at the app token and point to the org
Settings → Slack "connect workspace" step.
- SlackIntegrationsPanel gains "connect with a bot token" (paste xoxb-)
alongside the OAuth "Install to Slack".
Verified live: POST workspace with a token runs auth.test (fake → 502,
empty → 400). Backend + frontend build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Socket Mode auto-install: a Socket Mode app can use the same OAuth
"Install to Slack" flow as REST (Slack allows OAuth + Socket Mode; the
socket only delivers events). The global Socket app now collects client
id/secret too, and the manifest carries the OAuth redirect URL. Org admins
install via the one-click button instead of pasting a bot token (manual
paste stays as a fallback).
Multiple global apps: support more than one slack_app per deployment.
- New GET /orgs/{org}/slack/apps lists the installable apps.
- OAuth start takes ?app_id and errors when ambiguous; the chosen app id
is carried through the OAuth state to the callback.
- Inbound Events verify against ANY configured app's signing secret.
- One Socket Mode runner per socket app.
- Org Settings panel shows an app picker when more than one exists.
Verified live: list (4 apps), start without app_id → 400, start with
app_id → correct client_id, events verify against either app's secret,
wrong secret → 401.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fix the Socket-mode install error ("Slack app is missing its client id"):
client id/secret are now optional for Socket Mode, and the org Settings
panel adapts per app — apps with a client id get the one-click "Add to
Slack" OAuth button; apps without one (Socket / on-prem) get the
bot-token connect path. No more dead-end OAuth attempt.
Redesign the panel to match the GitHub App panel: logo + heading +
description, a single action box (app picker + primary action + bot-token
fallback), and a "Connected workspaces" section, instead of the cramped
header-row controls. Verified in-browser across the app-picker, OAuth, and
bot-token states.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…app) Service connections could only be created or deleted, so a Slack app set up without OAuth client credentials could never gain them — leaving its org install stuck on the bot-token path. Add an Edit action (reusing the create dialog): the connection type is locked, secret fields left blank keep their current values, and Save issues a PUT. This is what lets an admin add a Client ID/Secret to an existing Socket Mode app so the org Settings panel offers one-click "Add to Slack" instead of pasting a bot token. Verified in-browser: editing in OAuth creds flips the panel from the bot-token path to the "Add to Slack" button. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make the OAuth flow the clear, primary install action: when the app has OAuth credentials, the org Settings panel shows "Install into your workspace" with a caption explaining Helix sets the bot token automatically (Slack handles workspace selection + approval; the callback stores the token). When the app has no OAuth creds, point the admin at Service Connections → Edit to add a Client ID & Secret, with the bot-token paste as the fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… OAuth creds Make the global Slack app flow: always collect Client ID + Secret (both modes), then Helix configures the Slack app for the admin. - "Create the app in Slack" deep-link (api.slack.com/apps?new_app=1& manifest_json=…) opens Slack's create screen pre-filled with the mode-specific manifest (scopes, events, OAuth redirect URL, and Socket Mode / Events Request URL). The admin clicks Create, then copies back the Client ID/Secret (+ Signing Secret for REST or App-Level Token for Socket). Manifest paste kept as a fallback. - Client ID + Secret are now required for both REST and Socket, so the one-click "Install into your workspace" OAuth flow always works. - Setup steps collapsed to create → copy creds → done. Verified in-browser: setup dialog renders the deep-link button (href is the pre-filled manifest URL) and the simplified steps. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…idance
- Public distribution is now an explicit "Optional — only for installing
into workspaces other than the app owner" step, not a required one.
- Drop the manual "install the app / get bot token" step: Helix runs the
OAuth install itself. A closing note makes that explicit ("you don't
install it or copy a bot token yourself").
- Add redirect-URL guidance: the manifest sets it for new apps, but an
existing app must list it under OAuth & Permissions → Redirect URLs, or
the install fails with a redirect_uri mismatch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…origin The setup dialog's OAuth redirect URL / Events Request URL (and the manifest) now come from serverConfig.server_url (SERVER_URL), falling back to window.location.origin only when unset. This matches the redirect_uri the backend actually sends during OAuth, so the displayed/manifest URL can't drift from it when the admin loads the UI on a different host. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the per-org `streams.public_url`/`topics.public_url` config override for the github webhook payload URL; SERVER_URL is now the single source. - webhook_provisioner.resolvePublicURL() returns SERVER_URL only. - api resolveEffectivePublicURL() returns SERVER_URL only. - Remove the streams.public_url config spec (drops the Settings-page row). - Error messages point at SERVER_URL instead of the removed setting. - Update tests accordingly. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Record the slack_app ServiceConnection id on the workspace at OAuth-install time (carried through the OAuth state) and surface it as an "Installed app" column in org Settings → Slack, so admins can tell which global app each workspace belongs to when several exist. Manually bot-token-connected workspaces have no app link and show "Bot token". Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…annel
Replace per-channel Slack topics with one workspace-scoped topic, created
and removed automatically with the workspace connection. Users control
which channels the bot listens on by /invite-ing it — no channel config.
- Domain: SlackConfig drops Channel; a Slack topic = {service_connection_id}.
- Ingest: matches topics by workspace only, so every channel the bot is in
flows to the one topic (channel carried in Message.Extra).
- Outbound (A): a worker's reply routes back to the channel + thread it
answered — resolved from the inbound event it threads under (or the
latest inbound message), with the channel in Message.Extra winning.
- Reconciler: slackWorkspaceTopics.ensure/remove auto-creates/deletes the
deterministic s-slack-ws-<connId> topic on workspace connect/disconnect,
mirroring the team-topic ownership-by-convention pattern.
- Removed the per-topic channel-join provisioner (no channel to join).
- Frontend: dropped the manual Slack option + channel picker from the New
Topic dialog; Settings explains the /invite model.
Verified: workspace-scoped inbound publishes any channel's message to the
topic (live); outbound channel resolution unit-tested (explicit / thread /
latest-inbound / skip-worker-events).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OAuth installs already record the source app; the manual bot-token connect didn't, so those workspaces showed "Bot token" with no app in Settings. The connect request now carries the selected app's connection id (the panel's Slack-app picker), stored on the workspace so the "Installed app" column names it. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Worker replies to Slack by driving the Web API directly (curl chat.postMessage / reactions.add / files.upload) rather than the transport rendering a reply envelope — the transport owns ingress + the scoped credential, egress richness is the agent's job. This keeps the transport from chasing Slack's API and generalises to any service. - Add a "slack" credential.Provider: mint_credential provider=slack hands the Worker the workspace bot token (xoxb). Registered next to github in the mint_credential surface — no edits to the MCP tool. - slackWorkspaces.ByOrg resolves the org's workspace, preferring an app-linked (OAuth-installed) connection over a stale manual-token duplicate. Prototype simplification; the correct model is team-scoped minting keyed on the event's extra.slack_team_id. The reply coordinates (channel, message ts, thread ts) already reach the Worker via the inbound event's Extra, rendered into the activation prompt and surviving the router — so no new "where to reply" plumbing. Verified live: a !w-ai-1 Slack message routed through the filter processor activated the Worker, which minted the token and posted a threaded chat.postMessage (ok:true) back to the channel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The admin panel returned every service_connection unfiltered, so org-scoped installs (slack_workspace) from every org leaked in — and the frontend's type fallthrough rendered them as "ADO Service Principal". Filter in the backend (keep the frontend dumb): the default list now returns only deployment-global, admin-owned connections (organization_id empty) via a new ListGlobalServiceConnections store method. Org-scoped installs belong to an org and are managed in that org's own settings, not the global admin panel. An explicit ?organization_id= still scopes to a single org. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…cascade Egress is now the agent's job, not the transport's. A Worker replies to Slack (and reacts, uploads, …) by driving the Web API directly with a bot token it mints on demand — so the transport never models Slack's API and the same pattern generalises to any service. - Remove the Slack outbound emitter (outbound.go, PersonaResolver) — no RegisterOutbound for KindSlack. Worker replies go out via curl, not a rendered envelope. - mint_credential gains a generic, optional `resource` arg (credential.Provider.Mint(ctx, orgID, resource)). The slack provider mints the bot token for the workspace named by resource — the inbound event's extra.slack_team_id — resolved org-scoped so a Worker can never reach another org's token. github ignores resource (one identity). - Transport-provided reply hint: Message.ReplyHint, stamped by the slack ingest with the live channel/ts/team and the mint+postMessage recipe, rendered into the activation prompt as `how_to_reply`. Survives the router. Nothing Slack-specific lives in a Worker's Role anymore. - Delete cascade: deleting a global slack_app now removes every workspace install made from it (and each one's Topic) across all orgs — the installs depend on the app's signing secret / app token for inbound and are dead without it. Shared deleteSlackWorkspaceAndTopic helper. Verified: delete cascade e2e (app delete → workspace soft-deleted + topic gone). Build + unit tests green (resource passthrough, team-scoped resolve, ingest stamps ReplyHint). NOT yet re-verified live: the hint-driven, team-scoped reply (the test workspace was torn down before the live send) — needs a reconnected app+workspace to exercise end-to-end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Socket Mode connections were opened once at server boot from a one-shot scan of configured apps. A socket app created (or its token edited) after boot was never connected, so its workspaces received no inbound events until the next restart. Replace the boot-time scan with a SocketManager that reconciles live connections against the configured socket apps: it starts connections for newly-configured apps, stops them for deleted apps, and restarts them on token change. It runs on a 30s backstop interval and is Kicked by the service-connection create/update/delete handlers for instant pickup — so installing or removing a socket app takes effect with no restart. - SocketManager (transport pkg) with injected list/connect funcs, unit tested under -race: start/stop/token-change/idempotent/list-error-safe/ Kick/shutdown-stops-all. - connect runs slackcore.SocketMode.Run (already self-healing) under a per-app context whose cancel is the stop handle. - Kick is non-blocking + coalescing; reconcile always runs on the Run loop's context, so a request handler's context never bounds a socket. Verified live: creating a socket app connects it immediately with no restart (then self-heals on a bad token); deleting it stops the socket. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The topic-events SSE handler sent Content-Type "text/event-topic" (a casualty of the stream→topic rename). The browser's EventSource only processes a "text/event-stream" response, so it never delivered frames — the topic detail page showed the initial snapshot and never updated. Fix the Content-Type (+ the swagger @produce + regenerated docs). The push path was already correct: Publishing.Publish calls hub.Notify, the SSE handler wakes on it and re-emits. Verified: SSE now serves text/event-stream; connecting yields the initial frame and publishing an event pushes a fresh frame live. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Six small fixes from the demo: - Real connection validation (was a no-op that always went green): the Test endpoint now probes Slack — a Socket Mode app token via apps.connections.open, a bot token via auth.test — and reports failure for bad creds. A REST app (signing-secret only) honestly reports it has nothing to probe rather than a false OK. New slackcore ValidateAppToken / ValidateBotToken (TDD'd against an httptest Slack), wired into testServiceConnection + the Test-button endpoint (which now decrypts the stored Slack tokens). - Add reactions:write + files:write bot scopes (Go defaultSlackBotScopes + frontend manifest BOT_SCOPES) so Workers can react / upload files. - Auto-created Topic name is now "Slack: <workspace> (<app>)" via a TDD'd TopicName builder — never the bare connection uuid (the old empty-name fallback). - Manifest names the Slack app + bot after the connection name the operator typed, not a hardcoded "Helix". - Worker replies post with icon_url=https://github.com/helixml.png (a public URL Slack fetches, so it renders even on localhost) for a Helix avatar — via the transport reply hint. Verified live: validation rejects an invalid app token (invalid_auth), accepts a valid one, and reports REST-can't-validate. Unit tests green for the validators, TopicName, and the reply-hint icon. Topic naming + bot identity not re-installed live (needs a fresh workspace install). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
chat.postMessage takes a username (like icon_url, under chat:write.customize) to override the bot's display name per message. Add it to the transport reply hint: the worker posts as its own name, so participants can tell which worker replied — the persona model the customize scope exists for. The transport can't bake a worker name (routing picks the worker after inbound), so the hint instructs the worker to use its own. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
github.com/helixml.png 302-redirects to the CDN, and Slack's icon fetcher does not follow redirects — it silently fell back to the app's default (blueprint/ruler) icon while username still worked. Point icon_url at the direct CDN URL, which returns 200 image/png. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A Worker is activated with only the triggering message — not the surrounding conversation — so "look back at the first message" had no source. Rather than pre-loading a fixed history window into every prompt (a transport treadmill that still can't cover arbitrary look-backs), the reply hint now tells the Worker to pull context on demand with the bot token it already mints: conversations.replies for the thread, conversations.history for the channel. The history scopes are already requested, so this works on existing installs with no reinstall. Also thread off the thread root (threadTS or the message ts), so a reply to a message already in a thread lands in that thread instead of starting a sub-thread — and the same root keys the conversations.replies read. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Addresses a self-review of the Slack transport branch, prioritising minimal code, consistency, and use-case-driven tests. - Remove the auto-install seam that had no production implementer: streaming.AutoInstaller, InstallResult.Notice, Topics.autoInstall, the third Create return value, the create-topic provisioning_notice (DTO + REST + MCP + frontend consumer + regenerated OpenAPI/TS client). - Drop dead PostAs/Persona (the abandoned outbound-persona model; egress is agent-driven now). - Delete the unwired single-owner lock machinery (SingleOwner, PgAdvisoryLock, Locker, SetIntervals) and simplify SocketMode.Run to the single-replica reconnect loop it actually is. - Fix stale KindSlack/SlackConfig comments describing an outbound emitter and a Channel field that no longer exist. - Unify the create handler's Slack credential encryption onto the same slice pattern the update handler uses. - Derive the global app's manifest bot_events from bot_scopes instead of maintaining two hand-synced lists; name the backend as authoritative. - Trim a few restating-the-obvious comments. - Add a stale-timestamp (replay) rejection test for the Events handler and a mint-by-team_id forwarding test; relax brittle assertions on exact Usage/Description wording. - Memoize SlackIntegrationsPanel table props. Net -290 lines. go build ./pkg/..., the org + slack test suites, and frontend tsc all pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers graceful degradation when no global slack_app is configured (expected, not a failure), workspace install (OAuth + pasted token), inbound routing + agent-driven reply, and org isolation + app-delete cascade. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- LinkExternalRepositoryDialog: add the slack ExternalRepositoryType to the exhaustive typeMap Record (maps to 'other'; Slack is never selectable there but the new enum value must be covered) — fixes the build-frontend TS2741. - events_test: mark the fixed test HMAC key with //gitleaks:allow so the secret scanner doesn't flag a non-credential test fixture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…l Slack app The admin Service Connections panel rendered every non-GitHub connection (including the global slack_app) with lucide's generic Cloud icon. Wire the existing multicolour SlackLogo (ProviderIcons) into the type chip and the connection-type dropdown so the Slack app is recognisable; ADO keeps the Cloud fallback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… drop git-tag
Addresses review feedback on the Slack transport:
- The core HelixAPIServer struct no longer holds org-graph types. The
per-workspace Topic reconciler (slackWorkspaceTopics) and the Socket
Mode manager now live on the helix-org subsystem (helixOrgHandlers);
the core server keeps a single `helixOrg` handle, and the admin
service-connection handlers reach the socket only through the small
kickSlackSocket seam. The org composition root no longer pokes fields
back onto the core server.
- Drop the two bespoke store methods (ListGlobalServiceConnections,
GetServiceConnectionBySlackTeamID). Inbound team→workspace resolution
reuses ListServiceConnectionsByType(""); the admin global list reuses
ListServiceConnections("") + an in-handler filter. Removes the unused
slack_team_id index too.
- Slack is not a git provider: remove ExternalRepositoryTypeSlack and
stop tagging Slack connections with a ProviderType (their Type already
identifies them). Regenerated OpenAPI/TS client.
- Delete design/2026-06-23-helix-org-slack-serviceconnection.md.
go build ./pkg/... clean; store/org/slack vet + tests pass; frontend tsc
0 errors; gitleaks clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The kick is purely org-domain (it only touches helixOrg.slackSocket), so make it a nil-safe method on *helixOrgHandlers instead of *HelixAPIServer. The core admin service-connection handlers now call s.helixOrg.kickSlackSocket() directly — the core server references the Socket Mode manager nowhere, not even via a method. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
registerRoutes held the entire helix-org mount block inline — github
webhooks, slack events/oauth, the org-scoped slack endpoints, the
/orgs/{org}/ catch-all, the MCP backend, and the streamcron/socket
goroutines. None of that is core: it's the org subsystem wiring itself
up. Move it all into HelixAPIServer.mountHelixOrg in helix_org.go (the
dedicated, conditionally-mounted org server), so registerRoutes shrinks
to a single `if HelixOrgEnabled { mountHelixOrg(...) }`.
server.go's only remaining org footprint is the helixOrg subsystem
handle, which the admin service-connection handlers use to kick the
Socket Mode manager when a slack_app row changes.
go build ./pkg/... clean; server tests compile; slack/org suites pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The generic service-connection admin handlers reached into the helix-org subsystem to kick Socket Mode and cascade-delete Slack workspace installs on a slack_app change — a cross-cut between the core connection registry and helix-org. Replace it with a generic post-mutation hook: the handlers call notifyServiceConnectionChange(conn, deleted) after create/update/delete and know nothing about who reacts. helix-org registers the hook in mountHelixOrg (reactToServiceConnectionChange), so the slack_app reaction lives entirely in helix_org_slack.go. service_connection_handlers.go now has no dependency on api/pkg/org — it still handles slack_app credentials (validate/encrypt/test) the same self-contained way it handles github_app/ado, via the generic serviceconnection/slack protocol layer. go build ./pkg/... clean; server tests compile; slack/org suites pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
oauth.go (CodeExchanger.ExchangeCode + AuthorizeURL) had no test despite being the SaaS install happy path and built with a TokenURL override for exactly this. Add high-level coverage: authorize URL carries client/scope/state, code exchange forwards code+credentials and maps the Slack response to an Install, a Slack ok=false surfaces the error, and a malformed success (no token/team) is rejected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The observer indirection (reactToServiceConnectionChange) and the cross-org cascade (delete a global slack_app -> remove every workspace install AND its auto-managed Topic, across all orgs) were the riskiest untested seam. Add a focused server-package test over a tiny stateful service-connection store + the real in-memory org Topics store: - slack_app delete cascades workspaces + topics in every org, leaving a different app's install untouched - a non-slack_app (github_app) delete is ignored - a slack_app edit (deleted=false) reconciles only, never cascades go test ./pkg/server -run 'TestSlackApp|TestServiceConnectionChange' green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
8566e48 to
1922e1e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds Slack as a first-class Topic transport for helix-org, replacing the abandoned
feat/helix-org-slack-transportapproach (which embedded a bespoke router in the transport). Full rationale:design/2026-06-23-helix-org-slack-serviceconnection.md.Model
domain/processorfilter layer, not a bespoke router — one global app = one firehose, a filter predicate routes to the Worker.reply_hinttelling the Worker to mint a workspace bot token (mint_credential provider=slack) and drive the Slack Web API directly.Credential model (three tiers, all on
service_connections)slack_appslack_workspaceOAuthConnectionLayout
api/pkg/serviceconnection/slack/— generic, reusable protocol layer (client, Events-API verify/parse, Socket Mode, OAuth exchange), zero org concepts.api/pkg/org/domain/transport/slack.go—KindSlack+SlackConfig.api/pkg/org/infrastructure/transports/slack/— org wiring:Ingest(team_id → workspace → org → Topics → publish), credential provider, socket manager.api/pkg/server/helix_org_slack.go— Workspaces adapter, OAuth install handlers, org-scoped workspace list/delete, socket runner.Review pass (last two commits)
A self-review drove a cleanup commit removing dead/speculative code: the no-implementer auto-install seam, the abandoned outbound-persona helpers, and the unwired single-owner lock machinery; unified the credential-encryption handlers; deduped scope lists; and added replay-rejection + mint-by-team_id tests. Net −290 lines on top of the feature.
QA.md §18documents the critical flows (including graceful degradation when no global app is configured).Testing
go build ./pkg/...clean;./pkg/org/...+serviceconnection/slacksuites pass; frontendtsc0 src errors; OpenAPI/TS client regenerated.url_verification→ challenge, bad/stale signature → 401, signed message withteam_id→ published onto the bound Topic, unknown team dropped, org-scoped workspace list isolated.🤖 Generated with Claude Code