mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-27 14:24:47 -06:00
62d2a0fe6a
* fix: remove non-auth support from bootstrap wizard Auth is now mandatory for all deployments. Remove the TURNSTONE_AUTH_ENABLED toggle and make JWT_SECRET and AUTH_TOKEN required in the wizard's system prompt. * fix: remove auth disable support from runtime and infra Remove AuthConfig.enabled field — auth is always on. Drop TURNSTONE_AUTH_ENABLED env var, config toggle, and the check_request bypass. Update compose.yaml, Helm chart, Terraform, docs, and tests to match. * feat: deprecate config tokens, require JWT secret, prefer JWT auth Phase 1 of config-token removal: - load_jwt_secret() now exits with error if no secret is configured (was: silently auto-generated ephemeral secret) - _authenticate_token() logs deprecation warning on config token use - CLI /cluster commands use ServiceTokenManager when JWT secret is set - turnstone-admin tls-list uses ServiceTokenManager when JWT secret is set - Update bootstrap wizard, docker.md, security.md to mark TURNSTONE_AUTH_TOKEN as deprecated and JWT_SECRET as required - Console test fixtures use auth token + headers (auth always enforced) * feat: add service scope for inter-service JWT auth Add "service" to VALID_SCOPES and SCOPE_HIERARCHY. Service tokens bypass require_permission() RBAC checks, replacing the old empty-user-id bypass that config tokens relied on. All ServiceTokenManager instances that need admin access now include "service" in their scopes (console proxy, channel gateway, CLI, admin CLI). Read-only services (collector, notification) unchanged. * feat: phase 2 config token deprecation - SDK doc examples now show API tokens (ts_) instead of config tokens - Remove _get_config_token() from admin CLI (dead code) - Block config token exchange in handle_auth_login — only password and API token login allowed - Update login tests to use password-based auth instead of config token exchange * feat: phase 3 — remove config tokens entirely Complete removal of config-file token authentication: - Delete AuthConfig.tokens, check(), _ROLE_TO_SCOPES, hmac dispatch branch, and config token loading from load_auth_config() - Remove auth_config parameter from _authenticate_token() and check_request() — callers updated throughout - Remove TURNSTONE_AUTH_TOKEN from compose.yaml, Helm charts, Terraform, turnstone.example.toml - Remove --auth-token CLI flags from turnstone, turnstone-admin, and turnstone-console - Simplify console main() — always use ServiceTokenManager (no fallback to static tokens) - Delete config-token-specific tests, rewrite check_request and integration tests to use JWT auth with proper audience claims - Remove all config token references from docs (security.md, docker.md, sdk.md, console.md, architecture.md, bootstrap prompt) * fix: address code review findings - Fix 33 broken tests: add JWT auth to test_api_versioning, test_console_routing_proxy, test_tls_admin, test_tls_manager, test_server_live (jwt_secret + audience-scoped auth headers) - Add TestRequirePermissionServiceScope: 4 tests covering the service scope RBAC bypass path - Remove stale comments referencing config tokens in auth.py and console/server.py - Remove dead proxy_auth_token parameter from console create_app() and static token fallback in _proxy_auth_headers() - Remove TURNSTONE_AUTH_TOKEN from env.py scrub list * fix: address Copilot review — JWT audience, compose require secret - CLI /cluster: add audience=JWT_AUD_CONSOLE to ServiceTokenManager (console validates audience, JWTs without it were rejected) - Admin CLI tls-list: same audience fix - compose.yaml: TURNSTONE_JWT_SECRET now uses :? to fail fast if unset - SDK console: fix default port from 8081 to 8090 * test: add auth enforcement tests for TLS admin endpoints 5 new tests: unauthenticated requests return 401 (list, renew, delete), read-only-scoped requests return 403 (renew, delete). Closes the TLS auth enforcement test gap noted in PROGRESS.md. * fix: address remaining Copilot review feedback - Fix token_source="config" → "test" in TLS test fixtures - Fix AuthResult.token_source docstring to include service origins - Require TURNSTONE_JWT_SECRET in cluster compose profile (:?) - Helm: add auth.jwtSecret + auth.existingSecret values, wire TURNSTONE_JWT_SECRET into secret.yaml and both deployments - Terraform: replace auth_token with jwt_secret variable + secret, remove orphaned auth_token resources and IAM reference - Remove [[auth.tokens]] from security.md config example * fix: address full code review — 10 findings Critical: - Terraform: replace concat(common_env, auth_env) with common_env (auth_env local was removed but still referenced) - Channel gateway: remove hmac static token auth from _check_auth(), use JWT-only validation. Remove --auth-token CLI arg from channel - Rebalancer: add token_manager support so migration requests carry JWT auth (was sending unauthenticated POST to /internal/migrate) Major: - Guard _permissions_to_scopes() against "service" privilege escalation from DB role permissions - Remove dead AuthConfig class, load_auth_config(), and all auth_config parameters from create_app() signatures - Helm: inject JWT secret for both inline and existingSecret paths Minor: - Remove dead auth_token param from ClusterCollector - Remove empty TestLoadAuthConfig class - Short JWT secret now exits instead of warning - Compose: add generation command comment above JWT_SECRET - Clean stale config token references from 6 doc files - Clean stale AUTH_TOKEN reference from bootstrap wizard prompt * fix: remove remaining stale config token references from docs - channels.md: remove --auth-token from options table - oidc.md: remove "config-file tokens still work" claim - security.md: remove config token section, fix JWT secret docs (now required/exits, no ephemeral fallback), remove hmac from ASCII diagram, remove --auth-token reference
375 lines
14 KiB
Markdown
375 lines
14 KiB
Markdown
# Channel Integrations
|
|
|
|
The `turnstone-channel` gateway connects external messaging platforms to
|
|
turnstone workstreams via direct HTTP to the server (single-node) or the
|
|
console routing proxy (multi-node). Each platform adapter translates
|
|
platform-native events (messages, button clicks, slash commands) into
|
|
turnstone API calls, and renders workstream output back into the
|
|
platform's UI.
|
|
|
|
Discord ships as the first adapter. The adapter protocol is designed for
|
|
future Slack and Teams integrations.
|
|
|
|
---
|
|
|
|
## Architecture
|
|
|
|
```
|
|
Discord Gateway
|
|
|
|
|
v
|
|
turnstone-channel (Discord adapter)
|
|
|
|
|
v
|
|
turnstone-server (direct HTTP)
|
|
or
|
|
turnstone-console (routing proxy, multi-node)
|
|
```
|
|
|
|
Key components:
|
|
|
|
- **ChannelAdapter protocol** (`turnstone/channels/_protocol.py`) — generic
|
|
interface for any messaging platform. Defines `start()`, `stop()`,
|
|
`send()`, `send_notification()`, `edit_message()`,
|
|
`send_approval_request()`, `send_plan_review()`, and `create_thread()`.
|
|
- **ChannelRouter** (`turnstone/channels/_routing.py`) — maps
|
|
channel/thread IDs to turnstone workstream IDs. Handles workstream
|
|
creation via HTTP, stale route detection, and user identity resolution.
|
|
- **channel_users table** — maps `(channel_type, channel_user_id)` to a
|
|
turnstone `user_id`. Messages from unlinked users are silently dropped.
|
|
- **channel_routes table** — persistent channel-to-workstream mappings.
|
|
Survives bot restarts. Stale routes (evicted workstreams) are detected
|
|
and refreshed on the next message.
|
|
|
|
---
|
|
|
|
## Discord Setup
|
|
|
|
### 1. Create a Discord Application
|
|
|
|
1. Go to https://discord.com/developers/applications
|
|
2. Click **New Application** and give it a name
|
|
3. Navigate to the **Bot** tab and click **Reset Token** to generate a
|
|
bot token. Copy it immediately — it is shown only once.
|
|
4. On the same **Bot** tab, scroll down to **Privileged Gateway Intents**
|
|
and enable **MESSAGE CONTENT INTENT**
|
|
5. Navigate to **OAuth2 > URL Generator**
|
|
6. Under **Scopes**, check `bot` and `applications.commands`
|
|
7. Under **Bot Permissions**, check:
|
|
- View Channels
|
|
- Send Messages
|
|
- Send Messages in Threads
|
|
- Create Public Threads
|
|
- Read Message History
|
|
- Add Reactions
|
|
- Embed Links
|
|
8. Copy the generated URL, open it in a browser, and add the bot to your
|
|
Discord server
|
|
|
|
### 2. Configure Turnstone
|
|
|
|
**Environment variables** (recommended for Docker):
|
|
|
|
```bash
|
|
TURNSTONE_DISCORD_TOKEN=your-bot-token-here
|
|
TURNSTONE_DISCORD_GUILD=123456789 # optional, restrict to one guild
|
|
```
|
|
|
|
**CLI flags** (bare-metal):
|
|
|
|
```bash
|
|
turnstone-channel \
|
|
--discord-token "your-bot-token" \
|
|
--discord-guild 123456789 \
|
|
--server-url http://localhost:8080
|
|
```
|
|
|
|
**Docker Compose** (production profile):
|
|
|
|
```bash
|
|
# In .env file:
|
|
TURNSTONE_DISCORD_TOKEN=your-bot-token
|
|
TURNSTONE_DISCORD_GUILD=123456789
|
|
```
|
|
|
|
Then start the stack:
|
|
|
|
```bash
|
|
docker compose --profile production up
|
|
```
|
|
|
|
The `channel` service starts automatically when
|
|
`TURNSTONE_DISCORD_TOKEN` is set.
|
|
|
|
### 3. Link User Accounts
|
|
|
|
Discord users must link their account to a turnstone user before they can
|
|
interact with the bot. Unlinked users' messages are silently ignored.
|
|
|
|
1. The user must have a turnstone API token — created via the admin panel
|
|
or `turnstone-admin create-token`
|
|
2. In Discord, the user runs `/link`. A modal appears prompting for the
|
|
API token (the token is never visible in Discord audit logs because it
|
|
is submitted via modal, not as a slash command argument).
|
|
3. The token is validated against the database. If valid, a
|
|
`channel_users` mapping is created.
|
|
4. The user can now @mention the bot or use slash commands.
|
|
|
|
An admin can also force-link or unlink users via the console admin panel
|
|
(Admin > Channels tab).
|
|
|
|
---
|
|
|
|
## Usage
|
|
|
|
### Conversations
|
|
|
|
- **@mention** the bot in any allowed channel to start a new conversation.
|
|
The bot creates a Discord thread from the message and a turnstone
|
|
workstream behind it.
|
|
- All subsequent messages in the thread are routed to the same workstream.
|
|
- The bot streams responses via message edits, updated approximately every
|
|
1.5 seconds.
|
|
- If the workstream is evicted for capacity, the next message in the
|
|
thread auto-creates a new workstream and atomically resumes the
|
|
previous workstream via the `resume_ws` field on
|
|
`CreateWorkstreamMessage`. The server resumes the workstream during
|
|
creation (same HTTP request), and the server emits a
|
|
`WorkstreamResumedEvent` back to the channel. The thread receives a
|
|
*"Resumed: {name} ({count} messages restored)"* confirmation.
|
|
|
|
### Slash Commands
|
|
|
|
| Command | Description |
|
|
|---------|-------------|
|
|
| `/link` | Link Discord account to turnstone (opens modal for API token) |
|
|
| `/unlink` | Unlink Discord account |
|
|
| `/ask <message>` | Create a new thread and workstream with an initial message |
|
|
| `/status` | Show workstream info for the current thread (ephemeral) |
|
|
| `/close` | Close the workstream, delete the route, and archive the thread |
|
|
|
|
### Tool Approvals
|
|
|
|
When manual approval is enabled (the default), tool calls are displayed as
|
|
an orange embed with:
|
|
|
|
- Tool name and argument preview
|
|
- **Approve** (green), **Reject** (red), **Always Approve** (gray) buttons
|
|
- Only linked users can interact with approval buttons
|
|
- The approval decision is forwarded to the server via HTTP
|
|
|
|
Buttons use static `custom_id` values so they survive bot restarts.
|
|
Correlation data (`ws_id`, `correlation_id`) is stored in the embed footer.
|
|
|
|
**Auto-approval:** When `auto_approve` is true (via `--auto-approve`), or when
|
|
all tools in the request match the `auto_approve_tools` list in the adapter
|
|
config, the bot auto-responds with approval and posts a
|
|
"*Tool auto-approved.*" notice to the thread instead of showing buttons. The
|
|
`auto_approve_tools` list is set via the `ChannelConfig.auto_approve_tools`
|
|
field (useful for allowing specific tools like `bash` or `read_file` while
|
|
still requiring manual approval for others).
|
|
|
|
### Plan Reviews
|
|
|
|
Plan review requests are displayed as a blue embed with:
|
|
|
|
- **Approve Plan** (green) button — approves the plan with empty feedback
|
|
- **Request Changes** (gray) button — opens a modal for feedback text
|
|
(up to 2000 characters)
|
|
- Feedback is forwarded to the server via HTTP
|
|
|
|
---
|
|
|
|
## Configuration Reference
|
|
|
|
| CLI Flag | Env Var | Default | Description |
|
|
|----------|---------|---------|-------------|
|
|
| `--discord-token` | `TURNSTONE_DISCORD_TOKEN` | — | Bot token (required to enable Discord) |
|
|
| `--discord-guild` | — | `0` (all guilds) | Restrict to a single Discord guild |
|
|
| `--discord-channels` | — | empty (all) | Comma-separated channel IDs to allow |
|
|
| `--server-url` | `TURNSTONE_SERVER_URL` | `http://localhost:8080` | Server URL (single-node) |
|
|
| `--console-url` | `TURNSTONE_CONSOLE_URL` | — | Console URL (multi-node routing proxy) |
|
|
| `--model` | — | server default | Default model for new workstreams |
|
|
| `--auto-approve` | — | `false` | Auto-approve ALL tool calls (skips approval buttons entirely) |
|
|
| `--http-host` | — | `127.0.0.1` | HTTP server bind address for notify endpoint |
|
|
| `--http-port` | `TURNSTONE_CHANNEL_PORT` | `8091` | HTTP server port |
|
|
| `--log-level` | `TURNSTONE_LOG_LEVEL` | `INFO` | Log level |
|
|
| `--log-format` | `TURNSTONE_LOG_FORMAT` | `auto` | Log format (`auto`/`json`/`text`) |
|
|
|
|
---
|
|
|
|
## User Identity
|
|
|
|
- The `channel_users` table maps `(channel_type, channel_user_id)` to a
|
|
turnstone `user_id`
|
|
- Self-service linking via the `/link` slash command (modal input, not
|
|
visible in Discord audit logs)
|
|
- Admin can force-link or unlink via the console admin panel (Admin >
|
|
Channels tab). Unlinking uses a styled confirmation modal.
|
|
- Unlinked users' messages are silently dropped
|
|
- A user can be linked across multiple platforms (e.g. Discord + Slack)
|
|
|
|
See [Security: Database Schema](security.md#database-schema) for the
|
|
`channel_users` table definition.
|
|
|
|
---
|
|
|
|
## Workstream Lifecycle
|
|
|
|
1. **Creation** — @mention or `/ask` creates a Discord thread and a
|
|
turnstone workstream. The `ChannelRouter` persists the mapping in the
|
|
`channel_routes` table.
|
|
2. **Active** — messages are routed bidirectionally. The bot streams
|
|
responses via message edits (updated every ~1.5 seconds).
|
|
3. **Eviction** — the server evicts an idle workstream for capacity. The
|
|
route is preserved and the thread stays open.
|
|
4. **Reactivation** — the next message in the thread detects the stale
|
|
route and creates a new workstream with the old `ws_id`
|
|
as `resume_ws` on the creation request. The server resumes
|
|
the workstream during creation (no separate command or reverse lookup
|
|
needed). The channel receives a `WorkstreamResumedEvent`, and
|
|
the thread displays *"Resumed: {name} ({count} messages restored)"*.
|
|
If the old workstream was pruned, a fresh one starts with no error.
|
|
5. **Close** — `/close` command closes the workstream via HTTP, deletes the
|
|
route, unsubscribes from events, and archives the Discord thread.
|
|
|
|
---
|
|
|
|
## Notifications
|
|
|
|
> See also: [Notification Flow diagram](diagrams/png/17-notify-flow.png)
|
|
|
|
The `notify` tool allows the LLM to proactively send notifications to
|
|
users or channels on external platforms. This is useful for alerting
|
|
people about task completion, errors, or important updates without
|
|
waiting for them to check in.
|
|
|
|
### Targeting
|
|
|
|
Two modes:
|
|
|
|
- **Username** — provide a turnstone `username`. The gateway resolves
|
|
it via the `channel_users` table and sends to all linked channels
|
|
(e.g. Discord + future Slack).
|
|
- **Direct** — provide `channel_type` + `channel_id` to target a
|
|
specific platform channel or user DM.
|
|
|
|
### Delivery Flow
|
|
|
|
Notifications use direct HTTP for low latency. The server calls the channel
|
|
gateway directly over HTTP:
|
|
|
|
1. The LLM calls the `notify` tool with a message and target
|
|
2. `_exec_notify()` queries the `services` table for healthy channel
|
|
gateways (heartbeat within the last 120 seconds)
|
|
3. The server mints a service JWT (`aud: turnstone-channel`) via
|
|
`ServiceTokenManager` and POSTs to the first healthy gateway. The
|
|
payload includes the originating `ws_id` for reply routing.
|
|
4. The gateway validates the JWT, resolves the target, and calls
|
|
`adapter.send_notification()` which sends the message and tracks
|
|
the outgoing message ID for reply routing
|
|
5. On failure, the server tries the next gateway. If all fail, it
|
|
retries up to 2 more times (delays: 1s, 3s), re-querying the
|
|
service registry on each attempt
|
|
|
|
### Bidirectional Replies
|
|
|
|
Notifications support multi-turn DM conversations. When a user replies
|
|
to a notification DM:
|
|
|
|
1. The bot looks up the originating `ws_id` from the tracked message ID
|
|
(`_notify_ws_map`)
|
|
2. Verifies the replying user matches the original notification
|
|
recipient (defence in depth — Discord DMs are already private)
|
|
3. Routes the reply to the workstream via `router.send_message()`
|
|
4. Registers the DM channel for response forwarding
|
|
(`_notify_reply_channels`)
|
|
5. When the workstream responds (`TurnCompleteEvent`), the response is
|
|
forwarded to the DM
|
|
6. The response message is itself tracked, so the user can reply again
|
|
for another turn
|
|
|
|
This enables scenarios like an oncall engineer responding to a CI/CD
|
|
failure notification from their phone before opening a laptop.
|
|
|
|
**Limits:**
|
|
|
|
- Tracking map capped at 100 entries (FIFO eviction of oldest)
|
|
- Entries cleaned up on workstream close/unsubscribe
|
|
- Replying to an expired notification sends
|
|
*"This notification is no longer active."*
|
|
- DM reply content capped at 4096 characters
|
|
|
|
### Service Registry
|
|
|
|
The channel gateway registers itself in the `services` database table
|
|
on startup and sends a heartbeat every 30 seconds. On shutdown it
|
|
deregisters. Services are considered stale after 120 seconds (4 missed
|
|
heartbeats) and are excluded from `list_services()` queries.
|
|
|
|
The `services` table schema:
|
|
|
|
| Column | Description |
|
|
|--------|-------------|
|
|
| `service_type` | Service category (e.g. `"channel"`) |
|
|
| `service_id` | Unique instance ID (`channel-<hostname>-<random>`) |
|
|
| `url` | HTTP base URL for the service |
|
|
| `last_heartbeat` | ISO 8601 timestamp of last heartbeat |
|
|
| `created` | ISO 8601 timestamp of initial registration |
|
|
|
|
### Security
|
|
|
|
- **Authentication** — the gateway's `POST /v1/api/notify` endpoint
|
|
requires authentication. Configure `TURNSTONE_JWT_SECRET` so the
|
|
server can mint JWTs with `aud: turnstone-channel` automatically.
|
|
If the secret is not set, the gateway fails closed and rejects all
|
|
requests with 401. Server JWTs (`aud: turnstone-server`) are
|
|
rejected.
|
|
- **Rate limit** — maximum 5 notifications per turn. The counter only
|
|
increments on successful delivery, so failures don't consume the
|
|
budget.
|
|
- **SSRF protection** — only `http://` and `https://` service URLs
|
|
are allowed. Other schemes are silently skipped.
|
|
- **Mention sanitization** — `discord.utils.escape_mentions()` is
|
|
applied before sending, preventing `@everyone` / `@here` abuse.
|
|
- **Error redaction** — generic error messages are returned to the
|
|
LLM. Internal details (service IDs, URLs, exception messages) are
|
|
logged server-side only.
|
|
|
|
---
|
|
|
|
## Adding New Adapters
|
|
|
|
The `ChannelAdapter` protocol defines the interface any platform adapter
|
|
must implement:
|
|
|
|
```python
|
|
class ChannelAdapter(Protocol):
|
|
channel_type: str
|
|
|
|
async def start(self) -> None: ...
|
|
async def stop(self) -> None: ...
|
|
async def send(self, channel_id: str, content: str) -> str: ...
|
|
async def send_notification(self, channel_id: str, content: str, ws_id: str) -> str: ...
|
|
async def edit_message(self, channel_id: str, message_id: str, content: str) -> None: ...
|
|
async def send_approval_request(self, channel_id: str, ws_id: str, correlation_id: str, items: list[dict]) -> None: ...
|
|
async def send_plan_review(self, channel_id: str, ws_id: str, correlation_id: str, content: str) -> None: ...
|
|
async def create_thread(self, parent_channel_id: str, name: str, message_id: str = "") -> str: ...
|
|
```
|
|
|
|
`send_notification()` is like `send()` but associates the outgoing
|
|
message with a `ws_id` so that user replies can be routed back to the
|
|
originating workstream. Adapters must track the mapping from outgoing
|
|
message ID to `(ws_id, target_user_id)` and handle DM replies.
|
|
|
|
To add a new platform:
|
|
|
|
1. Create `turnstone/channels/<platform>/` package
|
|
2. Implement the `ChannelAdapter` protocol
|
|
3. Add a `--<platform>-token` flag and detection logic in
|
|
`turnstone/channels/cli.py`
|
|
4. Add the optional dependency in `pyproject.toml` (e.g.
|
|
`turnstone[slack]`)
|
|
|
|
See `turnstone/channels/discord/` as a reference implementation.
|