Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 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
aa616ed
refactor(discord): extract DiscordConnection (socket lifecycle) from …
lbangalosbt Sep 16, 2026
088f237
refactor(discord): route inbound messages by guild id in DiscordConne…
lbangalosbt Sep 16, 2026
dac6f66
style(discord): reformat test_bridge_agent_display_names
lbangalosbt Sep 16, 2026
e4efe62
feat(bridges): let a messaging install carry no per-install token (Di…
lbangalosbt Sep 16, 2026
b604a8e
feat(bridges): configuration and installer for the distributed Discor…
lbangalosbt Sep 16, 2026
4094224
feat(discord): a shared-delivery bridge is constructible and inert
lbangalosbt Sep 16, 2026
99a5db7
feat(discord): the shared deployment-level Gateway connection (Stage 4a)
lbangalosbt Sep 16, 2026
268219f
feat(discord): gate the members intent behind DISCORD_APP_MEMBERS
lbangalosbt Sep 16, 2026
3888f42
feat(discord): route shared-connection messages to their guild's brid…
lbangalosbt Sep 16, 2026
d9dd0c2
feat(discord): global slash-command routing by guild id (Stage 4c)
lbangalosbt Sep 16, 2026
ee54fb7
feat(discord): end an install when the bot is removed from its guild …
lbangalosbt Sep 16, 2026
7379898
test(discord): consolidate the shared-connection isolation guards (St…
lbangalosbt Sep 16, 2026
662245d
docs(config): document DISCORD_APP_* env vars in .env.example
lbangalosbt Sep 16, 2026
ba66d95
chore(gitleaks): allowlist the public Discord id dummy in a config test
lbangalosbt Sep 16, 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
29 changes: 29 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,35 @@ 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=

# Credentials of the distributed Discord app — the one a customer adds to their
# server by clicking "Add to Server", not the self-registered app whose token an
# operator pastes in (DISCORD_SETUP.md). Set all four or none; setting some is
# refused at startup, and MESSAGING_PUBLIC_URL must be set with them. The bot
# token lives here, in deployment config, because a Discord install carries no
# per-install token. See docs/old/bridges/DISCORD_DISTRIBUTED_APP.md.
# DISCORD_APP_CLIENT_ID=
# DISCORD_APP_CLIENT_SECRET=
# DISCORD_APP_BOT_TOKEN=
# DISCORD_APP_APPLICATION_ID=
# Privileged intents on the shared connection, each off by default (mention-only
# / API member fetches) and requiring Discord verification past ~100 guilds.
# DISCORD_APP_MESSAGE_CONTENT=false
# DISCORD_APP_MEMBERS=false

# ── Mattermost (local dev) ───────────────────────────────────────────────────
MATTERMOST_ADMIN_USER=admin
MATTERMOST_ADMIN_PASSWORD=
Expand Down
4 changes: 4 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ regexes = [
# Collaboration-adapter type registration; the mattermost-access-token
# rule mistakes the identifier sequence for a token. No secret here.
'''"mattermost", MattermostAdapter, MattermostConnectionConfig''',
# A Discord client/application id is a *public* identifier, not a secret, and
# these are obvious dummies in a config-validator test — but the default
# `discord-client-id` rule flags any 17-19 digit number next to the keyword.
'''discord_app_(?:client|application)_id="\d{17,19}"''',
]
paths = [
# Log-redaction tests exercise deliberately fake vendor tokens. Both spellings
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