slack-term 1.47.0 → 1.49.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,216 @@
1
+ # slack-term relay
2
+
3
+ A Cloudflare Worker that turns Slack's **Events API** into a resumable stream of
4
+ *doorbells* for `slack stream`. Slack posts each event to the Worker; the Worker
5
+ keeps only `{channel, ts, thread_ts}` and pushes it over a WebSocket (or, for
6
+ older clients, Server-Sent Events) to authenticated clients. Each client then
7
+ reads the message itself, with its own token.
8
+
9
+ ```
10
+ Slack ──POST /slack/events──▶ Worker ──▶ Relay Durable Object (SQLite)
11
+ │ seq {channel, ts, thread_ts}
12
+ slack stream ◀──GET /ws (WebSocket, hibernating)┤
13
+ older clients ◀──GET /stream (SSE, Bearer)──────┘
14
+ ```
15
+
16
+ ## Cost: why WebSocket
17
+
18
+ A Durable Object is billed for **duration** while it is in memory, and an open
19
+ SSE response keeps it there for as long as a client is connected — all day,
20
+ for a stream that is idle most of the time. `/ws` instead uses the Durable
21
+ Object [WebSocket Hibernation API](https://developers.cloudflare.com/durable-objects/best-practices/websockets/):
22
+
23
+ - sockets are accepted with `ctx.acceptWebSocket()`, so between bells the object
24
+ is evicted and not billed, while the connections stay open;
25
+ - the client's keepalive (`"ping"` every 25 s) is answered `"pong"` by the
26
+ runtime (`setWebSocketAutoResponse`) **without waking the object**;
27
+ - all per-client state (resume position, acked position, replay/live, stall
28
+ clock) lives in the socket's attachment (`serializeAttachment`), so it
29
+ survives hibernation, and nothing waits on a timer.
30
+
31
+ The object wakes only for a Slack event, a client connecting, a client's ack
32
+ (sent within ~100 ms of the bells it acknowledges, while the object is still
33
+ awake from the event), or the hourly TTL alarm.
34
+
35
+ **SSE is kept for one release** so `slack stream` clients from before `/ws`
36
+ keep working against a redeployed relay. Each connected SSE client keeps the
37
+ object awake as before; `/health` shows `sse_clients` so you can see when none
38
+ are left, after which `/stream` can be removed. New clients try `/ws` first and
39
+ fall back to `/stream` for a connection only if the socket cannot be opened at
40
+ all (an older relay, a proxy that strips upgrades, a runtime without
41
+ `WebSocket`); `SLACK_RELAY_TRANSPORT=websocket|sse` pins one.
42
+
43
+ ## Endpoints
44
+
45
+ | Path | Auth | What |
46
+ |---|---|---|
47
+ | `POST /slack/events` | Slack signing secret (`X-Slack-Signature`, ±5 min) | Answers `url_verification`; stores one bell per `message` / `app_mention` event. Acks with a single SQLite insert, well inside Slack's 3 s. Retries collapse on `(channel, ts)`. |
48
+ | `GET /ws?after=<seq>&epoch=<e>` | Subprotocols `slack-term-relay.v1` **and** `bearer.<base64url($RELAY_TOKEN)>` (or `Authorization: Bearer`) | WebSocket (below). Rejected with 401 before the upgrade is accepted. |
49
+ | `GET /stream?after=<seq>` | `Authorization: Bearer $RELAY_TOKEN` | SSE (kept for older clients). First an `event: hello` frame `{seq, gap, retention_sec}`, then the backlog after `seq`, then live `event: bell` frames (`id:` = seq). A keepalive comment goes out every 25 s. `Last-Event-ID` works as well as `?after`. Pass `&epoch=` from the last hello: the epoch names the relay's storage, so a client resuming against a reset relay is told `gap: true` and gets everything kept, even once the new numbers have passed its old seq. If `after` is omitted, the stream starts from now. `gap: true` means some bells after `after` are gone (expired, or the relay was reset): everything still kept is replayed, and the client should also catch up by polling. |
50
+ | `GET /health` | Bearer | `{epoch, latest, oldest, clients, websocket_clients, sse_clients, retention_sec}` |
51
+
52
+ At most 16 clients (both kinds) at once; more get 503.
53
+
54
+ ### The WebSocket protocol (`/ws`)
55
+
56
+ **Auth.** The token is never put in the URL (query strings end up in logs).
57
+ Node's built-in `WebSocket` cannot send an `Authorization` header, so the client
58
+ offers two subprotocols — `Sec-WebSocket-Protocol: slack-term-relay.v1,
59
+ bearer.<base64url(token)>` (base64url because subprotocols must be HTTP tokens;
60
+ the same scheme Kubernetes uses) — and the Worker compares the bearer one in
61
+ constant time before the request reaches the Durable Object. The server
62
+ selects only `slack-term-relay.v1`, never echoing the token.
63
+
64
+ **Messages** are JSON text, one per frame:
65
+
66
+ - server → client: `{"type":"hello","seq","gap","retention_sec","epoch"}` first
67
+ (same resume rules as SSE: `after`, `epoch`, `gap`); then
68
+ `{"type":"bell","seq":n,"bell":{channel,ts,thread_ts?,self?}}` — the backlog
69
+ after `seq`, then live bells; and `"pong"` (from the runtime).
70
+ - client → server: `"ping"` every 25 s; `{"type":"ack","seq":n}` for the highest
71
+ bell received — at once after 32 unacked bells, else within 100 ms.
72
+
73
+ **Flow control** replaces SSE's write backpressure, which a WebSocket send
74
+ does not give: the backlog is replayed 100 bells at a time, each batch only
75
+ after the previous one is acked, and the switch to live happens right after a
76
+ storage read comes back empty (synchronously, so no bell falls in between). A
77
+ client is closed — and resumes from storage with `?after` — when it has 64 live
78
+ bells unacked (`4008 overflow`), makes no ack progress for 30 s while owing one
79
+ (`4009 stalled`), or neither pings nor acks for 90 s (`4010 silent`). These
80
+ checks run whenever the object is awake (an event, a connect, the alarm), since
81
+ a hibernating object runs no timers.
82
+
83
+ ## Privacy
84
+
85
+ - **No message content is stored or served:** no text, no sender, no attachments.
86
+ A bell holds a channel id and two timestamps — plus, on posts by **this app's own
87
+ bot** only, `self`: that bot's user id. A client streaming as the bot never prints
88
+ its own posts, so it skips the Web API read such a bell would cost (and, for
89
+ `--replies-to`, learns from it that the bot is in the thread). No other sender
90
+ is ever recorded.
91
+ - **Bells are deleted 1 hour after arrival** (`RETENTION_SEC`, enforced by a
92
+ Durable Object alarm). That window exists only so a client can resume.
93
+ Anything older is covered by the client's catch-up poll, except replies
94
+ under a thread parent older than the client's `--thread-window`: the poll
95
+ cannot find those, so a bell that expired before the client read it is lost.
96
+ - **Nothing logs a request body**, and Workers Logs are off (`observability`).
97
+ - **Token holders see activity metadata.** Anyone with `RELAY_TOKEN` can watch
98
+ *when* and *where* messages are posted — channel ids, timestamps and thread
99
+ structure, for every conversation the bot is in, **including DMs** with it —
100
+ even though they never see text or senders. Treat the token like read access
101
+ to that timing data: give it only to consumers you trust, and rotate it
102
+ (`wrangler secret put RELAY_TOKEN`) when one should lose access.
103
+ - **A forged bell costs a few Web API reads at most:** one
104
+ `conversations.replies` call, a few pages if needed, and up to 8 retries
105
+ if the message is not found. The client applies its own filter to what
106
+ Slack returns, so a forged bell never prints anything.
107
+
108
+ ## Deploy
109
+
110
+ ### Without cloning (recommended)
111
+
112
+ The relay ships in the npm package as `slack-term/worker`. Scaffold a Worker
113
+ folder with the CLI:
114
+
115
+ ```sh
116
+ slack relay init my-relay # or: npx slack-term relay init my-relay
117
+ cd my-relay && npm install
118
+ npx wrangler login # or CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID (below)
119
+ npx wrangler deploy
120
+ ```
121
+
122
+ `init` writes `package.json` (slack-term as `^` the CLI's version, wrangler
123
+ pinned), `wrangler.jsonc` (the same config as this folder's, with `--name` or
124
+ the folder's name), `src/index.ts`, `.gitignore` and a `README.md`. It makes no
125
+ network calls and refuses to overwrite existing files unless `--force`. It
126
+ prints a freshly generated `RELAY_TOKEN` — not saved anywhere — and the
127
+ remaining steps: the secrets, the Slack app settings and the client env, all
128
+ as described below (run them from the new folder, with `npx wrangler`).
129
+
130
+ Already have a Worker project? `src/index.ts` is just
131
+
132
+ ```ts
133
+ export { default, Relay } from "slack-term/worker";
134
+ ```
135
+
136
+ with a `RELAY` Durable Object binding to class `Relay` and a
137
+ `new_sqlite_classes: ["Relay"]` migration — copy them from
138
+ [`wrangler.jsonc`](wrangler.jsonc). `Env` is exported too, for typing.
139
+ `@cloudflare/workers-types` is an optional peer dependency of slack-term.
140
+
141
+ To update, bump `slack-term` in the folder's package.json and redeploy.
142
+
143
+ ### From a clone of this repo (contributors)
144
+
145
+ Wrangler 4 needs **Node ≥ 22**.
146
+
147
+ Cloudflare credentials, in the environment:
148
+
149
+ - `CLOUDFLARE_API_TOKEN` — a token with the **Workers Scripts: Edit** permission
150
+ (the "Edit Cloudflare Workers" template).
151
+ - `CLOUDFLARE_ACCOUNT_ID` — from the dashboard sidebar.
152
+
153
+ ```sh
154
+ cd worker
155
+ npx --yes wrangler@4.105.0 deploy
156
+ npx --yes wrangler@4.105.0 secret put SLACK_SIGNING_SECRET # Slack app → Basic Information → Signing Secret
157
+ npx --yes wrangler@4.105.0 secret put RELAY_TOKEN # long random string, shared with consumers (e.g. openssl rand -hex 32)
158
+ npx --yes wrangler@4.105.0 secret put SLACK_TEAM_ID # optional: accept one workspace only (T…)
159
+ ```
160
+
161
+ Redeploying an existing relay keeps its storage (and epoch): no migration is
162
+ needed, the Durable Object class is unchanged. A deploy restarts the object,
163
+ which drops every connection; clients reconnect and resume from their last seq.
164
+
165
+ ### Then, either way
166
+
167
+ Check it (expects `{"ok":true,…}`):
168
+
169
+ ```sh
170
+ curl -H "Authorization: Bearer $RELAY_TOKEN" https://<worker>/health
171
+ # or, with SLACK_RELAY_URL / SLACK_RELAY_TOKEN set:
172
+ slack relay health
173
+ ```
174
+
175
+ Then, in the Slack app:
176
+
177
+ 1. Go to **Event Subscriptions** and turn on **Enable Events**.
178
+ 2. Set the **Request URL** to `https://<worker>/slack/events`. It should verify at once.
179
+ 3. Under **Subscribe to bot events**, add:
180
+ - `message.channels`
181
+ - `message.groups`
182
+ - `message.im`
183
+ - `app_mention`
184
+ - `message.mpim`, only if the bot has `mpim:history`.
185
+ Each needs its bot scope (`channels:history`, `groups:history`, `im:history`,
186
+ `app_mentions:read`); Slack offers to add them.
187
+ 4. Click **Save**, then reinstall the app if Slack asks.
188
+
189
+ The secrets must be in place first: Slack's URL verification is signed.
190
+
191
+ Consumers set `SLACK_RELAY_URL=https://<worker>` and `SLACK_RELAY_TOKEN=…`
192
+ (the same value as the `RELAY_TOKEN` secret). `slack stream` then uses the relay
193
+ automatically and says so on stderr: `relay connected (websocket)` — or
194
+ `(sse)` if it fell back. Optional: `SLACK_RELAY_TRANSPORT=websocket` or `sse`.
195
+
196
+ ## Develop
197
+
198
+ ```sh
199
+ cd worker && bun install && bun run typecheck
200
+ # or, from the repo root (what CI runs):
201
+ bun run typecheck:worker
202
+ ```
203
+
204
+ The signature and event parsing live in `src/slack.ts`, and the WebSocket
205
+ session logic (replay, acks, flow control, auth) in `src/hub.ts`; neither has
206
+ Cloudflare types, and the CLI suite tests both (`tests/relay.test.ts`,
207
+ `tests/relay-hub.test.ts`, `tests/relay-ws.test.ts`).
208
+
209
+ To run the Worker locally (Node ≥ 22; on an older Node,
210
+ `npx -y -p node@22 -- …` works):
211
+
212
+ ```sh
213
+ cd worker
214
+ printf 'SLACK_SIGNING_SECRET=dev\nRELAY_TOKEN=dev\n' > .dev.vars # git-ignored
215
+ npx --yes wrangler@4.105.0 dev --local
216
+ ```
@@ -0,0 +1,203 @@
1
+ // The relay's WebSocket side, as pure logic: no Cloudflare types, so the CLI's
2
+ // test suite exercises it under bun with fake sockets (tests/relay-hub.test.ts).
3
+ // index.ts adapts a hibernatable WebSocket to `Sock` and SQLite to `Store`.
4
+ //
5
+ // Why WebSocket: an open SSE response keeps the Durable Object in memory — and
6
+ // billed for duration — for as long as any client is connected. A socket
7
+ // accepted with ctx.acceptWebSocket() lets the object hibernate between bells,
8
+ // and the client's keepalive ("ping" → "pong") is answered by the runtime
9
+ // without waking it. Everything this module remembers about a client
10
+ // therefore lives in the socket's attachment, which survives hibernation;
11
+ // nothing lives in memory, and nothing waits on a timer.
12
+ //
13
+ // The guarantees are those of the SSE path:
14
+ // - hello first: {seq, gap, retention_sec, epoch}, from the same resume rules;
15
+ // - then the backlog after seq, read from storage in REPLAY_BATCH steps, and
16
+ // only then live bells — the switch happens right after a read that came
17
+ // back empty, synchronously, so a bell rung meanwhile is never lost: it is
18
+ // either in storage for the next read or delivered live;
19
+ // - a client that stops taking bells is cut off, not buffered for without
20
+ // bound. SSE learned that from write backpressure; a WebSocket send gives
21
+ // none, so the client acks ({"type":"ack","seq":n}) what it has received.
22
+ // A replay step waits for the ack of the previous one (one batch in flight
23
+ // at most); live bells are cut off past MAX_PENDING unacked; a client that
24
+ // leaves a send unacked for STALL_MS, or stops pinging for DEAD_MS, is
25
+ // closed. Closed clients reconnect with ?after and get the rest from storage.
26
+
27
+ import { type Doorbell as Bell, safeEqual } from "./slack.ts";
28
+
29
+ export type { Bell };
30
+ export type Row = { seq: number; channel: string; ts: string; thread_ts: string | null; self: string | null };
31
+ export type Hello = { seq: number; gap: boolean; retention_sec: number; epoch: string };
32
+
33
+ /** Per-socket state, kept with serializeAttachment (2 KiB max; this is ~100 B). */
34
+ export type Attachment = {
35
+ /** Highest seq sent to this client (its resume point, at the start). */
36
+ sent: number;
37
+ /** Highest seq the client has acked. */
38
+ acked: number;
39
+ /** Still being fed its backlog from storage; live bells reach it that way. */
40
+ replaying: boolean;
41
+ /** Since when (ms) the client owes an ack without having made progress;
42
+ * 0 while nothing is unacked. */
43
+ waitSince: number;
44
+ /** Last message from the client (ms): connect, or an ack. */
45
+ lastSeen: number;
46
+ };
47
+
48
+ export interface Sock {
49
+ send(data: string): void;
50
+ close(code: number, reason: string): void;
51
+ attachment(): Attachment | null;
52
+ setAttachment(a: Attachment): void;
53
+ /** Last runtime-answered "ping" (ms since epoch), null if none yet. */
54
+ lastPing(): number | null;
55
+ }
56
+
57
+ export interface Store {
58
+ rowsAfter(seq: number, limit: number): Row[];
59
+ }
60
+
61
+ /** The subprotocol a client must offer; the server echoes it back. */
62
+ export const PROTOCOL = "slack-term-relay.v1";
63
+ /** The other offered subprotocol carries the bearer: `bearer.<base64url(token)>`.
64
+ * A header, not the URL: query strings end up in logs. */
65
+ export const BEARER_PREFIX = "bearer.";
66
+ /** Backlog rows sent per replay step. */
67
+ export const REPLAY_BATCH = 100;
68
+ /** Live bells a client may have unacked before it is cut off. */
69
+ export const MAX_PENDING = 64;
70
+ /** An unacked send older than this frees the client's slot. */
71
+ export const STALL_MS = 30_000;
72
+ /** Clients ping every 25 s; one silent for this long is gone. */
73
+ export const DEAD_MS = 90_000;
74
+ /** Close codes (4000–4999: application-defined). */
75
+ export const CLOSE_OVERFLOW = 4008;
76
+ export const CLOSE_STALLED = 4009;
77
+ export const CLOSE_GONE = 4010;
78
+
79
+ export function base64url(s: string): string {
80
+ let bin = "";
81
+ for (const b of new TextEncoder().encode(s)) bin += String.fromCharCode(b);
82
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
83
+ }
84
+
85
+ /** Does a Sec-WebSocket-Protocol header offer our protocol and this token?
86
+ * Every offered entry is compared, so timing does not reveal which matched. */
87
+ export function protocolAuthorized(header: string | null, token: string): boolean {
88
+ if (!header || !token) return false;
89
+ const want = BEARER_PREFIX + base64url(token);
90
+ let ok = false;
91
+ let ours = false;
92
+ for (const p of header.split(",")) {
93
+ const v = p.trim();
94
+ if (v === PROTOCOL) ours = true;
95
+ if (safeEqual(v, want)) ok = true;
96
+ }
97
+ return ok && ours;
98
+ }
99
+
100
+ /** Where a (re)connecting client starts, from ?after (or Last-Event-ID) and
101
+ * ?epoch. Absent `after` means "from now". A seq we can no longer serve —
102
+ * expired, or from a relay whose storage was reset (a different epoch, or a
103
+ * number past our high-water mark) — is a gap: everything kept is replayed
104
+ * and the client must also catch up by polling. */
105
+ export function resumePoint(
106
+ rawAfter: string | null, clientEpoch: string | null, epoch: string, oldest: number | null, latest: number,
107
+ ): { from: number; gap: boolean } {
108
+ const after = rawAfter !== null && /^\d+$/.test(rawAfter) ? Number(rawAfter) : null;
109
+ if (after === null) return { from: latest, gap: false };
110
+ const reset = after > latest || (clientEpoch !== null && clientEpoch !== epoch);
111
+ const gap = reset || (oldest !== null ? after < oldest - 1 : after < latest);
112
+ return { from: reset ? 0 : after, gap };
113
+ }
114
+
115
+ export function bellOf(r: Row): Bell {
116
+ return {
117
+ channel: r.channel, ts: r.ts,
118
+ ...(r.thread_ts ? { thread_ts: r.thread_ts } : {}),
119
+ ...(r.self ? { self: r.self } : {}),
120
+ };
121
+ }
122
+
123
+ const bellMsg = (seq: number, b: Bell): string => JSON.stringify({ type: "bell", seq, bell: b });
124
+
125
+ function trySend(s: Sock, data: string): boolean {
126
+ try {
127
+ s.send(data);
128
+ return true;
129
+ } catch {
130
+ s.close(1011, "send failed");
131
+ return false;
132
+ }
133
+ }
134
+
135
+ /** One replay step: the next batch after `a.sent`, or — when that read comes
136
+ * back empty — the switch to live. Synchronous from read to switch. */
137
+ function step(s: Sock, a: Attachment, store: Store, now: number, prefix?: string): void {
138
+ if (prefix !== undefined && !trySend(s, prefix)) return;
139
+ const rows = store.rowsAfter(a.sent, REPLAY_BATCH);
140
+ for (const r of rows) {
141
+ if (!trySend(s, bellMsg(r.seq, bellOf(r)))) return;
142
+ a.sent = r.seq;
143
+ }
144
+ if (!rows.length) a.replaying = false;
145
+ else if (!a.waitSince) a.waitSince = now;
146
+ s.setAttachment(a);
147
+ }
148
+
149
+ /** A newly accepted socket: hello, then the first replay step. */
150
+ export function open(s: Sock, from: number, hello: Hello, store: Store, now: number): void {
151
+ const a: Attachment = { sent: from, acked: from, replaying: true, waitSince: 0, lastSeen: now };
152
+ step(s, a, store, now, JSON.stringify({ type: "hello", ...hello }));
153
+ }
154
+
155
+ /** A message from the client. Only acks mean anything; the rest is ignored. */
156
+ export function message(s: Sock, data: string | ArrayBuffer, store: Store, now: number): void {
157
+ const a = s.attachment();
158
+ if (!a) return s.close(1011, "no session");
159
+ a.lastSeen = now;
160
+ let seq: unknown;
161
+ if (typeof data === "string" && data.length <= 200) {
162
+ try {
163
+ const m = JSON.parse(data) as { type?: unknown; seq?: unknown };
164
+ if (m && m.type === "ack") seq = m.seq;
165
+ } catch { /* not JSON: ignored */ }
166
+ }
167
+ if (typeof seq === "number" && Number.isSafeInteger(seq)) {
168
+ const acked = Math.min(seq, a.sent);
169
+ // Progress restarts the stall clock: "stalled" means no ack progress for
170
+ // STALL_MS while something is unacked, not a long busy stretch.
171
+ if (acked > a.acked) a.waitSince = acked >= a.sent ? 0 : now;
172
+ a.acked = Math.max(a.acked, acked);
173
+ }
174
+ if (a.replaying && a.acked >= a.sent) return step(s, a, store, now);
175
+ s.setAttachment(a);
176
+ }
177
+
178
+ /** Close clients that stalled or went silent; return the rest. */
179
+ export function sweep<S extends Sock>(socks: Iterable<S>, now: number): S[] {
180
+ const live: S[] = [];
181
+ for (const s of socks) {
182
+ const a = s.attachment();
183
+ if (!a) { s.close(1011, "no session"); continue; }
184
+ if (a.waitSince && now - a.waitSince > STALL_MS) { s.close(CLOSE_STALLED, "stalled"); continue; }
185
+ if (now - Math.max(a.lastSeen, s.lastPing() ?? 0) > DEAD_MS) { s.close(CLOSE_GONE, "silent"); continue; }
186
+ live.push(s);
187
+ }
188
+ return live;
189
+ }
190
+
191
+ /** A new bell, already stored as `seq`: push it to every live client. */
192
+ export function deliver(socks: Iterable<Sock>, seq: number, bell: Bell, now: number): void {
193
+ const msg = bellMsg(seq, bell);
194
+ for (const s of sweep(socks, now)) {
195
+ const a = s.attachment()!;
196
+ if (a.replaying || seq <= a.sent) continue; // storage gets it to a replaying client
197
+ if (a.sent - a.acked >= MAX_PENDING) { s.close(CLOSE_OVERFLOW, "overflow"); continue; }
198
+ if (!trySend(s, msg)) continue;
199
+ if (!a.waitSince) a.waitSince = now;
200
+ a.sent = seq;
201
+ s.setAttachment(a);
202
+ }
203
+ }