@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
package/src/keys.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vault keys + env overrides + token validators shared by the channel, its
|
|
3
|
+
* subcommands, and the interactive setup wizard. Kept in their own module so the
|
|
4
|
+
* wizard / pair-flow helpers can import them without pulling in the plugin's
|
|
5
|
+
* full index.
|
|
6
|
+
*/
|
|
7
|
+
import { z } from '@moxxy/sdk';
|
|
8
|
+
import { resolveSecret } from '@moxxy/channel-kit';
|
|
9
|
+
|
|
10
|
+
/** Vault key for the Slack bot OAuth token (`xoxb-…`). */
|
|
11
|
+
export const SLACK_BOT_TOKEN_KEY = 'slack_bot_token';
|
|
12
|
+
/** Vault key for the Slack app signing secret (HMAC over the raw request body). */
|
|
13
|
+
export const SLACK_SIGNING_SECRET_KEY = 'slack_signing_secret';
|
|
14
|
+
/**
|
|
15
|
+
* Vault key for the authorized team/channel set (TOFU pairing). Stored as a
|
|
16
|
+
* JSON string of an authorization record so we can grow what we pin without a
|
|
17
|
+
* format change (today: `{ teamId, channelId? }`).
|
|
18
|
+
*/
|
|
19
|
+
export const SLACK_AUTHORIZED_KEY = 'slack_authorized';
|
|
20
|
+
|
|
21
|
+
/** Env override for the bot token (beats the vault, matching every other channel). */
|
|
22
|
+
export const SLACK_BOT_TOKEN_ENV = 'MOXXY_SLACK_BOT_TOKEN';
|
|
23
|
+
/** Env override for the signing secret. */
|
|
24
|
+
export const SLACK_SIGNING_SECRET_ENV = 'MOXXY_SLACK_SIGNING_SECRET';
|
|
25
|
+
|
|
26
|
+
/** A Slack bot token always starts with `xoxb-`. */
|
|
27
|
+
export const SLACK_BOT_TOKEN_RE = /^xoxb-[A-Za-z0-9-]{10,}$/;
|
|
28
|
+
|
|
29
|
+
/** zod validator for a bot token (shape only — connectivity is tested via `auth.test`). */
|
|
30
|
+
export const slackBotTokenSchema = z
|
|
31
|
+
.string()
|
|
32
|
+
.trim()
|
|
33
|
+
.regex(SLACK_BOT_TOKEN_RE, 'expected a Slack bot token like "xoxb-…"');
|
|
34
|
+
|
|
35
|
+
/** zod validator for a signing secret (hex-ish; Slack uses a 32-byte hex secret). */
|
|
36
|
+
export const slackSigningSecretSchema = z
|
|
37
|
+
.string()
|
|
38
|
+
.trim()
|
|
39
|
+
.min(16, 'signing secret looks too short')
|
|
40
|
+
.max(256, 'signing secret looks too long');
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve the bot token: env override first, then the vault (the shared
|
|
44
|
+
* env→vault resolution in @moxxy/channel-kit). Returns null when neither is
|
|
45
|
+
* set. Trimmed; never returns an empty string.
|
|
46
|
+
*/
|
|
47
|
+
export async function resolveBotToken(vault: {
|
|
48
|
+
get(name: string): Promise<string | null>;
|
|
49
|
+
}): Promise<string | null> {
|
|
50
|
+
return resolveSecret(vault, { envVar: SLACK_BOT_TOKEN_ENV, vaultKey: SLACK_BOT_TOKEN_KEY });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Resolve the signing secret: env override first, then the vault. */
|
|
54
|
+
export async function resolveSigningSecret(vault: {
|
|
55
|
+
get(name: string): Promise<string | null>;
|
|
56
|
+
}): Promise<string | null> {
|
|
57
|
+
return resolveSecret(vault, {
|
|
58
|
+
envVar: SLACK_SIGNING_SECRET_ENV,
|
|
59
|
+
vaultKey: SLACK_SIGNING_SECRET_KEY,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** What we persist under {@link SLACK_AUTHORIZED_KEY}. */
|
|
64
|
+
export interface SlackAuthorization {
|
|
65
|
+
readonly teamId: string;
|
|
66
|
+
/** Optionally narrow authorization to a single channel. */
|
|
67
|
+
readonly channelId?: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Parse the stored authorization record. Returns null for missing/corrupt. */
|
|
71
|
+
export function parseAuthorization(raw: string | null | undefined): SlackAuthorization | null {
|
|
72
|
+
if (!raw) return null;
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(raw) as Partial<SlackAuthorization>;
|
|
75
|
+
if (typeof parsed?.teamId === 'string' && parsed.teamId) {
|
|
76
|
+
return parsed.channelId
|
|
77
|
+
? { teamId: parsed.teamId, channelId: parsed.channelId }
|
|
78
|
+
: { teamId: parsed.teamId };
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
/* corrupt — treat as unpaired */
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Does an inbound event from `(teamId, channelId)` match the stored authorization? */
|
|
87
|
+
export function authorizationMatches(
|
|
88
|
+
auth: SlackAuthorization | null,
|
|
89
|
+
teamId: string | undefined,
|
|
90
|
+
channelId: string | undefined,
|
|
91
|
+
): boolean {
|
|
92
|
+
if (!auth || !teamId) return false;
|
|
93
|
+
if (auth.teamId !== teamId) return false;
|
|
94
|
+
if (auth.channelId && auth.channelId !== channelId) return false;
|
|
95
|
+
return true;
|
|
96
|
+
}
|
package/src/pair-flow.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { confirm, isCancel, log, outro, spinner } from '@clack/prompts';
|
|
2
|
+
import { exitAfterPairRequested, type ChannelSubcommandContext } from '@moxxy/sdk';
|
|
3
|
+
import type { VaultStore } from '@moxxy/plugin-vault';
|
|
4
|
+
import { SlackChannel, type PairCandidate } from './channel.js';
|
|
5
|
+
|
|
6
|
+
const ANSI = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
7
|
+
const dim = (s: string): string => (ANSI ? `\x1b[2m${s}\x1b[22m` : s);
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Drive the TOFU pairing flow end-to-end.
|
|
11
|
+
*
|
|
12
|
+
* 1. Build a SlackChannel from the subcommand ctx + wire the session's
|
|
13
|
+
* permission resolver.
|
|
14
|
+
* 2. Subscribe to pair candidates BEFORE starting so the first @mention can't
|
|
15
|
+
* race past us.
|
|
16
|
+
* 3. Start in `pair` mode (opens the tunnel; prints the Request URL).
|
|
17
|
+
* 4. Wait (with spinner) for the first verified inbound event.
|
|
18
|
+
* 5. Ask the operator to confirm the team/channel; on yes, persist it.
|
|
19
|
+
* 6. Hand off the running bot until Ctrl+C.
|
|
20
|
+
*
|
|
21
|
+
* Uses the default `proxyTunnel` provider (the channel imports it directly), so
|
|
22
|
+
* the public Request URL is available once the channel starts.
|
|
23
|
+
*/
|
|
24
|
+
export async function runSlackPairFlow(ctx: ChannelSubcommandContext): Promise<number> {
|
|
25
|
+
const session = ctx.session;
|
|
26
|
+
const channel = new SlackChannel({
|
|
27
|
+
vault: ctx.deps.vault as VaultStore,
|
|
28
|
+
logger: ctx.deps.logger as never,
|
|
29
|
+
});
|
|
30
|
+
session.setPermissionResolver(channel.permissionResolver);
|
|
31
|
+
|
|
32
|
+
let candidateResolve: ((c: PairCandidate) => void) | null = null;
|
|
33
|
+
const firstCandidate = new Promise<PairCandidate>((resolve) => {
|
|
34
|
+
candidateResolve = resolve;
|
|
35
|
+
});
|
|
36
|
+
const unsubscribe = channel.onPairCandidate((c) => {
|
|
37
|
+
candidateResolve?.(c);
|
|
38
|
+
candidateResolve = null;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
outro(dim('opening pairing window…'));
|
|
42
|
+
|
|
43
|
+
let handle;
|
|
44
|
+
try {
|
|
45
|
+
handle = await channel.start({ session, pair: true });
|
|
46
|
+
} catch (err) {
|
|
47
|
+
unsubscribe();
|
|
48
|
+
log.error(`Could not start the Slack channel: ${err instanceof Error ? err.message : String(err)}`);
|
|
49
|
+
return 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const stopBot = async (): Promise<void> => {
|
|
53
|
+
unsubscribe();
|
|
54
|
+
try {
|
|
55
|
+
await handle.stop('wizard');
|
|
56
|
+
} catch {
|
|
57
|
+
/* ignore */
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (channel.requestUrl) {
|
|
62
|
+
log.info(
|
|
63
|
+
`Slack Request URL (paste into Event Subscriptions):\n ${channel.requestUrl}\n` +
|
|
64
|
+
'Then mention the bot (@your-bot) in a channel to pair.',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const spin = spinner();
|
|
69
|
+
spin.start('Waiting for the first @mention from Slack…');
|
|
70
|
+
|
|
71
|
+
let candidate: PairCandidate;
|
|
72
|
+
try {
|
|
73
|
+
candidate = await firstCandidate;
|
|
74
|
+
} catch (err) {
|
|
75
|
+
spin.stop('pairing aborted');
|
|
76
|
+
log.error(`Pairing aborted: ${err instanceof Error ? err.message : String(err)}`);
|
|
77
|
+
await stopBot();
|
|
78
|
+
return 1;
|
|
79
|
+
}
|
|
80
|
+
spin.stop(`Got an event from team ${candidate.teamId}, channel ${candidate.channelId}.`);
|
|
81
|
+
|
|
82
|
+
const ok = await confirm({
|
|
83
|
+
message: `Authorize team ${candidate.teamId} / channel ${candidate.channelId}?`,
|
|
84
|
+
});
|
|
85
|
+
if (isCancel(ok) || !ok) {
|
|
86
|
+
log.warn('Pairing not confirmed.');
|
|
87
|
+
await stopBot();
|
|
88
|
+
return 0;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
await channel.confirmPairing(candidate);
|
|
92
|
+
log.success(`Paired ✓ — team ${candidate.teamId} is authorized.`);
|
|
93
|
+
|
|
94
|
+
if (exitAfterPairRequested(ctx)) {
|
|
95
|
+
// Orchestrated pairing (`moxxy onboard`): hand control back — the caller
|
|
96
|
+
// starts the bot under its own service afterwards.
|
|
97
|
+
await stopBot();
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
log.info('Bot is running. Press Ctrl+C to stop.');
|
|
102
|
+
|
|
103
|
+
const shutdown = async (): Promise<void> => {
|
|
104
|
+
await stopBot();
|
|
105
|
+
await session.close('SIGINT').catch(() => undefined);
|
|
106
|
+
process.exit(0);
|
|
107
|
+
};
|
|
108
|
+
const onSignal = (): void => void shutdown();
|
|
109
|
+
process.once('SIGINT', onSignal);
|
|
110
|
+
process.once('SIGTERM', onSignal);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await handle.running;
|
|
114
|
+
return 0;
|
|
115
|
+
} finally {
|
|
116
|
+
process.removeListener('SIGINT', onSignal);
|
|
117
|
+
process.removeListener('SIGTERM', onSignal);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import type { PendingToolCall, PermissionContext } from '@moxxy/sdk';
|
|
3
|
+
import { buildSlackPermissionResolver } from './permission.js';
|
|
4
|
+
|
|
5
|
+
const ctx: PermissionContext = { sessionId: 's1' };
|
|
6
|
+
function call(name: string): PendingToolCall {
|
|
7
|
+
return { callId: `c_${name}`, name, input: {} };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
describe('buildSlackPermissionResolver', () => {
|
|
11
|
+
it('auto-approves a listed tool', async () => {
|
|
12
|
+
const r = buildSlackPermissionResolver({
|
|
13
|
+
allowedTools: ['Read', 'Grep'],
|
|
14
|
+
allToolNames: ['Read', 'Grep', 'Bash', 'Write'],
|
|
15
|
+
});
|
|
16
|
+
const d = await r.check(call('Read'), ctx);
|
|
17
|
+
expect(d.mode).not.toBe('deny');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('denies an unlisted tool', async () => {
|
|
21
|
+
const r = buildSlackPermissionResolver({
|
|
22
|
+
allowedTools: ['Read'],
|
|
23
|
+
allToolNames: ['Read', 'Bash'],
|
|
24
|
+
});
|
|
25
|
+
const d = await r.check(call('Bash'), ctx);
|
|
26
|
+
expect(d.mode).toBe('deny');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('denies everything when the allow-list is empty (read-only)', async () => {
|
|
30
|
+
const r = buildSlackPermissionResolver({
|
|
31
|
+
allowedTools: [],
|
|
32
|
+
allToolNames: ['Read', 'Bash'],
|
|
33
|
+
});
|
|
34
|
+
expect((await r.check(call('Read'), ctx)).mode).toBe('deny');
|
|
35
|
+
expect((await r.check(call('Bash'), ctx)).mode).toBe('deny');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('expands "*" to every registered tool name', async () => {
|
|
39
|
+
const r = buildSlackPermissionResolver({
|
|
40
|
+
allowedTools: ['*'],
|
|
41
|
+
allToolNames: ['Read', 'Bash', 'Write'],
|
|
42
|
+
});
|
|
43
|
+
expect((await r.check(call('Read'), ctx)).mode).not.toBe('deny');
|
|
44
|
+
expect((await r.check(call('Bash'), ctx)).mode).not.toBe('deny');
|
|
45
|
+
expect((await r.check(call('Write'), ctx)).mode).not.toBe('deny');
|
|
46
|
+
// A name not in the registry is still denied even under '*'.
|
|
47
|
+
expect((await r.check(call('NotRegistered'), ctx)).mode).toBe('deny');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('logs each auto-approved call', async () => {
|
|
51
|
+
const info = vi.fn();
|
|
52
|
+
const r = buildSlackPermissionResolver({
|
|
53
|
+
allowedTools: ['Read'],
|
|
54
|
+
allToolNames: ['Read'],
|
|
55
|
+
logger: { info },
|
|
56
|
+
});
|
|
57
|
+
await r.check(call('Read'), ctx);
|
|
58
|
+
expect(info).toHaveBeenCalledTimes(1);
|
|
59
|
+
expect(info.mock.calls[0]?.[0]).toMatch(/auto-approved/);
|
|
60
|
+
// A denial is NOT logged as an approval.
|
|
61
|
+
info.mockClear();
|
|
62
|
+
await r.check(call('Bash'), ctx);
|
|
63
|
+
expect(info).not.toHaveBeenCalled();
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createAuditedAllowListResolver } from '@moxxy/channel-kit';
|
|
2
|
+
import type { PermissionResolver } from '@moxxy/sdk';
|
|
3
|
+
|
|
4
|
+
export interface SlackPermissionLogger {
|
|
5
|
+
info?(msg: string, meta?: Record<string, unknown>): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Build the Slack channel's autonomous permission resolver.
|
|
10
|
+
*
|
|
11
|
+
* The bot runs hands-off (no human-in-the-loop, like the HTTP channel): the
|
|
12
|
+
* operator declares trust upfront via `channels.slack.allowedTools`, and any
|
|
13
|
+
* tool NOT in that list is denied. The trust check + `'*'` expansion +
|
|
14
|
+
* audit-on-auto-approve wiring is the shared
|
|
15
|
+
* {@link createAuditedAllowListResolver} from `@moxxy/channel-kit` (which in
|
|
16
|
+
* turn reuses the SDK's `createAllowListResolver`: exact-name match →
|
|
17
|
+
* `allow_session`, else `deny`); this wrapper binds it to the Slack channel
|
|
18
|
+
* logger so every auto-approved call leaves a trail of what the autonomous run
|
|
19
|
+
* executed. An empty list denies everything (effectively read-only, since no
|
|
20
|
+
* side-effecting tool can run without a clicker).
|
|
21
|
+
*
|
|
22
|
+
* (Autonomous allow-list safety is a known trade-off; see TECH_DEBT — v1 has no
|
|
23
|
+
* Slack-button approval flow.)
|
|
24
|
+
*/
|
|
25
|
+
export function buildSlackPermissionResolver(opts: {
|
|
26
|
+
allowedTools: ReadonlyArray<string>;
|
|
27
|
+
allToolNames: ReadonlyArray<string>;
|
|
28
|
+
logger?: SlackPermissionLogger;
|
|
29
|
+
}): PermissionResolver {
|
|
30
|
+
return createAuditedAllowListResolver({
|
|
31
|
+
name: 'slack-allow-list',
|
|
32
|
+
allowedTools: opts.allowedTools,
|
|
33
|
+
allToolNames: opts.allToolNames,
|
|
34
|
+
onAutoApprove: (call, { wildcard }) => {
|
|
35
|
+
opts.logger?.info?.('slack: auto-approved tool call', {
|
|
36
|
+
tool: call.name,
|
|
37
|
+
callId: call.callId,
|
|
38
|
+
wildcard,
|
|
39
|
+
});
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack event idempotency cache. The implementation lives in
|
|
3
|
+
* `@moxxy/channel-kit` (the at-least-once delivery pattern is shared with
|
|
4
|
+
* every inbound-webhook channel and `@moxxy/plugin-webhooks`); re-exported
|
|
5
|
+
* here because it is part of this plugin's public API.
|
|
6
|
+
*/
|
|
7
|
+
export { DeliveryDedupeCache } from '@moxxy/channel-kit';
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
import { createHmac } from 'node:crypto';
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
3
|
+
import { IngestServer, SLACK_EVENTS_PATH, type DispatchContext } from './ingest-server.js';
|
|
4
|
+
import type { IngestServerHooks } from './ingest-server.js';
|
|
5
|
+
import type { SlackEventCallback } from './schema.js';
|
|
6
|
+
|
|
7
|
+
const SECRET = 'test-signing-secret-0123456789abcdef';
|
|
8
|
+
const BOT_USER = 'UBOT';
|
|
9
|
+
|
|
10
|
+
function sign(body: string, ts = Math.floor(Date.now() / 1000)): Record<string, string> {
|
|
11
|
+
const sig = `v0=${createHmac('sha256', SECRET).update(`v0:${ts}:${body}`).digest('hex')}`;
|
|
12
|
+
return { 'x-slack-request-timestamp': String(ts), 'x-slack-signature': sig };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface Harness {
|
|
16
|
+
server: IngestServer;
|
|
17
|
+
url: string;
|
|
18
|
+
dispatched: DispatchContext[];
|
|
19
|
+
verified: SlackEventCallback[];
|
|
20
|
+
authorized: { teamId?: string; channel?: string } | null;
|
|
21
|
+
pairConsumes: boolean;
|
|
22
|
+
stop(): Promise<void>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function makeServer(over: Partial<IngestServerHooks> = {}): Promise<Harness> {
|
|
26
|
+
const dispatched: DispatchContext[] = [];
|
|
27
|
+
const verified: SlackEventCallback[] = [];
|
|
28
|
+
const state: { authorized: { teamId?: string; channel?: string } | null; pairConsumes: boolean } =
|
|
29
|
+
{ authorized: { teamId: 'T1', channel: 'C1' }, pairConsumes: false };
|
|
30
|
+
|
|
31
|
+
const hooks: IngestServerHooks = {
|
|
32
|
+
botUserId: BOT_USER,
|
|
33
|
+
isAuthorized: (teamId, channel) =>
|
|
34
|
+
state.authorized != null &&
|
|
35
|
+
state.authorized.teamId === teamId &&
|
|
36
|
+
(state.authorized.channel === undefined || state.authorized.channel === channel),
|
|
37
|
+
onVerifiedEvent: (ev) => {
|
|
38
|
+
verified.push(ev);
|
|
39
|
+
return state.pairConsumes;
|
|
40
|
+
},
|
|
41
|
+
dispatch: (ctx) => dispatched.push(ctx),
|
|
42
|
+
...over,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const server = new IngestServer({ signingSecret: SECRET, hooks });
|
|
46
|
+
const bound = await server.start();
|
|
47
|
+
const url = `http://${bound.host}:${bound.port}${SLACK_EVENTS_PATH}`;
|
|
48
|
+
return {
|
|
49
|
+
server,
|
|
50
|
+
url,
|
|
51
|
+
dispatched,
|
|
52
|
+
verified,
|
|
53
|
+
get authorized() {
|
|
54
|
+
return state.authorized;
|
|
55
|
+
},
|
|
56
|
+
set authorized(v) {
|
|
57
|
+
state.authorized = v;
|
|
58
|
+
},
|
|
59
|
+
get pairConsumes() {
|
|
60
|
+
return state.pairConsumes;
|
|
61
|
+
},
|
|
62
|
+
set pairConsumes(v) {
|
|
63
|
+
state.pairConsumes = v;
|
|
64
|
+
},
|
|
65
|
+
stop: () => server.stop(),
|
|
66
|
+
} as Harness;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function appMention(over: Partial<Record<string, unknown>> = {}): string {
|
|
70
|
+
return JSON.stringify({
|
|
71
|
+
type: 'event_callback',
|
|
72
|
+
team_id: 'T1',
|
|
73
|
+
event_id: 'Ev1',
|
|
74
|
+
event: {
|
|
75
|
+
type: 'app_mention',
|
|
76
|
+
user: 'U1',
|
|
77
|
+
text: '<@UBOT> hi',
|
|
78
|
+
channel: 'C1',
|
|
79
|
+
ts: '1700000000.000100',
|
|
80
|
+
...over,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function post(
|
|
86
|
+
url: string,
|
|
87
|
+
body: string,
|
|
88
|
+
headers: Record<string, string>,
|
|
89
|
+
): Promise<{ status: number; json: unknown }> {
|
|
90
|
+
const res = await fetch(url, {
|
|
91
|
+
method: 'POST',
|
|
92
|
+
headers: { 'content-type': 'application/json', ...headers },
|
|
93
|
+
body,
|
|
94
|
+
});
|
|
95
|
+
let json: unknown = null;
|
|
96
|
+
try {
|
|
97
|
+
json = await res.json();
|
|
98
|
+
} catch {
|
|
99
|
+
/* no body */
|
|
100
|
+
}
|
|
101
|
+
return { status: res.status, json };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe('IngestServer', () => {
|
|
105
|
+
let h: Harness;
|
|
106
|
+
afterEach(async () => {
|
|
107
|
+
await h?.stop();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('rejects an unsigned request with 401 (before the session)', async () => {
|
|
111
|
+
h = await makeServer();
|
|
112
|
+
const body = appMention();
|
|
113
|
+
const res = await post(h.url, body, {}); // no signature headers
|
|
114
|
+
expect(res.status).toBe(401);
|
|
115
|
+
expect(h.dispatched).toHaveLength(0);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('rejects a tampered body with 401', async () => {
|
|
119
|
+
h = await makeServer();
|
|
120
|
+
const body = appMention();
|
|
121
|
+
const headers = sign(body);
|
|
122
|
+
const res = await post(h.url, appMention({ text: 'tampered' }), headers);
|
|
123
|
+
expect(res.status).toBe(401);
|
|
124
|
+
expect(h.dispatched).toHaveLength(0);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('answers the url_verification challenge', async () => {
|
|
128
|
+
h = await makeServer();
|
|
129
|
+
const body = JSON.stringify({ type: 'url_verification', challenge: 'abc123' });
|
|
130
|
+
const res = await post(h.url, body, sign(body));
|
|
131
|
+
expect(res.status).toBe(200);
|
|
132
|
+
expect(res.json).toEqual({ challenge: 'abc123' });
|
|
133
|
+
expect(h.dispatched).toHaveLength(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('rejects a signed-but-malformed envelope with 400', async () => {
|
|
137
|
+
h = await makeServer();
|
|
138
|
+
const body = JSON.stringify({ type: 'event_callback' /* no event */ });
|
|
139
|
+
const res = await post(h.url, body, sign(body));
|
|
140
|
+
expect(res.status).toBe(400);
|
|
141
|
+
expect(h.dispatched).toHaveLength(0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('acks 200 and dispatches an authorized app_mention', async () => {
|
|
145
|
+
h = await makeServer();
|
|
146
|
+
const body = appMention();
|
|
147
|
+
const res = await post(h.url, body, sign(body));
|
|
148
|
+
expect(res.status).toBe(200);
|
|
149
|
+
expect(h.dispatched).toHaveLength(1);
|
|
150
|
+
expect(h.dispatched[0]).toMatchObject({ channel: 'C1', text: '<@UBOT> hi', teamId: 'T1' });
|
|
151
|
+
// thread_ts falls back to ts when absent.
|
|
152
|
+
expect(h.dispatched[0]?.threadTs).toBe('1700000000.000100');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('threads under thread_ts when present', async () => {
|
|
156
|
+
h = await makeServer();
|
|
157
|
+
const body = appMention({ thread_ts: '1699999999.000001' });
|
|
158
|
+
await post(h.url, body, sign(body));
|
|
159
|
+
expect(h.dispatched[0]?.threadTs).toBe('1699999999.000001');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('drops a duplicate event_id (acks 200, does not re-dispatch)', async () => {
|
|
163
|
+
h = await makeServer();
|
|
164
|
+
const body = appMention();
|
|
165
|
+
const headers = sign(body);
|
|
166
|
+
const first = await post(h.url, body, headers);
|
|
167
|
+
const second = await post(h.url, body, sign(body));
|
|
168
|
+
expect(first.status).toBe(200);
|
|
169
|
+
expect(second.status).toBe(200);
|
|
170
|
+
expect(h.dispatched).toHaveLength(1);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('drops a Slack retry (X-Slack-Retry-Num) without dispatching', async () => {
|
|
174
|
+
h = await makeServer();
|
|
175
|
+
const body = appMention({ ts: '1700000000.999' });
|
|
176
|
+
const headers = { ...sign(body), 'x-slack-retry-num': '1' };
|
|
177
|
+
const res = await post(h.url, body, headers);
|
|
178
|
+
expect(res.status).toBe(200);
|
|
179
|
+
expect(h.dispatched).toHaveLength(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("drops the bot's own message (bot_id / matching user)", async () => {
|
|
183
|
+
h = await makeServer();
|
|
184
|
+
const byBotId = appMention({ bot_id: 'B1', event_id: 'EvB1' });
|
|
185
|
+
await post(h.url, byBotId, sign(byBotId));
|
|
186
|
+
const byUser = appMention({ user: BOT_USER, event_id: 'EvB2' });
|
|
187
|
+
await post(h.url, byUser, sign(byUser));
|
|
188
|
+
expect(h.dispatched).toHaveLength(0);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('drops an event from an unauthorized team/channel', async () => {
|
|
192
|
+
h = await makeServer();
|
|
193
|
+
h.authorized = { teamId: 'T-OTHER', channel: 'C-OTHER' };
|
|
194
|
+
const body = appMention();
|
|
195
|
+
const res = await post(h.url, body, sign(body));
|
|
196
|
+
expect(res.status).toBe(200);
|
|
197
|
+
expect(h.dispatched).toHaveLength(0);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('lets the pairing hook consume the first verified event (no dispatch)', async () => {
|
|
201
|
+
h = await makeServer();
|
|
202
|
+
h.authorized = null; // not yet paired
|
|
203
|
+
h.pairConsumes = true;
|
|
204
|
+
const body = appMention();
|
|
205
|
+
const res = await post(h.url, body, sign(body));
|
|
206
|
+
expect(res.status).toBe(200);
|
|
207
|
+
expect(h.verified).toHaveLength(1);
|
|
208
|
+
expect(h.dispatched).toHaveLength(0);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('ignores message events with an edit/system subtype', async () => {
|
|
212
|
+
h = await makeServer();
|
|
213
|
+
const body = JSON.stringify({
|
|
214
|
+
type: 'event_callback',
|
|
215
|
+
team_id: 'T1',
|
|
216
|
+
event_id: 'EvSub',
|
|
217
|
+
event: {
|
|
218
|
+
type: 'message',
|
|
219
|
+
subtype: 'message_changed',
|
|
220
|
+
channel: 'C1',
|
|
221
|
+
text: 'edited',
|
|
222
|
+
ts: '1700000000.1',
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
const res = await post(h.url, body, sign(body));
|
|
226
|
+
expect(res.status).toBe(200);
|
|
227
|
+
expect(h.dispatched).toHaveLength(0);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it('rejects an oversized body without ever dispatching', async () => {
|
|
231
|
+
const dispatched: DispatchContext[] = [];
|
|
232
|
+
const server = new IngestServer({
|
|
233
|
+
signingSecret: SECRET,
|
|
234
|
+
maxBodyBytes: 64,
|
|
235
|
+
hooks: {
|
|
236
|
+
botUserId: BOT_USER,
|
|
237
|
+
isAuthorized: () => true,
|
|
238
|
+
dispatch: (c) => dispatched.push(c),
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
const bound = await server.start();
|
|
242
|
+
const url = `http://${bound.host}:${bound.port}${SLACK_EVENTS_PATH}`;
|
|
243
|
+
const big = 'x'.repeat(500);
|
|
244
|
+
const body = JSON.stringify({ type: 'event_callback', big });
|
|
245
|
+
// readRequestBody destroys the socket once the cap is exceeded, so the
|
|
246
|
+
// client either sees a 413 or a dropped connection — both are acceptable
|
|
247
|
+
// "rejected, never reached the session" outcomes. What MUST hold is that
|
|
248
|
+
// the oversized body never drives a turn.
|
|
249
|
+
let status = 0;
|
|
250
|
+
try {
|
|
251
|
+
const res = await fetch(url, {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
headers: { 'content-type': 'application/json', ...sign(body) },
|
|
254
|
+
body,
|
|
255
|
+
});
|
|
256
|
+
status = res.status;
|
|
257
|
+
} catch {
|
|
258
|
+
status = -1; // connection dropped by the size-cap socket destroy
|
|
259
|
+
}
|
|
260
|
+
expect([413, -1]).toContain(status);
|
|
261
|
+
expect(dispatched).toHaveLength(0);
|
|
262
|
+
await server.stop();
|
|
263
|
+
// Reassign h so afterEach's stop() is a no-op on an already-stopped server.
|
|
264
|
+
h = { stop: async () => {} } as Harness;
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('a handler error never escalates (returns 404/4xx for junk)', async () => {
|
|
268
|
+
h = await makeServer();
|
|
269
|
+
// GET on the events path is a 404, not a thrown error.
|
|
270
|
+
const res = await fetch(h.url, { method: 'GET' });
|
|
271
|
+
expect(res.status).toBe(404);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it('serves a health probe', async () => {
|
|
275
|
+
h = await makeServer();
|
|
276
|
+
const base = h.url.replace(SLACK_EVENTS_PATH, '/slack/health');
|
|
277
|
+
const res = await fetch(base, { method: 'GET' });
|
|
278
|
+
expect(res.status).toBe(200);
|
|
279
|
+
});
|
|
280
|
+
});
|