Files
2026-05-27 23:29:53 +01:00

17 KiB

Capability Spec: slack-client

Package: @suaveplan/slack-client Status: ACCEPTED

Every requirement below carries a **Verified by:** line at authoring time.

Requirements

Requirement: SlackApiClient sends messages via chat.postMessage

The package SHALL expose SlackApiClient.sendMessage(message) which POSTs to ${baseUrl}/chat.postMessage with an Authorization: Bearer <token> header and a JSON body containing channel, text, and the optional blocks, thread_ts, unfurl_links, unfurl_media, metadata fields. On a successful Slack response carrying ok: true and ts, the client SHALL record a SlackDeliveryReport { status: "delivered" } retrievable via getStatus(ts).

Scenario: successful send populates delivery report

  • WHEN the caller invokes sendMessage({ channel: "C1", text: "hi" }) and the fake fetch resolves { ok: true, channel: "C1", ts: "99.99" }
  • THEN the returned SlackResponse.ok is true, getStatus("99.99") returns a delivered report with channel C1, and the OTel slack.requests counter increments once with { method: "chat.postMessage", outcome: "ok" }

Scenario: blocks + thread + metadata forwarded verbatim

  • WHEN sendMessage is called with blocks, threadTs, unfurlLinks, unfurlMedia, metadata
  • THEN the captured request body contains thread_ts, unfurl_links, unfurl_media, blocks, metadata matching the input

Scenario: default channel used when message channel is empty

  • WHEN the client is constructed with defaultChannel: "C999" and sendMessage({ channel: "", text: "hi" }) is invoked
  • THEN the request body posts to channel C999

Scenario: no channel throws SLACK.CHANNEL_NOT_FOUND

  • WHEN sendMessage({ channel: "", text: "hi" }) is invoked with no defaultChannel
  • THEN the call rejects with SlackChannelNotFoundError carrying code SLACK.CHANNEL_NOT_FOUND

Verified by: src/client/slack-api-client.test.ts::"sendMessage posts to chat.postMessage and records a delivered report" AND src/client/slack-api-client.test.ts::"sendMessage forwards blocks thread metadata and unfurl flags" AND src/client/slack-api-client.test.ts::"sendMessage uses defaultChannel when message.channel is empty" AND src/client/slack-api-client.test.ts::"sendMessage throws SLACK.CHANNEL_NOT_FOUND when no channel is available"


Requirement: SlackApiClient updates and deletes messages

The package SHALL expose updateMessage(channel, ts, text, blocks?) and deleteMessage(channel, ts) which POST to chat.update and chat.delete respectively with the supplied identifiers.

Scenario: updateMessage forwards new text and optional blocks

  • WHEN updateMessage("C1", "1.1", "Updated", blocks) is invoked
  • THEN the request body contains channel: "C1", ts: "1.1", text: "Updated", and the supplied blocks

Scenario: deleteMessage forwards channel and ts

  • WHEN deleteMessage("C1", "1.1") is invoked
  • THEN the request body contains exactly { channel: "C1", ts: "1.1" }

Verified by: src/client/slack-api-client.test.ts::"updateMessage posts to chat.update with new text and blocks" AND src/client/slack-api-client.test.ts::"deleteMessage posts to chat.delete with channel and ts"


Requirement: SlackApiClient lists channels and users with mapping

The package SHALL expose listChannels(limit?), getChannelInfo(channelId), and listUsers(limit?). The client SHALL map snake_case Slack payload shapes (is_private, topic.value, purpose.value, num_members, real_name, profile.email, is_bot) onto camelCase domain types (SlackChannel, SlackUser). Missing arrays SHALL yield [], not undefined.

Scenario: listChannels maps snake_case to domain

  • WHEN the fake fetch returns { ok: true, channels: [{ id, name, is_private, topic, purpose, num_members }, ...] }
  • THEN the result is SlackChannel[] with camelCase fields populated

Scenario: listChannels returns empty array when key absent

  • WHEN the fake fetch returns { ok: true } with no channels key
  • THEN the result is []

Scenario: getChannelInfo maps single channel

  • WHEN getChannelInfo("C1") is invoked and the fake fetch returns a channel object
  • THEN the returned SlackChannel matches the mapped shape

Scenario: listUsers maps snake_case to domain

  • WHEN the fake fetch returns { ok: true, members: [...] }
  • THEN the result is SlackUser[] with realName, email, isBot populated

Scenario: listUsers returns empty array when key absent

  • WHEN the fake fetch returns { ok: true } with no members key
  • THEN the result is []

Verified by: src/client/slack-api-client.test.ts::"listChannels maps snake_case payload to SlackChannel" AND src/client/slack-api-client.test.ts::"listChannels returns empty array when channels key is absent" AND src/client/slack-api-client.test.ts::"getChannelInfo maps a single channel" AND src/client/slack-api-client.test.ts::"listUsers maps snake_case payload to SlackUser" AND src/client/slack-api-client.test.ts::"listUsers returns empty array when members key is absent"


Requirement: SlackApiClient classifies HTTP errors into typed Slack codes

The package SHALL convert HTTP 401/403 to SLACK.AUTH_ERROR, HTTP 404 to SLACK.CHANNEL_NOT_FOUND, HTTP 408/504 to SLACK.TIMEOUT, HTTP 429 to SLACK.RATE_LIMITED (with retryAfterMs populated from Retry-After or x-ratelimit-reset), and other non-2xx to SLACK.API_ERROR. A non-ok: true JSON body containing error: "channel_not_found" | "is_archived" | "not_in_channel" SHALL yield SLACK.CHANNEL_NOT_FOUND; invalid_auth | not_authed | token_revoked | account_inactive SHALL yield SLACK.AUTH_ERROR; team_not_found | no_workspace SHALL yield SLACK.WORKSPACE_NOT_FOUND; everything else SHALL yield SLACK.API_ERROR.

Scenario: HTTP 401 yields SLACK.AUTH_ERROR

  • WHEN the fake fetch returns HTTP 401
  • THEN the call rejects with SlackAuthError carrying code SLACK.AUTH_ERROR

Scenario: HTTP 404 yields SLACK.CHANNEL_NOT_FOUND

  • WHEN the fake fetch returns HTTP 404
  • THEN the call rejects with SlackChannelNotFoundError

Scenario: HTTP 429 yields SLACK.RATE_LIMITED with parsed retry-after

  • WHEN the fake fetch returns HTTP 429 with Retry-After: "30"
  • THEN the rejection is SlackRateLimitedError whose context.retryAfterMs is 30000

Scenario: HTTP 500 yields SLACK.API_ERROR

  • WHEN the fake fetch returns HTTP 500
  • THEN the call rejects with SlackApiError

Scenario: Slack error string classifies to typed code

  • WHEN the fake fetch returns { ok: false, error: "channel_not_found" }
  • THEN the call rejects with SlackChannelNotFoundError

Scenario: invalid_auth string yields SLACK.AUTH_ERROR

  • WHEN the fake fetch returns { ok: false, error: "invalid_auth" }
  • THEN the call rejects with SlackAuthError

Scenario: team_not_found string yields SLACK.WORKSPACE_NOT_FOUND

  • WHEN the fake fetch returns { ok: false, error: "team_not_found" }
  • THEN the call rejects with SlackWorkspaceNotFoundError

Scenario: unknown error string yields SLACK.API_ERROR

  • WHEN the fake fetch returns { ok: false, error: "ratelimited_internal" }
  • THEN the call rejects with SlackApiError

Verified by: src/client/classify.test.ts::"HTTP 401 classifies to SLACK.AUTH_ERROR" AND src/client/classify.test.ts::"HTTP 404 classifies to SLACK.CHANNEL_NOT_FOUND" AND src/client/classify.test.ts::"HTTP 500 classifies to SLACK.API_ERROR" AND src/client/classify.test.ts::"slack error string channel_not_found maps to SLACK.CHANNEL_NOT_FOUND" AND src/client/classify.test.ts::"slack error string invalid_auth maps to SLACK.AUTH_ERROR" AND src/client/classify.test.ts::"slack error string team_not_found maps to SLACK.WORKSPACE_NOT_FOUND" AND src/client/classify.test.ts::"unknown slack error string falls back to SLACK.API_ERROR" AND src/client/slack-api-client.test.ts::"HTTP 429 produces SlackRateLimitedError with parsed retryAfterMs"


Requirement: Retry-After header parsing supports three forms

The package SHALL parse the Retry-After HTTP header in delay-seconds ("30") and HTTP-date ("Wed, 21 Oct 2026 07:28:00 GMT") forms, and fall back to x-ratelimit-reset in epoch-seconds, epoch-milliseconds, or ISO-8601 form. The parser SHALL return undefined when no header is present or the value is unparseable, and SHALL clamp negative deltas to 0.

Scenario: delay-seconds form

  • WHEN the response carries Retry-After: "45"
  • THEN parseRetryAfterMs returns 45000

Scenario: HTTP-date form

  • WHEN the response carries Retry-After: <ISO date 60s in the future> and the injected clock is at the present
  • THEN the parser returns a value >= 0 (≈60000)

Scenario: x-ratelimit-reset epoch seconds fallback

  • WHEN no Retry-After is present but x-ratelimit-reset is epoch seconds in the future
  • THEN the parser returns the difference in milliseconds

Scenario: x-ratelimit-reset ISO fallback

  • WHEN no Retry-After is present but x-ratelimit-reset is an ISO timestamp in the future
  • THEN the parser returns the difference in milliseconds

Scenario: missing headers yields undefined

  • WHEN neither header is present
  • THEN the parser returns undefined

Scenario: negative delta clamps to 0

  • WHEN the header points at a past timestamp
  • THEN the parser returns 0

Verified by: src/client/retry-after.test.ts::"parses delay-seconds form" AND src/client/retry-after.test.ts::"parses HTTP-date form" AND src/client/retry-after.test.ts::"falls back to x-ratelimit-reset epoch seconds" AND src/client/retry-after.test.ts::"falls back to x-ratelimit-reset ISO string" AND src/client/retry-after.test.ts::"returns undefined when no headers are present" AND src/client/retry-after.test.ts::"clamps negative deltas to 0"


Requirement: SlackApiClient enforces AbortSignal-driven timeout

The package SHALL abort an in-flight Slack request after timeoutMs (default 10000) and surface the abort as SlackTimeoutError (code SLACK.TIMEOUT). Caller-supplied AbortSignal SHALL also terminate the request and propagate.

Scenario: internal timeout surfaces SlackTimeoutError

  • WHEN the fake fetch never resolves and timeoutMs: 1 is configured
  • THEN the call rejects with SlackTimeoutError

Scenario: caller signal aborts the request

  • WHEN the caller passes a pre-aborted AbortSignal
  • THEN the call rejects with SlackTimeoutError whose context.url matches

Verified by: src/client/slack-api-client.test.ts::"timeoutMs aborts an in-flight request with SlackTimeoutError" AND src/client/slack-api-client.test.ts::"caller-supplied AbortSignal cancels the request with SlackTimeoutError"


Requirement: SlackWebhook posts to an incoming webhook URL

The package SHALL expose SlackWebhook.send(message) and SlackWebhook.sendText(text). Both SHALL POST a JSON body with text and optional blocks to the configured webhookUrl with Content-Type: application/json; charset=utf-8. On 2xx, the returned SlackDeliveryReport.status SHALL be "delivered". On non-2xx, the call SHALL reject with SlackAuthError (401/403) or SlackWebhookFailedError (otherwise). On network failure or timeout, the call SHALL reject with SlackTimeoutError.

Scenario: send returns delivered report on 2xx

  • WHEN the fake fetch returns HTTP 200
  • THEN the report's status is "delivered", channel "webhook", and messageId is non-empty

Scenario: sendText forwards plain text

  • WHEN sendText("hi") is invoked
  • THEN the request body parses to { text: "hi" } with no blocks

Scenario: 401 yields SlackAuthError

  • WHEN the fake fetch returns HTTP 401
  • THEN the call rejects with SlackAuthError

Scenario: 400 yields SlackWebhookFailedError

  • WHEN the fake fetch returns HTTP 400 with body "invalid_payload"
  • THEN the call rejects with SlackWebhookFailedError carrying the body in context

Scenario: message IDs are monotonic

  • WHEN two successive sends complete
  • THEN their messageIds differ

Verified by: src/webhook/slack-webhook.test.ts::"send returns a delivered report on HTTP 200" AND src/webhook/slack-webhook.test.ts::"sendText posts only the text field" AND src/webhook/slack-webhook.test.ts::"HTTP 401 rejects with SlackAuthError" AND src/webhook/slack-webhook.test.ts::"HTTP 400 rejects with SlackWebhookFailedError" AND src/webhook/slack-webhook.test.ts::"sequential sends yield distinct messageIds"


Requirement: Constructors validate configuration via Zod

The package SHALL reject construction of SlackApiClient with an empty token and SlackWebhook with an empty webhookUrl, throwing SlackConfigMissingError (code SLACK.CONFIG_MISSING) at construct time. All public option bundles SHALL be validated through a Zod schema; safeParse SHALL surface a ZodError cause on the thrown SlackConfigMissingError.

Scenario: empty token rejected

  • WHEN new SlackApiClient({ token: "" }) is invoked
  • THEN the constructor throws SlackConfigMissingError

Scenario: empty webhookUrl rejected

  • WHEN new SlackWebhook({ webhookUrl: "" }) is invoked
  • THEN the constructor throws SlackConfigMissingError

Scenario: schemas are exposed for boundary parse

  • WHEN SlackMessageSchema.safeParse({ channel: 1, text: "x" }) is invoked
  • THEN success is false

Verified by: src/client/slack-api-client.test.ts::"empty token rejects with SlackConfigMissingError" AND src/webhook/slack-webhook.test.ts::"empty webhookUrl rejects with SlackConfigMissingError" AND src/schemas/schemas.test.ts::"SlackMessageSchema rejects non-string channel"


Requirement: OTel instruments emit on every Slack request

The package SHALL emit four OpenTelemetry instruments on every production path: a slack.requests counter, a slack.errors counter, a slack.latency_ms histogram, and a slack.webhook_sends counter. Counters SHALL carry attributes { method, outcome } where outcome ∈ { "ok", "error" }.

Scenario: successful sendMessage emits slack.requests + slack.latency_ms

  • WHEN sendMessage succeeds
  • THEN the in-memory MeterProvider records one slack.requests increment with outcome: "ok" and one slack.latency_ms observation

Scenario: failed sendMessage emits slack.requests + slack.errors

  • WHEN sendMessage rejects with SlackApiError
  • THEN the in-memory MeterProvider records one slack.requests with outcome: "error" and one slack.errors increment

Scenario: webhook send emits slack.webhook_sends

  • WHEN SlackWebhook.send succeeds
  • THEN the in-memory MeterProvider records one slack.webhook_sends increment with outcome: "ok"

Verified by: src/otel/metrics.test.ts::"successful sendMessage emits slack.requests with outcome ok" AND src/otel/metrics.test.ts::"failed sendMessage emits slack.requests outcome error and slack.errors" AND src/otel/metrics.test.ts::"webhook send emits slack.webhook_sends"


Requirement: Stable error code registry

The package SHALL export SLACK_CODES (built via defineCodes("SLACK", [...])) containing eight stable codes: API_ERROR, AUTH_ERROR, RATE_LIMITED, CHANNEL_NOT_FOUND, WORKSPACE_NOT_FOUND, CONFIG_MISSING, WEBHOOK_FAILED, TIMEOUT. Each code SHALL have a typed SuaveplanError subclass and SHALL be thrown by at least one production path. src/index.ts SHALL side-effect import ./error-codes.js so subpath consumers register codes too.

Scenario: SLACK_CODES contains all eight reasons

  • WHEN consumer imports SLACK_CODES
  • THEN the record has exactly eight keys mapping reason → SLACK.<REASON>

Scenario: each error subclass carries its code

  • WHEN an instance of SlackApiError, SlackAuthError, SlackRateLimitedError, SlackChannelNotFoundError, SlackWorkspaceNotFoundError, SlackConfigMissingError, SlackWebhookFailedError, or SlackTimeoutError is constructed
  • THEN its .code equals the corresponding SLACK.<REASON> literal

Verified by: src/errors/errors.test.ts::"SLACK_CODES exposes exactly the eight stable reasons" AND src/errors/errors.test.ts::"every error subclass carries its SLACK.<REASON> code"


Requirement: Deterministic FakeFetch test double

The package SHALL ship a FakeFetch under src/fakes/fake-fetch.ts which the test suite uses to drive every transport-level scenario without monkey-patching globalThis.fetch and without vi.mock. FakeFetch SHALL queue scripted responses, record every request, and surface a FetchLike instance for direct injection into client constructors.

Scenario: queued response is consumed in FIFO order

  • WHEN two responses are queued and two requests are made
  • THEN the first request receives the first queued response

Scenario: pending request abort propagates to AbortError

  • WHEN a request is queued as pending and the caller aborts the signal
  • THEN the returned promise rejects with AbortError

Verified by: src/fakes/fake-fetch.test.ts::"queued responses are consumed in FIFO order" AND src/fakes/fake-fetch.test.ts::"pending request rejects with AbortError when signal aborts"