A real-time, two-way chat between operators in the portal and the Standout team in Slack. Design approved; implementation pending. An operator chats from a game's factsheet; messages land in a Slack thread (one thread per operator + game); team replies appear in the operator's portal instantly over a WebSocket.
CONCEPT · proposeddocs/superpowers/specs/2026-06-16-portal-slack-chat-design.md.
For full detail — including exact D1 schema DDL, Slack signature verification
pseudocode, and test-plan specifics — read that file directly.
(operator email, game slug) pair maps to exactly one Slack thread — conversations don't bleed across games or operators.mailto.
A new ChatRoom Durable Object — one instance per
conversation. Its name is derived server-side:
idFromName(`${email}|${gameSlug}`), from the verified session — an
operator can only ever reach their own rooms. The browser never names the room.
| Layer | Component | Responsibility |
|---|---|---|
| Cloudflare Worker | apps/portal — existing Worker |
Routes the WebSocket upgrade (session-verified) and the Slack Events API webhook (signature-verified). Acks the Slack event within 3 s; defers work to ctx.waitUntil(). |
| Durable Object | ChatRoom — new DO class |
Accepts operator WebSockets using the hibernation API (idle rooms cost nothing). Replays last ~50 messages from D1 on connect. Persists operator messages to D1 and relays them to Slack. Broadcasts inbound team replies to all connected sockets for that conversation. |
| D1 | Existing portal D1 instance | Durable store and source of truth. Critically holds the thread_ts → conversation mapping that the stateless Slack webhook needs to route replies — the webhook has no session and must resolve the room by thread_ts alone. |
| Slack | Slack app (owned by Geoff, one-time setup) | One shared channel for all operator threads (v1). Bot scopes: chat:write, channels:history. Event Subscriptions enabled; Request URL → /api/portal/slack/events. |
| Table | Key columns | Notes |
|---|---|---|
portal_chats |
id PK,
email,
game_slug,
slack_thread_ts (nullable until first message),
slack_channel,
created_at
|
One row per conversation. Unique on (email, game_slug). Created on the operator's first message; slack_thread_ts is set once the Slack root post is confirmed. |
portal_chat_messages |
id PK,
chat_id FK → portal_chats,
sender (operator | team),
author (email or Slack display name),
text,
slack_ts,
created_at
|
One row per message. sender distinguishes direction for UI layout. slack_ts is the Slack message timestamp returned by chat.postMessage. |
GET /api/portal/chat/ws?game=<slug>.
ChatRoom stub via
idFromName(email|slug) and forwards the upgrade to the DO.
Unauthenticated upgrades are rejected with 401 — no DO involvement.
portal_chats row on first message.chat.postMessage. The first message creates the Slack thread — the root post is formatted with context (e.g. "op@casino.com · Underworld"). The returned ts is stored as slack_thread_ts. Later messages post with thread_ts so they thread./api/portal/slack/events on the Worker.
v0= HMAC-SHA256 of v0:timestamp:rawBody with SLACK_SIGNING_SECRET. Stale timestamps (more than 5 minutes) are rejected.url_verification challenge.ctx.waitUntil().thread_ts.portal_chats by slack_thread_ts to find the conversation, persists the reply as a team message, then calls broadcast() on the matching ChatRoom stub.
A chat panel in apps/portal/src/islands on each factsheet:
mailto — the chat is always game-scoped.| Failure case | Handling |
|---|---|
| Unauthenticated WS upgrade | 401 returned; no Durable Object is touched. |
| Slack signature or timestamp failure | 401 returned; event is not processed. |
Slack chat.postMessage failure |
The message is still persisted to D1 and shown to the operator. A subtle "couldn't reach Slack" indicator is surfaced; retry on next send. |
| DO / D1 write failure | The socket reports a transient error; the client may resend. The operator message is not shown as delivered until the write succeeds. |
Pure / isolable pieces TDD'd with bun test:
url_verification.(email, slug)), append message, fetch history, resolve by thread_ts.
The DO wiring and live WebSocket fan-out is verified by a local end-to-end run:
dev-login + a ?game= chat against a real test Slack channel — operator send
appears in the Slack thread; team reply in thread appears in the browser.
SLACK_BOT_TOKEN, SLACK_SIGNING_SECRET, SLACK_CHANNEL_ID added as Worker secrets)./api/portal/slack/events).