@maroonedsoftware/slack 1.8.2 → 2.0.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/README.md +53 -0
- package/dist/chunk-7QVYU63E.js +7 -0
- package/dist/chunk-7QVYU63E.js.map +1 -0
- package/dist/comms.d.ts +34 -0
- package/dist/comms.d.ts.map +1 -0
- package/dist/comms.js +129 -0
- package/dist/comms.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +70 -19
- package/dist/index.js.map +1 -1
- package/dist/slack.signature.d.ts +1 -1
- package/dist/slack.signature.d.ts.map +1 -1
- package/dist/slack.signature.policy.d.ts +71 -0
- package/dist/slack.signature.policy.d.ts.map +1 -0
- package/package.json +30 -7
package/README.md
CHANGED
|
@@ -25,6 +25,7 @@ pnpm add @maroonedsoftware/slack
|
|
|
25
25
|
| `SlackInteractionHandlerMap` | `Map<routingKey, SlackInteractionHandler>` — keys are `${type}:${identifier}`; see [interaction routing](#interaction-routing). |
|
|
26
26
|
| `SlackError` | `ServerkitError` subclass for non-HTTP domain failures (signature mismatch, webhook POST failed, …). |
|
|
27
27
|
| `verifySlackSignature(input)` | Pure helper that validates Slack's v0 HMAC scheme + replay window. No request/context coupling. |
|
|
28
|
+
| `SlackSignaturePolicy` | `@maroonedsoftware/policies` form of `verifySlackSignature` (registered under `SLACK_SIGNATURE_POLICY`). Delegates to the helper but answers as a `PolicyResult`, so it slots into ServerKit's policy pipeline. |
|
|
28
29
|
| `interactionRouteKey(payload)` | Helper that produces the `SlackInteractionHandlerMap` key for a given payload. |
|
|
29
30
|
|
|
30
31
|
## Configuration
|
|
@@ -271,6 +272,58 @@ On any failure the helper throws `SlackError` with `internalDetails.reason` set
|
|
|
271
272
|
|
|
272
273
|
For deterministic tests, pass `now` (Unix seconds) to override the clock.
|
|
273
274
|
|
|
275
|
+
### As a policy
|
|
276
|
+
|
|
277
|
+
`SlackSignaturePolicy` is the same rule wrapped as a `@maroonedsoftware/policies` policy, so signature verification slots into ServerKit's policy pipeline alongside session/MFA policies. It delegates to `verifySlackSignature` (one source of truth) but returns a `PolicyResult` instead of throwing — denying with the same `SlackSignatureFailureReason` as the denial `reason`, and anchoring the replay window to the evaluation's `envelope.now`.
|
|
278
|
+
|
|
279
|
+
Register it in your `PolicyRegistryMap` and evaluate it via `PolicyService`:
|
|
280
|
+
|
|
281
|
+
```ts
|
|
282
|
+
import { SlackSignaturePolicy, SLACK_SIGNATURE_POLICY, SlackConfig } from '@maroonedsoftware/slack';
|
|
283
|
+
|
|
284
|
+
// wiring
|
|
285
|
+
registry.set(SLACK_SIGNATURE_POLICY, SlackSignaturePolicy);
|
|
286
|
+
|
|
287
|
+
// in a route handler (ctx is a ServerKit Koa context)
|
|
288
|
+
const result = await ctx.container.get(PolicyService).check(SLACK_SIGNATURE_POLICY, {
|
|
289
|
+
rawBody: ctx.rawBody,
|
|
290
|
+
getHeader: name => ctx.get(name),
|
|
291
|
+
options: ctx.container.get(SlackConfig),
|
|
292
|
+
});
|
|
293
|
+
if (isPolicyResultDenied(result)) throw httpError(401).withInternalDetails(result.internalDetails ?? {});
|
|
294
|
+
```
|
|
295
|
+
|
|
296
|
+
The context (`rawBody` + a case-insensitive `getHeader` + `options`) is structurally compatible with `@maroonedsoftware/koa`'s `SignaturePolicyContext<SlackSignatureOptions>`, so the koa `requireSignature` middleware can drive this policy when it's registered under the signature policy name — no koa dependency in this package.
|
|
297
|
+
|
|
298
|
+
## Use with `@maroonedsoftware/comms`
|
|
299
|
+
|
|
300
|
+
The `@maroonedsoftware/slack/comms` subpath adapts this package to the channel-agnostic
|
|
301
|
+
[`@maroonedsoftware/comms`](../comms) router, so a `command` / `action` / `message` handler written
|
|
302
|
+
once runs on Slack and every other wired channel. It declares `@maroonedsoftware/comms` as an
|
|
303
|
+
**optional peer** — the slack core doesn't depend on it.
|
|
304
|
+
|
|
305
|
+
```ts
|
|
306
|
+
import { SlackClient, SlackConfig, verifySlackSignature } from '@maroonedsoftware/slack';
|
|
307
|
+
import { dispatchSlackCommand, dispatchSlackInteraction, dispatchSlackEvent, createSlackNotifier } from '@maroonedsoftware/slack/comms';
|
|
308
|
+
import { router } from './router.js'; // a shared ChannelRouter
|
|
309
|
+
|
|
310
|
+
router.post('/slack/commands', async (ctx) => {
|
|
311
|
+
const raw = await rawBody(ctx.req, { encoding: 'utf8' });
|
|
312
|
+
verifySlackSignature({ signingSecret: ctx.container.get(SlackConfig).signingSecret, rawBody: raw,
|
|
313
|
+
timestamp: ctx.get('x-slack-request-timestamp'), signature: ctx.get('x-slack-signature') });
|
|
314
|
+
await dispatchSlackCommand(router, ctx.container.get(SlackClient), Object.fromEntries(new URLSearchParams(raw)) as never);
|
|
315
|
+
ctx.status = 200; ctx.body = '';
|
|
316
|
+
});
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
- `dispatchSlackEvent` (→ `message`/`app_mention`, replies via `chat.postMessage`; also returns the
|
|
320
|
+
`url_verification` challenge), `dispatchSlackCommand` (→ `command`, replies via `response_url`),
|
|
321
|
+
`dispatchSlackInteraction` (→ `action` for `block_actions`). `view_submission`/modals stay on the
|
|
322
|
+
native `SlackInteractionHandlerMap`.
|
|
323
|
+
- `createSlackNotifier(client, router.templates)` sends proactively. Buttons render as a Block Kit
|
|
324
|
+
`actions` block; `reply.sendTemplate(name, data)` renders a registered Slack template;
|
|
325
|
+
`reply.sendNative(payload)` posts raw Block Kit.
|
|
326
|
+
|
|
274
327
|
## Limitations
|
|
275
328
|
|
|
276
329
|
- v1 supports a single workspace via the bot token in `SlackConfig`. Multi-workspace OAuth install is out of scope.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
|
package/dist/comms.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@maroonedsoftware/slack/comms` — adapter binding the Slack package to the
|
|
3
|
+
* channel-agnostic `@maroonedsoftware/comms` router. Importing this subpath
|
|
4
|
+
* pulls in `@maroonedsoftware/comms` (an optional peer); the slack core does not.
|
|
5
|
+
*/
|
|
6
|
+
import { type ChannelRouter, type Notifier, type TemplateRegistry } from '@maroonedsoftware/comms';
|
|
7
|
+
import { SlackClient } from './client/slack.client.js';
|
|
8
|
+
import type { SlackCommandPayload } from './slack.command.handler.js';
|
|
9
|
+
import type { SlackInteractionPayload } from './slack.interaction.handler.js';
|
|
10
|
+
import type { SlackEventsRequest } from './slack.dispatcher.js';
|
|
11
|
+
/**
|
|
12
|
+
* Builds a {@link Notifier} that sends portable messages and registered templates
|
|
13
|
+
* through {@link SlackClient}. The recipient string is either a `response_url`
|
|
14
|
+
* (used as an incoming webhook) or a channel id (`chat.postMessage`).
|
|
15
|
+
*/
|
|
16
|
+
export declare const createSlackNotifier: (client: SlackClient, templates: TemplateRegistry) => Notifier;
|
|
17
|
+
/**
|
|
18
|
+
* Dispatches a parsed Slack Events API body. Returns the `url_verification`
|
|
19
|
+
* challenge for the handshake; for `message` / `app_mention` events it routes a
|
|
20
|
+
* normalized `message` event to the {@link ChannelRouter} (replying via
|
|
21
|
+
* `chat.postMessage` to the event's channel). Returns `undefined` otherwise.
|
|
22
|
+
*/
|
|
23
|
+
export declare const dispatchSlackEvent: (router: ChannelRouter, client: SlackClient, body: SlackEventsRequest) => Promise<{
|
|
24
|
+
challenge: string;
|
|
25
|
+
} | undefined>;
|
|
26
|
+
/** Dispatches a parsed slash-command payload as a normalized `command` event (reply via `response_url`). */
|
|
27
|
+
export declare const dispatchSlackCommand: (router: ChannelRouter, client: SlackClient, payload: SlackCommandPayload) => Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Dispatches a parsed interactive payload. Only `block_actions` is normalized
|
|
30
|
+
* (to an `action` event keyed by the first action's `action_id`); other types
|
|
31
|
+
* (e.g. `view_submission`) stay on the slack package's native handlers.
|
|
32
|
+
*/
|
|
33
|
+
export declare const dispatchSlackInteraction: (router: ChannelRouter, client: SlackClient, payload: SlackInteractionPayload) => Promise<void>;
|
|
34
|
+
//# sourceMappingURL=comms.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"comms.d.ts","sourceRoot":"","sources":["../src/comms.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AACH,OAAO,EAAyB,KAAK,aAAa,EAAsB,KAAK,QAAQ,EAAwB,KAAK,gBAAgB,EAAE,MAAM,yBAAyB,CAAC;AACpK,OAAO,EAAE,WAAW,EAAE,MAAM,0BAA0B,CAAC;AACvD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,4BAA4B,CAAC;AACtE,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,gCAAgC,CAAC;AAE9E,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,uBAAuB,CAAC;AAyBhE;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,GAAI,QAAQ,WAAW,EAAE,WAAW,gBAAgB,KAAG,QASrF,CAAC;AAEH;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,GAAU,QAAQ,aAAa,EAAE,QAAQ,WAAW,EAAE,MAAM,kBAAkB,KAAG,OAAO,CAAC;IAAE,SAAS,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,CAYxJ,CAAC;AAEF,4GAA4G;AAC5G,eAAO,MAAM,oBAAoB,GAAU,QAAQ,aAAa,EAAE,QAAQ,WAAW,EAAE,SAAS,mBAAmB,KAAG,OAAO,CAAC,IAAI,CAYjI,CAAC;AAEF;;;;GAIG;AACH,eAAO,MAAM,wBAAwB,GAAU,QAAQ,aAAa,EAAE,QAAQ,WAAW,EAAE,SAAS,uBAAuB,KAAG,OAAO,CAAC,IAAI,CAgBzI,CAAC"}
|
package/dist/comms.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__name
|
|
3
|
+
} from "./chunk-7QVYU63E.js";
|
|
4
|
+
|
|
5
|
+
// src/comms.ts
|
|
6
|
+
import { bindReply, CommsError } from "@maroonedsoftware/comms";
|
|
7
|
+
var render = /* @__PURE__ */ __name((message) => {
|
|
8
|
+
if (!message.buttons?.length) return {
|
|
9
|
+
text: message.text
|
|
10
|
+
};
|
|
11
|
+
return {
|
|
12
|
+
text: message.text,
|
|
13
|
+
blocks: [
|
|
14
|
+
{
|
|
15
|
+
type: "section",
|
|
16
|
+
text: {
|
|
17
|
+
type: "mrkdwn",
|
|
18
|
+
text: message.text
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
type: "actions",
|
|
23
|
+
elements: message.buttons.map((b) => ({
|
|
24
|
+
type: "button",
|
|
25
|
+
action_id: b.id,
|
|
26
|
+
text: {
|
|
27
|
+
type: "plain_text",
|
|
28
|
+
text: b.label
|
|
29
|
+
},
|
|
30
|
+
value: b.value ?? b.id
|
|
31
|
+
}))
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
};
|
|
35
|
+
}, "render");
|
|
36
|
+
var deliver = /* @__PURE__ */ __name((client, to, payload) => to.startsWith("http") ? client.postWebhook(payload, to) : client.postMessage({
|
|
37
|
+
channel: to,
|
|
38
|
+
...payload
|
|
39
|
+
}), "deliver");
|
|
40
|
+
var createSlackNotifier = /* @__PURE__ */ __name((client, templates) => ({
|
|
41
|
+
channel: "slack",
|
|
42
|
+
send: /* @__PURE__ */ __name(async (to, message) => void await deliver(client, to, render(message)), "send"),
|
|
43
|
+
sendTemplate: /* @__PURE__ */ __name(async (to, name, data) => {
|
|
44
|
+
const resolved = templates.render(name, "slack", data);
|
|
45
|
+
if (!resolved) throw new CommsError(`No comms template registered for "${name}"`).withInternalDetails({
|
|
46
|
+
channel: "slack",
|
|
47
|
+
name
|
|
48
|
+
});
|
|
49
|
+
await deliver(client, to, resolved.kind === "native" ? resolved.payload : render(resolved.message));
|
|
50
|
+
}, "sendTemplate"),
|
|
51
|
+
sendNative: /* @__PURE__ */ __name(async (to, payload) => void await deliver(client, to, payload), "sendNative")
|
|
52
|
+
}), "createSlackNotifier");
|
|
53
|
+
var dispatchSlackEvent = /* @__PURE__ */ __name(async (router, client, body) => {
|
|
54
|
+
if (body.type === "url_verification") return {
|
|
55
|
+
challenge: body.challenge
|
|
56
|
+
};
|
|
57
|
+
if (body.type !== "event_callback") return void 0;
|
|
58
|
+
const envelope = body;
|
|
59
|
+
const ev = envelope.event;
|
|
60
|
+
if ((ev.type === "message" || ev.type === "app_mention") && ev.user && !ev.bot_id && ev.subtype !== "bot_message") {
|
|
61
|
+
const channel = ev.channel ?? "";
|
|
62
|
+
const event = {
|
|
63
|
+
channel: "slack",
|
|
64
|
+
kind: "message",
|
|
65
|
+
user: {
|
|
66
|
+
id: ev.user
|
|
67
|
+
},
|
|
68
|
+
conversation: {
|
|
69
|
+
id: channel
|
|
70
|
+
},
|
|
71
|
+
text: ev.text,
|
|
72
|
+
raw: envelope
|
|
73
|
+
};
|
|
74
|
+
await router.dispatch(event, bindReply(createSlackNotifier(client, router.templates), channel));
|
|
75
|
+
}
|
|
76
|
+
return void 0;
|
|
77
|
+
}, "dispatchSlackEvent");
|
|
78
|
+
var dispatchSlackCommand = /* @__PURE__ */ __name(async (router, client, payload) => {
|
|
79
|
+
const event = {
|
|
80
|
+
channel: "slack",
|
|
81
|
+
kind: "command",
|
|
82
|
+
user: {
|
|
83
|
+
id: payload.user_id,
|
|
84
|
+
username: payload.user_name
|
|
85
|
+
},
|
|
86
|
+
conversation: {
|
|
87
|
+
id: payload.channel_id
|
|
88
|
+
},
|
|
89
|
+
text: `${payload.command} ${payload.text}`.trim(),
|
|
90
|
+
command: {
|
|
91
|
+
name: payload.command,
|
|
92
|
+
args: payload.text
|
|
93
|
+
},
|
|
94
|
+
raw: payload
|
|
95
|
+
};
|
|
96
|
+
const to = payload.response_url || payload.channel_id;
|
|
97
|
+
await router.dispatch(event, bindReply(createSlackNotifier(client, router.templates), to));
|
|
98
|
+
}, "dispatchSlackCommand");
|
|
99
|
+
var dispatchSlackInteraction = /* @__PURE__ */ __name(async (router, client, payload) => {
|
|
100
|
+
if (payload.type !== "block_actions") return;
|
|
101
|
+
const first = payload.actions?.[0];
|
|
102
|
+
if (!first) return;
|
|
103
|
+
const channel = payload.channel?.id ?? "";
|
|
104
|
+
const event = {
|
|
105
|
+
channel: "slack",
|
|
106
|
+
kind: "action",
|
|
107
|
+
user: {
|
|
108
|
+
id: payload.user?.id ?? "",
|
|
109
|
+
username: payload.user?.name
|
|
110
|
+
},
|
|
111
|
+
conversation: {
|
|
112
|
+
id: channel
|
|
113
|
+
},
|
|
114
|
+
action: {
|
|
115
|
+
id: first.action_id,
|
|
116
|
+
value: first.value
|
|
117
|
+
},
|
|
118
|
+
raw: payload
|
|
119
|
+
};
|
|
120
|
+
const to = payload.response_url || channel;
|
|
121
|
+
await router.dispatch(event, bindReply(createSlackNotifier(client, router.templates), to));
|
|
122
|
+
}, "dispatchSlackInteraction");
|
|
123
|
+
export {
|
|
124
|
+
createSlackNotifier,
|
|
125
|
+
dispatchSlackCommand,
|
|
126
|
+
dispatchSlackEvent,
|
|
127
|
+
dispatchSlackInteraction
|
|
128
|
+
};
|
|
129
|
+
//# sourceMappingURL=comms.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/comms.ts"],"sourcesContent":["/**\n * `@maroonedsoftware/slack/comms` — adapter binding the Slack package to the\n * channel-agnostic `@maroonedsoftware/comms` router. Importing this subpath\n * pulls in `@maroonedsoftware/comms` (an optional peer); the slack core does not.\n */\nimport { bindReply, CommsError, type ChannelRouter, type IncomingEvent, type Notifier, type OutgoingMessage, type TemplateRegistry } from '@maroonedsoftware/comms';\nimport { SlackClient } from './client/slack.client.js';\nimport type { SlackCommandPayload } from './slack.command.handler.js';\nimport type { SlackInteractionPayload } from './slack.interaction.handler.js';\nimport type { SlackEventCallback } from './slack.event.handler.js';\nimport type { SlackEventsRequest } from './slack.dispatcher.js';\n\ntype SlackPayload = Record<string, unknown>;\n\n/** Renders a portable message to a Slack chat payload (`text`, or `text` + Block Kit `actions`). */\nconst render = (message: OutgoingMessage): SlackPayload => {\n if (!message.buttons?.length) return { text: message.text };\n return {\n text: message.text,\n blocks: [\n { type: 'section', text: { type: 'mrkdwn', text: message.text } },\n {\n type: 'actions',\n elements: message.buttons.map(b => ({ type: 'button', action_id: b.id, text: { type: 'plain_text', text: b.label }, value: b.value ?? b.id })),\n },\n ],\n };\n};\n\n/** Delivers a Slack payload: to a `response_url` (http) via webhook, otherwise to a channel id. */\nconst deliver = (client: SlackClient, to: string, payload: SlackPayload): Promise<unknown> =>\n to.startsWith('http')\n ? client.postWebhook(payload as Parameters<SlackClient['postWebhook']>[0], to)\n : client.postMessage({ channel: to, ...payload } as Parameters<SlackClient['postMessage']>[0]);\n\n/**\n * Builds a {@link Notifier} that sends portable messages and registered templates\n * through {@link SlackClient}. The recipient string is either a `response_url`\n * (used as an incoming webhook) or a channel id (`chat.postMessage`).\n */\nexport const createSlackNotifier = (client: SlackClient, templates: TemplateRegistry): Notifier => ({\n channel: 'slack',\n send: async (to, message) => void (await deliver(client, to, render(message))),\n sendTemplate: async (to, name, data) => {\n const resolved = templates.render(name, 'slack', data);\n if (!resolved) throw new CommsError(`No comms template registered for \"${name}\"`).withInternalDetails({ channel: 'slack', name });\n await deliver(client, to, resolved.kind === 'native' ? (resolved.payload as SlackPayload) : render(resolved.message));\n },\n sendNative: async (to, payload) => void (await deliver(client, to, payload as SlackPayload)),\n});\n\n/**\n * Dispatches a parsed Slack Events API body. Returns the `url_verification`\n * challenge for the handshake; for `message` / `app_mention` events it routes a\n * normalized `message` event to the {@link ChannelRouter} (replying via\n * `chat.postMessage` to the event's channel). Returns `undefined` otherwise.\n */\nexport const dispatchSlackEvent = async (router: ChannelRouter, client: SlackClient, body: SlackEventsRequest): Promise<{ challenge: string } | undefined> => {\n if (body.type === 'url_verification') return { challenge: (body as { challenge: string }).challenge };\n if (body.type !== 'event_callback') return undefined;\n\n const envelope = body as SlackEventCallback;\n const ev = envelope.event as { type: string; channel?: string; user?: string; text?: string; bot_id?: string; subtype?: string };\n if ((ev.type === 'message' || ev.type === 'app_mention') && ev.user && !ev.bot_id && ev.subtype !== 'bot_message') {\n const channel = ev.channel ?? '';\n const event: IncomingEvent = { channel: 'slack', kind: 'message', user: { id: ev.user }, conversation: { id: channel }, text: ev.text, raw: envelope };\n await router.dispatch(event, bindReply(createSlackNotifier(client, router.templates), channel));\n }\n return undefined;\n};\n\n/** Dispatches a parsed slash-command payload as a normalized `command` event (reply via `response_url`). */\nexport const dispatchSlackCommand = async (router: ChannelRouter, client: SlackClient, payload: SlackCommandPayload): Promise<void> => {\n const event: IncomingEvent = {\n channel: 'slack',\n kind: 'command',\n user: { id: payload.user_id, username: payload.user_name },\n conversation: { id: payload.channel_id },\n text: `${payload.command} ${payload.text}`.trim(),\n command: { name: payload.command, args: payload.text },\n raw: payload,\n };\n const to = payload.response_url || payload.channel_id;\n await router.dispatch(event, bindReply(createSlackNotifier(client, router.templates), to));\n};\n\n/**\n * Dispatches a parsed interactive payload. Only `block_actions` is normalized\n * (to an `action` event keyed by the first action's `action_id`); other types\n * (e.g. `view_submission`) stay on the slack package's native handlers.\n */\nexport const dispatchSlackInteraction = async (router: ChannelRouter, client: SlackClient, payload: SlackInteractionPayload): Promise<void> => {\n if (payload.type !== 'block_actions') return;\n const first = payload.actions?.[0];\n if (!first) return;\n\n const channel = (payload as { channel?: { id?: string } }).channel?.id ?? '';\n const event: IncomingEvent = {\n channel: 'slack',\n kind: 'action',\n user: { id: payload.user?.id ?? '', username: payload.user?.name },\n conversation: { id: channel },\n action: { id: first.action_id, value: first.value },\n raw: payload,\n };\n const to = payload.response_url || channel;\n await router.dispatch(event, bindReply(createSlackNotifier(client, router.templates), to));\n};\n"],"mappings":"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","names":["bindReply","CommsError","render","message","buttons","length","text","blocks","type","elements","map","b","action_id","id","label","value","deliver","client","to","payload","startsWith","postWebhook","postMessage","channel","createSlackNotifier","templates","send","sendTemplate","name","data","resolved","CommsError","withInternalDetails","kind","sendNative","dispatchSlackEvent","router","body","challenge","undefined","envelope","ev","event","user","bot_id","subtype","conversation","raw","dispatch","bindReply","dispatchSlackCommand","user_id","username","user_name","channel_id","command","trim","args","response_url","dispatchSlackInteraction","first","actions","action"]}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export * from './slack.config.js';
|
|
2
2
|
export * from './slack.error.js';
|
|
3
3
|
export * from './slack.signature.js';
|
|
4
|
+
export * from './slack.signature.policy.js';
|
|
4
5
|
export * from './slack.event.handler.js';
|
|
5
6
|
export * from './slack.command.handler.js';
|
|
6
7
|
export * from './slack.interaction.handler.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,0BAA0B,CAAC;AACzC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,kCAAkC,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,mBAAmB,CAAC;AAClC,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,0BAA0B,CAAC;AACzC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,uBAAuB,CAAC;AACtC,cAAc,0BAA0B,CAAC;AACzC,cAAc,kCAAkC,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import {
|
|
2
|
+
__name
|
|
3
|
+
} from "./chunk-7QVYU63E.js";
|
|
3
4
|
|
|
4
5
|
// src/slack.config.ts
|
|
5
6
|
import { Injectable } from "injectkit";
|
|
@@ -30,9 +31,10 @@ var IsSlackError = /* @__PURE__ */ __name((error) => error instanceof SlackError
|
|
|
30
31
|
|
|
31
32
|
// src/slack.signature.ts
|
|
32
33
|
import { createHmac, timingSafeEqual } from "crypto";
|
|
34
|
+
import { DateTime } from "luxon";
|
|
33
35
|
var SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS = 300;
|
|
34
36
|
var verifySlackSignature = /* @__PURE__ */ __name((input) => {
|
|
35
|
-
const { signingSecret, rawBody, timestamp, signature, maxAgeSeconds = SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS, now = Math.floor(
|
|
37
|
+
const { signingSecret, rawBody, timestamp, signature, maxAgeSeconds = SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS, now = Math.floor(DateTime.now().toSeconds()) } = input;
|
|
36
38
|
if (!timestamp) {
|
|
37
39
|
throw new SlackError("Slack request missing X-Slack-Request-Timestamp header").withInternalDetails({
|
|
38
40
|
reason: "missing_timestamp"
|
|
@@ -68,6 +70,51 @@ var verifySlackSignature = /* @__PURE__ */ __name((input) => {
|
|
|
68
70
|
}
|
|
69
71
|
}, "verifySlackSignature");
|
|
70
72
|
|
|
73
|
+
// src/slack.signature.policy.ts
|
|
74
|
+
import { Injectable as Injectable2 } from "injectkit";
|
|
75
|
+
import { Policy } from "@maroonedsoftware/policies";
|
|
76
|
+
function _ts_decorate2(decorators, target, key, desc) {
|
|
77
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
78
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
79
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
80
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
81
|
+
}
|
|
82
|
+
__name(_ts_decorate2, "_ts_decorate");
|
|
83
|
+
var SLACK_SIGNATURE_POLICY = "slack.signature.valid";
|
|
84
|
+
var SLACK_REQUEST_TIMESTAMP_HEADER = "X-Slack-Request-Timestamp";
|
|
85
|
+
var SLACK_SIGNATURE_HEADER = "X-Slack-Signature";
|
|
86
|
+
var SlackSignaturePolicy = class extends Policy {
|
|
87
|
+
static {
|
|
88
|
+
__name(this, "SlackSignaturePolicy");
|
|
89
|
+
}
|
|
90
|
+
async evaluate(context, envelope) {
|
|
91
|
+
const { rawBody, getHeader, options } = context;
|
|
92
|
+
const body = typeof rawBody === "string" ? rawBody : Buffer.from(rawBody).toString("utf8");
|
|
93
|
+
try {
|
|
94
|
+
verifySlackSignature({
|
|
95
|
+
signingSecret: options.signingSecret,
|
|
96
|
+
rawBody: body,
|
|
97
|
+
timestamp: getHeader(SLACK_REQUEST_TIMESTAMP_HEADER),
|
|
98
|
+
signature: getHeader(SLACK_SIGNATURE_HEADER),
|
|
99
|
+
maxAgeSeconds: options.signatureMaxAgeSeconds,
|
|
100
|
+
now: Math.floor(envelope.now.toSeconds())
|
|
101
|
+
});
|
|
102
|
+
return this.allow();
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (!IsSlackError(error)) throw error;
|
|
105
|
+
const internalDetails = error.internalDetails ?? {};
|
|
106
|
+
const reason = typeof internalDetails.reason === "string" ? internalDetails.reason : "invalid_signature";
|
|
107
|
+
return this.deny(reason, void 0, {
|
|
108
|
+
message: error.message,
|
|
109
|
+
...internalDetails
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
SlackSignaturePolicy = _ts_decorate2([
|
|
115
|
+
Injectable2()
|
|
116
|
+
], SlackSignaturePolicy);
|
|
117
|
+
|
|
71
118
|
// src/slack.interaction.handler.ts
|
|
72
119
|
var interactionRouteKey = /* @__PURE__ */ __name((payload) => {
|
|
73
120
|
switch (payload.type) {
|
|
@@ -91,15 +138,15 @@ var interactionRouteKey = /* @__PURE__ */ __name((payload) => {
|
|
|
91
138
|
}, "interactionRouteKey");
|
|
92
139
|
|
|
93
140
|
// src/slack.dispatcher.ts
|
|
94
|
-
import { Injectable as
|
|
141
|
+
import { Injectable as Injectable3 } from "injectkit";
|
|
95
142
|
import { Logger } from "@maroonedsoftware/logger";
|
|
96
|
-
function
|
|
143
|
+
function _ts_decorate3(decorators, target, key, desc) {
|
|
97
144
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
98
145
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
99
146
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
100
147
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
101
148
|
}
|
|
102
|
-
__name(
|
|
149
|
+
__name(_ts_decorate3, "_ts_decorate");
|
|
103
150
|
function _ts_metadata(k, v) {
|
|
104
151
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
105
152
|
}
|
|
@@ -109,24 +156,24 @@ var SlackCommandHandlerMap = class extends Map {
|
|
|
109
156
|
__name(this, "SlackCommandHandlerMap");
|
|
110
157
|
}
|
|
111
158
|
};
|
|
112
|
-
SlackCommandHandlerMap =
|
|
113
|
-
|
|
159
|
+
SlackCommandHandlerMap = _ts_decorate3([
|
|
160
|
+
Injectable3()
|
|
114
161
|
], SlackCommandHandlerMap);
|
|
115
162
|
var SlackEventHandlerMap = class extends Map {
|
|
116
163
|
static {
|
|
117
164
|
__name(this, "SlackEventHandlerMap");
|
|
118
165
|
}
|
|
119
166
|
};
|
|
120
|
-
SlackEventHandlerMap =
|
|
121
|
-
|
|
167
|
+
SlackEventHandlerMap = _ts_decorate3([
|
|
168
|
+
Injectable3()
|
|
122
169
|
], SlackEventHandlerMap);
|
|
123
170
|
var SlackInteractionHandlerMap = class extends Map {
|
|
124
171
|
static {
|
|
125
172
|
__name(this, "SlackInteractionHandlerMap");
|
|
126
173
|
}
|
|
127
174
|
};
|
|
128
|
-
SlackInteractionHandlerMap =
|
|
129
|
-
|
|
175
|
+
SlackInteractionHandlerMap = _ts_decorate3([
|
|
176
|
+
Injectable3()
|
|
130
177
|
], SlackInteractionHandlerMap);
|
|
131
178
|
var SlackDispatcher = class {
|
|
132
179
|
static {
|
|
@@ -221,8 +268,8 @@ var SlackDispatcher = class {
|
|
|
221
268
|
return await handler.handle(payload);
|
|
222
269
|
}
|
|
223
270
|
};
|
|
224
|
-
SlackDispatcher =
|
|
225
|
-
|
|
271
|
+
SlackDispatcher = _ts_decorate3([
|
|
272
|
+
Injectable3(),
|
|
226
273
|
_ts_metadata("design:type", Function),
|
|
227
274
|
_ts_metadata("design:paramtypes", [
|
|
228
275
|
typeof SlackEventHandlerMap === "undefined" ? Object : SlackEventHandlerMap,
|
|
@@ -233,7 +280,7 @@ SlackDispatcher = _ts_decorate2([
|
|
|
233
280
|
], SlackDispatcher);
|
|
234
281
|
|
|
235
282
|
// src/client/slack.client.ts
|
|
236
|
-
import { Injectable as
|
|
283
|
+
import { Injectable as Injectable4 } from "injectkit";
|
|
237
284
|
import { WebClient } from "@slack/web-api";
|
|
238
285
|
import { Logger as Logger2 } from "@maroonedsoftware/logger";
|
|
239
286
|
|
|
@@ -263,13 +310,13 @@ var adaptLogger = /* @__PURE__ */ __name((logger, name = "slack-web-api") => {
|
|
|
263
310
|
}, "adaptLogger");
|
|
264
311
|
|
|
265
312
|
// src/client/slack.client.ts
|
|
266
|
-
function
|
|
313
|
+
function _ts_decorate4(decorators, target, key, desc) {
|
|
267
314
|
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
268
315
|
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
269
316
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
270
317
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
271
318
|
}
|
|
272
|
-
__name(
|
|
319
|
+
__name(_ts_decorate4, "_ts_decorate");
|
|
273
320
|
function _ts_metadata2(k, v) {
|
|
274
321
|
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
|
|
275
322
|
}
|
|
@@ -338,8 +385,8 @@ var SlackClient = class {
|
|
|
338
385
|
}
|
|
339
386
|
}
|
|
340
387
|
};
|
|
341
|
-
SlackClient =
|
|
342
|
-
|
|
388
|
+
SlackClient = _ts_decorate4([
|
|
389
|
+
Injectable4(),
|
|
343
390
|
_ts_metadata2("design:type", Function),
|
|
344
391
|
_ts_metadata2("design:paramtypes", [
|
|
345
392
|
typeof SlackConfig === "undefined" ? Object : SlackConfig,
|
|
@@ -348,7 +395,10 @@ SlackClient = _ts_decorate3([
|
|
|
348
395
|
], SlackClient);
|
|
349
396
|
export {
|
|
350
397
|
IsSlackError,
|
|
398
|
+
SLACK_REQUEST_TIMESTAMP_HEADER,
|
|
351
399
|
SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS,
|
|
400
|
+
SLACK_SIGNATURE_HEADER,
|
|
401
|
+
SLACK_SIGNATURE_POLICY,
|
|
352
402
|
SlackClient,
|
|
353
403
|
SlackCommandHandlerMap,
|
|
354
404
|
SlackConfig,
|
|
@@ -356,6 +406,7 @@ export {
|
|
|
356
406
|
SlackError,
|
|
357
407
|
SlackEventHandlerMap,
|
|
358
408
|
SlackInteractionHandlerMap,
|
|
409
|
+
SlackSignaturePolicy,
|
|
359
410
|
adaptLogger,
|
|
360
411
|
interactionRouteKey,
|
|
361
412
|
verifySlackSignature
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/slack.config.ts","../src/slack.error.ts","../src/slack.signature.ts","../src/slack.interaction.handler.ts","../src/slack.dispatcher.ts","../src/client/slack.client.ts","../src/client/slack.logger.adapter.ts"],"sourcesContent":["/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */\nimport { Injectable } from 'injectkit';\n\n/**\n * Configuration for the Slack package. Declared as an abstract `@Injectable()`\n * class so it doubles as a DI token (mirrors the `Logger` pattern in\n * `@maroonedsoftware/logger`).\n *\n * Consumers register a concrete value at bootstrap, typically resolved from\n * `AppConfig`:\n *\n * ```ts\n * const slackConfig = appConfig.getAs<SlackConfig>('slack');\n * container.register(SlackConfig, { useValue: slackConfig });\n * ```\n *\n * Services in this package take `SlackConfig` directly in their constructor.\n */\nexport interface SlackConfig {\n /** Bot user OAuth token (`xoxb-...`). Required for Web API calls. */\n botToken: string;\n /** App-level signing secret used to verify request signatures. */\n signingSecret: string;\n /** Optional incoming webhook URL used as the default target for `SlackClient.postWebhook`. */\n incomingWebhookUrl?: string;\n /**\n * Maximum age (in seconds) for request timestamps before signature\n * verification rejects them as replays. Defaults to `300` (5 minutes).\n */\n signatureMaxAgeSeconds?: number;\n}\n\n@Injectable()\nexport abstract class SlackConfig implements SlackConfig {}\n","import { ServerkitError } from '@maroonedsoftware/errors';\n\n/**\n * Domain error raised by the Slack package for non-HTTP failures (e.g.\n * incoming-webhook POST failed, unknown handler dispatch).\n *\n * Extends {@link ServerkitError} so `errorMiddleware` renders a 500 with\n * `{ message, details }` if one of these escapes a route handler. Inside\n * route handlers, throw `httpError(...)` directly for status-coded responses.\n */\nexport class SlackError extends ServerkitError {}\n\n/**\n * Type guard for {@link SlackError}. Narrows `unknown` to `SlackError` so\n * `details`, `internalDetails`, and the chainable setters are accessible\n * without further checks. Returns `true` for any subclass.\n */\nexport const IsSlackError = (error: unknown): error is SlackError => error instanceof SlackError;\n","import { createHmac, timingSafeEqual } from 'node:crypto';\nimport { SlackError } from './slack.error.js';\n\n/** Default replay-protection window in seconds (5 minutes — matches Slack's recommendation). */\nexport const SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS = 300;\n\n/**\n * Reason codes attached to {@link SlackError.internalDetails} when verification\n * fails. Useful for callers that want to log structured reasons without\n * pattern-matching on error messages.\n */\nexport type SlackSignatureFailureReason =\n | 'missing_timestamp'\n | 'invalid_timestamp'\n | 'stale_timestamp'\n | 'missing_signature'\n | 'invalid_signature';\n\n/**\n * Inputs to {@link verifySlackSignature}. All values are taken verbatim from\n * the request — the helper does no header lookups or body reads of its own.\n */\nexport type VerifySlackSignatureInput = {\n /** App signing secret (`SlackConfig.signingSecret`). */\n signingSecret: string;\n /** Raw, unparsed request body — exactly as Slack sent it. */\n rawBody: string;\n /** Value of the `X-Slack-Request-Timestamp` header. */\n timestamp: string | undefined;\n /** Value of the `X-Slack-Signature` header (e.g. `\"v0=abc123…\"`). */\n signature: string | undefined;\n /**\n * Maximum age in seconds before the request is rejected as a replay.\n * Defaults to {@link SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS}.\n */\n maxAgeSeconds?: number;\n /**\n * Override for the current Unix time in seconds. Mostly useful for tests;\n * defaults to `Math.floor(Date.now() / 1000)`.\n */\n now?: number;\n};\n\n/**\n * Verifies a Slack request signature against the app signing secret.\n *\n * Implements Slack's v0 scheme:\n * 1. Reject the request if `X-Slack-Request-Timestamp` is missing, non-numeric,\n * or older than `maxAgeSeconds` (replay protection).\n * 2. Compute `v0=` + `HMAC-SHA256(signingSecret, \"v0:{timestamp}:{rawBody}\")`\n * as hex.\n * 3. Compare against the provided `X-Slack-Signature` value using a\n * constant-time compare.\n *\n * Pure: no request/context coupling. The caller extracts the headers and raw\n * body from whatever transport it's using and passes them in.\n *\n * @throws {@link SlackError} on any failure. The error's `internalDetails.reason`\n * is one of {@link SlackSignatureFailureReason}; map to HTTP 401 at the route boundary.\n *\n * @example\n * ```ts\n * try {\n * verifySlackSignature({\n * signingSecret: config.signingSecret,\n * rawBody,\n * timestamp: req.headers['x-slack-request-timestamp'],\n * signature: req.headers['x-slack-signature'],\n * });\n * } catch (err) {\n * throw httpError(401).withCause(err);\n * }\n * ```\n */\nexport const verifySlackSignature = (input: VerifySlackSignatureInput): void => {\n const {\n signingSecret,\n rawBody,\n timestamp,\n signature,\n maxAgeSeconds = SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS,\n now = Math.floor(Date.now() / 1000),\n } = input;\n\n if (!timestamp) {\n throw new SlackError('Slack request missing X-Slack-Request-Timestamp header').withInternalDetails({\n reason: 'missing_timestamp' satisfies SlackSignatureFailureReason,\n });\n }\n\n const ts = Number(timestamp);\n if (!Number.isFinite(ts) || !Number.isInteger(ts)) {\n throw new SlackError('Slack request timestamp is not a valid integer').withInternalDetails({\n reason: 'invalid_timestamp' satisfies SlackSignatureFailureReason,\n timestamp,\n });\n }\n\n if (Math.abs(now - ts) > maxAgeSeconds) {\n throw new SlackError('Slack request timestamp is outside the allowed window').withInternalDetails({\n reason: 'stale_timestamp' satisfies SlackSignatureFailureReason,\n timestamp: ts,\n now,\n maxAgeSeconds,\n });\n }\n\n if (!signature) {\n throw new SlackError('Slack request missing X-Slack-Signature header').withInternalDetails({\n reason: 'missing_signature' satisfies SlackSignatureFailureReason,\n });\n }\n\n const expected = `v0=${createHmac('sha256', signingSecret).update(`v0:${ts}:${rawBody}`).digest('hex')}`;\n const expectedBuf = Buffer.from(expected, 'utf8');\n const providedBuf = Buffer.from(signature, 'utf8');\n\n // timingSafeEqual throws on length mismatch — short-circuit so the caller\n // gets a uniform \"invalid_signature\" error instead of a crypto exception.\n if (expectedBuf.length !== providedBuf.length || !timingSafeEqual(expectedBuf, providedBuf)) {\n throw new SlackError('Slack request signature does not match').withInternalDetails({\n reason: 'invalid_signature' satisfies SlackSignatureFailureReason,\n });\n }\n};\n","/**\n * The supported interactive payload types Slack POSTs to the interactivity\n * endpoint. Each maps to a different identifier shape (see\n * {@link interactionRouteKey}).\n */\nexport type SlackInteractionType = 'block_actions' | 'view_submission' | 'view_closed' | 'shortcut' | 'message_action' | string;\n\n/**\n * Loose typing for the interactive payload; consumers narrow per handler.\n * Slack's payloads vary by type, but every variant has a `type` field plus\n * one of: `actions[].action_id`, `view.callback_id`, or top-level `callback_id`.\n */\nexport type SlackInteractionPayload = {\n type: SlackInteractionType;\n team?: { id: string; domain?: string };\n user?: { id: string; name?: string };\n trigger_id?: string;\n response_url?: string;\n actions?: Array<{ action_id: string; block_id?: string; value?: string; [key: string]: unknown }>;\n view?: { id: string; callback_id: string; [key: string]: unknown };\n callback_id?: string;\n [key: string]: unknown;\n};\n\n/**\n * Optional response Slack accepts for `view_submission` / `view_closed`\n * payloads (e.g. to display validation errors or update a modal).\n */\nexport type SlackInteractionResponse = {\n response_action?: 'errors' | 'update' | 'push' | 'clear';\n errors?: Record<string, string>;\n view?: unknown;\n [key: string]: unknown;\n};\n\n/**\n * Handler for one interactive payload, keyed in {@link SlackInteractionHandlerMap}\n * by `${type}:${identifier}` — see {@link interactionRouteKey}.\n */\nexport interface SlackInteractionHandler {\n handle(payload: SlackInteractionPayload): Promise<SlackInteractionResponse | void>;\n}\n\n/**\n * Computes the routing key used by {@link SlackDispatcher.dispatchInteraction}\n * to look a handler up in {@link SlackInteractionHandlerMap}.\n *\n * - `block_actions` → `block_actions:<first action.action_id>`\n * - `view_submission` / `view_closed` → `<type>:<view.callback_id>`\n * - `shortcut` / `message_action` → `<type>:<callback_id>`\n * - any other type with a `callback_id` → `<type>:<callback_id>`\n *\n * @returns The routing key, or `undefined` if the payload doesn't carry an\n * identifier we can route on (e.g. a `block_actions` payload with no actions).\n */\nexport const interactionRouteKey = (payload: SlackInteractionPayload): string | undefined => {\n switch (payload.type) {\n case 'block_actions': {\n const id = payload.actions?.[0]?.action_id;\n return id ? `block_actions:${id}` : undefined;\n }\n case 'view_submission':\n case 'view_closed': {\n const id = payload.view?.callback_id;\n return id ? `${payload.type}:${id}` : undefined;\n }\n case 'shortcut':\n case 'message_action': {\n return payload.callback_id ? `${payload.type}:${payload.callback_id}` : undefined;\n }\n default: {\n return payload.callback_id ? `${payload.type}:${payload.callback_id}` : undefined;\n }\n }\n};\n","import { Injectable } from 'injectkit';\nimport { Logger } from '@maroonedsoftware/logger';\nimport type { SlackEventCallback, SlackEventHandler } from './slack.event.handler.js';\nimport type { SlackCommandHandler, SlackCommandPayload, SlackCommandResponse } from './slack.command.handler.js';\nimport {\n interactionRouteKey,\n SlackInteractionHandler,\n type SlackInteractionPayload,\n type SlackInteractionResponse,\n} from './slack.interaction.handler.js';\n\n/**\n * Body shape Slack POSTs to the Events API endpoint. The handshake variant\n * (`url_verification`) is sent once during app configuration; the rest of the\n * traffic is `event_callback` envelopes (or other future top-level types).\n */\nexport type SlackEventsRequest =\n | { type: 'url_verification'; challenge: string; token?: string }\n | SlackEventCallback\n | { type: string; [key: string]: unknown };\n\n/**\n * Response Slack expects for the `url_verification` handshake. For\n * `event_callback` and unknown event types, the dispatcher returns\n * `undefined` and the caller should ack with HTTP 200.\n */\nexport type SlackEventsResponse = { challenge: string } | undefined;\n\n/**\n * Injectable map of command keyword (e.g. `/deploy`) → {@link SlackCommandHandler}.\n *\n * @example\n * ```ts\n * const commands = new SlackCommandHandlerMap();\n * commands.set('/deploy', container.get(DeployCommandHandler));\n * container.register(SlackCommandHandlerMap, { useValue: commands });\n * ```\n */\n@Injectable()\nexport class SlackCommandHandlerMap extends Map<string, SlackCommandHandler> {}\n\n/**\n * Injectable map of Slack event type → {@link SlackEventHandler}. Consumers\n * register handlers at bootstrap and place an instance of this map in their\n * DI container; {@link SlackDispatcher.dispatchEvent} resolves it per request.\n *\n * @example\n * ```ts\n * const handlers = new SlackEventHandlerMap();\n * handlers.set('app_mention', container.get(MyAppMentionHandler));\n * container.register(SlackEventHandlerMap, { useValue: handlers });\n * ```\n */\n@Injectable()\nexport class SlackEventHandlerMap extends Map<string, SlackEventHandler> {}\n\n/**\n * Injectable map of interaction routing keys → {@link SlackInteractionHandler}.\n *\n * Keys are produced by `interactionRouteKey(payload)`, which combines the\n * payload `type` with the relevant identifier (`action_id`, `callback_id`,\n * etc.). Register handlers under the same key shape:\n *\n * @example\n * ```ts\n * const interactions = new SlackInteractionHandlerMap();\n * interactions.set('block_actions:approve_button', container.get(ApproveHandler));\n * interactions.set('view_submission:create_ticket_modal', container.get(CreateTicketHandler));\n * container.register(SlackInteractionHandlerMap, { useValue: interactions });\n * ```\n */\n@Injectable()\nexport class SlackInteractionHandlerMap extends Map<string, SlackInteractionHandler> {}\n\n/**\n * Single entry point for dispatching parsed Slack payloads to registered\n * handlers. Transport-agnostic: the consumer is responsible for receiving\n * the HTTP request, verifying the signature, parsing the body, calling the\n * appropriate `dispatch*` method, and serializing the response.\n *\n * @example Koa route\n * ```ts\n * router.post('/slack/events', async (ctx) => {\n * const raw = await rawBody(ctx.req, { encoding: 'utf8' });\n * verifySlackSignature({\n * signingSecret: ctx.container.get(SlackConfig).signingSecret,\n * rawBody: raw,\n * timestamp: ctx.get('x-slack-request-timestamp'),\n * signature: ctx.get('x-slack-signature'),\n * });\n * const result = await ctx.container.get(SlackDispatcher).dispatchEvent(JSON.parse(raw));\n * if (result) ctx.body = result;\n * else { ctx.status = 200; ctx.body = ''; }\n * });\n * ```\n */\n@Injectable()\nexport class SlackDispatcher {\n constructor(\n private readonly events: SlackEventHandlerMap,\n private readonly commands: SlackCommandHandlerMap,\n private readonly interactions: SlackInteractionHandlerMap,\n private readonly logger: Logger,\n ) {}\n\n /**\n * Dispatch a parsed Events API body.\n *\n * - Returns `{ challenge }` for `url_verification` — the caller serializes\n * it as the response body.\n * - For `event_callback`, looks up a handler in {@link SlackEventHandlerMap}\n * keyed by `event.type` and invokes it. Returns `undefined`. Slack retries\n * any non-2xx so unknown event types are logged at debug and acked.\n * - For any other top-level type, logs and returns `undefined`.\n */\n async dispatchEvent(body: SlackEventsRequest): Promise<SlackEventsResponse> {\n if (body.type === 'url_verification') {\n return { challenge: (body as { challenge: string }).challenge };\n }\n\n if (body.type === 'event_callback') {\n const envelope = body as SlackEventCallback;\n const handler = this.events.get(envelope.event.type);\n if (handler) {\n await handler.handle(envelope.event, {\n teamId: envelope.team_id,\n eventId: envelope.event_id,\n eventTime: envelope.event_time,\n envelope,\n });\n } else {\n this.logger.debug('No Slack event handler registered for event type', { type: envelope.event.type });\n }\n return undefined;\n }\n\n this.logger.debug('Unhandled Slack events payload type', { type: body.type });\n return undefined;\n }\n\n /**\n * Dispatch a parsed slash-command payload.\n *\n * Looks up a handler in {@link SlackCommandHandlerMap} keyed by\n * `payload.command` (e.g. `/deploy`). If the handler returns a response,\n * the caller serializes it as JSON; otherwise the caller acks with `200 ''`\n * and the handler is expected to follow up via `payload.response_url`.\n */\n async dispatchCommand(payload: SlackCommandPayload): Promise<SlackCommandResponse | void> {\n const handler = this.commands.get(payload.command);\n if (!handler) {\n this.logger.debug('No Slack command handler registered', { command: payload.command });\n return undefined;\n }\n return await handler.handle(payload);\n }\n\n /**\n * Dispatch a parsed interactive payload (block actions, view submission,\n * shortcut, etc.). Computes a routing key via {@link interactionRouteKey}\n * and looks it up in {@link SlackInteractionHandlerMap}.\n */\n async dispatchInteraction(payload: SlackInteractionPayload): Promise<SlackInteractionResponse | void> {\n const key = interactionRouteKey(payload);\n if (!key) {\n this.logger.debug('Slack interaction payload missing routable identifier', { type: payload.type });\n return undefined;\n }\n const handler = this.interactions.get(key);\n if (!handler) {\n this.logger.debug('No Slack interaction handler registered', { key });\n return undefined;\n }\n return await handler.handle(payload);\n }\n}\n","import { Injectable } from 'injectkit';\nimport { WebClient } from '@slack/web-api';\nimport type { ChatPostMessageArguments, ChatPostMessageResponse, ChatUpdateArguments, ChatUpdateResponse, ChatDeleteArguments, ChatDeleteResponse, ViewsOpenArguments, ViewsOpenResponse } from '@slack/web-api';\nimport { Logger } from '@maroonedsoftware/logger';\nimport { SlackConfig } from '../slack.config.js';\nimport { SlackError } from '../slack.error.js';\nimport { adaptLogger } from './slack.logger.adapter.js';\n\n/**\n * Payload for an incoming-webhook POST. Mirrors the subset of fields Slack's\n * incoming webhooks accept (text, blocks, attachments, response shaping).\n * The body is JSON-stringified verbatim, so any extra fields are preserved.\n */\nexport type IncomingWebhookPayload = {\n text?: string;\n blocks?: unknown[];\n attachments?: unknown[];\n thread_ts?: string;\n response_type?: 'in_channel' | 'ephemeral';\n replace_original?: boolean;\n delete_original?: boolean;\n unfurl_links?: boolean;\n unfurl_media?: boolean;\n [key: string]: unknown;\n};\n\n/**\n * Thin DI-friendly wrapper around `@slack/web-api`'s `WebClient`. Constructed\n * once per request scope (or as a singleton, depending on how the consumer\n * registers it) and exposes typed passthroughs for the most common Web API\n * methods plus a `postWebhook` helper for incoming-webhook URLs and the\n * `response_url` returned by slash commands and interactive payloads.\n *\n * Reach for {@link SlackClient.web} directly for anything else the underlying\n * client supports.\n *\n * @example\n * ```ts\n * await container.get(SlackClient).postMessage({ channel: '#ops', text: 'hello' });\n * await container.get(SlackClient).postWebhook({ text: 'follow-up' }, payload.response_url);\n * ```\n */\n@Injectable()\nexport class SlackClient {\n /** Underlying `@slack/web-api` client. */\n readonly web: WebClient;\n\n constructor(\n private readonly config: SlackConfig,\n private readonly logger: Logger,\n ) {\n this.web = new WebClient(config.botToken, { logger: adaptLogger(logger) });\n }\n\n /** Posts a message via `chat.postMessage`. */\n postMessage(args: ChatPostMessageArguments): Promise<ChatPostMessageResponse> {\n return this.web.chat.postMessage(args);\n }\n\n /** Updates a message via `chat.update`. */\n updateMessage(args: ChatUpdateArguments): Promise<ChatUpdateResponse> {\n return this.web.chat.update(args);\n }\n\n /** Deletes a message via `chat.delete`. */\n deleteMessage(args: ChatDeleteArguments): Promise<ChatDeleteResponse> {\n return this.web.chat.delete(args);\n }\n\n /** Opens a modal view via `views.open`. */\n openView(args: ViewsOpenArguments): Promise<ViewsOpenResponse> {\n return this.web.views.open(args);\n }\n\n /**\n * POSTs a payload to a Slack incoming-webhook-style URL — either the\n * configured `incomingWebhookUrl` or an explicit URL (e.g. the\n * `response_url` from a slash command or interactive payload).\n *\n * @throws {@link SlackError} if no URL is available or the response is non-2xx.\n */\n async postWebhook(payload: IncomingWebhookPayload, url?: string): Promise<void> {\n const target = url ?? this.config.incomingWebhookUrl;\n if (!target) {\n throw new SlackError('SlackClient.postWebhook called but no incomingWebhookUrl is configured and no url was provided');\n }\n const response = await fetch(target, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(payload),\n });\n if (!response.ok) {\n const body = await response.text().catch(() => '');\n this.logger.warn('Slack webhook POST returned non-OK status', { status: response.status, body });\n throw new SlackError(`Slack webhook POST returned ${response.status}`).withInternalDetails({ status: response.status, body, url: target });\n }\n }\n}\n","import type { Logger as SlackLogger, LogLevel } from '@slack/web-api';\nimport { Logger } from '@maroonedsoftware/logger';\n\n/**\n * Adapts a ServerKit {@link Logger} to the `@slack/web-api` {@link SlackLogger}\n * interface so the WebClient can route its diagnostics through the host\n * application's logger.\n *\n * The Slack SDK's logger calls `logger.info(...args)` with a variable number\n * of arguments and no separate \"primary message\"; the adapter forwards them\n * to ServerKit's `(message, ...optionalParams)` shape, with an empty-string\n * primary when no args are passed.\n *\n * `setLevel`, `setName`, and `getLevel` are stored locally — ServerKit\n * loggers do not expose these knobs but the SDK expects them on its logger.\n *\n * @param logger - The ServerKit logger to forward calls to.\n * @param name - Initial value for the SDK logger's name. Defaults to `'slack-web-api'`.\n * @returns A `@slack/web-api`-compatible logger object.\n */\nexport const adaptLogger = (logger: Logger, name = 'slack-web-api'): SlackLogger => {\n const state = { name, level: 'info' as LogLevel };\n const forward = (fn: (message: unknown, ...optionalParams: unknown[]) => void) => (...msg: unknown[]) => {\n const [first, ...rest] = msg;\n fn(first ?? '', ...rest);\n };\n return {\n debug: forward(logger.debug.bind(logger)),\n info: forward(logger.info.bind(logger)),\n warn: forward(logger.warn.bind(logger)),\n error: forward(logger.error.bind(logger)),\n setLevel: (level: LogLevel) => {\n state.level = level;\n },\n getLevel: () => state.level,\n setName: (n: string) => {\n state.name = n;\n },\n };\n};\n"],"mappings":"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","names":["Injectable","SlackConfig","ServerkitError","SlackError","ServerkitError","IsSlackError","error","createHmac","timingSafeEqual","SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS","verifySlackSignature","input","signingSecret","rawBody","timestamp","signature","maxAgeSeconds","now","Math","floor","Date","SlackError","withInternalDetails","reason","ts","Number","isFinite","isInteger","abs","expected","createHmac","update","digest","expectedBuf","Buffer","from","providedBuf","length","timingSafeEqual","interactionRouteKey","payload","type","id","actions","action_id","undefined","view","callback_id","Injectable","Logger","SlackCommandHandlerMap","Map","SlackEventHandlerMap","SlackInteractionHandlerMap","SlackDispatcher","events","commands","interactions","logger","dispatchEvent","body","type","challenge","envelope","handler","get","event","handle","teamId","team_id","eventId","event_id","eventTime","event_time","debug","undefined","dispatchCommand","payload","command","dispatchInteraction","key","interactionRouteKey","Injectable","WebClient","Logger","adaptLogger","logger","name","state","level","forward","fn","msg","first","rest","debug","bind","info","warn","error","setLevel","getLevel","setName","n","SlackClient","web","config","logger","WebClient","botToken","adaptLogger","postMessage","args","chat","updateMessage","update","deleteMessage","delete","openView","views","open","postWebhook","payload","url","target","incomingWebhookUrl","SlackError","response","fetch","method","headers","body","JSON","stringify","ok","text","catch","warn","status","withInternalDetails"]}
|
|
1
|
+
{"version":3,"sources":["../src/slack.config.ts","../src/slack.error.ts","../src/slack.signature.ts","../src/slack.signature.policy.ts","../src/slack.interaction.handler.ts","../src/slack.dispatcher.ts","../src/client/slack.client.ts","../src/client/slack.logger.adapter.ts"],"sourcesContent":["/* eslint-disable @typescript-eslint/no-unsafe-declaration-merging */\nimport { Injectable } from 'injectkit';\n\n/**\n * Configuration for the Slack package. Declared as an abstract `@Injectable()`\n * class so it doubles as a DI token (mirrors the `Logger` pattern in\n * `@maroonedsoftware/logger`).\n *\n * Consumers register a concrete value at bootstrap, typically resolved from\n * `AppConfig`:\n *\n * ```ts\n * const slackConfig = appConfig.getAs<SlackConfig>('slack');\n * container.register(SlackConfig, { useValue: slackConfig });\n * ```\n *\n * Services in this package take `SlackConfig` directly in their constructor.\n */\nexport interface SlackConfig {\n /** Bot user OAuth token (`xoxb-...`). Required for Web API calls. */\n botToken: string;\n /** App-level signing secret used to verify request signatures. */\n signingSecret: string;\n /** Optional incoming webhook URL used as the default target for `SlackClient.postWebhook`. */\n incomingWebhookUrl?: string;\n /**\n * Maximum age (in seconds) for request timestamps before signature\n * verification rejects them as replays. Defaults to `300` (5 minutes).\n */\n signatureMaxAgeSeconds?: number;\n}\n\n@Injectable()\nexport abstract class SlackConfig implements SlackConfig {}\n","import { ServerkitError } from '@maroonedsoftware/errors';\n\n/**\n * Domain error raised by the Slack package for non-HTTP failures (e.g.\n * incoming-webhook POST failed, unknown handler dispatch).\n *\n * Extends {@link ServerkitError} so `errorMiddleware` renders a 500 with\n * `{ message, details }` if one of these escapes a route handler. Inside\n * route handlers, throw `httpError(...)` directly for status-coded responses.\n */\nexport class SlackError extends ServerkitError {}\n\n/**\n * Type guard for {@link SlackError}. Narrows `unknown` to `SlackError` so\n * `details`, `internalDetails`, and the chainable setters are accessible\n * without further checks. Returns `true` for any subclass.\n */\nexport const IsSlackError = (error: unknown): error is SlackError => error instanceof SlackError;\n","import { createHmac, timingSafeEqual } from 'node:crypto';\nimport { DateTime } from 'luxon';\nimport { SlackError } from './slack.error.js';\n\n/** Default replay-protection window in seconds (5 minutes — matches Slack's recommendation). */\nexport const SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS = 300;\n\n/**\n * Reason codes attached to {@link SlackError.internalDetails} when verification\n * fails. Useful for callers that want to log structured reasons without\n * pattern-matching on error messages.\n */\nexport type SlackSignatureFailureReason =\n | 'missing_timestamp'\n | 'invalid_timestamp'\n | 'stale_timestamp'\n | 'missing_signature'\n | 'invalid_signature';\n\n/**\n * Inputs to {@link verifySlackSignature}. All values are taken verbatim from\n * the request — the helper does no header lookups or body reads of its own.\n */\nexport type VerifySlackSignatureInput = {\n /** App signing secret (`SlackConfig.signingSecret`). */\n signingSecret: string;\n /** Raw, unparsed request body — exactly as Slack sent it. */\n rawBody: string;\n /** Value of the `X-Slack-Request-Timestamp` header. */\n timestamp: string | undefined;\n /** Value of the `X-Slack-Signature` header (e.g. `\"v0=abc123…\"`). */\n signature: string | undefined;\n /**\n * Maximum age in seconds before the request is rejected as a replay.\n * Defaults to {@link SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS}.\n */\n maxAgeSeconds?: number;\n /**\n * Override for the current Unix time in seconds. Mostly useful for tests;\n * defaults to `Math.floor(DateTime.now().toSeconds())`.\n */\n now?: number;\n};\n\n/**\n * Verifies a Slack request signature against the app signing secret.\n *\n * Implements Slack's v0 scheme:\n * 1. Reject the request if `X-Slack-Request-Timestamp` is missing, non-numeric,\n * or older than `maxAgeSeconds` (replay protection).\n * 2. Compute `v0=` + `HMAC-SHA256(signingSecret, \"v0:{timestamp}:{rawBody}\")`\n * as hex.\n * 3. Compare against the provided `X-Slack-Signature` value using a\n * constant-time compare.\n *\n * Pure: no request/context coupling. The caller extracts the headers and raw\n * body from whatever transport it's using and passes them in.\n *\n * @throws {@link SlackError} on any failure. The error's `internalDetails.reason`\n * is one of {@link SlackSignatureFailureReason}; map to HTTP 401 at the route boundary.\n *\n * @example\n * ```ts\n * try {\n * verifySlackSignature({\n * signingSecret: config.signingSecret,\n * rawBody,\n * timestamp: req.headers['x-slack-request-timestamp'],\n * signature: req.headers['x-slack-signature'],\n * });\n * } catch (err) {\n * throw httpError(401).withCause(err);\n * }\n * ```\n */\nexport const verifySlackSignature = (input: VerifySlackSignatureInput): void => {\n const {\n signingSecret,\n rawBody,\n timestamp,\n signature,\n maxAgeSeconds = SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS,\n now = Math.floor(DateTime.now().toSeconds()),\n } = input;\n\n if (!timestamp) {\n throw new SlackError('Slack request missing X-Slack-Request-Timestamp header').withInternalDetails({\n reason: 'missing_timestamp' satisfies SlackSignatureFailureReason,\n });\n }\n\n const ts = Number(timestamp);\n if (!Number.isFinite(ts) || !Number.isInteger(ts)) {\n throw new SlackError('Slack request timestamp is not a valid integer').withInternalDetails({\n reason: 'invalid_timestamp' satisfies SlackSignatureFailureReason,\n timestamp,\n });\n }\n\n if (Math.abs(now - ts) > maxAgeSeconds) {\n throw new SlackError('Slack request timestamp is outside the allowed window').withInternalDetails({\n reason: 'stale_timestamp' satisfies SlackSignatureFailureReason,\n timestamp: ts,\n now,\n maxAgeSeconds,\n });\n }\n\n if (!signature) {\n throw new SlackError('Slack request missing X-Slack-Signature header').withInternalDetails({\n reason: 'missing_signature' satisfies SlackSignatureFailureReason,\n });\n }\n\n const expected = `v0=${createHmac('sha256', signingSecret).update(`v0:${ts}:${rawBody}`).digest('hex')}`;\n const expectedBuf = Buffer.from(expected, 'utf8');\n const providedBuf = Buffer.from(signature, 'utf8');\n\n // timingSafeEqual throws on length mismatch — short-circuit so the caller\n // gets a uniform \"invalid_signature\" error instead of a crypto exception.\n if (expectedBuf.length !== providedBuf.length || !timingSafeEqual(expectedBuf, providedBuf)) {\n throw new SlackError('Slack request signature does not match').withInternalDetails({\n reason: 'invalid_signature' satisfies SlackSignatureFailureReason,\n });\n }\n};\n","import { BinaryLike } from 'node:crypto';\nimport { Injectable } from 'injectkit';\nimport { Policy, PolicyEnvelope, PolicyResult } from '@maroonedsoftware/policies';\nimport { SlackConfig } from './slack.config.js';\nimport { IsSlackError } from './slack.error.js';\nimport { verifySlackSignature, type SlackSignatureFailureReason } from './slack.signature.js';\n\n/**\n * Policy name under which {@link SlackSignaturePolicy} is registered. Use as the\n * key when wiring your `PolicyRegistryMap`, and pass to `PolicyService.check`.\n */\nexport const SLACK_SIGNATURE_POLICY = 'slack.signature.valid' as const;\n\n/** Header carrying the request timestamp Slack signs into the HMAC. */\nexport const SLACK_REQUEST_TIMESTAMP_HEADER = 'X-Slack-Request-Timestamp';\n/** Header carrying the `v0=`-prefixed request signature. */\nexport const SLACK_SIGNATURE_HEADER = 'X-Slack-Signature';\n\n/**\n * Configuration the {@link SlackSignaturePolicy} reads. A structural subset of\n * {@link SlackConfig}, so a `SlackConfig` value satisfies it directly — e.g.\n * `requireSignature<SlackSignatureOptions>('slack')` with the Slack config\n * stored under that `AppConfig` key.\n */\nexport type SlackSignatureOptions = Pick<SlackConfig, 'signingSecret' | 'signatureMaxAgeSeconds'>;\n\n/**\n * Context for {@link SlackSignaturePolicy}: the raw request bytes, a\n * case-insensitive header accessor, and the {@link SlackSignatureOptions}.\n *\n * Structurally compatible with `@maroonedsoftware/koa`'s\n * `SignaturePolicyContext<SlackSignatureOptions>`, so the koa `requireSignature`\n * middleware can drive this policy without the slack package depending on koa —\n * register `SlackSignaturePolicy` under the signature policy name and point the\n * middleware at the `AppConfig` key holding the Slack config.\n */\nexport interface SlackSignaturePolicyContext {\n /** Raw, unparsed request body — exactly as Slack sent it (from `ctx.rawBody`). */\n rawBody: BinaryLike;\n /**\n * Case-insensitive request header accessor (Koa's `ctx.get`); returns `''`\n * when the header is absent.\n */\n getHeader: (name: string) => string;\n /** Slack signing configuration. */\n options: SlackSignatureOptions;\n}\n\n/**\n * Policy form of {@link verifySlackSignature}: verifies a Slack request against\n * the app signing secret using Slack's v0 scheme (HMAC over\n * `v0:{timestamp}:{rawBody}`, `v0=`-prefixed, with timestamp replay\n * protection).\n *\n * Delegates to {@link verifySlackSignature} so the crypto/timestamp logic has a\n * single source of truth, but answers as a {@link PolicyResult} rather than\n * throwing: allows on success, denies on failure with the helper's\n * {@link SlackSignatureFailureReason} as the denial `reason` and its diagnostics\n * (timestamps, window) on `internalDetails` — never the signing secret, never\n * on the wire. The replay window is anchored to `envelope.now` so all policies\n * in an evaluation share one clock.\n *\n * Registered by default under {@link SLACK_SIGNATURE_POLICY}.\n *\n * @example\n * ```ts\n * // Direct evaluation in a route handler:\n * const result = await policyService.check(SLACK_SIGNATURE_POLICY, {\n * rawBody: ctx.rawBody,\n * getHeader: name => ctx.get(name),\n * options: ctx.container.get(SlackConfig),\n * });\n * if (isPolicyResultDenied(result)) throw httpError(401);\n * ```\n */\n@Injectable()\nexport class SlackSignaturePolicy extends Policy<SlackSignaturePolicyContext> {\n async evaluate(context: SlackSignaturePolicyContext, envelope: PolicyEnvelope): Promise<PolicyResult> {\n const { rawBody, getHeader, options } = context;\n\n // Slack signs the raw text body; `ctx.rawBody` may arrive as a Buffer.\n const body = typeof rawBody === 'string' ? rawBody : Buffer.from(rawBody as Uint8Array).toString('utf8');\n\n try {\n verifySlackSignature({\n signingSecret: options.signingSecret,\n rawBody: body,\n timestamp: getHeader(SLACK_REQUEST_TIMESTAMP_HEADER),\n signature: getHeader(SLACK_SIGNATURE_HEADER),\n maxAgeSeconds: options.signatureMaxAgeSeconds,\n now: Math.floor(envelope.now.toSeconds()),\n });\n return this.allow();\n } catch (error) {\n if (!IsSlackError(error)) throw error;\n\n const internalDetails = error.internalDetails ?? {};\n const reason = typeof internalDetails.reason === 'string' ? internalDetails.reason : ('invalid_signature' satisfies SlackSignatureFailureReason);\n return this.deny(reason, undefined, { message: error.message, ...internalDetails });\n }\n }\n}\n","/**\n * The supported interactive payload types Slack POSTs to the interactivity\n * endpoint. Each maps to a different identifier shape (see\n * {@link interactionRouteKey}).\n */\nexport type SlackInteractionType = 'block_actions' | 'view_submission' | 'view_closed' | 'shortcut' | 'message_action' | string;\n\n/**\n * Loose typing for the interactive payload; consumers narrow per handler.\n * Slack's payloads vary by type, but every variant has a `type` field plus\n * one of: `actions[].action_id`, `view.callback_id`, or top-level `callback_id`.\n */\nexport type SlackInteractionPayload = {\n type: SlackInteractionType;\n team?: { id: string; domain?: string };\n user?: { id: string; name?: string };\n trigger_id?: string;\n response_url?: string;\n actions?: Array<{ action_id: string; block_id?: string; value?: string; [key: string]: unknown }>;\n view?: { id: string; callback_id: string; [key: string]: unknown };\n callback_id?: string;\n [key: string]: unknown;\n};\n\n/**\n * Optional response Slack accepts for `view_submission` / `view_closed`\n * payloads (e.g. to display validation errors or update a modal).\n */\nexport type SlackInteractionResponse = {\n response_action?: 'errors' | 'update' | 'push' | 'clear';\n errors?: Record<string, string>;\n view?: unknown;\n [key: string]: unknown;\n};\n\n/**\n * Handler for one interactive payload, keyed in {@link SlackInteractionHandlerMap}\n * by `${type}:${identifier}` — see {@link interactionRouteKey}.\n */\nexport interface SlackInteractionHandler {\n handle(payload: SlackInteractionPayload): Promise<SlackInteractionResponse | void>;\n}\n\n/**\n * Computes the routing key used by {@link SlackDispatcher.dispatchInteraction}\n * to look a handler up in {@link SlackInteractionHandlerMap}.\n *\n * - `block_actions` → `block_actions:<first action.action_id>`\n * - `view_submission` / `view_closed` → `<type>:<view.callback_id>`\n * - `shortcut` / `message_action` → `<type>:<callback_id>`\n * - any other type with a `callback_id` → `<type>:<callback_id>`\n *\n * @returns The routing key, or `undefined` if the payload doesn't carry an\n * identifier we can route on (e.g. a `block_actions` payload with no actions).\n */\nexport const interactionRouteKey = (payload: SlackInteractionPayload): string | undefined => {\n switch (payload.type) {\n case 'block_actions': {\n const id = payload.actions?.[0]?.action_id;\n return id ? `block_actions:${id}` : undefined;\n }\n case 'view_submission':\n case 'view_closed': {\n const id = payload.view?.callback_id;\n return id ? `${payload.type}:${id}` : undefined;\n }\n case 'shortcut':\n case 'message_action': {\n return payload.callback_id ? `${payload.type}:${payload.callback_id}` : undefined;\n }\n default: {\n return payload.callback_id ? `${payload.type}:${payload.callback_id}` : undefined;\n }\n }\n};\n","import { Injectable } from 'injectkit';\nimport { Logger } from '@maroonedsoftware/logger';\nimport type { SlackEventCallback, SlackEventHandler } from './slack.event.handler.js';\nimport type { SlackCommandHandler, SlackCommandPayload, SlackCommandResponse } from './slack.command.handler.js';\nimport {\n interactionRouteKey,\n SlackInteractionHandler,\n type SlackInteractionPayload,\n type SlackInteractionResponse,\n} from './slack.interaction.handler.js';\n\n/**\n * Body shape Slack POSTs to the Events API endpoint. The handshake variant\n * (`url_verification`) is sent once during app configuration; the rest of the\n * traffic is `event_callback` envelopes (or other future top-level types).\n */\nexport type SlackEventsRequest =\n | { type: 'url_verification'; challenge: string; token?: string }\n | SlackEventCallback\n | { type: string; [key: string]: unknown };\n\n/**\n * Response Slack expects for the `url_verification` handshake. For\n * `event_callback` and unknown event types, the dispatcher returns\n * `undefined` and the caller should ack with HTTP 200.\n */\nexport type SlackEventsResponse = { challenge: string } | undefined;\n\n/**\n * Injectable map of command keyword (e.g. `/deploy`) → {@link SlackCommandHandler}.\n *\n * @example\n * ```ts\n * const commands = new SlackCommandHandlerMap();\n * commands.set('/deploy', container.get(DeployCommandHandler));\n * container.register(SlackCommandHandlerMap, { useValue: commands });\n * ```\n */\n@Injectable()\nexport class SlackCommandHandlerMap extends Map<string, SlackCommandHandler> {}\n\n/**\n * Injectable map of Slack event type → {@link SlackEventHandler}. Consumers\n * register handlers at bootstrap and place an instance of this map in their\n * DI container; {@link SlackDispatcher.dispatchEvent} resolves it per request.\n *\n * @example\n * ```ts\n * const handlers = new SlackEventHandlerMap();\n * handlers.set('app_mention', container.get(MyAppMentionHandler));\n * container.register(SlackEventHandlerMap, { useValue: handlers });\n * ```\n */\n@Injectable()\nexport class SlackEventHandlerMap extends Map<string, SlackEventHandler> {}\n\n/**\n * Injectable map of interaction routing keys → {@link SlackInteractionHandler}.\n *\n * Keys are produced by `interactionRouteKey(payload)`, which combines the\n * payload `type` with the relevant identifier (`action_id`, `callback_id`,\n * etc.). Register handlers under the same key shape:\n *\n * @example\n * ```ts\n * const interactions = new SlackInteractionHandlerMap();\n * interactions.set('block_actions:approve_button', container.get(ApproveHandler));\n * interactions.set('view_submission:create_ticket_modal', container.get(CreateTicketHandler));\n * container.register(SlackInteractionHandlerMap, { useValue: interactions });\n * ```\n */\n@Injectable()\nexport class SlackInteractionHandlerMap extends Map<string, SlackInteractionHandler> {}\n\n/**\n * Single entry point for dispatching parsed Slack payloads to registered\n * handlers. Transport-agnostic: the consumer is responsible for receiving\n * the HTTP request, verifying the signature, parsing the body, calling the\n * appropriate `dispatch*` method, and serializing the response.\n *\n * @example Koa route\n * ```ts\n * router.post('/slack/events', async (ctx) => {\n * const raw = await rawBody(ctx.req, { encoding: 'utf8' });\n * verifySlackSignature({\n * signingSecret: ctx.container.get(SlackConfig).signingSecret,\n * rawBody: raw,\n * timestamp: ctx.get('x-slack-request-timestamp'),\n * signature: ctx.get('x-slack-signature'),\n * });\n * const result = await ctx.container.get(SlackDispatcher).dispatchEvent(JSON.parse(raw));\n * if (result) ctx.body = result;\n * else { ctx.status = 200; ctx.body = ''; }\n * });\n * ```\n */\n@Injectable()\nexport class SlackDispatcher {\n constructor(\n private readonly events: SlackEventHandlerMap,\n private readonly commands: SlackCommandHandlerMap,\n private readonly interactions: SlackInteractionHandlerMap,\n private readonly logger: Logger,\n ) {}\n\n /**\n * Dispatch a parsed Events API body.\n *\n * - Returns `{ challenge }` for `url_verification` — the caller serializes\n * it as the response body.\n * - For `event_callback`, looks up a handler in {@link SlackEventHandlerMap}\n * keyed by `event.type` and invokes it. Returns `undefined`. Slack retries\n * any non-2xx so unknown event types are logged at debug and acked.\n * - For any other top-level type, logs and returns `undefined`.\n */\n async dispatchEvent(body: SlackEventsRequest): Promise<SlackEventsResponse> {\n if (body.type === 'url_verification') {\n return { challenge: (body as { challenge: string }).challenge };\n }\n\n if (body.type === 'event_callback') {\n const envelope = body as SlackEventCallback;\n const handler = this.events.get(envelope.event.type);\n if (handler) {\n await handler.handle(envelope.event, {\n teamId: envelope.team_id,\n eventId: envelope.event_id,\n eventTime: envelope.event_time,\n envelope,\n });\n } else {\n this.logger.debug('No Slack event handler registered for event type', { type: envelope.event.type });\n }\n return undefined;\n }\n\n this.logger.debug('Unhandled Slack events payload type', { type: body.type });\n return undefined;\n }\n\n /**\n * Dispatch a parsed slash-command payload.\n *\n * Looks up a handler in {@link SlackCommandHandlerMap} keyed by\n * `payload.command` (e.g. `/deploy`). If the handler returns a response,\n * the caller serializes it as JSON; otherwise the caller acks with `200 ''`\n * and the handler is expected to follow up via `payload.response_url`.\n */\n async dispatchCommand(payload: SlackCommandPayload): Promise<SlackCommandResponse | void> {\n const handler = this.commands.get(payload.command);\n if (!handler) {\n this.logger.debug('No Slack command handler registered', { command: payload.command });\n return undefined;\n }\n return await handler.handle(payload);\n }\n\n /**\n * Dispatch a parsed interactive payload (block actions, view submission,\n * shortcut, etc.). Computes a routing key via {@link interactionRouteKey}\n * and looks it up in {@link SlackInteractionHandlerMap}.\n */\n async dispatchInteraction(payload: SlackInteractionPayload): Promise<SlackInteractionResponse | void> {\n const key = interactionRouteKey(payload);\n if (!key) {\n this.logger.debug('Slack interaction payload missing routable identifier', { type: payload.type });\n return undefined;\n }\n const handler = this.interactions.get(key);\n if (!handler) {\n this.logger.debug('No Slack interaction handler registered', { key });\n return undefined;\n }\n return await handler.handle(payload);\n }\n}\n","import { Injectable } from 'injectkit';\nimport { WebClient } from '@slack/web-api';\nimport type { ChatPostMessageArguments, ChatPostMessageResponse, ChatUpdateArguments, ChatUpdateResponse, ChatDeleteArguments, ChatDeleteResponse, ViewsOpenArguments, ViewsOpenResponse } from '@slack/web-api';\nimport { Logger } from '@maroonedsoftware/logger';\nimport { SlackConfig } from '../slack.config.js';\nimport { SlackError } from '../slack.error.js';\nimport { adaptLogger } from './slack.logger.adapter.js';\n\n/**\n * Payload for an incoming-webhook POST. Mirrors the subset of fields Slack's\n * incoming webhooks accept (text, blocks, attachments, response shaping).\n * The body is JSON-stringified verbatim, so any extra fields are preserved.\n */\nexport type IncomingWebhookPayload = {\n text?: string;\n blocks?: unknown[];\n attachments?: unknown[];\n thread_ts?: string;\n response_type?: 'in_channel' | 'ephemeral';\n replace_original?: boolean;\n delete_original?: boolean;\n unfurl_links?: boolean;\n unfurl_media?: boolean;\n [key: string]: unknown;\n};\n\n/**\n * Thin DI-friendly wrapper around `@slack/web-api`'s `WebClient`. Constructed\n * once per request scope (or as a singleton, depending on how the consumer\n * registers it) and exposes typed passthroughs for the most common Web API\n * methods plus a `postWebhook` helper for incoming-webhook URLs and the\n * `response_url` returned by slash commands and interactive payloads.\n *\n * Reach for {@link SlackClient.web} directly for anything else the underlying\n * client supports.\n *\n * @example\n * ```ts\n * await container.get(SlackClient).postMessage({ channel: '#ops', text: 'hello' });\n * await container.get(SlackClient).postWebhook({ text: 'follow-up' }, payload.response_url);\n * ```\n */\n@Injectable()\nexport class SlackClient {\n /** Underlying `@slack/web-api` client. */\n readonly web: WebClient;\n\n constructor(\n private readonly config: SlackConfig,\n private readonly logger: Logger,\n ) {\n this.web = new WebClient(config.botToken, { logger: adaptLogger(logger) });\n }\n\n /** Posts a message via `chat.postMessage`. */\n postMessage(args: ChatPostMessageArguments): Promise<ChatPostMessageResponse> {\n return this.web.chat.postMessage(args);\n }\n\n /** Updates a message via `chat.update`. */\n updateMessage(args: ChatUpdateArguments): Promise<ChatUpdateResponse> {\n return this.web.chat.update(args);\n }\n\n /** Deletes a message via `chat.delete`. */\n deleteMessage(args: ChatDeleteArguments): Promise<ChatDeleteResponse> {\n return this.web.chat.delete(args);\n }\n\n /** Opens a modal view via `views.open`. */\n openView(args: ViewsOpenArguments): Promise<ViewsOpenResponse> {\n return this.web.views.open(args);\n }\n\n /**\n * POSTs a payload to a Slack incoming-webhook-style URL — either the\n * configured `incomingWebhookUrl` or an explicit URL (e.g. the\n * `response_url` from a slash command or interactive payload).\n *\n * @throws {@link SlackError} if no URL is available or the response is non-2xx.\n */\n async postWebhook(payload: IncomingWebhookPayload, url?: string): Promise<void> {\n const target = url ?? this.config.incomingWebhookUrl;\n if (!target) {\n throw new SlackError('SlackClient.postWebhook called but no incomingWebhookUrl is configured and no url was provided');\n }\n const response = await fetch(target, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify(payload),\n });\n if (!response.ok) {\n const body = await response.text().catch(() => '');\n this.logger.warn('Slack webhook POST returned non-OK status', { status: response.status, body });\n throw new SlackError(`Slack webhook POST returned ${response.status}`).withInternalDetails({ status: response.status, body, url: target });\n }\n }\n}\n","import type { Logger as SlackLogger, LogLevel } from '@slack/web-api';\nimport { Logger } from '@maroonedsoftware/logger';\n\n/**\n * Adapts a ServerKit {@link Logger} to the `@slack/web-api` {@link SlackLogger}\n * interface so the WebClient can route its diagnostics through the host\n * application's logger.\n *\n * The Slack SDK's logger calls `logger.info(...args)` with a variable number\n * of arguments and no separate \"primary message\"; the adapter forwards them\n * to ServerKit's `(message, ...optionalParams)` shape, with an empty-string\n * primary when no args are passed.\n *\n * `setLevel`, `setName`, and `getLevel` are stored locally — ServerKit\n * loggers do not expose these knobs but the SDK expects them on its logger.\n *\n * @param logger - The ServerKit logger to forward calls to.\n * @param name - Initial value for the SDK logger's name. Defaults to `'slack-web-api'`.\n * @returns A `@slack/web-api`-compatible logger object.\n */\nexport const adaptLogger = (logger: Logger, name = 'slack-web-api'): SlackLogger => {\n const state = { name, level: 'info' as LogLevel };\n const forward = (fn: (message: unknown, ...optionalParams: unknown[]) => void) => (...msg: unknown[]) => {\n const [first, ...rest] = msg;\n fn(first ?? '', ...rest);\n };\n return {\n debug: forward(logger.debug.bind(logger)),\n info: forward(logger.info.bind(logger)),\n warn: forward(logger.warn.bind(logger)),\n error: forward(logger.error.bind(logger)),\n setLevel: (level: LogLevel) => {\n state.level = level;\n },\n getLevel: () => state.level,\n setName: (n: string) => {\n state.name = n;\n },\n };\n};\n"],"mappings":"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","names":["Injectable","SlackConfig","ServerkitError","SlackError","ServerkitError","IsSlackError","error","createHmac","timingSafeEqual","DateTime","SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS","verifySlackSignature","input","signingSecret","rawBody","timestamp","signature","maxAgeSeconds","now","Math","floor","DateTime","toSeconds","SlackError","withInternalDetails","reason","ts","Number","isFinite","isInteger","abs","expected","createHmac","update","digest","expectedBuf","Buffer","from","providedBuf","length","timingSafeEqual","Injectable","Policy","SLACK_SIGNATURE_POLICY","SLACK_REQUEST_TIMESTAMP_HEADER","SLACK_SIGNATURE_HEADER","SlackSignaturePolicy","Policy","evaluate","context","envelope","rawBody","getHeader","options","body","Buffer","from","toString","verifySlackSignature","signingSecret","timestamp","signature","maxAgeSeconds","signatureMaxAgeSeconds","now","Math","floor","toSeconds","allow","error","IsSlackError","internalDetails","reason","deny","undefined","message","interactionRouteKey","payload","type","id","actions","action_id","undefined","view","callback_id","Injectable","Logger","SlackCommandHandlerMap","Map","SlackEventHandlerMap","SlackInteractionHandlerMap","SlackDispatcher","events","commands","interactions","logger","dispatchEvent","body","type","challenge","envelope","handler","get","event","handle","teamId","team_id","eventId","event_id","eventTime","event_time","debug","undefined","dispatchCommand","payload","command","dispatchInteraction","key","interactionRouteKey","Injectable","WebClient","Logger","adaptLogger","logger","name","state","level","forward","fn","msg","first","rest","debug","bind","info","warn","error","setLevel","getLevel","setName","n","SlackClient","web","config","logger","WebClient","botToken","adaptLogger","postMessage","args","chat","updateMessage","update","deleteMessage","delete","openView","views","open","postWebhook","payload","url","target","incomingWebhookUrl","SlackError","response","fetch","method","headers","body","JSON","stringify","ok","text","catch","warn","status","withInternalDetails"]}
|
|
@@ -26,7 +26,7 @@ export type VerifySlackSignatureInput = {
|
|
|
26
26
|
maxAgeSeconds?: number;
|
|
27
27
|
/**
|
|
28
28
|
* Override for the current Unix time in seconds. Mostly useful for tests;
|
|
29
|
-
* defaults to `Math.floor(
|
|
29
|
+
* defaults to `Math.floor(DateTime.now().toSeconds())`.
|
|
30
30
|
*/
|
|
31
31
|
now?: number;
|
|
32
32
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slack.signature.d.ts","sourceRoot":"","sources":["../src/slack.signature.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"slack.signature.d.ts","sourceRoot":"","sources":["../src/slack.signature.ts"],"names":[],"mappings":"AAIA,gGAAgG;AAChG,eAAO,MAAM,uCAAuC,MAAM,CAAC;AAE3D;;;;GAIG;AACH,MAAM,MAAM,2BAA2B,GACnC,mBAAmB,GACnB,mBAAmB,GACnB,iBAAiB,GACjB,mBAAmB,GACnB,mBAAmB,CAAC;AAExB;;;GAGG;AACH,MAAM,MAAM,yBAAyB,GAAG;IACtC,wDAAwD;IACxD,aAAa,EAAE,MAAM,CAAC;IACtB,6DAA6D;IAC7D,OAAO,EAAE,MAAM,CAAC;IAChB,uDAAuD;IACvD,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B,qEAAqE;IACrE,SAAS,EAAE,MAAM,GAAG,SAAS,CAAC;IAC9B;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,eAAO,MAAM,oBAAoB,GAAI,OAAO,yBAAyB,KAAG,IAkDvE,CAAC"}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { BinaryLike } from 'node:crypto';
|
|
2
|
+
import { Policy, PolicyEnvelope, PolicyResult } from '@maroonedsoftware/policies';
|
|
3
|
+
import { SlackConfig } from './slack.config.js';
|
|
4
|
+
/**
|
|
5
|
+
* Policy name under which {@link SlackSignaturePolicy} is registered. Use as the
|
|
6
|
+
* key when wiring your `PolicyRegistryMap`, and pass to `PolicyService.check`.
|
|
7
|
+
*/
|
|
8
|
+
export declare const SLACK_SIGNATURE_POLICY: "slack.signature.valid";
|
|
9
|
+
/** Header carrying the request timestamp Slack signs into the HMAC. */
|
|
10
|
+
export declare const SLACK_REQUEST_TIMESTAMP_HEADER = "X-Slack-Request-Timestamp";
|
|
11
|
+
/** Header carrying the `v0=`-prefixed request signature. */
|
|
12
|
+
export declare const SLACK_SIGNATURE_HEADER = "X-Slack-Signature";
|
|
13
|
+
/**
|
|
14
|
+
* Configuration the {@link SlackSignaturePolicy} reads. A structural subset of
|
|
15
|
+
* {@link SlackConfig}, so a `SlackConfig` value satisfies it directly — e.g.
|
|
16
|
+
* `requireSignature<SlackSignatureOptions>('slack')` with the Slack config
|
|
17
|
+
* stored under that `AppConfig` key.
|
|
18
|
+
*/
|
|
19
|
+
export type SlackSignatureOptions = Pick<SlackConfig, 'signingSecret' | 'signatureMaxAgeSeconds'>;
|
|
20
|
+
/**
|
|
21
|
+
* Context for {@link SlackSignaturePolicy}: the raw request bytes, a
|
|
22
|
+
* case-insensitive header accessor, and the {@link SlackSignatureOptions}.
|
|
23
|
+
*
|
|
24
|
+
* Structurally compatible with `@maroonedsoftware/koa`'s
|
|
25
|
+
* `SignaturePolicyContext<SlackSignatureOptions>`, so the koa `requireSignature`
|
|
26
|
+
* middleware can drive this policy without the slack package depending on koa —
|
|
27
|
+
* register `SlackSignaturePolicy` under the signature policy name and point the
|
|
28
|
+
* middleware at the `AppConfig` key holding the Slack config.
|
|
29
|
+
*/
|
|
30
|
+
export interface SlackSignaturePolicyContext {
|
|
31
|
+
/** Raw, unparsed request body — exactly as Slack sent it (from `ctx.rawBody`). */
|
|
32
|
+
rawBody: BinaryLike;
|
|
33
|
+
/**
|
|
34
|
+
* Case-insensitive request header accessor (Koa's `ctx.get`); returns `''`
|
|
35
|
+
* when the header is absent.
|
|
36
|
+
*/
|
|
37
|
+
getHeader: (name: string) => string;
|
|
38
|
+
/** Slack signing configuration. */
|
|
39
|
+
options: SlackSignatureOptions;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Policy form of {@link verifySlackSignature}: verifies a Slack request against
|
|
43
|
+
* the app signing secret using Slack's v0 scheme (HMAC over
|
|
44
|
+
* `v0:{timestamp}:{rawBody}`, `v0=`-prefixed, with timestamp replay
|
|
45
|
+
* protection).
|
|
46
|
+
*
|
|
47
|
+
* Delegates to {@link verifySlackSignature} so the crypto/timestamp logic has a
|
|
48
|
+
* single source of truth, but answers as a {@link PolicyResult} rather than
|
|
49
|
+
* throwing: allows on success, denies on failure with the helper's
|
|
50
|
+
* {@link SlackSignatureFailureReason} as the denial `reason` and its diagnostics
|
|
51
|
+
* (timestamps, window) on `internalDetails` — never the signing secret, never
|
|
52
|
+
* on the wire. The replay window is anchored to `envelope.now` so all policies
|
|
53
|
+
* in an evaluation share one clock.
|
|
54
|
+
*
|
|
55
|
+
* Registered by default under {@link SLACK_SIGNATURE_POLICY}.
|
|
56
|
+
*
|
|
57
|
+
* @example
|
|
58
|
+
* ```ts
|
|
59
|
+
* // Direct evaluation in a route handler:
|
|
60
|
+
* const result = await policyService.check(SLACK_SIGNATURE_POLICY, {
|
|
61
|
+
* rawBody: ctx.rawBody,
|
|
62
|
+
* getHeader: name => ctx.get(name),
|
|
63
|
+
* options: ctx.container.get(SlackConfig),
|
|
64
|
+
* });
|
|
65
|
+
* if (isPolicyResultDenied(result)) throw httpError(401);
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
export declare class SlackSignaturePolicy extends Policy<SlackSignaturePolicyContext> {
|
|
69
|
+
evaluate(context: SlackSignaturePolicyContext, envelope: PolicyEnvelope): Promise<PolicyResult>;
|
|
70
|
+
}
|
|
71
|
+
//# sourceMappingURL=slack.signature.policy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"slack.signature.policy.d.ts","sourceRoot":"","sources":["../src/slack.signature.policy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAEzC,OAAO,EAAE,MAAM,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAClF,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAIhD;;;GAGG;AACH,eAAO,MAAM,sBAAsB,EAAG,uBAAgC,CAAC;AAEvE,uEAAuE;AACvE,eAAO,MAAM,8BAA8B,8BAA8B,CAAC;AAC1E,4DAA4D;AAC5D,eAAO,MAAM,sBAAsB,sBAAsB,CAAC;AAE1D;;;;;GAKG;AACH,MAAM,MAAM,qBAAqB,GAAG,IAAI,CAAC,WAAW,EAAE,eAAe,GAAG,wBAAwB,CAAC,CAAC;AAElG;;;;;;;;;GASG;AACH,MAAM,WAAW,2BAA2B;IAC1C,kFAAkF;IAClF,OAAO,EAAE,UAAU,CAAC;IACpB;;;OAGG;IACH,SAAS,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,mCAAmC;IACnC,OAAO,EAAE,qBAAqB,CAAC;CAChC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,qBACa,oBAAqB,SAAQ,MAAM,CAAC,2BAA2B,CAAC;IACrE,QAAQ,CAAC,OAAO,EAAE,2BAA2B,EAAE,QAAQ,EAAE,cAAc,GAAG,OAAO,CAAC,YAAY,CAAC;CAwBtG"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maroonedsoftware/slack",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Slack utilities for ServerKit.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Marooned Software",
|
|
@@ -18,29 +18,52 @@
|
|
|
18
18
|
],
|
|
19
19
|
"repository": {
|
|
20
20
|
"type": "git",
|
|
21
|
-
"url": "https://github.com/MaroonedSoftware/serverkit.git"
|
|
21
|
+
"url": "git+https://github.com/MaroonedSoftware/serverkit.git"
|
|
22
22
|
},
|
|
23
23
|
"private": false,
|
|
24
24
|
"type": "module",
|
|
25
25
|
"main": "./dist/index.js",
|
|
26
26
|
"module": "./dist/index.js",
|
|
27
27
|
"types": "./dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"import": "./dist/index.js"
|
|
32
|
+
},
|
|
33
|
+
"./comms": {
|
|
34
|
+
"types": "./dist/comms.d.ts",
|
|
35
|
+
"import": "./dist/comms.js"
|
|
36
|
+
},
|
|
37
|
+
"./package.json": "./package.json"
|
|
38
|
+
},
|
|
28
39
|
"license": "MIT",
|
|
29
40
|
"files": [
|
|
30
41
|
"dist/**"
|
|
31
42
|
],
|
|
32
43
|
"dependencies": {
|
|
33
|
-
"@slack/web-api": "^7.
|
|
34
|
-
"injectkit": "^1.
|
|
35
|
-
"
|
|
36
|
-
"@maroonedsoftware/logger": "1.1.1"
|
|
44
|
+
"@slack/web-api": "^7.17.0",
|
|
45
|
+
"injectkit": "^1.5.0",
|
|
46
|
+
"luxon": "^3.7.2",
|
|
47
|
+
"@maroonedsoftware/logger": "1.1.1",
|
|
48
|
+
"@maroonedsoftware/policies": "0.5.0",
|
|
49
|
+
"@maroonedsoftware/errors": "1.7.0"
|
|
37
50
|
},
|
|
38
51
|
"devDependencies": {
|
|
52
|
+
"@types/luxon": "^3.7.2",
|
|
53
|
+
"@maroonedsoftware/comms": "0.2.0",
|
|
39
54
|
"@repo/config-eslint": "0.2.1",
|
|
40
55
|
"@repo/config-typescript": "0.1.0"
|
|
41
56
|
},
|
|
57
|
+
"peerDependencies": {
|
|
58
|
+
"@maroonedsoftware/comms": "0.2.0"
|
|
59
|
+
},
|
|
60
|
+
"peerDependenciesMeta": {
|
|
61
|
+
"@maroonedsoftware/comms": {
|
|
62
|
+
"optional": true
|
|
63
|
+
}
|
|
64
|
+
},
|
|
42
65
|
"scripts": {
|
|
43
|
-
"build": "tsup src/index.ts --format esm --sourcemap
|
|
66
|
+
"build": "tsup src/index.ts src/comms.ts --format esm --sourcemap && tsc --emitDeclarationOnly --declaration",
|
|
44
67
|
"build:ci": "eslint --max-warnings=0 && pnpm run build",
|
|
45
68
|
"lint": "eslint --fix",
|
|
46
69
|
"format": "prettier --write .",
|