@maroonedsoftware/slack 3.0.1 → 3.0.4

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.
Files changed (2) hide show
  1. package/AGENTS.md +206 -0
  2. package/package.json +11 -10
package/AGENTS.md ADDED
@@ -0,0 +1,206 @@
1
+ # AGENTS.md — @maroonedsoftware/slack
2
+
3
+ Machine-oriented guide for AI agents. Human prose and long-form examples live in [README.md](./README.md).
4
+ Repo-wide conventions live in the [root AGENTS.md](../../AGENTS.md).
5
+
6
+ ## Purpose
7
+
8
+ A Slack dispatcher for ServerKit: signature verification as a policy, DI-registered handler maps for
9
+ events, slash commands, and interactive payloads, a `SlackClient` over `@slack/web-api`, and an
10
+ optional adapter that binds it all to the channel-agnostic `@maroonedsoftware/comms` router.
11
+
12
+ Reach for the native handler maps when you want Slack-specific richness (Block Kit modals,
13
+ `view_submission`). Reach for `./comms` when you want one bot that also runs on Discord, Telegram,
14
+ and WhatsApp. The two coexist: `./comms` normalises the common cases and leaves the rest on the
15
+ native maps.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ pnpm add @maroonedsoftware/slack
21
+ pnpm add @maroonedsoftware/comms # only for the ./comms adapter
22
+ pnpm add @maroonedsoftware/cache # only for idempotency
23
+ ```
24
+
25
+ Runtime dependencies: `@maroonedsoftware/errors`, `@maroonedsoftware/logger`,
26
+ `@maroonedsoftware/policies`, `@slack/web-api`, `injectkit`, `luxon`. Optional peers:
27
+ `@maroonedsoftware/comms`, `@maroonedsoftware/cache`.
28
+
29
+ ## Position in the graph
30
+
31
+ - **Depends on:** `errors`, `logger`, `policies`. `comms` and `cache` are **optional** peers.
32
+ - **Depended on by:** nothing internal.
33
+ - **Subpath exports:**
34
+ - `.` — config, errors, signature verification, handler maps, dispatcher, client.
35
+ - `./comms` — the adapter. Pulls in `@maroonedsoftware/comms`. It lives here, not in `comms`,
36
+ because `comms` must stay channel-free; see the root AGENTS.md.
37
+
38
+ **Not a dependency: `koa`.** Your route parses the request and calls the dispatcher.
39
+
40
+ ## API surface
41
+
42
+ ### `.` — config and errors
43
+
44
+ | Export | Kind | Shape | Notes |
45
+ | -------------- | -------------------------- | ---------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
46
+ | `SlackConfig` | interface + abstract class | `{ botToken, signingSecret, incomingWebhookUrl?, signatureMaxAgeSeconds?, requestTimeoutMs? }` | Declaration-merged so one symbol is type and DI token. |
47
+ | `SlackError` | class | `extends ServerkitError` | — |
48
+ | `IsSlackError` | type guard | `(error: unknown) => error is SlackError` | — |
49
+
50
+ ### `.` — signature verification
51
+
52
+ | Export | Kind | Shape | Notes |
53
+ | ----------------------------------------- | --------- | ----------------------------------------------------------- | ------------------------------------------ |
54
+ | `verifySlackSignature` | function | `(input: VerifySlackSignatureInput) => void` | Pure. **Throws** `SlackError` on failure. |
55
+ | `VerifySlackSignatureInput` | type | Raw body, headers, and `SlackSignatureOptions` | — |
56
+ | `SlackSignatureOptions` | type | Signing secret plus max age | — |
57
+ | `SlackSignatureFailureReason` | type | Reason codes landing in `internalDetails.reason` | — |
58
+ | `SlackSignaturePolicy` | class | `extends Policy<SlackSignaturePolicyContext>` | Policy form — denies rather than throwing. |
59
+ | `SlackSignaturePolicyContext` | interface | Structurally compatible with koa's `SignaturePolicyContext` | What lets `requireSignature` drive it. |
60
+ | `SLACK_SIGNATURE_POLICY` | constant | The `PolicyRegistryMap` key | — |
61
+ | `SLACK_SIGNATURE_HEADER` | constant | `'X-Slack-Signature'` | — |
62
+ | `SLACK_REQUEST_TIMESTAMP_HEADER` | constant | `'X-Slack-Request-Timestamp'` | — |
63
+ | `SLACK_SIGNATURE_DEFAULT_MAX_AGE_SECONDS` | constant | Replay window | — |
64
+
65
+ ### `.` — handlers and dispatch
66
+
67
+ | Export | Kind | Shape | Notes |
68
+ | ---------------------------- | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------- |
69
+ | `SlackEventHandler<TEvent>` | interface | `handle(event, context: SlackEventContext)` | — |
70
+ | `SlackEventHandlerMap` | class | `extends Map<string, SlackEventHandler>` | Keyed by event type (`app_mention`, `message`). |
71
+ | `SlackCommandHandler` | interface | `handle(payload: SlackCommandPayload)` | — |
72
+ | `SlackCommandHandlerMap` | class | `extends Map<string, SlackCommandHandler>` | Keyed by command name. |
73
+ | `SlackInteractionHandler` | interface | `handle(payload: SlackInteractionPayload)` | — |
74
+ | `SlackInteractionHandlerMap` | class | `extends Map<string, SlackInteractionHandler>` | Keyed by `interactionRouteKey(payload)`. |
75
+ | `interactionRouteKey` | function | `(payload) => string \| undefined` | `block_actions:<action_id>`, `view_submission:<callback_id>`, … |
76
+ | `SlackDispatcher` | class | `dispatchEvent(body, options?)`, `dispatchCommand(payload)`, `dispatchInteraction(payload)` | The entry point. |
77
+ | `slackEventIdempotencyKey` | function | `(envelope: Pick<SlackEventCallback, 'event_id' \| 'team_id'>) => string` | Team-scoped, so ids are unique across workspaces. |
78
+ | Payload types | — | `SlackEventsRequest`, `SlackEventsResponse`, `SlackEventCallback`, `SlackEventContext`, `SlackCommandPayload`, `SlackCommandResponse`, `SlackInteractionPayload`, `SlackInteractionResponse`, `SlackInteractionType`, `IncomingWebhookPayload` | — |
79
+
80
+ `dispatchEvent(body, { idempotency })` wraps the handler in `IdempotencyStore.deduplicate` keyed by
81
+ `slackEventIdempotencyKey`. Slack redelivers events, so this is a real redelivery net, not just a
82
+ guard.
83
+
84
+ ### `.` — client
85
+
86
+ | Export | Kind | Shape | Notes |
87
+ | ---------------------------------- | -------- | --------------------------------------------------------------- | -------------------------------------- |
88
+ | `SlackClient` | class | `postMessage`, `postWebhook`, … | Over `@slack/web-api`. |
89
+ | `adaptLogger` | function | Bridges `@maroonedsoftware/logger` to `@slack/web-api`'s logger | — |
90
+ | `redactSlackUrl` | function | Strips the token from a webhook URL before logging | Use it before logging any webhook URL. |
91
+ | `SLACK_DEFAULT_REQUEST_TIMEOUT_MS` | constant | — | — |
92
+
93
+ ### `./comms`
94
+
95
+ | Export | Kind | Shape | Notes |
96
+ | -------------------------- | -------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------- |
97
+ | `createSlackNotifier` | function | `(client: SlackClient, templates: TemplateRegistry) => Notifier` | Recipient is a `response_url` (webhook) **or** a channel id. |
98
+ | `dispatchSlackEvent` | function | `(router, client, body) => Promise<{ challenge: string } \| undefined>` | Returns the `url_verification` challenge; routes `message`/`app_mention`. |
99
+ | `dispatchSlackCommand` | function | `(router, client, payload) => Promise<void>` | Replies via `response_url` when present. |
100
+ | `dispatchSlackInteraction` | function | `(router, client, payload) => Promise<void>` | **Only `block_actions`** is normalised. |
101
+
102
+ ## Canonical usage
103
+
104
+ ```typescript
105
+ import {
106
+ SlackConfig,
107
+ SlackDispatcher,
108
+ SlackEventHandlerMap,
109
+ SlackSignaturePolicy,
110
+ SLACK_SIGNATURE_POLICY,
111
+ type SlackSignatureOptions,
112
+ } from '@maroonedsoftware/slack';
113
+
114
+ // Composition root
115
+ registry.register(SlackConfig).useValue(appConfig.getAs<SlackConfig>('slack'));
116
+ const events = new SlackEventHandlerMap();
117
+ events.set('app_mention', container.get(MentionHandler));
118
+ registry.register(SlackEventHandlerMap).useValue(events);
119
+ policies.set(SLACK_SIGNATURE_POLICY, SlackSignaturePolicy);
120
+
121
+ // Route — signature first, then dispatch
122
+ router.post('/slack/events', requireSignature<SlackSignatureOptions>('slack', { policy: SLACK_SIGNATURE_POLICY }), async ctx => {
123
+ const body = JSON.parse(ctx.rawBody as string);
124
+ ctx.body = await ctx.container.get(SlackDispatcher).dispatchEvent(body, { idempotency: ctx.container.get(IdempotencyStore) });
125
+ });
126
+ ```
127
+
128
+ Channel-agnostic instead:
129
+
130
+ ```typescript
131
+ import { dispatchSlackCommand, createSlackNotifier } from '@maroonedsoftware/slack/comms';
132
+
133
+ await dispatchSlackCommand(router, client, payload);
134
+ ```
135
+
136
+ ## Rules for generated code
137
+
138
+ - Verify the signature **before** parsing or dispatching, using `requireSignature` with
139
+ `SLACK_SIGNATURE_POLICY`. The verification needs `ctx.rawBody`, so it must run before anything
140
+ that re-serialises the body.
141
+ - Store `SlackConfig` in `AppConfig` and register the typed section. Never inline `signingSecret` or
142
+ `botToken`.
143
+ - Pass an `IdempotencyStore` to `dispatchEvent`. Slack retries deliveries, so without it a flaky
144
+ handler produces duplicate side effects.
145
+ - Scope idempotency keys by team — `slackEventIdempotencyKey` already does, and a raw `event_id` is
146
+ not unique across workspaces.
147
+ - Handle the `url_verification` challenge. `dispatchSlackEvent` returns it; the native path expects
148
+ you to echo it.
149
+ - Ack within 3 seconds and do slow work in a job. Slack times out and retries.
150
+ - Never log a webhook URL without `redactSlackUrl` — the token is in the path.
151
+ - Import `./comms` functions from `@maroonedsoftware/slack/comms`, never from the root.
152
+
153
+ ## Gotchas
154
+
155
+ - **`./comms` normalises only part of the surface.** `dispatchSlackInteraction` handles
156
+ `block_actions` and returns silently for everything else; `view_submission` and `view_closed` stay
157
+ on `SlackInteractionHandlerMap`. Mixing the two paths is expected, not a mistake.
158
+ - **The comms adapter sanitises broadcast sequences.** `<!everyone>`, `<!channel>`, `<!here>` (and
159
+ their `<!channel|label>` forms) in outbound text are rewritten to literal `@everyone` / `@channel`
160
+ / `@here` so user-supplied text cannot ping a workspace. The **native** `SlackClient` path does
161
+ **not** do this — sanitise yourself there.
162
+ - **The recipient string is overloaded.** `createSlackNotifier` sends to a `response_url` when the
163
+ string starts with `http`, and to a channel id otherwise. A channel id that somehow starts with
164
+ `http` would be misrouted.
165
+ - **Bot messages are filtered out** of the comms path (`bot_id`, `subtype: 'bot_message'`) to avoid
166
+ loops. The native event handlers see them.
167
+ - **`verifySlackSignature` throws; `SlackSignaturePolicy` denies.** Same logic, two shapes.
168
+ - **The signature has a max-age replay window.** Clock skew on your host produces spurious
169
+ verification failures.
170
+ - **`@slack/web-api` is a hard dependency**, unlike `comms` and `cache`. Installing this package
171
+ pulls it in even if you only use the comms path.
172
+
173
+ ## Working inside this package
174
+
175
+ ```
176
+ src/
177
+ index.ts Root barrel
178
+ slack.config.ts SlackConfig (interface + token)
179
+ slack.error.ts SlackError, IsSlackError
180
+ slack.signature.ts verifySlackSignature + header/constant exports
181
+ slack.signature.policy.ts SlackSignaturePolicy, SLACK_SIGNATURE_POLICY
182
+ slack.event.handler.ts Event handler + map, slackEventIdempotencyKey
183
+ slack.command.handler.ts Command handler + map
184
+ slack.interaction.handler.ts Interaction handler + map, interactionRouteKey
185
+ slack.dispatcher.ts SlackDispatcher
186
+ client/slack.client.ts SlackClient
187
+ client/slack.logger.adapter.ts adaptLogger, redactSlackUrl
188
+ comms.ts Subpath entry — notifier, render, and the three dispatch functions
189
+ ```
190
+
191
+ Tests are in `tests/`, mirroring `src/`.
192
+
193
+ Invariants a change must not break:
194
+
195
+ - **Nothing reachable from `src/index.ts` may import `@maroonedsoftware/comms` or
196
+ `@maroonedsoftware/cache`.** Both are optional peers; `cache` is imported `type`-only in the
197
+ dispatcher for exactly this reason.
198
+ - No dependency on `@maroonedsoftware/koa`. `SlackSignaturePolicyContext` is structurally
199
+ compatible with koa's context, which is what keeps the arrow out.
200
+ - Signature comparison stays constant-time, and the replay window stays enforced.
201
+ - The comms adapter's broadcast sanitisation is a security control, not formatting.
202
+ - The four chat packages (`slack`, `discord`, `telegram`, `whatsapp`) share a deliberate shape:
203
+ config token, error + guard, verification function + policy, handler maps, dispatcher, client,
204
+ `./comms` adapter. Keep a change consistent across all four.
205
+
206
+ User-visible changes need a changeset in `.changeset/`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@maroonedsoftware/slack",
3
- "version": "3.0.1",
3
+ "version": "3.0.4",
4
4
  "description": "Slack utilities for ServerKit.",
5
5
  "author": {
6
6
  "name": "Marooned Software",
@@ -38,26 +38,27 @@
38
38
  },
39
39
  "license": "MIT",
40
40
  "files": [
41
+ "AGENTS.md",
41
42
  "dist/**"
42
43
  ],
43
44
  "dependencies": {
44
45
  "@slack/web-api": "^7.18.0",
45
46
  "injectkit": "^1.6.0",
46
47
  "luxon": "^3.7.2",
47
- "@maroonedsoftware/errors": "1.8.0",
48
- "@maroonedsoftware/policies": "0.6.0",
49
- "@maroonedsoftware/logger": "1.1.3"
48
+ "@maroonedsoftware/errors": "1.8.3",
49
+ "@maroonedsoftware/policies": "0.6.3",
50
+ "@maroonedsoftware/logger": "1.1.6"
50
51
  },
51
52
  "devDependencies": {
52
53
  "@types/luxon": "^3.7.2",
53
- "@maroonedsoftware/cache": "0.4.0",
54
- "@maroonedsoftware/comms": "0.2.3",
55
- "@repo/config-eslint": "0.2.1",
56
- "@repo/config-typescript": "0.1.0"
54
+ "@maroonedsoftware/comms": "0.2.6",
55
+ "@maroonedsoftware/cache": "0.4.3",
56
+ "@repo/config-typescript": "0.1.0",
57
+ "@repo/config-eslint": "0.2.1"
57
58
  },
58
59
  "peerDependencies": {
59
- "@maroonedsoftware/cache": "0.4.0",
60
- "@maroonedsoftware/comms": "0.2.3"
60
+ "@maroonedsoftware/cache": "0.4.3",
61
+ "@maroonedsoftware/comms": "0.2.6"
61
62
  },
62
63
  "peerDependenciesMeta": {
63
64
  "@maroonedsoftware/cache": {