Standout Builder Studio · planning Index / Portal ↔ Slack chat review Updated 16 Jun 2026

Portal ↔ Slack chat

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 · proposed
Source spec
This page summarises docs/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.

Goals

Non-goals (YAGNI)


Architecture

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.

Data model — two new D1 tables

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.

Flow — operator to Slack

  1. Operator opens a factsheet. The portal island opens a WebSocket to GET /api/portal/chat/ws?game=<slug>.
  2. The Worker verifies the session cookie on the upgrade and derives the operator's email. It resolves the ChatRoom stub via idFromName(email|slug) and forwards the upgrade to the DO. Unauthenticated upgrades are rejected with 401 — no DO involvement.
  3. On connect, the DO replays the last ~50 messages from D1 to the new socket.
  4. Operator sends a message over the WebSocket. The DO:
    • Persists it to D1, creating the portal_chats row on first message.
    • Calls Slack 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.
    • Broadcasts to the operator's other open tabs via the DO's connected sockets.

Flow — Slack to operator

  1. A team member replies in the Slack thread. Slack POSTs the event to /api/portal/slack/events on the Worker.
  2. The Worker (no session on this route — Slack-signature-verified only):
    • Verifies the Slack signature — v0= HMAC-SHA256 of v0:timestamp:rawBody with SLACK_SIGNING_SECRET. Stale timestamps (more than 5 minutes) are rejected.
    • Answers the one-time url_verification challenge.
    • Acks within 3 s and does the real work in ctx.waitUntil().
    • Ignores the bot's own messages and any message without a thread_ts.
    • Looks up 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.
  3. Connected operator browsers receive the reply instantly. If no sockets are open, the message is in D1 and loads on the next factsheet visit.

UI

A chat panel in apps/portal/src/islands on each factsheet:


Error handling

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.

Testing plan

Pure / isolable pieces TDD'd with bun test:

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.


Setup dependencies (not code)