Skip to content

feat(slack): helix-org Slack transport via ServiceConnection tiers + processor routing#2725

Open
philwinder wants to merge 39 commits into
mainfrom
feat/org-slack-transport-v2
Open

feat(slack): helix-org Slack transport via ServiceConnection tiers + processor routing#2725
philwinder wants to merge 39 commits into
mainfrom
feat/org-slack-transport-v2

Conversation

@philwinder

Copy link
Copy Markdown
Member

Summary

Adds Slack as a first-class Topic transport for helix-org, replacing the abandoned feat/helix-org-slack-transport approach (which embedded a bespoke router in the transport). Full rationale: design/2026-06-23-helix-org-slack-serviceconnection.md.

Model

  • One global Slack app per deployment — secrets never in source.
    • SaaS: REST/Events-API app; org admins OAuth-install it into their workspace.
    • Self-hosted: operator's own app in Socket Mode (runtime-reconciled, no restart to pick up an install/edit).
  • Slack is a Topic transport. A Topic binds to an org's installed workspace; it ingests every channel the bot is in.
  • Routing is the existing domain/processor filter layer, not a bespoke router — one global app = one firehose, a filter predicate routes to the Worker.
  • Egress is agent-driven. No outbound emitter: each inbound message carries a reply_hint telling 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)

Tier type scope
Global app slack_app global (helix-admin)
Workspace install slack_workspace per-org (many)
User token OAuthConnection user (unchanged)

Layout

  • 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.goKindSlack + 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.
  • Frontend: admin global-app setup, org install surface, Topic transport picker.

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 §18 documents the critical flows (including graceful degradation when no global app is configured).

Testing

  • go build ./pkg/... clean; ./pkg/org/... + serviceconnection/slack suites pass; frontend tsc 0 src errors; OpenAPI/TS client regenerated.
  • Backend verified e2e on localhost (REST): signed url_verification → challenge, bad/stale signature → 401, signed message with team_id → published onto the bound Topic, unknown team dropped, org-scoped workspace list isolated.

🤖 Generated with Claude Code

@philwinder philwinder force-pushed the feat/org-slack-transport-v2 branch from bbabe8a to 995aa84 Compare June 25, 2026 09:47
philwinder and others added 29 commits June 25, 2026 23:43
…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>
philwinder and others added 10 commits June 25, 2026 23:43
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>
@philwinder philwinder force-pushed the feat/org-slack-transport-v2 branch from 8566e48 to 1922e1e Compare June 25, 2026 21:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant