Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
45d1c66
feat(db): messaging_installs and the lookup that routes a workspace t…
petr-sandbox Sep 11, 2026
438ac75
feat(bridges): install protocol for the distributed Slack app (CHOO-2…
petr-sandbox Sep 11, 2026
21b661c
feat(bridges): an install that completes, from the button to a runnin…
petr-sandbox Sep 11, 2026
5e3f78a
feat(collaboration): route inbound platform events to the installing …
petr-sandbox Sep 11, 2026
47b9fb2
feat(deploy): route /messaging to switch-core, and say the tenant mus…
petr-sandbox Sep 14, 2026
444876e
feat(config): MESSAGING_PUBLIC_URL, separate from the gateway's publi…
petr-sandbox Sep 15, 2026
c8af4d8
feat(deploy): wire the distributed Slack app through the chart
petr-sandbox Sep 15, 2026
5131d63
feat(db): let an install end, and a workspace be installed again (CHO…
petr-sandbox Sep 15, 2026
71bc056
feat(messaging): let an install end (CHOO-2626)
petr-sandbox Sep 15, 2026
4c4463e
feat(messaging): routes for ending an install (CHOO-2626)
petr-sandbox Sep 15, 2026
5f15bff
fix(console): re-sync the bundled standalone compose
petr-sandbox Sep 15, 2026
0eab523
fix(console): record why the managed stack sets no messaging-app vars
petr-sandbox Sep 15, 2026
a6cb478
feat(messaging): handle an inbound platform event once (CHOO-2626)
petr-sandbox Sep 15, 2026
89d2627
fix(gateway): refuse to delete a bridge an install built (CHOO-2626)
petr-sandbox Sep 15, 2026
5cdbe40
feat(gateway): install and disconnect the Switch app from the dashboa…
petr-sandbox Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,20 @@ FRONTEND_BASE_URL=http://localhost:5173
# deeplink.
# GATEWAY_PUBLIC_URL=http://localhost:8000

# Public origin a messaging platform reaches Switch on: the base of the OAuth
# redirect and the event URLs under /messaging for the distributed Slack app.
# Must be https and must resolve from the internet, so it is usually a
# different host from GATEWAY_PUBLIC_URL. Required to offer workspace installs.
# MESSAGING_PUBLIC_URL=https://switch.example.com

# Credentials of the distributed Slack app — the one a customer installs by
# clicking a button, not the one an operator registers for themselves (that is
# SLACK_BOT_TOKEN et al). Set all three or none; setting some is refused at
# startup. See docs/old/bridges/SLACK_DISTRIBUTED_APP.md.
# SLACK_APP_CLIENT_ID=
# SLACK_APP_CLIENT_SECRET=
# SLACK_APP_SIGNING_SECRET=

# ── Mattermost (local dev) ───────────────────────────────────────────────────
MATTERMOST_ADMIN_USER=admin
MATTERMOST_ADMIN_PASSWORD=
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,24 @@ describe('buildEnvFile', () => {
const interpolated = new Set(
[...composeBody.matchAll(/\$\{([A-Z_][A-Z0-9_]*)/g)].map((m) => m[1])
);
// Nothing is exempt today. An entry here must say why the stack is correct
// without it — leaving a var unset is a decision, not a default.
const intentionallyUnset = new Set<string>();
// An entry here must say why the stack is correct without it — leaving a
// var unset is a decision, not a default.
const intentionallyUnset = new Set<string>([
// The four below configure switch-core as a distributed messaging app —
// one app we own, installed by a customer into their own workspace, with
// the platform posting events to URLs declared once in the app manifest.
// A managed stack cannot be one of those and is not meant to be: it binds
// to loopback, so no platform can reach its callback or event URLs, and
// the credentials are the app owner's rather than anything this machine
// could hold. switch-core registers no installer without them and the
// operator UI says so rather than offering a button that would fail at
// Slack. Connecting a workspace from here is the other path — an operator
// registering a bridge with their own app's token.
'MESSAGING_PUBLIC_URL',
'SLACK_APP_CLIENT_ID',
'SLACK_APP_CLIENT_SECRET',
'SLACK_APP_SIGNING_SECRET',
]);

const missing = [...interpolated]
.filter((key) => !intentionallyUnset.has(key))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,10 @@ services:
GATEWAY_ADMIN_PASSWORD: ${GATEWAY_ADMIN_PASSWORD}
FRONTEND_BASE_URL: ${FRONTEND_BASE_URL}
GATEWAY_PUBLIC_URL: ${GATEWAY_PUBLIC_URL:-}
MESSAGING_PUBLIC_URL: ${MESSAGING_PUBLIC_URL:-}
SLACK_APP_CLIENT_ID: ${SLACK_APP_CLIENT_ID:-}
SLACK_APP_CLIENT_SECRET: ${SLACK_APP_CLIENT_SECRET:-}
SLACK_APP_SIGNING_SECRET: ${SLACK_APP_SIGNING_SECRET:-}
depends_on:
postgres:
condition: service_healthy
Expand Down
8 changes: 8 additions & 0 deletions core/switch_core/bridges/agent/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@

from switch_core.bridges.agent.api_key_cache import ApiKeyCache
from switch_core.bridges.agent.registration_bootstrap import REGISTRATION_KEY_TYPES
from switch_core.bridges.collaboration.install import (
PUBLIC_PATH_PREFIX as MESSAGING_INSTALL_PREFIX,
)
from switch_core.db.models import Agent, ApiKey
from switch_core.db.session_scope import tenant_session
from switch_core.db.stores.agent_store import AgentStore
Expand All @@ -37,6 +40,11 @@
# Public switchdash:// deeplink HTTP redirect — followed by whoever clicks
# the "Open in Switch Console" link in an external channel, so no bearer token.
"/deeplink",
# Workspace installs of the distributed messaging apps: the OAuth callback
# and the platforms' event webhooks. Unauthenticated by nature — an inbound
# Slack event carries no credential of ours — so each route proves its own
# origin from the platform's signature before it does anything else.
MESSAGING_INSTALL_PREFIX,
)


Expand Down
26 changes: 25 additions & 1 deletion core/switch_core/bridges/collaboration/adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from abc import ABC, abstractmethod
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, replace
from typing import ClassVar
from typing import Any, ClassVar

from switch_core.agent_display_name import defuse_label_markup
from switch_core.agent_icon import default_icon_url
Expand All @@ -20,6 +20,7 @@
InboundMessage,
InboundUserJoin,
OutboundAttachment,
WebhookDeliveryUnsupported,
)

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -248,6 +249,29 @@ async def start(
@abstractmethod
async def stop(self) -> None: ...

async def dispatch_event(
self, *, envelope_type: str, payload: dict[str, Any]
) -> None:
"""Handle one event that arrived over the public webhook.

Concrete on the base and raising, rather than abstract, because
receiving events over HTTP is a property of a platform and of which app
a bridge's token came from — most adapters dial out and are handed
their events on a connection they opened, and have nothing to override
here.

Raising rather than returning quietly matters: the caller is a route
that has already proved the request genuine and resolved which bridge
it belongs to, so reaching an adapter that cannot take it means a
workspace's traffic is being delivered nowhere. Silence there is the
failure that reads as "the platform has gone quiet".
"""
raise WebhookDeliveryUnsupported(
f"{type(self).__name__} does not receive events over HTTP, so the "
"event posted for this bridge cannot be delivered. A bridge reached "
"this way was installed as a distributed app; this one was not."
)

@abstractmethod
async def send_message(
self,
Expand Down
Loading
Loading