@moxxy/plugin-channel-slack 0.27.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.
- package/LICENSE +21 -0
- package/dist/channel/slack-client.d.ts +53 -0
- package/dist/channel/slack-client.d.ts.map +1 -0
- package/dist/channel/slack-client.js +82 -0
- package/dist/channel/slack-client.js.map +1 -0
- package/dist/channel/turn-runner.d.ts +39 -0
- package/dist/channel/turn-runner.d.ts.map +1 -0
- package/dist/channel/turn-runner.js +81 -0
- package/dist/channel/turn-runner.js.map +1 -0
- package/dist/channel.d.ts +100 -0
- package/dist/channel.d.ts.map +1 -0
- package/dist/channel.js +286 -0
- package/dist/channel.js.map +1 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +207 -0
- package/dist/index.js.map +1 -0
- package/dist/keys.d.ts +50 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +78 -0
- package/dist/keys.js.map +1 -0
- package/dist/pair-flow.d.ts +18 -0
- package/dist/pair-flow.d.ts.map +1 -0
- package/dist/pair-flow.js +106 -0
- package/dist/pair-flow.js.map +1 -0
- package/dist/permission.d.ts +27 -0
- package/dist/permission.d.ts.map +1 -0
- package/dist/permission.js +33 -0
- package/dist/permission.js.map +1 -0
- package/dist/server/dedupe.d.ts +8 -0
- package/dist/server/dedupe.d.ts.map +1 -0
- package/dist/server/dedupe.js +8 -0
- package/dist/server/dedupe.js.map +1 -0
- package/dist/server/ingest-server.d.ts +80 -0
- package/dist/server/ingest-server.d.ts.map +1 -0
- package/dist/server/ingest-server.js +142 -0
- package/dist/server/ingest-server.js.map +1 -0
- package/dist/server/schema.d.ts +257 -0
- package/dist/server/schema.d.ts.map +1 -0
- package/dist/server/schema.js +69 -0
- package/dist/server/schema.js.map +1 -0
- package/dist/server/verify.d.ts +39 -0
- package/dist/server/verify.d.ts.map +1 -0
- package/dist/server/verify.js +73 -0
- package/dist/server/verify.js.map +1 -0
- package/dist/setup-wizard.d.ts +17 -0
- package/dist/setup-wizard.d.ts.map +1 -0
- package/dist/setup-wizard.js +92 -0
- package/dist/setup-wizard.js.map +1 -0
- package/package.json +97 -0
- package/src/channel/slack-client.ts +123 -0
- package/src/channel/turn-runner.test.ts +143 -0
- package/src/channel/turn-runner.ts +107 -0
- package/src/channel.ts +378 -0
- package/src/index.ts +254 -0
- package/src/keys.ts +96 -0
- package/src/pair-flow.ts +119 -0
- package/src/permission.test.ts +65 -0
- package/src/permission.ts +42 -0
- package/src/server/dedupe.ts +7 -0
- package/src/server/ingest-server.test.ts +280 -0
- package/src/server/ingest-server.ts +205 -0
- package/src/server/schema.test.ts +67 -0
- package/src/server/schema.ts +77 -0
- package/src/server/verify.test.ts +132 -0
- package/src/server/verify.ts +88 -0
- package/src/setup-wizard.ts +121 -0
- package/src/subcommands.test.ts +189 -0
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import {
|
|
3
|
+
DeliveryDedupeCache,
|
|
4
|
+
IngestHttpServer,
|
|
5
|
+
respondJson,
|
|
6
|
+
type IngestHttpServerHandle,
|
|
7
|
+
} from '@moxxy/channel-kit';
|
|
8
|
+
import { slackEnvelopeSchema, type SlackEventCallback } from './schema.js';
|
|
9
|
+
import { verifySlackSignature } from './verify.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The HTTP front-end for the Slack channel. Binds a `node:http` server to an
|
|
13
|
+
* ephemeral loopback port; the channel exposes it publicly via the proxy
|
|
14
|
+
* tunnel. Slack POSTs every event to `<public>/slack/events`.
|
|
15
|
+
*
|
|
16
|
+
* The transport scaffold (routing, health probe, size-capped raw-body read,
|
|
17
|
+
* verify gate, catch-all error handling) is `@moxxy/channel-kit`'s
|
|
18
|
+
* {@link IngestHttpServer}; Slack's HMAC scheme stays HERE as its verify hook,
|
|
19
|
+
* and this module owns everything after verification.
|
|
20
|
+
*
|
|
21
|
+
* Handler order — every gate runs BEFORE the session is touched (skill A8/A46):
|
|
22
|
+
* 1. POST-only (+ a GET `/slack/health` liveness probe). [kit]
|
|
23
|
+
* 2. read the RAW body bytes (size cap) — required for the HMAC. [kit]
|
|
24
|
+
* 3. signature gate → 401 (verify over the raw bytes, before JSON.parse).
|
|
25
|
+
* 4. zod-validate the envelope → 400.
|
|
26
|
+
* 5. `url_verification` → echo `{ challenge }` (the handshake).
|
|
27
|
+
* 6. dedupe — drop on `X-Slack-Retry-Num` or a seen `event_id`.
|
|
28
|
+
* 7. drop the bot's own messages (`event.user === botUserId` / `event.bot_id`).
|
|
29
|
+
* 8. pairing gate — ignore unless the team/channel is authorized.
|
|
30
|
+
* 9. ACK 200 synchronously, THEN run the turn fire-and-forget (Slack's 3s
|
|
31
|
+
* ack budget; never run `runTurn` on the request path).
|
|
32
|
+
*
|
|
33
|
+
* Every handler error is caught so a bad request can never escalate to a
|
|
34
|
+
* process-level uncaughtException.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
const EVENTS_PATH = '/slack/events';
|
|
38
|
+
const HEALTH_PATH = '/slack/health';
|
|
39
|
+
|
|
40
|
+
/** Decision the channel makes about an inbound event. */
|
|
41
|
+
export interface DispatchContext {
|
|
42
|
+
readonly teamId: string | undefined;
|
|
43
|
+
readonly channel: string;
|
|
44
|
+
readonly text: string;
|
|
45
|
+
readonly user: string | undefined;
|
|
46
|
+
readonly threadTs: string;
|
|
47
|
+
readonly eventType: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface IngestServerHooks {
|
|
51
|
+
/** The bot's own user id, captured at start via `auth.test` (drop self-messages). */
|
|
52
|
+
readonly botUserId: string;
|
|
53
|
+
/** Is this team/channel authorized to drive the session? (pairing gate) */
|
|
54
|
+
isAuthorized(teamId: string | undefined, channel: string | undefined): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* Observe a verified inbound event from a (possibly unauthorized) team/channel.
|
|
57
|
+
* Used by the TOFU `pair` flow to capture the first event and persist it.
|
|
58
|
+
* Returns true if the event was consumed by pairing (so it should NOT also
|
|
59
|
+
* drive a turn).
|
|
60
|
+
*/
|
|
61
|
+
onVerifiedEvent?(ev: SlackEventCallback): boolean | Promise<boolean>;
|
|
62
|
+
/** Run a turn for an authorized, deduped, non-self event (fire-and-forget). */
|
|
63
|
+
dispatch(ctx: DispatchContext): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface IngestServerOptions {
|
|
67
|
+
readonly host?: string;
|
|
68
|
+
readonly signingSecret: string;
|
|
69
|
+
readonly hooks: IngestServerHooks;
|
|
70
|
+
/** Max request body size in bytes. Default 1MB. */
|
|
71
|
+
readonly maxBodyBytes?: number;
|
|
72
|
+
/** Override dedupe cache (tests). */
|
|
73
|
+
readonly dedupe?: DeliveryDedupeCache;
|
|
74
|
+
readonly logger?: {
|
|
75
|
+
info?(msg: string, meta?: Record<string, unknown>): void;
|
|
76
|
+
warn?(msg: string, meta?: Record<string, unknown>): void;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export type IngestServerHandle = IngestHttpServerHandle;
|
|
81
|
+
|
|
82
|
+
export class IngestServer {
|
|
83
|
+
private readonly inner: IngestHttpServer;
|
|
84
|
+
private readonly dedupe: DeliveryDedupeCache;
|
|
85
|
+
|
|
86
|
+
constructor(private readonly opts: IngestServerOptions) {
|
|
87
|
+
this.dedupe = opts.dedupe ?? new DeliveryDedupeCache();
|
|
88
|
+
this.inner = new IngestHttpServer({
|
|
89
|
+
...(opts.host ? { host: opts.host } : {}),
|
|
90
|
+
eventsPath: EVENTS_PATH,
|
|
91
|
+
healthPath: HEALTH_PATH,
|
|
92
|
+
healthBody: () => ({ status: 'ok', listener: 'slack' }),
|
|
93
|
+
...(opts.maxBodyBytes !== undefined ? { maxBodyBytes: opts.maxBodyBytes } : {}),
|
|
94
|
+
label: 'slack',
|
|
95
|
+
verify: ({ rawBody, headers }) =>
|
|
96
|
+
verifySlackSignature({ rawBody, headers, signingSecret: opts.signingSecret }),
|
|
97
|
+
handleVerified: (raw, req, res) => this.handleVerified(raw, req, res),
|
|
98
|
+
...(opts.logger ? { logger: opts.logger } : {}),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get port(): number {
|
|
103
|
+
return this.inner.port;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Bind on an ephemeral loopback port. Resolves once listening. */
|
|
107
|
+
start(): Promise<IngestServerHandle> {
|
|
108
|
+
return this.inner.start();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
stop(): Promise<void> {
|
|
112
|
+
return this.inner.stop();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Steps 4–9: everything after the raw-body + signature gates. */
|
|
116
|
+
private async handleVerified(
|
|
117
|
+
raw: Buffer,
|
|
118
|
+
req: IncomingMessage,
|
|
119
|
+
res: ServerResponse,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
// 4) zod-validate the envelope.
|
|
122
|
+
let parsedJson: unknown;
|
|
123
|
+
try {
|
|
124
|
+
parsedJson = JSON.parse(raw.toString('utf8'));
|
|
125
|
+
} catch {
|
|
126
|
+
respondJson(res, 400, { error: 'invalid_json' });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const parsed = slackEnvelopeSchema.safeParse(parsedJson);
|
|
130
|
+
if (!parsed.success) {
|
|
131
|
+
this.opts.logger?.warn?.('slack: malformed envelope', {
|
|
132
|
+
issue: parsed.error.issues[0]?.message,
|
|
133
|
+
});
|
|
134
|
+
respondJson(res, 400, { error: 'bad_request' });
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const envelope = parsed.data;
|
|
138
|
+
|
|
139
|
+
// 5) url_verification handshake.
|
|
140
|
+
if (envelope.type === 'url_verification') {
|
|
141
|
+
respondJson(res, 200, { challenge: envelope.challenge });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// From here on it's an event_callback. ACK fast; do everything else after
|
|
146
|
+
// (or fire-and-forget) so we stay inside Slack's 3-second ack budget.
|
|
147
|
+
const callback = envelope;
|
|
148
|
+
|
|
149
|
+
// 6) Dedupe — drop retries / repeated event ids. We record the id only for
|
|
150
|
+
// verified events so an unverified request can never poison the cache.
|
|
151
|
+
const isRetry = req.headers['x-slack-retry-num'] !== undefined;
|
|
152
|
+
const eventId = callback.event_id;
|
|
153
|
+
const dupe = isRetry || (eventId ? !this.dedupe.check(eventId) : false);
|
|
154
|
+
|
|
155
|
+
// 7) Self-message guard: never re-trigger on our own posts.
|
|
156
|
+
const ev = callback.event;
|
|
157
|
+
const isSelf =
|
|
158
|
+
ev.bot_id !== undefined ||
|
|
159
|
+
(ev.user !== undefined && ev.user === this.opts.hooks.botUserId);
|
|
160
|
+
|
|
161
|
+
// ACK synchronously BEFORE running anything.
|
|
162
|
+
respondJson(res, 200, { status: 'ok' });
|
|
163
|
+
|
|
164
|
+
if (dupe || isSelf) return;
|
|
165
|
+
|
|
166
|
+
// Pairing TOFU hook gets first crack at a verified event (even unauthorized),
|
|
167
|
+
// so it can capture + persist the first team/channel.
|
|
168
|
+
try {
|
|
169
|
+
const consumed = (await this.opts.hooks.onVerifiedEvent?.(callback)) ?? false;
|
|
170
|
+
if (consumed) return;
|
|
171
|
+
} catch (err) {
|
|
172
|
+
this.opts.logger?.warn?.('slack: pairing hook threw', { err: String(err) });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 8) Pairing gate — only authorized team/channel drives the session.
|
|
176
|
+
if (!this.opts.hooks.isAuthorized(callback.team_id, ev.channel)) {
|
|
177
|
+
this.opts.logger?.info?.('slack: dropped event from unauthorized team/channel', {
|
|
178
|
+
team: callback.team_id,
|
|
179
|
+
channel: ev.channel,
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Only act on message-ish events (we subscribe to app_mention primarily;
|
|
185
|
+
// message events with an edit/system subtype are ignored).
|
|
186
|
+
const eventType = ev.type;
|
|
187
|
+
if (eventType !== 'app_mention' && eventType !== 'message') return;
|
|
188
|
+
if (ev.subtype) return; // message_changed / message_deleted / channel_join …
|
|
189
|
+
const channel = ev.channel;
|
|
190
|
+
const text = (ev.text ?? '').trim();
|
|
191
|
+
if (!channel || !text) return;
|
|
192
|
+
|
|
193
|
+
// 9) Run the turn fire-and-forget (already ACKed).
|
|
194
|
+
this.opts.hooks.dispatch({
|
|
195
|
+
teamId: callback.team_id,
|
|
196
|
+
channel,
|
|
197
|
+
text,
|
|
198
|
+
user: ev.user,
|
|
199
|
+
threadTs: ev.thread_ts ?? ev.ts ?? channel,
|
|
200
|
+
eventType,
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export { EVENTS_PATH as SLACK_EVENTS_PATH };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { slackEnvelopeSchema, urlVerificationSchema, eventCallbackSchema } from './schema.js';
|
|
3
|
+
|
|
4
|
+
describe('slack schemas', () => {
|
|
5
|
+
it('parses a url_verification challenge', () => {
|
|
6
|
+
const parsed = slackEnvelopeSchema.safeParse({
|
|
7
|
+
type: 'url_verification',
|
|
8
|
+
token: 'verif-token',
|
|
9
|
+
challenge: 'the-challenge-string',
|
|
10
|
+
});
|
|
11
|
+
expect(parsed.success).toBe(true);
|
|
12
|
+
if (parsed.success && parsed.data.type === 'url_verification') {
|
|
13
|
+
expect(parsed.data.challenge).toBe('the-challenge-string');
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('rejects a url_verification with an empty challenge', () => {
|
|
18
|
+
const parsed = urlVerificationSchema.safeParse({ type: 'url_verification', challenge: '' });
|
|
19
|
+
expect(parsed.success).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('parses an app_mention event_callback', () => {
|
|
23
|
+
const parsed = slackEnvelopeSchema.safeParse({
|
|
24
|
+
type: 'event_callback',
|
|
25
|
+
team_id: 'T123',
|
|
26
|
+
event_id: 'Ev999',
|
|
27
|
+
event: {
|
|
28
|
+
type: 'app_mention',
|
|
29
|
+
user: 'U123',
|
|
30
|
+
text: '<@UBOT> hello',
|
|
31
|
+
channel: 'C123',
|
|
32
|
+
ts: '1700000000.000100',
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
expect(parsed.success).toBe(true);
|
|
36
|
+
if (parsed.success && parsed.data.type === 'event_callback') {
|
|
37
|
+
expect(parsed.data.event.type).toBe('app_mention');
|
|
38
|
+
expect(parsed.data.team_id).toBe('T123');
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('rejects a body that is neither handshake nor event_callback', () => {
|
|
43
|
+
const parsed = slackEnvelopeSchema.safeParse({ type: 'something_else', foo: 1 });
|
|
44
|
+
expect(parsed.success).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('rejects an event_callback whose event is not an object', () => {
|
|
48
|
+
const parsed = eventCallbackSchema.safeParse({ type: 'event_callback', event: 'nope' });
|
|
49
|
+
expect(parsed.success).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('rejects an event_callback missing the inner event type', () => {
|
|
53
|
+
const parsed = eventCallbackSchema.safeParse({
|
|
54
|
+
type: 'event_callback',
|
|
55
|
+
event: { text: 'hi' },
|
|
56
|
+
});
|
|
57
|
+
expect(parsed.success).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('tolerates unknown extra fields on the inner event (passthrough)', () => {
|
|
61
|
+
const parsed = eventCallbackSchema.safeParse({
|
|
62
|
+
type: 'event_callback',
|
|
63
|
+
event: { type: 'app_mention', some_future_field: { nested: true } },
|
|
64
|
+
});
|
|
65
|
+
expect(parsed.success).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { z } from '@moxxy/sdk';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* zod schemas for the Slack Events API request bodies we accept. Every inbound
|
|
5
|
+
* body is validated against these BEFORE any field is read or the session is
|
|
6
|
+
* touched (AGENTS.md A8: validate inbound frames with zod first). Unknown event
|
|
7
|
+
* subtypes parse to the permissive envelope so we never throw on a Slack event
|
|
8
|
+
* type we don't subscribe to — we just ignore it.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The URL-verification handshake Slack sends once when you set the Request URL. */
|
|
12
|
+
export const urlVerificationSchema = z.object({
|
|
13
|
+
type: z.literal('url_verification'),
|
|
14
|
+
token: z.string().optional(),
|
|
15
|
+
challenge: z.string().min(1),
|
|
16
|
+
});
|
|
17
|
+
export type SlackUrlVerification = z.infer<typeof urlVerificationSchema>;
|
|
18
|
+
|
|
19
|
+
/** A `message` or `app_mention` inner event. Loose on the many optional fields. */
|
|
20
|
+
export const messageEventSchema = z.object({
|
|
21
|
+
type: z.enum(['message', 'app_mention']),
|
|
22
|
+
/** Author user id. Absent for some bot/system messages. */
|
|
23
|
+
user: z.string().optional(),
|
|
24
|
+
/** Set for messages posted by a bot integration (including our own). */
|
|
25
|
+
bot_id: z.string().optional(),
|
|
26
|
+
/** Message text (may be empty for attachment-only messages). */
|
|
27
|
+
text: z.string().optional(),
|
|
28
|
+
/** Channel the event occurred in. */
|
|
29
|
+
channel: z.string().optional(),
|
|
30
|
+
/** Message timestamp (also the thread root when no `thread_ts`). */
|
|
31
|
+
ts: z.string().optional(),
|
|
32
|
+
/** Present when the message is inside a thread. */
|
|
33
|
+
thread_ts: z.string().optional(),
|
|
34
|
+
/** `message_changed` / `message_deleted` etc. — we ignore edited/system subtypes. */
|
|
35
|
+
subtype: z.string().optional(),
|
|
36
|
+
});
|
|
37
|
+
export type SlackMessageEvent = z.infer<typeof messageEventSchema>;
|
|
38
|
+
|
|
39
|
+
/** The `event_callback` envelope wrapping an inner event. */
|
|
40
|
+
export const eventCallbackSchema = z.object({
|
|
41
|
+
type: z.literal('event_callback'),
|
|
42
|
+
/** Workspace/team id — the unit we pair against. */
|
|
43
|
+
team_id: z.string().optional(),
|
|
44
|
+
api_app_id: z.string().optional(),
|
|
45
|
+
/** Stable per-event id used for at-least-once dedupe. */
|
|
46
|
+
event_id: z.string().optional(),
|
|
47
|
+
event_time: z.number().optional(),
|
|
48
|
+
/** The actual event. We only act on message/app_mention; others pass through. */
|
|
49
|
+
event: z
|
|
50
|
+
.object({
|
|
51
|
+
type: z.string(),
|
|
52
|
+
user: z.string().optional(),
|
|
53
|
+
bot_id: z.string().optional(),
|
|
54
|
+
text: z.string().optional(),
|
|
55
|
+
channel: z.string().optional(),
|
|
56
|
+
ts: z.string().optional(),
|
|
57
|
+
thread_ts: z.string().optional(),
|
|
58
|
+
subtype: z.string().optional(),
|
|
59
|
+
})
|
|
60
|
+
.passthrough(),
|
|
61
|
+
/** Bot user ids this event was authorized for — lets us detect self-authored messages. */
|
|
62
|
+
authorizations: z
|
|
63
|
+
.array(z.object({ user_id: z.string().optional() }).passthrough())
|
|
64
|
+
.optional(),
|
|
65
|
+
});
|
|
66
|
+
export type SlackEventCallback = z.infer<typeof eventCallbackSchema>;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The top-level Slack request envelope: either the one-off url_verification
|
|
70
|
+
* handshake or an event_callback. A discriminated union on `type` so a body
|
|
71
|
+
* that is neither is rejected with a clear error.
|
|
72
|
+
*/
|
|
73
|
+
export const slackEnvelopeSchema = z.discriminatedUnion('type', [
|
|
74
|
+
urlVerificationSchema,
|
|
75
|
+
eventCallbackSchema,
|
|
76
|
+
]);
|
|
77
|
+
export type SlackEnvelope = z.infer<typeof slackEnvelopeSchema>;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
import { SLACK_REPLAY_WINDOW_SEC, verifySlackSignature } from './verify.js';
|
|
4
|
+
|
|
5
|
+
const SECRET = 'slack_signing_secret_abcdef0123456789';
|
|
6
|
+
|
|
7
|
+
/** Build the headers Slack would send for `body` at epoch-seconds `ts`. */
|
|
8
|
+
function sign(body: string, ts: number, secret = SECRET): Record<string, string> {
|
|
9
|
+
const base = `v0:${ts}:${body}`;
|
|
10
|
+
const sig = `v0=${createHmac('sha256', secret).update(base).digest('hex')}`;
|
|
11
|
+
return {
|
|
12
|
+
'x-slack-request-timestamp': String(ts),
|
|
13
|
+
'x-slack-signature': sig,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('verifySlackSignature', () => {
|
|
18
|
+
it('accepts a correctly signed request', () => {
|
|
19
|
+
const body = '{"type":"event_callback"}';
|
|
20
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
21
|
+
const res = verifySlackSignature({
|
|
22
|
+
rawBody: Buffer.from(body),
|
|
23
|
+
headers: sign(body, ts),
|
|
24
|
+
signingSecret: SECRET,
|
|
25
|
+
nowMs: Date.now(),
|
|
26
|
+
});
|
|
27
|
+
expect(res.ok).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('verifies over the RAW bytes (a reserialized body fails)', () => {
|
|
31
|
+
// The signature is computed over the exact bytes; a body whose JSON has been
|
|
32
|
+
// reserialized (different whitespace) must not verify.
|
|
33
|
+
const rawBody = '{"a":1,"b":2}';
|
|
34
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
35
|
+
const headers = sign(rawBody, ts);
|
|
36
|
+
const reserialized = JSON.stringify(JSON.parse(rawBody)); // identical here, so tweak spacing
|
|
37
|
+
const tweaked = '{ "a": 1, "b": 2 }';
|
|
38
|
+
expect(reserialized).not.toBe(tweaked);
|
|
39
|
+
const res = verifySlackSignature({
|
|
40
|
+
rawBody: Buffer.from(tweaked),
|
|
41
|
+
headers,
|
|
42
|
+
signingSecret: SECRET,
|
|
43
|
+
nowMs: Date.now(),
|
|
44
|
+
});
|
|
45
|
+
expect(res.ok).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('rejects a wrong signature', () => {
|
|
49
|
+
const body = '{"x":1}';
|
|
50
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
51
|
+
const headers = sign(body, ts);
|
|
52
|
+
headers['x-slack-signature'] = 'v0=deadbeef';
|
|
53
|
+
const res = verifySlackSignature({
|
|
54
|
+
rawBody: Buffer.from(body),
|
|
55
|
+
headers,
|
|
56
|
+
signingSecret: SECRET,
|
|
57
|
+
nowMs: Date.now(),
|
|
58
|
+
});
|
|
59
|
+
expect(res.ok).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('rejects a request signed with a different secret', () => {
|
|
63
|
+
const body = '{"x":1}';
|
|
64
|
+
const ts = Math.floor(Date.now() / 1000);
|
|
65
|
+
const headers = sign(body, ts, 'a-different-secret-entirely');
|
|
66
|
+
const res = verifySlackSignature({
|
|
67
|
+
rawBody: Buffer.from(body),
|
|
68
|
+
headers,
|
|
69
|
+
signingSecret: SECRET,
|
|
70
|
+
nowMs: Date.now(),
|
|
71
|
+
});
|
|
72
|
+
expect(res.ok).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('rejects a request outside the 5-minute replay window', () => {
|
|
76
|
+
const body = '{"x":1}';
|
|
77
|
+
const now = Date.now();
|
|
78
|
+
const oldTs = Math.floor(now / 1000) - (SLACK_REPLAY_WINDOW_SEC + 10);
|
|
79
|
+
const res = verifySlackSignature({
|
|
80
|
+
rawBody: Buffer.from(body),
|
|
81
|
+
headers: sign(body, oldTs),
|
|
82
|
+
signingSecret: SECRET,
|
|
83
|
+
nowMs: now,
|
|
84
|
+
});
|
|
85
|
+
expect(res.ok).toBe(false);
|
|
86
|
+
if (!res.ok) expect(res.reason).toMatch(/replay window/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('accepts a request at the edge of the replay window', () => {
|
|
90
|
+
const body = '{"x":1}';
|
|
91
|
+
const now = Date.now();
|
|
92
|
+
const edgeTs = Math.floor(now / 1000) - (SLACK_REPLAY_WINDOW_SEC - 5);
|
|
93
|
+
const res = verifySlackSignature({
|
|
94
|
+
rawBody: Buffer.from(body),
|
|
95
|
+
headers: sign(body, edgeTs),
|
|
96
|
+
signingSecret: SECRET,
|
|
97
|
+
nowMs: now,
|
|
98
|
+
});
|
|
99
|
+
expect(res.ok).toBe(true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('rejects when headers are missing', () => {
|
|
103
|
+
const res = verifySlackSignature({
|
|
104
|
+
rawBody: Buffer.from('{}'),
|
|
105
|
+
headers: {},
|
|
106
|
+
signingSecret: SECRET,
|
|
107
|
+
});
|
|
108
|
+
expect(res.ok).toBe(false);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('rejects a non-numeric timestamp', () => {
|
|
112
|
+
const body = '{}';
|
|
113
|
+
const headers = sign(body, Math.floor(Date.now() / 1000));
|
|
114
|
+
headers['x-slack-request-timestamp'] = 'not-a-number';
|
|
115
|
+
const res = verifySlackSignature({
|
|
116
|
+
rawBody: Buffer.from(body),
|
|
117
|
+
headers,
|
|
118
|
+
signingSecret: SECRET,
|
|
119
|
+
});
|
|
120
|
+
expect(res.ok).toBe(false);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('rejects when no signing secret is configured', () => {
|
|
124
|
+
const body = '{}';
|
|
125
|
+
const res = verifySlackSignature({
|
|
126
|
+
rawBody: Buffer.from(body),
|
|
127
|
+
headers: sign(body, Math.floor(Date.now() / 1000)),
|
|
128
|
+
signingSecret: '',
|
|
129
|
+
});
|
|
130
|
+
expect(res.ok).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Slack request-signature verification.
|
|
5
|
+
*
|
|
6
|
+
* Slack signs each request with HMAC-SHA256 over the string
|
|
7
|
+
* `v0:{X-Slack-Request-Timestamp}:{rawBody}`, hex-encoded and prefixed `v0=`,
|
|
8
|
+
* delivered in the `X-Slack-Signature` header. This is structurally identical
|
|
9
|
+
* to the Stripe HMAC scheme in `@moxxy/plugin-webhooks/src/verify.ts` — a
|
|
10
|
+
* timestamped HMAC over the raw bytes with a replay window — so the logic here
|
|
11
|
+
* mirrors it: verify over the EXACT raw body bytes (never the reserialized
|
|
12
|
+
* JSON), constant-time compare, and reject deliveries outside a ±5-minute
|
|
13
|
+
* window to bound replay.
|
|
14
|
+
*
|
|
15
|
+
* Always returns a structured verdict; the caller decides whether to log the
|
|
16
|
+
* reason (useful in dev) or hide it (preferable on a public endpoint).
|
|
17
|
+
*
|
|
18
|
+
* See: https://api.slack.com/authentication/verifying-requests-from-slack
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Slack's documented replay window: reject requests older than 5 minutes. */
|
|
22
|
+
export const SLACK_REPLAY_WINDOW_SEC = 60 * 5;
|
|
23
|
+
|
|
24
|
+
export type SlackVerifyResult =
|
|
25
|
+
| { readonly ok: true }
|
|
26
|
+
| { readonly ok: false; readonly reason: string };
|
|
27
|
+
|
|
28
|
+
function lower(
|
|
29
|
+
headers: Record<string, string | string[] | undefined>,
|
|
30
|
+
name: string,
|
|
31
|
+
): string | null {
|
|
32
|
+
const v = headers[name.toLowerCase()];
|
|
33
|
+
if (Array.isArray(v)) return v[0] ?? null;
|
|
34
|
+
return v ?? null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Constant-time compare of two `v0=…` hex signatures. */
|
|
38
|
+
function safeEqual(a: string, b: string): boolean {
|
|
39
|
+
const ab = Buffer.from(a);
|
|
40
|
+
const bb = Buffer.from(b);
|
|
41
|
+
if (ab.length !== bb.length) return false;
|
|
42
|
+
try {
|
|
43
|
+
return timingSafeEqual(ab, bb);
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface VerifySlackSignatureInput {
|
|
50
|
+
readonly rawBody: Buffer;
|
|
51
|
+
readonly headers: Record<string, string | string[] | undefined>;
|
|
52
|
+
readonly signingSecret: string;
|
|
53
|
+
/** Epoch-ms used to enforce the replay window. Defaults to `Date.now()`. */
|
|
54
|
+
readonly nowMs?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Verify a Slack request signature against the raw body. Pass the EXACT bytes
|
|
59
|
+
* read off the socket, BEFORE `JSON.parse` — reserializing the body changes
|
|
60
|
+
* whitespace/key-order and breaks the HMAC.
|
|
61
|
+
*/
|
|
62
|
+
export function verifySlackSignature(input: VerifySlackSignatureInput): SlackVerifyResult {
|
|
63
|
+
const { rawBody, headers, signingSecret } = input;
|
|
64
|
+
if (!signingSecret) return { ok: false, reason: 'no signing secret configured' };
|
|
65
|
+
|
|
66
|
+
const timestamp = lower(headers, 'x-slack-request-timestamp');
|
|
67
|
+
const signature = lower(headers, 'x-slack-signature');
|
|
68
|
+
if (!timestamp) return { ok: false, reason: 'missing X-Slack-Request-Timestamp' };
|
|
69
|
+
if (!signature) return { ok: false, reason: 'missing X-Slack-Signature' };
|
|
70
|
+
|
|
71
|
+
const tsNum = Number(timestamp);
|
|
72
|
+
if (!Number.isFinite(tsNum)) return { ok: false, reason: 'non-numeric timestamp' };
|
|
73
|
+
|
|
74
|
+
const now = input.nowMs ?? Date.now();
|
|
75
|
+
const driftSec = Math.abs(now / 1000 - tsNum);
|
|
76
|
+
if (driftSec > SLACK_REPLAY_WINDOW_SEC) {
|
|
77
|
+
return { ok: false, reason: `timestamp drift ${Math.round(driftSec)}s exceeds replay window` };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// HMAC over `v0:{ts}:{rawBody}` — note the raw bytes, not a reserialized JSON.
|
|
81
|
+
const base = Buffer.concat([
|
|
82
|
+
Buffer.from(`v0:${timestamp}:`, 'utf8'),
|
|
83
|
+
rawBody,
|
|
84
|
+
]);
|
|
85
|
+
const computed = `v0=${createHmac('sha256', signingSecret).update(base).digest('hex')}`;
|
|
86
|
+
if (safeEqual(computed, signature)) return { ok: true };
|
|
87
|
+
return { ok: false, reason: 'signature mismatch' };
|
|
88
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cancel,
|
|
3
|
+
intro,
|
|
4
|
+
isCancel,
|
|
5
|
+
log,
|
|
6
|
+
note,
|
|
7
|
+
outro,
|
|
8
|
+
password,
|
|
9
|
+
spinner,
|
|
10
|
+
text,
|
|
11
|
+
} from '@clack/prompts';
|
|
12
|
+
import type { ChannelSubcommandContext } from '@moxxy/sdk';
|
|
13
|
+
import type { VaultStore } from '@moxxy/plugin-vault';
|
|
14
|
+
import {
|
|
15
|
+
SLACK_BOT_TOKEN_KEY,
|
|
16
|
+
SLACK_BOT_TOKEN_RE,
|
|
17
|
+
SLACK_SIGNING_SECRET_KEY,
|
|
18
|
+
slackSigningSecretSchema,
|
|
19
|
+
} from './keys.js';
|
|
20
|
+
import { SlackClient } from './channel/slack-client.js';
|
|
21
|
+
|
|
22
|
+
const ANSI = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
23
|
+
const bold = (s: string): string => (ANSI ? `\x1b[1m${s}\x1b[22m` : s);
|
|
24
|
+
const dim = (s: string): string => (ANSI ? `\x1b[2m${s}\x1b[22m` : s);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Interactive Slack setup wizard (the channel's `interactiveCommand`).
|
|
28
|
+
*
|
|
29
|
+
* Walks the operator through:
|
|
30
|
+
* 1. paste the bot token (`xoxb-…`) into the vault,
|
|
31
|
+
* 2. paste the signing secret into the vault,
|
|
32
|
+
* 3. validate the token via `auth.test`,
|
|
33
|
+
* 4. choose the working folder + the autonomous tool allow-list,
|
|
34
|
+
* 5. start the channel (opens the proxy tunnel) and PRINT the public Request
|
|
35
|
+
* URL to paste into the Slack app's Event Subscriptions.
|
|
36
|
+
*
|
|
37
|
+
* Headless invocations bypass this and start the channel directly (the dispatch
|
|
38
|
+
* caller decides; this is only reached on a TTY).
|
|
39
|
+
*/
|
|
40
|
+
export async function runSlackWizard(ctx: ChannelSubcommandContext): Promise<number> {
|
|
41
|
+
const vault = ctx.deps.vault as VaultStore;
|
|
42
|
+
intro(bold('moxxy slack setup'));
|
|
43
|
+
|
|
44
|
+
note(
|
|
45
|
+
'Create a Slack app at https://api.slack.com/apps → "From scratch".\n' +
|
|
46
|
+
'• OAuth & Permissions → add the bot scopes app_mentions:read, chat:write,\n' +
|
|
47
|
+
' channels:history → Install to Workspace → copy the Bot User OAuth Token (xoxb-…).\n' +
|
|
48
|
+
'• Basic Information → App Credentials → copy the Signing Secret.\n' +
|
|
49
|
+
'Both go straight into the moxxy vault — no env vars needed.',
|
|
50
|
+
'create a Slack app',
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const token = await password({
|
|
54
|
+
message: 'Paste the Bot User OAuth Token',
|
|
55
|
+
mask: '•',
|
|
56
|
+
validate: (v) => {
|
|
57
|
+
if (!v || !v.trim()) return 'required';
|
|
58
|
+
if (!SLACK_BOT_TOKEN_RE.test(v.trim())) return 'expected a token like "xoxb-…"';
|
|
59
|
+
return undefined;
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
if (isCancel(token)) {
|
|
63
|
+
cancel('cancelled.');
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const secret = await password({
|
|
68
|
+
message: 'Paste the Signing Secret',
|
|
69
|
+
mask: '•',
|
|
70
|
+
validate: (v) => {
|
|
71
|
+
const parsed = slackSigningSecretSchema.safeParse(v);
|
|
72
|
+
return parsed.success ? undefined : parsed.error.issues[0]?.message ?? 'invalid';
|
|
73
|
+
},
|
|
74
|
+
});
|
|
75
|
+
if (isCancel(secret)) {
|
|
76
|
+
cancel('cancelled.');
|
|
77
|
+
return 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Validate the token before persisting anything the operator can act on.
|
|
81
|
+
const spin = spinner();
|
|
82
|
+
spin.start('Validating the bot token (auth.test)…');
|
|
83
|
+
try {
|
|
84
|
+
const client = new SlackClient({ token: String(token).trim() });
|
|
85
|
+
const auth = await client.authTest();
|
|
86
|
+
spin.stop(`Token OK — bot user ${auth.botUserId}${auth.team ? ` on ${auth.team}` : ''}.`);
|
|
87
|
+
} catch (err) {
|
|
88
|
+
spin.stop('Token validation failed.');
|
|
89
|
+
log.error(err instanceof Error ? err.message : String(err));
|
|
90
|
+
return 1;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
await vault.set(SLACK_BOT_TOKEN_KEY, String(token).trim(), ['slack']);
|
|
94
|
+
await vault.set(SLACK_SIGNING_SECRET_KEY, String(secret).trim(), ['slack']);
|
|
95
|
+
log.success('Stored bot token + signing secret in the vault.');
|
|
96
|
+
|
|
97
|
+
const allow = await text({
|
|
98
|
+
message: 'Autonomous tool allow-list (comma-separated; "*" = all, blank = read-only)',
|
|
99
|
+
placeholder: 'Read, Grep, Glob',
|
|
100
|
+
});
|
|
101
|
+
if (isCancel(allow)) {
|
|
102
|
+
cancel('cancelled.');
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
const allowedTools = String(allow)
|
|
106
|
+
.split(',')
|
|
107
|
+
.map((s) => s.trim())
|
|
108
|
+
.filter(Boolean);
|
|
109
|
+
|
|
110
|
+
note(
|
|
111
|
+
'Starting the channel — it opens a proxy tunnel and prints a public Request URL.\n' +
|
|
112
|
+
'Paste that URL into your Slack app → Event Subscriptions → Request URL, then\n' +
|
|
113
|
+
'subscribe to the bot event app_mention. Mention the bot in a channel to pair.',
|
|
114
|
+
'next steps',
|
|
115
|
+
);
|
|
116
|
+
log.info('Starting the bot. Press Ctrl+C to stop.');
|
|
117
|
+
outro(dim('handing off to the channel…'));
|
|
118
|
+
|
|
119
|
+
// Start in pairing mode so the first @mention establishes trust automatically.
|
|
120
|
+
return ctx.startChannel({ pair: true, allowedTools });
|
|
121
|
+
}
|