diff --git a/agent/run.sh b/agent/run.sh index da5fb57..810794a 100755 --- a/agent/run.sh +++ b/agent/run.sh @@ -217,6 +217,8 @@ DOCKER_ARGS=( [[ -n "${DRY_RUN:-}" ]] && DOCKER_ARGS+=(-e "DRY_RUN=${DRY_RUN}") [[ -n "${MAX_TURNS:-}" ]] && DOCKER_ARGS+=(-e "MAX_TURNS=${MAX_TURNS}") [[ -n "${MAX_BUDGET_USD:-}" ]] && DOCKER_ARGS+=(-e "MAX_BUDGET_USD=${MAX_BUDGET_USD}") +[[ -n "${LINEAR_API_TOKEN:-}" ]] && DOCKER_ARGS+=(-e "LINEAR_API_TOKEN=${LINEAR_API_TOKEN}") +[[ -n "${LINEAR_API_TOKEN_SECRET_ARN:-}" ]] && DOCKER_ARGS+=(-e "LINEAR_API_TOKEN_SECRET_ARN=${LINEAR_API_TOKEN_SECRET_ARN}") # Local events mode: connect to DynamoDB Local via the agent-local network if [[ "$LOCAL_EVENTS" == true ]]; then diff --git a/agent/src/channel_mcp.py b/agent/src/channel_mcp.py new file mode 100644 index 0000000..f9c51c0 --- /dev/null +++ b/agent/src/channel_mcp.py @@ -0,0 +1,112 @@ +"""Channel-specific MCP configuration for the agent container. + +For Linear-origin tasks we write (or merge into) ``.mcp.json`` in the cloned +repo ``cwd`` so the Claude Agent SDK — configured with +``setting_sources=["project"]`` — picks up the Linear MCP at session start +and exposes ``mcp__linear-server__*`` tools. + +For all other channel sources this is a no-op: no MCP is written, and the +SDK sees no Linear tools. That's the gate keeping Slack/API/webhook tasks +from touching Linear. + +See: cdk/src/handlers/linear-webhook-processor.ts (inbound), runner.py +(SDK invocation), plans at ~/.claude/plans/linear-mcp-findings.md. +""" + +from __future__ import annotations + +import json +import os +from typing import Any + +from shell import log + +#: Linear MCP endpoint — hosted by Linear, Streamable HTTP transport. +LINEAR_MCP_URL = "https://mcp.linear.app/mcp" + +#: Key name inside ``mcpServers``. Tools surface as +#: ``mcp__linear-server__*`` in the Agent SDK (verified in findings). +LINEAR_MCP_SERVER_KEY = "linear-server" + +#: Env var name the MCP server entry reads via ``${LINEAR_API_TOKEN}`` +#: placeholder expansion. Populated from ``LinearApiTokenSecret`` by run.sh. +LINEAR_API_TOKEN_ENV = "LINEAR_API_TOKEN" # noqa: S105 — env var *name*, not a secret value + + +def _linear_server_entry() -> dict[str, Any]: + """Build the `mcpServers` entry for Linear's hosted MCP.""" + return { + "type": "http", + "url": LINEAR_MCP_URL, + "headers": { + "Authorization": f"Bearer ${{{LINEAR_API_TOKEN_ENV}}}", + }, + } + + +def _read_existing_mcp_config(path: str) -> dict[str, Any]: + """Return the parsed .mcp.json at ``path``, or an empty dict if absent/invalid. + + Malformed JSON is logged and treated as absent — we prefer to overlay a + valid Linear entry than to crash the agent because a user committed a + broken .mcp.json to their repo. + """ + if not os.path.isfile(path): + return {} + try: + with open(path, encoding="utf-8") as f: + parsed = json.load(f) + if isinstance(parsed, dict): + return parsed + log("WARN", f"Ignoring non-object .mcp.json at {path} (got {type(parsed).__name__})") + except (OSError, json.JSONDecodeError) as e: + log("WARN", f"Failed to read existing .mcp.json at {path}: {type(e).__name__}: {e}") + return {} + + +def configure_channel_mcp(repo_dir: str, channel_source: str) -> bool: + """Write or merge a channel-specific ``.mcp.json`` into ``repo_dir``. + + Gated on ``channel_source``: + * ``'linear'`` → ensure the ``linear-server`` entry is present in + ``.mcp.json`` (merges into any existing config without clobbering + other servers). Returns True. + * anything else → no-op. Returns False. + + Args: + repo_dir: the cloned-repo working directory the SDK will use as ``cwd``. + channel_source: inbound channel (``TaskConfig.channel_source``). + + Returns: + True if a Linear MCP entry was (re)written into ``repo_dir/.mcp.json``, + False otherwise (including any non-Linear channel or missing repo_dir). + """ + if channel_source != "linear": + return False + + if not repo_dir or not os.path.isdir(repo_dir): + log("WARN", f"configure_channel_mcp: repo_dir missing or not a directory: {repo_dir!r}") + return False + + mcp_path = os.path.join(repo_dir, ".mcp.json") + config = _read_existing_mcp_config(mcp_path) + + servers = config.get("mcpServers") + if not isinstance(servers, dict): + servers = {} + servers[LINEAR_MCP_SERVER_KEY] = _linear_server_entry() + config["mcpServers"] = servers + + try: + with open(mcp_path, "w", encoding="utf-8") as f: + json.dump(config, f, indent=2) + f.write("\n") + except OSError as e: + log("ERROR", f"Failed to write Linear MCP config to {mcp_path}: {e}") + return False + + log( + "TASK", + f"Linear MCP configured at {mcp_path} (server key: {LINEAR_MCP_SERVER_KEY})", + ) + return True diff --git a/agent/src/config.py b/agent/src/config.py index f3f07fc..0e9e495 100644 --- a/agent/src/config.py +++ b/agent/src/config.py @@ -37,6 +37,42 @@ def resolve_github_token() -> str: return "" +def resolve_linear_api_token() -> str: + """Resolve the Linear personal API token from Secrets Manager or env. + + Mirrors ``resolve_github_token``: in deployed mode + ``LINEAR_API_TOKEN_SECRET_ARN`` is set and the token is fetched once + and cached in ``LINEAR_API_TOKEN``. For local development, falls back + to ``LINEAR_API_TOKEN`` directly. + + Returns an empty string if the secret is absent or empty — the agent-side + MCP config then renders with an unresolved ``${LINEAR_API_TOKEN}`` env + placeholder, and the Linear MCP will reject the request (fail-closed). + This function is only called when ``channel_source == 'linear'``. + """ + cached = os.environ.get("LINEAR_API_TOKEN", "") + if cached: + return cached + secret_arn = os.environ.get("LINEAR_API_TOKEN_SECRET_ARN") + if not secret_arn: + return "" + try: + import boto3 + + region = os.environ.get("AWS_REGION") or os.environ.get("AWS_DEFAULT_REGION") + client = boto3.client("secretsmanager", region_name=region) + resp = client.get_secret_value(SecretId=secret_arn) + token = resp.get("SecretString", "") or "" + if token: + os.environ["LINEAR_API_TOKEN"] = token + return token + except Exception as e: + # Never let a Secrets Manager outage crash the agent. The Linear MCP + # will simply fail on first call with a clear auth error. + print(f"[config] resolve_linear_api_token failed: {type(e).__name__}: {e}", flush=True) + return "" + + def build_config( repo_url: str, task_description: str = "", @@ -52,6 +88,8 @@ def build_config( task_type: str = "new_task", branch_name: str = "", pr_number: str = "", + channel_source: str = "", + channel_metadata: dict[str, str] | None = None, trace: bool = False, user_id: str = "", ) -> TaskConfig: @@ -104,6 +142,8 @@ def build_config( branch_name=branch_name, pr_number=pr_number, task_id=task_id or uuid.uuid4().hex[:12], + channel_source=channel_source, + channel_metadata=channel_metadata or {}, trace=trace, user_id=user_id, ) diff --git a/agent/src/linear_reactions.py b/agent/src/linear_reactions.py new file mode 100644 index 0000000..f7144de --- /dev/null +++ b/agent/src/linear_reactions.py @@ -0,0 +1,144 @@ +"""Linear issue-level reaction helper for Linear-origin tasks. + +Posts a 👀 reaction on the originating Linear issue at task start, then +swaps it for ✅/❌ on terminal status — mirroring the Slack integration's +terminal-emoji status signal (👀 → ✅/❌, no lingering "watching" marker). + +Implementation: ``react_task_started`` captures the reaction id returned by +``reactionCreate`` and hands it back to the caller, which passes it into +``react_task_finished`` so we can ``reactionDelete`` the 👀 before posting +the terminal emoji. + +Gating: every function is a no-op unless ``channel_source == 'linear'`` +and the Linear issue id is present in ``channel_metadata``. All network +errors are logged and swallowed — a transient Linear API failure must +never fail the task itself (reactions are advisory UX, not load-bearing). + +Why a direct GraphQL call instead of MCP: Linear's MCP v1 does not expose +a reactions tool (confirmed 2026-05-06). Once an MCP ``create_reaction`` +tool ships, this module should be retired in favour of a prompt addendum +that has the agent call it directly. + +See: ``agent/src/channel_mcp.py`` for the parallel MCP gate, and +``~/.claude/plans/linear-mcp-findings.md`` for the locked spec. +""" + +from __future__ import annotations + +import os +from typing import Any + +import requests + +from shell import log + +#: Linear GraphQL endpoint. The same auth flow the MCP server uses. +LINEAR_GRAPHQL_URL = "https://api.linear.app/graphql" + +#: Request timeout — reactions are fire-and-forget status UX; never block +#: the task pipeline for more than a couple of seconds. +REQUEST_TIMEOUT_SECONDS = 5.0 + +#: Reactions in emoji short-code form (Linear accepts both emoji chars and +#: short codes; short codes are more portable in logs). +EMOJI_STARTED = "eyes" +EMOJI_SUCCESS = "white_check_mark" +EMOJI_FAILURE = "x" + +_CREATE_MUTATION = """ +mutation ReactIssue($issueId: String!, $emoji: String!) { + reactionCreate(input: { issueId: $issueId, emoji: $emoji }) { + success + reaction { id } + } +} +""".strip() + +_DELETE_MUTATION = """ +mutation UnreactIssue($id: String!) { + reactionDelete(id: $id) { success } +} +""".strip() + + +def _enabled(channel_source: str, channel_metadata: dict[str, str] | None) -> str | None: + """Return the Linear issue id if reactions should fire, else None. + + Gating mirrors ``channel_mcp.configure_channel_mcp`` — the same + ``channel_source == 'linear'`` check, plus a metadata presence check so + we don't fire GraphQL calls we can't address. + """ + if channel_source != "linear": + return None + if not channel_metadata: + return None + return channel_metadata.get("linear_issue_id") or None + + +def _graphql(query: str, variables: dict[str, Any]) -> dict[str, Any] | None: + """POST a GraphQL query. Return parsed data on success, None on any failure. + + Swallows network / auth / schema errors with a WARN log — reactions are + advisory and never gate the pipeline. + """ + token = os.environ.get("LINEAR_API_TOKEN", "") + if not token: + log("WARN", "linear_reactions: LINEAR_API_TOKEN not set; skipping reaction") + return None + + try: + resp = requests.post( + LINEAR_GRAPHQL_URL, + json={"query": query, "variables": variables}, + headers={ + "Authorization": token, + "Content-Type": "application/json", + }, + timeout=REQUEST_TIMEOUT_SECONDS, + ) + except requests.RequestException as e: + log("WARN", f"linear_reactions: request failed ({type(e).__name__}): {e}") + return None + + if resp.status_code != 200: + log("WARN", f"linear_reactions: HTTP {resp.status_code} from Linear") + return None + + body = resp.json() if resp.content else {} + if body.get("errors"): + log("WARN", f"linear_reactions: GraphQL errors: {body['errors']}") + return None + + return body.get("data") or {} + + +def react_task_started( + channel_source: str, + channel_metadata: dict[str, str] | None, +) -> str | None: + """Post 👀 on the Linear issue. Return the reaction id (or None on failure/no-op).""" + issue_id = _enabled(channel_source, channel_metadata) + if not issue_id: + return None + data = _graphql(_CREATE_MUTATION, {"issueId": issue_id, "emoji": EMOJI_STARTED}) + if not data: + return None + return (data.get("reactionCreate") or {}).get("reaction", {}).get("id") + + +def react_task_finished( + channel_source: str, + channel_metadata: dict[str, str] | None, + success: bool, + started_reaction_id: str | None = None, +) -> None: + """Delete the 👀 (if we have its id) and post ✅/❌ as a replacement.""" + issue_id = _enabled(channel_source, channel_metadata) + if not issue_id: + return + if started_reaction_id: + _graphql(_DELETE_MUTATION, {"id": started_reaction_id}) + _graphql( + _CREATE_MUTATION, + {"issueId": issue_id, "emoji": EMOJI_SUCCESS if success else EMOJI_FAILURE}, + ) diff --git a/agent/src/models.py b/agent/src/models.py index a08ae70..255c18f 100644 --- a/agent/src/models.py +++ b/agent/src/models.py @@ -108,6 +108,11 @@ class TaskConfig(BaseModel): branch_name: str = "" pr_number: str = "" task_id: str = "" + # Inbound channel the task was submitted from (mirrors ChannelSource in + # cdk/src/handlers/shared/types.ts). Gates channel-specific MCP wiring and + # prompt additions. Empty string means "no channel context" (legacy / local). + channel_source: str = "" + channel_metadata: dict[str, str] = Field(default_factory=dict) # Platform user_id (Cognito ``sub``) threaded from the orchestrator # payload. Required ONLY when ``trace`` is true — the agent writes # the trajectory dump to ``traces//.jsonl.gz`` diff --git a/agent/src/pipeline.py b/agent/src/pipeline.py index 81ecc59..9a20afe 100644 --- a/agent/src/pipeline.py +++ b/agent/src/pipeline.py @@ -13,8 +13,10 @@ import memory as agent_memory import task_state -from config import AGENT_WORKSPACE, build_config, get_config +from channel_mcp import configure_channel_mcp +from config import AGENT_WORKSPACE, build_config, get_config, resolve_linear_api_token from context import assemble_prompt, fetch_github_issue +from linear_reactions import react_task_finished, react_task_started from models import AgentResult, HydratedContext, RepoSetup, TaskConfig, TaskResult from observability import task_span from post_hooks import ( @@ -242,6 +244,8 @@ def run_task( branch_name: str = "", pr_number: str = "", cedar_policies: list[str] | None = None, + channel_source: str = "", + channel_metadata: dict[str, str] | None = None, trace: bool = False, user_id: str = "", ) -> dict: @@ -274,6 +278,8 @@ def run_task( task_type=task_type, branch_name=branch_name, pr_number=pr_number, + channel_source=channel_source, + channel_metadata=channel_metadata, trace=trace, user_id=user_id, ) @@ -403,6 +409,24 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: system_prompt = build_system_prompt(config, setup, hc, system_prompt_overrides) + # Channel-specific MCP wiring (Linear only, for v1). Must happen + # before discover_project_config so the scan picks up the file we + # just wrote. Resolve the API token from Secrets Manager *before* + # writing .mcp.json so the child SDK process inherits the env var + # that the MCP server entry references via ${LINEAR_API_TOKEN}. + if config.channel_source == "linear": + resolve_linear_api_token() + configure_channel_mcp(setup.repo_dir, config.channel_source) + + # 👀 on the Linear issue — acknowledges the task is picked up. + # No-op for non-Linear tasks. Best-effort; failures are logged + # but do not block the pipeline. Capture the reaction id so we + # can delete it at terminal status (👀 → ✅/❌). + linear_eyes_reaction_id = react_task_started( + config.channel_source, + config.channel_metadata, + ) + # Log discovered repo-level project configuration # (all files loaded by setting_sources=["project"]) repo_dir = setup.repo_dir @@ -565,6 +589,15 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: pr_url=pr_url, ) + # ✅/❌ on the Linear issue (removes the 👀 first so the final + # status stands alone). No-op for non-Linear tasks. + react_task_finished( + config.channel_source, + config.channel_metadata, + success=(overall_status == "success"), + started_reaction_id=linear_eyes_reaction_id, + ) + # --trace trajectory S3 upload (design §10.1). Runs AFTER # post-hooks but BEFORE ``write_terminal`` so the resulting # ``trace_s3_uri`` can be persisted atomically with the @@ -675,6 +708,16 @@ def _on_trace_truncated(max_bytes: int, first_dropped: int) -> None: trace_s3_uri=crash_trace_s3_uri, ) task_state.write_terminal(config.task_id, "FAILED", crash_result.model_dump()) + # Best-effort ❌ on the Linear issue so the stale 👀 doesn't linger. + # No-op for non-Linear tasks; network/GraphQL failures are swallowed. + # `linear_eyes_reaction_id` may be unbound if we crashed before the + # start-reaction call — guarded with locals() to stay safe. + react_task_finished( + config.channel_source, + config.channel_metadata, + success=False, + started_reaction_id=locals().get("linear_eyes_reaction_id"), + ) raise diff --git a/agent/src/prompt_builder.py b/agent/src/prompt_builder.py index 574b060..f530a92 100644 --- a/agent/src/prompt_builder.py +++ b/agent/src/prompt_builder.py @@ -75,9 +75,48 @@ def build_system_prompt( n = len(overrides) log("TASK", f"Applied system prompt overrides ({n} chars)") + # Channel-specific guidance (appended last so channel instructions sit + # close to the end of the prompt, where the model weights recency). + channel_addendum = _channel_prompt_addendum(config) + if channel_addendum: + system_prompt += channel_addendum + return system_prompt +def _channel_prompt_addendum(config: TaskConfig) -> str: + """Return channel-specific prompt guidance, or empty string. + + For Linear-origin tasks, instruct the agent to post progress comments and + transition state using the already-loaded Linear MCP tools. The tool names + are stated explicitly so the agent doesn't grope for them. + """ + if config.channel_source != "linear": + return "" + issue_identifier = config.channel_metadata.get("linear_issue_identifier") or "" + issue_ref = f" (`{issue_identifier}`)" if issue_identifier else "" + return ( + "\n\n## Linear issue progress updates (REQUIRED)\n\n" + f"This task was submitted from Linear issue{issue_ref}. The Linear MCP " + "server is loaded. You MUST perform these three updates; they are part " + "of the task contract, not optional:\n\n" + "1. **At start** — call `mcp__linear-server__save_comment` with a short " + '"🤖 Starting on this issue…" message.\n' + "2. **When you open the PR** — call `mcp__linear-server__save_comment` " + "with the PR URL, then call `mcp__linear-server__save_issue` to " + "transition the issue state. Use `mcp__linear-server__list_issue_statuses` " + "first if you don't already know the state ids; pick the one named " + "`In Review` (fall back to `In Progress` if that state doesn't exist). " + "If neither exists, skip the state transition — the PR comment alone " + "is enough. Do not invent state names or loop on `list_issue_statuses`.\n" + "3. **On completion or failure** — call `mcp__linear-server__save_comment` " + "with the final status (succeeded / failed + short reason).\n\n" + "Keep comments concise. Do not mirror the full agent transcript back to " + "Linear. Even small tasks must post all three updates — users rely on " + "them to track progress." + ) + + def discover_project_config(repo_dir: str) -> dict[str, list[str]]: """Scan the cloned repo for project-level configuration files. diff --git a/agent/src/server.py b/agent/src/server.py index a4eb510..470b29b 100644 --- a/agent/src/server.py +++ b/agent/src/server.py @@ -254,6 +254,8 @@ def _run_task_background( branch_name: str = "", pr_number: str = "", cedar_policies: list[str] | None = None, + channel_source: str = "", + channel_metadata: dict[str, str] | None = None, trace: bool = False, user_id: str = "", ) -> None: @@ -301,6 +303,8 @@ def _run_task_background( branch_name=branch_name, pr_number=pr_number, cedar_policies=cedar_policies, + channel_source=channel_source, + channel_metadata=channel_metadata, trace=trace, user_id=user_id, ) @@ -348,6 +352,8 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: branch_name = inp.get("branch_name", "") pr_number = str(inp.get("pr_number", "")) cedar_policies = inp.get("cedar_policies") or [] + channel_source = inp.get("channel_source", "") or "" + channel_metadata = inp.get("channel_metadata") or {} # ``trace`` is strictly opt-in (design §10.1). Accept only real # booleans from the orchestrator — a string "false" would otherwise # flip the flag on. @@ -393,6 +399,8 @@ def _extract_invocation_params(inp: dict, request: Request) -> dict: "branch_name": branch_name, "pr_number": pr_number, "cedar_policies": cedar_policies, + "channel_source": channel_source, + "channel_metadata": channel_metadata, "trace": trace, "user_id": user_id, } diff --git a/agent/tests/test_channel_mcp.py b/agent/tests/test_channel_mcp.py new file mode 100644 index 0000000..9ef4c22 --- /dev/null +++ b/agent/tests/test_channel_mcp.py @@ -0,0 +1,141 @@ +"""Unit tests for channel_mcp.configure_channel_mcp — Linear MCP gating + merge.""" + +from __future__ import annotations + +import json +import os + +from channel_mcp import ( + LINEAR_API_TOKEN_ENV, + LINEAR_MCP_SERVER_KEY, + LINEAR_MCP_URL, + configure_channel_mcp, +) + + +def _read_mcp(repo_dir: str) -> dict: + path = os.path.join(repo_dir, ".mcp.json") + with open(path, encoding="utf-8") as f: + return json.load(f) + + +class TestChannelGate: + """Only channel_source=='linear' writes anything — everything else is a no-op.""" + + def test_no_op_for_slack_channel(self, tmp_path): + wrote = configure_channel_mcp(str(tmp_path), "slack") + assert wrote is False + assert not (tmp_path / ".mcp.json").exists() + + def test_no_op_for_api_channel(self, tmp_path): + wrote = configure_channel_mcp(str(tmp_path), "api") + assert wrote is False + assert not (tmp_path / ".mcp.json").exists() + + def test_no_op_for_webhook_channel(self, tmp_path): + wrote = configure_channel_mcp(str(tmp_path), "webhook") + assert wrote is False + assert not (tmp_path / ".mcp.json").exists() + + def test_no_op_for_empty_channel(self, tmp_path): + wrote = configure_channel_mcp(str(tmp_path), "") + assert wrote is False + assert not (tmp_path / ".mcp.json").exists() + + +class TestLinearWrite: + """channel_source=='linear' writes .mcp.json with the linear-server entry.""" + + def test_creates_mcp_json_with_linear_server_key(self, tmp_path): + wrote = configure_channel_mcp(str(tmp_path), "linear") + assert wrote is True + config = _read_mcp(str(tmp_path)) + assert LINEAR_MCP_SERVER_KEY in config["mcpServers"] + + def test_renders_linear_url_and_token_placeholder(self, tmp_path): + configure_channel_mcp(str(tmp_path), "linear") + entry = _read_mcp(str(tmp_path))["mcpServers"][LINEAR_MCP_SERVER_KEY] + assert entry["type"] == "http" + assert entry["url"] == LINEAR_MCP_URL + assert entry["headers"]["Authorization"] == f"Bearer ${{{LINEAR_API_TOKEN_ENV}}}" + + def test_server_key_is_linear_server(self): + # If this ever changes, tools surface under a different mcp__ prefix and + # the agent prompt (prompt_builder._channel_prompt_addendum) must be + # updated in lockstep. + assert LINEAR_MCP_SERVER_KEY == "linear-server" + + +class TestMerge: + """Existing .mcp.json must not be clobbered.""" + + def test_adds_linear_to_existing_empty_mcp_json(self, tmp_path): + (tmp_path / ".mcp.json").write_text("{}") + wrote = configure_channel_mcp(str(tmp_path), "linear") + assert wrote is True + assert LINEAR_MCP_SERVER_KEY in _read_mcp(str(tmp_path))["mcpServers"] + + def test_preserves_existing_mcp_servers(self, tmp_path): + existing = { + "mcpServers": { + "other-server": {"type": "stdio", "command": "/usr/bin/my-mcp"}, + }, + } + (tmp_path / ".mcp.json").write_text(json.dumps(existing)) + + configure_channel_mcp(str(tmp_path), "linear") + merged = _read_mcp(str(tmp_path)) + assert "other-server" in merged["mcpServers"] + assert merged["mcpServers"]["other-server"]["command"] == "/usr/bin/my-mcp" + assert LINEAR_MCP_SERVER_KEY in merged["mcpServers"] + + def test_overwrites_existing_linear_server_entry(self, tmp_path): + # If someone committed a stale Linear entry with a wrong token var, we + # want the fresh ABCA-written entry to win — otherwise the MCP would + # fail to auth. + existing = { + "mcpServers": { + LINEAR_MCP_SERVER_KEY: { + "type": "http", + "url": "https://stale.example", + "headers": {"Authorization": "Bearer stale"}, + }, + }, + } + (tmp_path / ".mcp.json").write_text(json.dumps(existing)) + + configure_channel_mcp(str(tmp_path), "linear") + entry = _read_mcp(str(tmp_path))["mcpServers"][LINEAR_MCP_SERVER_KEY] + assert entry["url"] == LINEAR_MCP_URL + assert "stale" not in entry["headers"]["Authorization"] + + def test_tolerates_mcp_json_without_mcpservers_key(self, tmp_path): + # A .mcp.json that only has unrelated top-level keys should still + # gain an mcpServers map. + (tmp_path / ".mcp.json").write_text(json.dumps({"version": 1})) + configure_channel_mcp(str(tmp_path), "linear") + merged = _read_mcp(str(tmp_path)) + assert merged["version"] == 1 + assert LINEAR_MCP_SERVER_KEY in merged["mcpServers"] + + def test_malformed_mcp_json_is_replaced(self, tmp_path): + # Malformed JSON is treated as absent (logged as a warning in shell.log) + # rather than crashing the pipeline. + (tmp_path / ".mcp.json").write_text("{not json") + wrote = configure_channel_mcp(str(tmp_path), "linear") + assert wrote is True + merged = _read_mcp(str(tmp_path)) + assert LINEAR_MCP_SERVER_KEY in merged["mcpServers"] + + +class TestRepoDirGuard: + """Missing repo_dir must not raise — the pipeline should keep going.""" + + def test_missing_repo_dir(self, tmp_path): + missing = tmp_path / "does-not-exist" + wrote = configure_channel_mcp(str(missing), "linear") + assert wrote is False + + def test_empty_repo_dir_string(self): + wrote = configure_channel_mcp("", "linear") + assert wrote is False diff --git a/agent/tests/test_linear_reactions.py b/agent/tests/test_linear_reactions.py new file mode 100644 index 0000000..57b33e2 --- /dev/null +++ b/agent/tests/test_linear_reactions.py @@ -0,0 +1,166 @@ +"""Unit tests for linear_reactions — channel gating + GraphQL wire shape.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from linear_reactions import ( + EMOJI_FAILURE, + EMOJI_STARTED, + EMOJI_SUCCESS, + LINEAR_GRAPHQL_URL, + react_task_finished, + react_task_started, +) + + +def _ok_response(reaction_id: str = "r-1") -> MagicMock: + resp = MagicMock() + resp.status_code = 200 + payload = {"data": {"reactionCreate": {"success": True, "reaction": {"id": reaction_id}}}} + resp.content = b'{"ok": true}' + resp.json.return_value = payload + return resp + + +def _ok_delete_response() -> MagicMock: + resp = MagicMock() + resp.status_code = 200 + payload = {"data": {"reactionDelete": {"success": True}}} + resp.content = b'{"ok": true}' + resp.json.return_value = payload + return resp + + +class TestChannelGate: + """Non-Linear channels are silent no-ops — no network, no log noise.""" + + def test_start_noop_for_slack(self): + with patch("linear_reactions.requests.post") as post: + assert react_task_started("slack", {"linear_issue_id": "X"}) is None + post.assert_not_called() + + def test_start_noop_for_api(self): + with patch("linear_reactions.requests.post") as post: + assert react_task_started("api", None) is None + post.assert_not_called() + + def test_finish_noop_for_webhook(self): + with patch("linear_reactions.requests.post") as post: + react_task_finished("webhook", None, success=True) + post.assert_not_called() + + def test_start_noop_when_issue_id_missing(self): + """Linear channel but no issue_id — can't address a reaction.""" + with patch("linear_reactions.requests.post") as post: + assert react_task_started("linear", {}) is None + assert react_task_started("linear", None) is None + post.assert_not_called() + + +class TestLinearPath: + """channel='linear' with issue id → correct GraphQL shape per hook.""" + + def test_start_posts_eyes_and_returns_reaction_id(self, monkeypatch): + monkeypatch.setenv("LINEAR_API_TOKEN", "lin_api_test") + with patch( + "linear_reactions.requests.post", + return_value=_ok_response(reaction_id="react-42"), + ) as post: + rid = react_task_started("linear", {"linear_issue_id": "issue-123"}) + assert rid == "react-42" + post.assert_called_once() + args, kwargs = post.call_args + assert args[0] == LINEAR_GRAPHQL_URL + assert kwargs["headers"]["Authorization"] == "lin_api_test" + vars_ = kwargs["json"]["variables"] + assert vars_["issueId"] == "issue-123" + assert vars_["emoji"] == EMOJI_STARTED + + def test_finish_success_deletes_eyes_then_posts_check(self, monkeypatch): + """✅ path: delete the 👀 reaction first, then post the success emoji.""" + monkeypatch.setenv("LINEAR_API_TOKEN", "lin_api_test") + with patch( + "linear_reactions.requests.post", + side_effect=[_ok_delete_response(), _ok_response()], + ) as post: + react_task_finished( + "linear", + {"linear_issue_id": "issue-abc"}, + success=True, + started_reaction_id="react-42", + ) + assert post.call_count == 2 + # First call: delete + assert post.call_args_list[0].kwargs["json"]["variables"] == {"id": "react-42"} + # Second call: create ✅ + assert post.call_args_list[1].kwargs["json"]["variables"]["emoji"] == EMOJI_SUCCESS + assert post.call_args_list[1].kwargs["json"]["variables"]["issueId"] == "issue-abc" + + def test_finish_failure_deletes_eyes_then_posts_x(self, monkeypatch): + monkeypatch.setenv("LINEAR_API_TOKEN", "lin_api_test") + with patch( + "linear_reactions.requests.post", + side_effect=[_ok_delete_response(), _ok_response()], + ) as post: + react_task_finished( + "linear", + {"linear_issue_id": "issue-abc"}, + success=False, + started_reaction_id="react-42", + ) + assert post.call_count == 2 + assert post.call_args_list[1].kwargs["json"]["variables"]["emoji"] == EMOJI_FAILURE + + def test_finish_without_reaction_id_only_posts_terminal(self, monkeypatch): + """If the 👀 POST failed earlier, there's nothing to delete — skip deletion.""" + monkeypatch.setenv("LINEAR_API_TOKEN", "lin_api_test") + with patch( + "linear_reactions.requests.post", + return_value=_ok_response(), + ) as post: + react_task_finished( + "linear", + {"linear_issue_id": "issue-abc"}, + success=True, + started_reaction_id=None, + ) + assert post.call_count == 1 + assert post.call_args.kwargs["json"]["variables"]["emoji"] == EMOJI_SUCCESS + + +class TestFailureIsSwallowed: + """Reactions are advisory — network/API failures never propagate.""" + + def test_http_error_does_not_raise(self, monkeypatch): + monkeypatch.setenv("LINEAR_API_TOKEN", "lin_api_test") + resp = MagicMock() + resp.status_code = 500 + resp.content = b"server error" + resp.json.return_value = {} + with patch("linear_reactions.requests.post", return_value=resp): + # must not raise + react_task_started("linear", {"linear_issue_id": "issue-1"}) + + def test_request_exception_does_not_raise(self, monkeypatch): + import requests as rq + + monkeypatch.setenv("LINEAR_API_TOKEN", "lin_api_test") + with patch("linear_reactions.requests.post", side_effect=rq.ConnectionError("nope")): + # must not raise + react_task_finished("linear", {"linear_issue_id": "issue-1"}, success=True) + + def test_graphql_errors_do_not_raise(self, monkeypatch): + monkeypatch.setenv("LINEAR_API_TOKEN", "lin_api_test") + resp = MagicMock() + resp.status_code = 200 + resp.content = b'{"errors":[{"message":"boom"}]}' + resp.json.return_value = {"errors": [{"message": "boom"}]} + with patch("linear_reactions.requests.post", return_value=resp): + react_task_started("linear", {"linear_issue_id": "issue-1"}) + + def test_missing_token_does_not_raise(self, monkeypatch): + monkeypatch.delenv("LINEAR_API_TOKEN", raising=False) + with patch("linear_reactions.requests.post") as post: + react_task_started("linear", {"linear_issue_id": "issue-1"}) + post.assert_not_called() diff --git a/agent/tests/test_server.py b/agent/tests/test_server.py index ed4c0d8..619702e 100644 --- a/agent/tests/test_server.py +++ b/agent/tests/test_server.py @@ -56,12 +56,22 @@ def boom(**_kwargs): }, ) + # Wait for the background thread to actually finish before asserting. + # The previous pattern polled /ping for the failure flag, but the flag + # flips *before* the backup write_terminal runs in the same thread — + # producing a race where /ping returns 503 but mock_write.assert_called() + # fires before the call happens. Joining the thread eliminates the race. deadline = time.time() + 5.0 while time.time() < deadline: - r = client.get("/ping") - if r.status_code == 503: + with server._threads_lock: + live = [t for t in server._active_threads if t.is_alive()] + if not live: break - time.sleep(0.05) + time.sleep(0.02) + else: + pytest.fail("Background thread did not exit within 5s") + + r = client.get("/ping") assert r.status_code == 503 body = r.json() assert body["status"] == "unhealthy" diff --git a/cdk/src/constructs/linear-integration.ts b/cdk/src/constructs/linear-integration.ts new file mode 100644 index 0000000..93f0898 --- /dev/null +++ b/cdk/src/constructs/linear-integration.ts @@ -0,0 +1,305 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as path from 'path'; +import { Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import * as apigw from 'aws-cdk-lib/aws-apigateway'; +import * as cognito from 'aws-cdk-lib/aws-cognito'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { Runtime, Architecture } from 'aws-cdk-lib/aws-lambda'; +import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import { NagSuppressions } from 'cdk-nag'; +import { Construct } from 'constructs'; +import { LinearProjectMappingTable } from './linear-project-mapping-table'; +import { LinearUserMappingTable } from './linear-user-mapping-table'; + +/** + * Properties for LinearIntegration construct. + */ +export interface LinearIntegrationProps { + /** The existing REST API to add Linear routes to. */ + readonly api: apigw.RestApi; + + /** Cognito user pool for the /linear/link endpoint (Cognito-authenticated). */ + readonly userPool: cognito.IUserPool; + + /** The DynamoDB task table. */ + readonly taskTable: dynamodb.ITable; + + /** The DynamoDB task events table. */ + readonly taskEventsTable: dynamodb.ITable; + + /** The DynamoDB repo config table (optional — for repo onboarding checks). */ + readonly repoTable?: dynamodb.ITable; + + /** Orchestrator Lambda function ARN for async task invocation. */ + readonly orchestratorFunctionArn?: string; + + /** Bedrock Guardrail ID for input screening. */ + readonly guardrailId?: string; + + /** Bedrock Guardrail version for input screening. */ + readonly guardrailVersion?: string; + + /** Task retention in days for TTL computation. */ + readonly taskRetentionDays?: number; + + /** Removal policy for Linear DynamoDB tables. */ + readonly removalPolicy?: RemovalPolicy; +} + +/** + * CDK construct that adds Linear integration to the ABCA platform. + * + * Inbound-only adapter: Linear → webhook → task creation. Outbound progress + * updates happen agent-side via the Linear MCP server (see agent/src/channel_mcp.py), + * so there is NO DynamoDB Streams consumer and NO outbound-notify Lambda here. + * + * Creates: + * - LinearProjectMappingTable (Linear project → GitHub repo mapping) + * - LinearUserMappingTable (Linear user → platform user mapping) + * - LinearWebhookDedupTable (60s TTL dedup for webhook retries) + * - Lambda handlers for the webhook receiver, async processor, and account linking + * - API Gateway routes under /linear/* + * - Two Secrets Manager secrets (webhook signing secret + personal API token) + */ +export class LinearIntegration extends Construct { + /** Linear project → repo mapping table. */ + public readonly projectMappingTable: dynamodb.Table; + + /** Linear user → platform user mapping table. */ + public readonly userMappingTable: dynamodb.Table; + + /** Webhook dedup table — (issue_id, action) keys with 60s TTL. */ + public readonly webhookDedupTable: dynamodb.Table; + + /** Linear webhook signing secret (placeholder — populated by `bgagent linear setup`). */ + public readonly webhookSecret: secretsmanager.Secret; + + /** + * Linear personal API token used by the agent-side MCP (placeholder — + * populated by `bgagent linear setup`). + */ + public readonly apiTokenSecret: secretsmanager.Secret; + + constructor(scope: Construct, id: string, props: LinearIntegrationProps) { + super(scope, id); + + const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY; + + // --- DynamoDB tables --- + const projectMapping = new LinearProjectMappingTable(this, 'ProjectMappingTable', { removalPolicy }); + const userMapping = new LinearUserMappingTable(this, 'UserMappingTable', { removalPolicy }); + this.projectMappingTable = projectMapping.table; + this.userMappingTable = userMapping.table; + + // Dedup table: linear webhook retries collapse to a single processor invoke + // within the 60s TTL window. Keyed on `{issue_id}#{action}`. + this.webhookDedupTable = new dynamodb.Table(this, 'WebhookDedupTable', { + partitionKey: { name: 'dedup_key', type: dynamodb.AttributeType.STRING }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + timeToLiveAttribute: 'ttl', + pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true }, + removalPolicy, + }); + + // --- Secrets (CDK-created placeholders, populated by `bgagent linear setup`) --- + this.webhookSecret = new secretsmanager.Secret(this, 'WebhookSecret', { + description: 'Linear webhook signing secret — populate via `bgagent linear setup`', + removalPolicy, + }); + this.apiTokenSecret = new secretsmanager.Secret(this, 'ApiTokenSecret', { + description: 'Linear personal API token for agent-side MCP — populate via `bgagent linear setup`', + removalPolicy, + }); + + // --- Shared Lambda configuration --- + const handlersDir = path.join(__dirname, '..', 'handlers'); + const commonBundling: lambda.BundlingOptions = { + externalModules: ['@aws-sdk/*'], + }; + + // --- Task creation environment (matches TaskApi / SlackIntegration pattern) --- + const createTaskEnv: Record = { + TASK_TABLE_NAME: props.taskTable.tableName, + TASK_EVENTS_TABLE_NAME: props.taskEventsTable.tableName, + TASK_RETENTION_DAYS: String(props.taskRetentionDays ?? 90), + }; + if (props.repoTable) { + createTaskEnv.REPO_TABLE_NAME = props.repoTable.tableName; + } + if (props.orchestratorFunctionArn) { + createTaskEnv.ORCHESTRATOR_FUNCTION_ARN = props.orchestratorFunctionArn; + } + if (props.guardrailId && props.guardrailVersion) { + createTaskEnv.GUARDRAIL_ID = props.guardrailId; + createTaskEnv.GUARDRAIL_VERSION = props.guardrailVersion; + } + + // --- Cognito Authorizer (for /linear/link) --- + const cognitoAuthorizer = new apigw.CognitoUserPoolsAuthorizer(this, 'LinearCognitoAuthorizer', { + cognitoUserPools: [props.userPool], + }); + const cognitoAuthOptions: apigw.MethodOptions = { + authorizer: cognitoAuthorizer, + authorizationType: apigw.AuthorizationType.COGNITO, + }; + const noneAuthOptions: apigw.MethodOptions = { + authorizationType: apigw.AuthorizationType.NONE, + }; + + // ═══════════════════════════════════════════════════════════════════════════ + // Lambda Handlers + // ═══════════════════════════════════════════════════════════════════════════ + + // --- Webhook processor (async, invoked by receiver) --- + const webhookProcessorFn = new lambda.NodejsFunction(this, 'WebhookProcessorFn', { + entry: path.join(handlersDir, 'linear-webhook-processor.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(30), + environment: { + ...createTaskEnv, + LINEAR_PROJECT_MAPPING_TABLE_NAME: this.projectMappingTable.tableName, + LINEAR_USER_MAPPING_TABLE_NAME: this.userMappingTable.tableName, + }, + bundling: commonBundling, + }); + this.projectMappingTable.grantReadData(webhookProcessorFn); + this.userMappingTable.grantReadData(webhookProcessorFn); + props.taskTable.grantReadWriteData(webhookProcessorFn); + props.taskEventsTable.grantReadWriteData(webhookProcessorFn); + if (props.repoTable) { + props.repoTable.grantReadData(webhookProcessorFn); + } + if (props.orchestratorFunctionArn) { + webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['lambda:InvokeFunction'], + resources: [props.orchestratorFunctionArn], + })); + } + if (props.guardrailId) { + webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['bedrock:ApplyGuardrail'], + resources: [ + Stack.of(this).formatArn({ + service: 'bedrock', + resource: 'guardrail', + resourceName: props.guardrailId, + }), + ], + })); + } + + // --- Webhook receiver (verifies HMAC, dedups, invokes processor) --- + const webhookFn = new lambda.NodejsFunction(this, 'WebhookFn', { + entry: path.join(handlersDir, 'linear-webhook.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(10), + environment: { + LINEAR_WEBHOOK_SECRET_ARN: this.webhookSecret.secretArn, + LINEAR_WEBHOOK_DEDUP_TABLE_NAME: this.webhookDedupTable.tableName, + LINEAR_WEBHOOK_PROCESSOR_FUNCTION_NAME: webhookProcessorFn.functionName, + }, + bundling: commonBundling, + }); + this.webhookSecret.grantRead(webhookFn); + this.webhookDedupTable.grantReadWriteData(webhookFn); + webhookProcessorFn.grantInvoke(webhookFn); + + // --- Account linking (Cognito-authenticated) --- + const linkFn = new lambda.NodejsFunction(this, 'LinkFn', { + entry: path.join(handlersDir, 'linear-link.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(10), + environment: { + LINEAR_USER_MAPPING_TABLE_NAME: this.userMappingTable.tableName, + }, + bundling: commonBundling, + }); + this.userMappingTable.grantReadWriteData(linkFn); + + // ═══════════════════════════════════════════════════════════════════════════ + // API Gateway Routes + // ═══════════════════════════════════════════════════════════════════════════ + + const linear = props.api.root.addResource('linear'); + + // POST /v1/linear/webhook — HMAC-verified; no Cognito. + const webhookResource = linear.addResource('webhook'); + const webhookMethod = webhookResource.addMethod( + 'POST', + new apigw.LambdaIntegration(webhookFn), + noneAuthOptions, + ); + + // POST /v1/linear/link — Cognito-authenticated. + const linkResource = linear.addResource('link'); + linkResource.addMethod( + 'POST', + new apigw.LambdaIntegration(linkFn), + cognitoAuthOptions, + ); + + // ═══════════════════════════════════════════════════════════════════════════ + // cdk-nag suppressions + // ═══════════════════════════════════════════════════════════════════════════ + + NagSuppressions.addResourceSuppressions(webhookMethod, [ + { + id: 'AwsSolutions-APIG4', + reason: 'Linear webhook endpoint uses Linear-Signature HMAC verification instead of Cognito — by design for Linear webhook integration', + }, + { + id: 'AwsSolutions-COG4', + reason: 'Linear webhook endpoint uses Linear-Signature HMAC verification instead of Cognito — by design for Linear webhook integration', + }, + ]); + + for (const secret of [this.webhookSecret, this.apiTokenSecret]) { + NagSuppressions.addResourceSuppressions(secret, [ + { + id: 'AwsSolutions-SMG4', + reason: 'Linear credentials are managed externally (Linear web UI) — automatic rotation is not applicable', + }, + ]); + } + + const allFunctions = [webhookFn, webhookProcessorFn, linkFn]; + for (const fn of allFunctions) { + NagSuppressions.addResourceSuppressions(fn, [ + { + id: 'AwsSolutions-IAM4', + reason: 'AWSLambdaBasicExecutionRole is the AWS-recommended managed policy for Lambda functions', + }, + { + id: 'AwsSolutions-IAM5', + reason: 'Wildcard permissions are scoped by DynamoDB index ARN patterns', + }, + ], true); + } + } +} diff --git a/cdk/src/constructs/linear-project-mapping-table.ts b/cdk/src/constructs/linear-project-mapping-table.ts new file mode 100644 index 0000000..4a0d8b0 --- /dev/null +++ b/cdk/src/constructs/linear-project-mapping-table.ts @@ -0,0 +1,81 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { Construct } from 'constructs'; + +/** + * Properties for LinearProjectMappingTable construct. + */ +export interface LinearProjectMappingTableProps { + /** + * Optional table name override. + * @default - auto-generated by CloudFormation + */ + readonly tableName?: string; + + /** + * Removal policy for the table. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to enable point-in-time recovery. + * @default true + */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * DynamoDB table mapping Linear projects to GitHub repositories. + * + * Schema: linear_project_id (PK). + * + * Fields: + * - repo — `owner/repo` + * - team_id — Linear team UUID (the project's team) + * - label_filter — Linear issue label that triggers a task (default `bgagent`) + * - status — 'active' | 'removed' + * - onboarded_at, updated_at — ISO timestamps + */ +export class LinearProjectMappingTable extends Construct { + /** + * The underlying DynamoDB table. + */ + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: LinearProjectMappingTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'linear_project_id', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + } +} diff --git a/cdk/src/constructs/linear-user-mapping-table.ts b/cdk/src/constructs/linear-user-mapping-table.ts new file mode 100644 index 0000000..b4ff358 --- /dev/null +++ b/cdk/src/constructs/linear-user-mapping-table.ts @@ -0,0 +1,92 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { Construct } from 'constructs'; + +/** + * Properties for LinearUserMappingTable construct. + */ +export interface LinearUserMappingTableProps { + /** + * Optional table name override. + * @default - auto-generated by CloudFormation + */ + readonly tableName?: string; + + /** + * Removal policy for the table. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to enable point-in-time recovery. + * @default true + */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * DynamoDB table for mapping Linear user identities to platform user IDs. + * + * Schema: linear_identity (PK) — composite key `{workspace_id}#{user_id}` for confirmed mappings, + * `pending#{code}` for in-flight link codes (with TTL). + * + * GSIs: + * - PlatformUserIndex (PK: platform_user_id, SK: linked_at) — list linked Linear accounts for a user + */ +export class LinearUserMappingTable extends Construct { + /** + * GSI name for querying mappings by platform user. + * PK: platform_user_id, SK: linked_at. + */ + public static readonly PLATFORM_USER_INDEX = 'PlatformUserIndex'; + + /** + * The underlying DynamoDB table. + */ + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: LinearUserMappingTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'linear_identity', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + timeToLiveAttribute: 'ttl', + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + + this.table.addGlobalSecondaryIndex({ + indexName: LinearUserMappingTable.PLATFORM_USER_INDEX, + partitionKey: { name: 'platform_user_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'linked_at', type: dynamodb.AttributeType.STRING }, + projectionType: dynamodb.ProjectionType.ALL, + }); + } +} diff --git a/cdk/src/constructs/slack-installation-table.ts b/cdk/src/constructs/slack-installation-table.ts new file mode 100644 index 0000000..3273621 --- /dev/null +++ b/cdk/src/constructs/slack-installation-table.ts @@ -0,0 +1,77 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { Construct } from 'constructs'; + +/** + * Properties for SlackInstallationTable construct. + */ +export interface SlackInstallationTableProps { + /** + * Optional table name override. + * @default - auto-generated by CloudFormation + */ + readonly tableName?: string; + + /** + * Removal policy for the table. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to enable point-in-time recovery. + * @default true + */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * DynamoDB table for Slack workspace installations. + * + * Schema: team_id (PK) — one record per installed Slack workspace. + * Stores workspace metadata and a pointer to the bot token in Secrets Manager. + * Bot tokens are stored in Secrets Manager at `bgagent/slack/{team_id}`. + */ +export class SlackInstallationTable extends Construct { + /** + * The underlying DynamoDB table. + */ + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: SlackInstallationTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'team_id', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + timeToLiveAttribute: 'ttl', + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + } +} diff --git a/cdk/src/constructs/slack-integration.ts b/cdk/src/constructs/slack-integration.ts new file mode 100644 index 0000000..b2d287e --- /dev/null +++ b/cdk/src/constructs/slack-integration.ts @@ -0,0 +1,453 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as path from 'path'; +import { ArnFormat, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import * as apigw from 'aws-cdk-lib/aws-apigateway'; +import * as cognito from 'aws-cdk-lib/aws-cognito'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { Runtime, Architecture, StartingPosition, FilterCriteria, FilterRule } from 'aws-cdk-lib/aws-lambda'; +import * as lambdaEventSources from 'aws-cdk-lib/aws-lambda-event-sources'; +import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import { NagSuppressions } from 'cdk-nag'; +import { Construct } from 'constructs'; +import { SlackInstallationTable } from './slack-installation-table'; +import { SlackUserMappingTable } from './slack-user-mapping-table'; + +/** + * Properties for SlackIntegration construct. + */ +export interface SlackIntegrationProps { + /** The existing REST API to add Slack routes to. */ + readonly api: apigw.RestApi; + + /** Cognito user pool for the /slack/link endpoint (Cognito-authenticated). */ + readonly userPool: cognito.IUserPool; + + /** The DynamoDB task table. */ + readonly taskTable: dynamodb.ITable; + + /** The DynamoDB task events table (must have DynamoDB Streams enabled). */ + readonly taskEventsTable: dynamodb.ITable; + + /** The DynamoDB repo config table (optional — for repo onboarding checks). */ + readonly repoTable?: dynamodb.ITable; + + /** Orchestrator Lambda function ARN for async task invocation. */ + readonly orchestratorFunctionArn?: string; + + /** Bedrock Guardrail ID for input screening. */ + readonly guardrailId?: string; + + /** Bedrock Guardrail version for input screening. */ + readonly guardrailVersion?: string; + + /** Task retention in days for TTL computation. */ + readonly taskRetentionDays?: number; + + /** Removal policy for Slack DynamoDB tables. */ + readonly removalPolicy?: RemovalPolicy; +} + +/** + * CDK construct that adds Slack integration to the ABCA platform. + * + * Creates: + * - SlackInstallationTable (per-workspace installation records) + * - SlackUserMappingTable (Slack user → platform user mappings) + * - Lambda handlers for OAuth, slash commands, events, notifications, and account linking + * - API Gateway routes under /slack/* + * - DynamoDB Streams event source for outbound notifications + */ +export class SlackIntegration extends Construct { + /** The Slack installation table. */ + public readonly installationTable: dynamodb.Table; + + /** The Slack user mapping table. */ + public readonly userMappingTable: dynamodb.Table; + + /** The Slack signing secret (placeholder — user populates after creating the Slack App). */ + public readonly signingSecret: secretsmanager.Secret; + + /** The Slack client secret (placeholder — user populates after creating the Slack App). */ + public readonly clientSecret: secretsmanager.Secret; + + /** The Slack client ID secret (placeholder — user populates after creating the Slack App). */ + public readonly clientIdSecret: secretsmanager.Secret; + + constructor(scope: Construct, id: string, props: SlackIntegrationProps) { + super(scope, id); + + const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY; + + // --- DynamoDB Tables --- + const installationTable = new SlackInstallationTable(this, 'InstallationTable', { removalPolicy }); + const userMappingTable = new SlackUserMappingTable(this, 'UserMappingTable', { removalPolicy }); + this.installationTable = installationTable.table; + this.userMappingTable = userMappingTable.table; + + // --- Slack App Secrets (CDK-created placeholders) --- + // Users populate these after creating the Slack App via the SlackAppCreateUrl output. + this.signingSecret = new secretsmanager.Secret(this, 'SigningSecret', { + description: 'Slack App signing secret — populate after creating the Slack App', + removalPolicy, + }); + this.clientSecret = new secretsmanager.Secret(this, 'ClientSecret', { + description: 'Slack App client secret (OAuth) — populate after creating the Slack App', + removalPolicy, + }); + this.clientIdSecret = new secretsmanager.Secret(this, 'ClientIdSecret', { + description: 'Slack App client ID — populate after creating the Slack App', + removalPolicy, + }); + + // --- Shared Lambda configuration --- + const handlersDir = path.join(__dirname, '..', 'handlers'); + const commonBundling: lambda.BundlingOptions = { + externalModules: ['@aws-sdk/*'], + }; + + // Secrets Manager ARN prefix for Slack secrets (bgagent/slack/*) + const slackSecretArnPrefix = Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + resourceName: 'bgagent/slack/*', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + }); + + // IAM policy for reading Slack secrets from Secrets Manager + const readSlackSecretsPolicy = new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue'], + resources: [slackSecretArnPrefix], + }); + + // --- Cognito Authorizer (for /slack/link endpoint) --- + const cognitoAuthorizer = new apigw.CognitoUserPoolsAuthorizer(this, 'SlackCognitoAuthorizer', { + cognitoUserPools: [props.userPool], + }); + + const cognitoAuthOptions: apigw.MethodOptions = { + authorizer: cognitoAuthorizer, + authorizationType: apigw.AuthorizationType.COGNITO, + }; + + const noneAuthOptions: apigw.MethodOptions = { + authorizationType: apigw.AuthorizationType.NONE, + }; + + // --- Task creation environment (matches TaskApi createTaskEnv pattern) --- + const createTaskEnv: Record = { + TASK_TABLE_NAME: props.taskTable.tableName, + TASK_EVENTS_TABLE_NAME: props.taskEventsTable.tableName, + TASK_RETENTION_DAYS: String(props.taskRetentionDays ?? 90), + }; + if (props.repoTable) { + createTaskEnv.REPO_TABLE_NAME = props.repoTable.tableName; + } + if (props.orchestratorFunctionArn) { + createTaskEnv.ORCHESTRATOR_FUNCTION_ARN = props.orchestratorFunctionArn; + } + if (props.guardrailId && props.guardrailVersion) { + createTaskEnv.GUARDRAIL_ID = props.guardrailId; + createTaskEnv.GUARDRAIL_VERSION = props.guardrailVersion; + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Lambda Handlers + // ═══════════════════════════════════════════════════════════════════════════ + + // --- OAuth Callback --- + const oauthCallbackFn = new lambda.NodejsFunction(this, 'OAuthCallbackFn', { + entry: path.join(handlersDir, 'slack-oauth-callback.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(15), + environment: { + SLACK_INSTALLATION_TABLE_NAME: this.installationTable.tableName, + SLACK_CLIENT_ID_SECRET_ARN: this.clientIdSecret.secretArn, + SLACK_CLIENT_SECRET_ARN: this.clientSecret.secretArn, + }, + bundling: commonBundling, + }); + this.installationTable.grantWriteData(oauthCallbackFn); + this.clientIdSecret.grantRead(oauthCallbackFn); + this.clientSecret.grantRead(oauthCallbackFn); + oauthCallbackFn.addToRolePolicy(readSlackSecretsPolicy); + // CreateSecret + UpdateSecret for bot tokens + oauthCallbackFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:CreateSecret'], + resources: ['*'], + conditions: { + StringLike: { 'secretsmanager:Name': 'bgagent/slack/*' }, + }, + })); + oauthCallbackFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:UpdateSecret', 'secretsmanager:TagResource', 'secretsmanager:RestoreSecret'], + resources: [slackSecretArnPrefix], + })); + + // --- Slack Events --- + // Note: SLACK_COMMAND_PROCESSOR_FUNCTION_NAME is set below after commandProcessorFn is created. + const slackEventsFn = new lambda.NodejsFunction(this, 'SlackEventsFn', { + entry: path.join(handlersDir, 'slack-events.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(10), + environment: { + SLACK_INSTALLATION_TABLE_NAME: this.installationTable.tableName, + SLACK_SIGNING_SECRET_ARN: this.signingSecret.secretArn, + }, + bundling: commonBundling, + }); + + // Keep one instance warm — Slack's URL verification during app creation + // times out on cold starts, and the retry UX is poor. + const slackEventsAlias = slackEventsFn.addAlias('live', { + provisionedConcurrentExecutions: 1, + }); + this.installationTable.grantReadWriteData(slackEventsFn); + this.signingSecret.grantRead(slackEventsFn); + slackEventsFn.addToRolePolicy(readSlackSecretsPolicy); + slackEventsFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:DeleteSecret'], + resources: [slackSecretArnPrefix], + })); + + // --- Slash Command Processor (async worker) --- + const commandProcessorFn = new lambda.NodejsFunction(this, 'CommandProcessorFn', { + entry: path.join(handlersDir, 'slack-command-processor.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(30), + environment: { + ...createTaskEnv, + SLACK_USER_MAPPING_TABLE_NAME: this.userMappingTable.tableName, + SLACK_INSTALLATION_TABLE_NAME: this.installationTable.tableName, + }, + bundling: commonBundling, + }); + this.userMappingTable.grantReadWriteData(commandProcessorFn); + this.installationTable.grantReadData(commandProcessorFn); + commandProcessorFn.addToRolePolicy(readSlackSecretsPolicy); + props.taskTable.grantReadWriteData(commandProcessorFn); + props.taskEventsTable.grantReadWriteData(commandProcessorFn); + if (props.repoTable) { + props.repoTable.grantReadData(commandProcessorFn); + } + if (props.orchestratorFunctionArn) { + commandProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['lambda:InvokeFunction'], + resources: [props.orchestratorFunctionArn], + })); + } + if (props.guardrailId) { + commandProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['bedrock:ApplyGuardrail'], + resources: [ + Stack.of(this).formatArn({ + service: 'bedrock', + resource: 'guardrail', + resourceName: props.guardrailId, + }), + ], + })); + } + + // Wire events handler to command processor for @mention forwarding. + slackEventsFn.addEnvironment('SLACK_COMMAND_PROCESSOR_FUNCTION_NAME', commandProcessorFn.functionName); + commandProcessorFn.grantInvoke(slackEventsFn); + + // --- Slack Interactions (Block Kit button actions) --- + const slackInteractionsFn = new lambda.NodejsFunction(this, 'SlackInteractionsFn', { + entry: path.join(handlersDir, 'slack-interactions.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(10), + environment: { + SLACK_SIGNING_SECRET_ARN: this.signingSecret.secretArn, + TASK_TABLE_NAME: props.taskTable.tableName, + SLACK_USER_MAPPING_TABLE_NAME: this.userMappingTable.tableName, + }, + bundling: commonBundling, + }); + this.signingSecret.grantRead(slackInteractionsFn); + slackInteractionsFn.addToRolePolicy(readSlackSecretsPolicy); + props.taskTable.grantReadWriteData(slackInteractionsFn); + this.userMappingTable.grantReadData(slackInteractionsFn); + + // --- Slash Command Acknowledger --- + const slackCommandsFn = new lambda.NodejsFunction(this, 'SlackCommandsFn', { + entry: path.join(handlersDir, 'slack-commands.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(3), + environment: { + SLACK_SIGNING_SECRET_ARN: this.signingSecret.secretArn, + SLACK_COMMAND_PROCESSOR_FUNCTION_NAME: commandProcessorFn.functionName, + }, + bundling: commonBundling, + }); + // slackCommandsFn verifies the Slack signing secret (granted above) and + // async-invokes the processor Lambda — it never reads bot tokens, so the + // bgagent/slack/* wildcard grant is intentionally omitted here. + this.signingSecret.grantRead(slackCommandsFn); + commandProcessorFn.grantInvoke(slackCommandsFn); + + // --- Account Linking (Cognito-authenticated) --- + const slackLinkFn = new lambda.NodejsFunction(this, 'SlackLinkFn', { + entry: path.join(handlersDir, 'slack-link.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(10), + environment: { + SLACK_USER_MAPPING_TABLE_NAME: this.userMappingTable.tableName, + }, + bundling: commonBundling, + }); + this.userMappingTable.grantReadWriteData(slackLinkFn); + + // --- Outbound Notification Handler (DynamoDB Streams trigger) --- + const slackNotifyFn = new lambda.NodejsFunction(this, 'SlackNotifyFn', { + entry: path.join(handlersDir, 'slack-notify.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(30), + environment: { + TASK_TABLE_NAME: props.taskTable.tableName, + }, + bundling: commonBundling, + }); + props.taskTable.grantReadWriteData(slackNotifyFn); + slackNotifyFn.addToRolePolicy(readSlackSecretsPolicy); + + // DynamoDB Streams event source with filtering + slackNotifyFn.addEventSource(new lambdaEventSources.DynamoEventSource(props.taskEventsTable, { + startingPosition: StartingPosition.LATEST, + batchSize: 10, + maxBatchingWindow: Duration.seconds(0), + retryAttempts: 3, + bisectBatchOnError: true, + filters: [ + FilterCriteria.filter({ + eventName: FilterRule.isEqual('INSERT'), + }), + ], + })); + + // ═══════════════════════════════════════════════════════════════════════════ + // API Gateway Routes + // ═══════════════════════════════════════════════════════════════════════════ + + const slack = props.api.root.addResource('slack'); + + // OAuth callback: GET /v1/slack/oauth/callback + const oauthResource = slack.addResource('oauth'); + const oauthCallbackResource = oauthResource.addResource('callback'); + const oauthCallbackMethod = oauthCallbackResource.addMethod( + 'GET', + new apigw.LambdaIntegration(oauthCallbackFn), + noneAuthOptions, + ); + + // Slack events: POST /v1/slack/events + const eventsResource = slack.addResource('events'); + const eventsMethod = eventsResource.addMethod( + 'POST', + new apigw.LambdaIntegration(slackEventsAlias), + noneAuthOptions, + ); + + // Slash commands: POST /v1/slack/commands + const commandsResource = slack.addResource('commands'); + const commandsMethod = commandsResource.addMethod( + 'POST', + new apigw.LambdaIntegration(slackCommandsFn), + noneAuthOptions, + ); + + // Block Kit interactions: POST /v1/slack/interactions + const interactionsResource = slack.addResource('interactions'); + const interactionsMethod = interactionsResource.addMethod( + 'POST', + new apigw.LambdaIntegration(slackInteractionsFn), + noneAuthOptions, + ); + + // Account linking: POST /v1/slack/link (Cognito-authenticated) + const linkResource = slack.addResource('link'); + linkResource.addMethod( + 'POST', + new apigw.LambdaIntegration(slackLinkFn), + cognitoAuthOptions, + ); + + // ═══════════════════════════════════════════════════════════════════════════ + // cdk-nag suppressions + // ═══════════════════════════════════════════════════════════════════════════ + + // Suppress APIG4 and COG4 on routes that use Slack signing secret instead of Cognito + const slackVerifiedMethods = [oauthCallbackMethod, eventsMethod, commandsMethod, interactionsMethod]; + for (const method of slackVerifiedMethods) { + NagSuppressions.addResourceSuppressions(method, [ + { + id: 'AwsSolutions-APIG4', + reason: 'Slack endpoint uses Slack signing secret verification instead of Cognito — by design for Slack API integration', + }, + { + id: 'AwsSolutions-COG4', + reason: 'Slack endpoint uses Slack signing secret verification instead of Cognito — by design for Slack API integration', + }, + ]); + } + + // Slack secrets are managed externally (populated by the user after creating the Slack App) + for (const secret of [this.signingSecret, this.clientSecret, this.clientIdSecret]) { + NagSuppressions.addResourceSuppressions(secret, [ + { + id: 'AwsSolutions-SMG4', + reason: 'Slack App credentials are managed externally — automatic rotation is not applicable', + }, + ]); + } + + // Standard Lambda suppressions + const allFunctions = [oauthCallbackFn, slackEventsFn, slackCommandsFn, commandProcessorFn, slackLinkFn, slackNotifyFn, slackInteractionsFn]; + for (const fn of allFunctions) { + NagSuppressions.addResourceSuppressions(fn, [ + { + id: 'AwsSolutions-IAM4', + reason: 'AWSLambdaBasicExecutionRole is the AWS-recommended managed policy for Lambda functions', + }, + { + id: 'AwsSolutions-IAM5', + reason: 'Wildcard permissions are scoped by condition (secretsmanager:Name prefix) or by DynamoDB index ARN patterns', + }, + ], true); + } + } +} diff --git a/cdk/src/constructs/slack-user-mapping-table.ts b/cdk/src/constructs/slack-user-mapping-table.ts new file mode 100644 index 0000000..e5852e3 --- /dev/null +++ b/cdk/src/constructs/slack-user-mapping-table.ts @@ -0,0 +1,92 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { RemovalPolicy } from 'aws-cdk-lib'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { Construct } from 'constructs'; + +/** + * Properties for SlackUserMappingTable construct. + */ +export interface SlackUserMappingTableProps { + /** + * Optional table name override. + * @default - auto-generated by CloudFormation + */ + readonly tableName?: string; + + /** + * Removal policy for the table. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to enable point-in-time recovery. + * @default true + */ + readonly pointInTimeRecovery?: boolean; +} + +/** + * DynamoDB table for mapping Slack user identities to platform user IDs. + * + * Schema: slack_identity (PK) — composite key `{team_id}#{user_id}`. + * Also used for pending link records (with status='pending' and TTL). + * + * GSIs: + * - PlatformUserIndex (PK: platform_user_id, SK: linked_at) — list linked Slack accounts for a user + */ +export class SlackUserMappingTable extends Construct { + /** + * GSI name for querying mappings by platform user. + * PK: platform_user_id, SK: linked_at. + */ + public static readonly PLATFORM_USER_INDEX = 'PlatformUserIndex'; + + /** + * The underlying DynamoDB table. + */ + public readonly table: dynamodb.Table; + + constructor(scope: Construct, id: string, props: SlackUserMappingTableProps = {}) { + super(scope, id); + + this.table = new dynamodb.Table(this, 'Table', { + tableName: props.tableName, + partitionKey: { + name: 'slack_identity', + type: dynamodb.AttributeType.STRING, + }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + timeToLiveAttribute: 'ttl', + pointInTimeRecoverySpecification: { + pointInTimeRecoveryEnabled: props.pointInTimeRecovery ?? true, + }, + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + }); + + this.table.addGlobalSecondaryIndex({ + indexName: SlackUserMappingTable.PLATFORM_USER_INDEX, + partitionKey: { name: 'platform_user_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'linked_at', type: dynamodb.AttributeType.STRING }, + projectionType: dynamodb.ProjectionType.ALL, + }); + } +} diff --git a/cdk/src/constructs/task-api.ts b/cdk/src/constructs/task-api.ts index 2309fe0..2443bbe 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -240,6 +240,25 @@ export class TaskApi extends Construct { managedRuleGroupStatement: { vendorName: 'AWS', name: 'AWSManagedRulesCommonRuleSet', + // Inbound webhook payloads from mature SaaS tools (Linear ships + // full Issue payloads > 8 KB) trip SizeRestrictions_BODY in this + // ruleset. Exempt the Linear webhook path from CRS entirely: + // the route is HMAC-verified in the Lambda, parsed as strict + // JSON, never interpolated into SQL/HTML, and rate-limited by + // the priority-3 rule below. CRS still applies to every other + // route (user API, Slack, etc.). + scopeDownStatement: { + notStatement: { + statement: { + byteMatchStatement: { + fieldToMatch: { uriPath: {} }, + positionalConstraint: 'EXACTLY', + searchString: '/v1/linear/webhook', + textTransformations: [{ priority: 0, type: 'NONE' }], + }, + }, + }, + }, }, }, visibilityConfig: { diff --git a/cdk/src/handlers/linear-link.ts b/cdk/src/handlers/linear-link.ts new file mode 100644 index 0000000..8dde765 --- /dev/null +++ b/cdk/src/handlers/linear-link.ts @@ -0,0 +1,110 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { ulid } from 'ulid'; +import { extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import { parseBody } from './shared/validation'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +const USER_MAPPING_TABLE = process.env.LINEAR_USER_MAPPING_TABLE_NAME!; + +interface LinkRequest { + readonly code: string; +} + +/** + * POST /v1/linear/link — Complete Linear account linking. + * + * Called from the CLI (`bgagent linear link `) with a Cognito JWT. + * Looks up the pending link record, maps the Linear identity to the + * authenticated platform user, and cleans up the pending record. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + + try { + const userId = extractUserId(event); + if (!userId) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Authentication required.', requestId); + } + + const body = parseBody(event.body ?? null); + if (!body?.code) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Request body must include a "code" field.', requestId); + } + + const code = body.code.trim().toUpperCase(); + + const pending = await ddb.send(new GetCommand({ + TableName: USER_MAPPING_TABLE, + Key: { linear_identity: `pending#${code}` }, + })); + + if (!pending.Item || pending.Item.status !== 'pending') { + return errorResponse(404, ErrorCode.VALIDATION_ERROR, 'Invalid or expired link code.', requestId); + } + + const workspaceId = pending.Item.linear_workspace_id as string; + const linearUserId = pending.Item.linear_user_id as string; + const now = new Date().toISOString(); + + await ddb.send(new PutCommand({ + TableName: USER_MAPPING_TABLE, + Item: { + linear_identity: `${workspaceId}#${linearUserId}`, + platform_user_id: userId, + linear_workspace_id: workspaceId, + linear_user_id: linearUserId, + linked_at: now, + status: 'active', + link_method: 'cli', + }, + })); + + await ddb.send(new DeleteCommand({ + TableName: USER_MAPPING_TABLE, + Key: { linear_identity: `pending#${code}` }, + })); + + logger.info('Linear account linked', { + platform_user_id: userId, + linear_workspace_id: workspaceId, + linear_user_id: linearUserId, + }); + + return successResponse(200, { + message: 'Linear account linked successfully.', + linear_workspace_id: workspaceId, + linear_user_id: linearUserId, + linked_at: now, + }, requestId); + } catch (err) { + logger.error('Linear link handler failed', { + error: err instanceof Error ? err.message : String(err), + request_id: requestId, + }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId); + } +} diff --git a/cdk/src/handlers/linear-webhook-processor.ts b/cdk/src/handlers/linear-webhook-processor.ts new file mode 100644 index 0000000..261a456 --- /dev/null +++ b/cdk/src/handlers/linear-webhook-processor.ts @@ -0,0 +1,261 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; +import { createTaskCore } from './shared/create-task-core'; +import { logger } from './shared/logger'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +const PROJECT_MAPPING_TABLE = process.env.LINEAR_PROJECT_MAPPING_TABLE_NAME!; +const USER_MAPPING_TABLE = process.env.LINEAR_USER_MAPPING_TABLE_NAME!; +const DEFAULT_LABEL_FILTER = 'bgagent'; + +/** Shape of Linear `Issue` webhook payloads we care about. Undocumented fields are tolerated. */ +interface LinearIssueEvent { + readonly action: 'create' | 'update' | 'remove' | string; + readonly type: 'Issue'; + readonly data: { + readonly id: string; + readonly identifier?: string; + readonly title?: string; + readonly description?: string; + readonly projectId?: string; + readonly teamId?: string; + readonly labels?: Array<{ id: string; name: string }>; + readonly labelIds?: string[]; + readonly creatorId?: string; + readonly [key: string]: unknown; + }; + readonly actor?: { + readonly id?: string; + readonly name?: string; + }; + readonly updatedFrom?: { + readonly labelIds?: string[]; + readonly [key: string]: unknown; + }; + readonly organizationId?: string; + readonly webhookTimestamp?: number; + readonly webhookId?: string; +} + +interface ProcessorEvent { + readonly raw_body: string; +} + +/** + * Async processor for verified Linear webhooks. + * + * Responsibilities: + * - Parse the `Issue` payload. + * - Detect whether the configured trigger label was just added (create) or present on update. + * - Resolve the Linear project → GitHub repo mapping. + * - Resolve the Linear actor → platform user mapping. + * - Call `createTaskCore` with `channelSource: 'linear'` and metadata the agent uses + * to address the originating issue via the Linear MCP. + */ +export async function handler(event: ProcessorEvent): Promise { + if (!event.raw_body) { + logger.error('Linear webhook processor invoked without raw_body'); + return; + } + + let payload: LinearIssueEvent; + try { + payload = JSON.parse(event.raw_body) as LinearIssueEvent; + } catch (err) { + logger.error('Linear webhook processor could not parse raw_body', { + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + if (payload.type !== 'Issue') { + logger.info('Linear processor skipping non-Issue payload', { type: payload.type }); + return; + } + + const issue = payload.data; + const projectId = issue.projectId; + if (!projectId) { + logger.info('Linear Issue has no projectId — skipping (cannot route to a repo)', { + issue_id: issue.id, + }); + return; + } + + // Look up project → repo mapping. + const mapping = await ddb.send(new GetCommand({ + TableName: PROJECT_MAPPING_TABLE, + Key: { linear_project_id: projectId }, + })); + if (!mapping.Item || mapping.Item.status !== 'active') { + logger.info('Linear project is not onboarded or is removed — skipping', { + linear_project_id: projectId, + issue_id: issue.id, + }); + return; + } + const repo = mapping.Item.repo as string; + const labelFilter = (mapping.Item.label_filter as string | undefined) ?? DEFAULT_LABEL_FILTER; + + // Only trigger when the configured label is present AND this event is a transition + // that meaningfully added/asserts the label — `create` with the label on it, or + // `update` that newly added it. + if (!shouldTrigger(payload, labelFilter)) { + logger.info('Linear webhook does not match trigger criteria', { + action: payload.action, + issue_id: issue.id, + label_filter: labelFilter, + current_labels: issue.labels?.map((l) => l?.name), + updated_from_keys: Object.keys(payload.updatedFrom ?? {}), + updated_from_label_ids: payload.updatedFrom?.labelIds, + current_label_ids: issue.labels?.map((l) => l?.id), + }); + return; + } + + // Resolve the actor → platform user. Fall back to creator if the actor is missing + // (e.g. automation that set the label). If neither resolves, we cannot attribute + // the task to a platform user and must drop the event. + const workspaceId = payload.organizationId ?? ''; + const actorId = payload.actor?.id ?? issue.creatorId; + if (!workspaceId || !actorId) { + logger.warn('Linear webhook missing organization or actor — cannot attribute task', { + issue_id: issue.id, + organization_id: workspaceId, + actor_id: actorId, + }); + return; + } + + const platformUserId = await lookupPlatformUser(workspaceId, actorId); + if (!platformUserId) { + logger.warn('Linear actor has no linked platform user — skipping task creation', { + linear_workspace_id: workspaceId, + linear_user_id: actorId, + issue_id: issue.id, + }); + return; + } + + const taskDescription = buildTaskDescription(issue); + + const channelMetadata: Record = { + linear_issue_id: issue.id, + linear_workspace_id: workspaceId, + linear_project_id: projectId, + }; + if (issue.identifier) { + channelMetadata.linear_issue_identifier = issue.identifier; + } + if (issue.teamId) { + channelMetadata.linear_team_id = issue.teamId; + } + + const requestId = crypto.randomUUID(); + const result = await createTaskCore( + { + repo, + task_description: taskDescription, + }, + { + userId: platformUserId, + channelSource: 'linear', + channelMetadata, + }, + requestId, + ); + + if (result.statusCode !== 201) { + logger.warn('Linear-triggered task creation returned non-201', { + status: result.statusCode, + body: result.body, + issue_id: issue.id, + }); + return; + } + + logger.info('Linear-triggered task created', { + issue_id: issue.id, + linear_issue_identifier: issue.identifier, + repo, + request_id: requestId, + }); +} + +/** + * Decide whether a Linear Issue event should trigger a task. + * + * - `create` with the label already on the issue → trigger + * - `update` where labelIds transitions to include the label (previously didn't) → trigger + * - Everything else → no-op + */ +function shouldTrigger(payload: LinearIssueEvent, labelFilter: string): boolean { + const current = payload.data.labels ?? []; + const hasLabel = current.some((l) => l?.name?.toLowerCase() === labelFilter.toLowerCase()); + + if (payload.action === 'create') { + return hasLabel; + } + + if (payload.action === 'update') { + if (!hasLabel) return false; + // If the event doesn't include a label change, skip — something else on the + // issue was edited, and we shouldn't re-submit on every title/description edit. + const updatedFrom = payload.updatedFrom ?? {}; + const labelIdsChanged = Object.prototype.hasOwnProperty.call(updatedFrom, 'labelIds'); + if (!labelIdsChanged) return false; + // The label must have just been added, not removed. If it was present before, + // another Linear user probably toggled a different label — avoid re-triggering. + const previousIds = new Set((updatedFrom.labelIds as string[] | undefined) ?? []); + const currentLabelId = current.find((l) => l?.name?.toLowerCase() === labelFilter.toLowerCase())?.id; + if (!currentLabelId) return false; + return !previousIds.has(currentLabelId); + } + + return false; +} + +function buildTaskDescription(issue: LinearIssueEvent['data']): string { + const parts: string[] = []; + if (issue.identifier && issue.title) { + parts.push(`${issue.identifier}: ${issue.title}`); + } else if (issue.title) { + parts.push(issue.title); + } + if (issue.description && issue.description.trim()) { + parts.push(''); + parts.push(issue.description.trim()); + } + return parts.join('\n') || 'Linear issue'; +} + +async function lookupPlatformUser(workspaceId: string, userId: string): Promise { + const key = `${workspaceId}#${userId}`; + const result = await ddb.send(new GetCommand({ + TableName: USER_MAPPING_TABLE, + Key: { linear_identity: key }, + })); + if (!result.Item || result.Item.status === 'pending') return null; + return (result.Item.platform_user_id as string) ?? null; +} diff --git a/cdk/src/handlers/linear-webhook.ts b/cdk/src/handlers/linear-webhook.ts new file mode 100644 index 0000000..72def0e --- /dev/null +++ b/cdk/src/handlers/linear-webhook.ts @@ -0,0 +1,196 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { ConditionalCheckFailedException, DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { DeleteCommand, DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { isWebhookTimestampFresh, verifyLinearRequest } from './shared/linear-verify'; +import { logger } from './shared/logger'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const lambdaClient = new LambdaClient({}); + +const WEBHOOK_SECRET_ARN = process.env.LINEAR_WEBHOOK_SECRET_ARN!; +const DEDUP_TABLE_NAME = process.env.LINEAR_WEBHOOK_DEDUP_TABLE_NAME!; +const PROCESSOR_FUNCTION_NAME = process.env.LINEAR_WEBHOOK_PROCESSOR_FUNCTION_NAME!; + +/** + * Dedup window (seconds). Must exceed Linear's full retry horizon: first + * retry is +1m, then +1h, then +6h (~7h total from the initial delivery). + * A window shorter than this lets the +1h / +6h retries land after the + * dedup row has TTL'd out, which would double-create the task when an + * ack was lost. 8h is comfortably over the horizon with slack for clock + * skew, without making stale rows live meaningfully longer (DDB TTL is + * async best-effort anyway). + */ +const DEDUP_TTL_SECONDS = 8 * 60 * 60; + +/** + * Shape of the top-level Linear webhook payload we care about for dedup + routing. + * Full payload is forwarded to the processor without re-serialization risk — + * the processor parses its own copy from the raw body. + */ +interface LinearWebhookEnvelope { + readonly action?: string; + readonly type?: string; + readonly webhookTimestamp?: number; + readonly webhookId?: string; + readonly organizationId?: string; + readonly data?: { + readonly id?: string; + readonly [key: string]: unknown; + }; +} + +/** + * POST /v1/linear/webhook — Linear webhook receiver. + * + * Verifies the `Linear-Signature` HMAC over the raw body, rejects stale + * `webhookTimestamp` values (replay protection), dedups on + * `(issue_id, action)` with a 60s TTL, and async-invokes the processor + * Lambda so we can ack within Linear's 5s timeout. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + try { + if (!event.body) { + return jsonResponse(400, { error: 'Request body is required' }); + } + + const signature = event.headers['Linear-Signature'] ?? event.headers['linear-signature'] ?? ''; + if (!signature) { + logger.warn('Linear webhook missing Linear-Signature header'); + return jsonResponse(401, { error: 'Missing signature' }); + } + + if (!await verifyLinearRequest(WEBHOOK_SECRET_ARN, signature, event.body)) { + logger.warn('Invalid Linear webhook signature'); + return jsonResponse(401, { error: 'Invalid signature' }); + } + + let payload: LinearWebhookEnvelope; + try { + payload = JSON.parse(event.body) as LinearWebhookEnvelope; + } catch (err) { + logger.warn('Linear webhook body is not valid JSON', { + error: err instanceof Error ? err.message : String(err), + }); + return jsonResponse(400, { error: 'Invalid JSON' }); + } + + if (!isWebhookTimestampFresh(payload.webhookTimestamp)) { + logger.warn('Linear webhook timestamp outside replay window', { + webhook_timestamp: payload.webhookTimestamp, + webhook_id: payload.webhookId, + }); + return jsonResponse(401, { error: 'Stale webhook timestamp' }); + } + + // Only Issue events flow through to task creation. Every other type is + // acknowledged silently so Linear stops retrying. + if (payload.type !== 'Issue') { + logger.info('Ignoring non-Issue Linear webhook', { type: payload.type, action: payload.action }); + return jsonResponse(200, { ok: true }); + } + + const issueId = payload.data?.id; + const action = payload.action ?? 'unknown'; + if (!issueId) { + logger.warn('Linear Issue webhook missing data.id', { action }); + return jsonResponse(400, { error: 'Missing issue id' }); + } + + // Dedup via conditional PutItem. + // + // Linear's `webhookId` in the payload body is the *webhook configuration* + // ID — reused on every delivery, not per-delivery. `webhookTimestamp` + // (UNIX ms) is unique per delivery; Linear reuses it for retries of a + // single delivery. Compose `${issueId}#${action}#${webhookTimestamp}` so + // retries of the same event collapse (same timestamp) while distinct + // events do not. A missing timestamp would have already failed the replay + // check above, so treat its presence as a precondition. + const dedupKey = `${issueId}#${action}#${payload.webhookTimestamp}`; + const nowSeconds = Math.floor(Date.now() / 1000); + try { + await ddb.send(new PutCommand({ + TableName: DEDUP_TABLE_NAME, + Item: { + dedup_key: dedupKey, + created_at: new Date().toISOString(), + ttl: nowSeconds + DEDUP_TTL_SECONDS, + }, + ConditionExpression: 'attribute_not_exists(dedup_key)', + })); + } catch (err) { + if (err instanceof ConditionalCheckFailedException) { + logger.info('Linear webhook dedup hit — skipping reprocess', { + dedup_key: dedupKey, + webhook_id: payload.webhookId, + }); + return jsonResponse(200, { ok: true, deduped: true }); + } + throw err; + } + + // Async-invoke the processor with the raw body so it can re-parse safely. + try { + await lambdaClient.send(new InvokeCommand({ + FunctionName: PROCESSOR_FUNCTION_NAME, + InvocationType: 'Event', + Payload: new TextEncoder().encode(JSON.stringify({ raw_body: event.body })), + })); + } catch (invokeErr) { + logger.error('Failed to invoke Linear webhook processor', { + error: invokeErr instanceof Error ? invokeErr.message : String(invokeErr), + issue_id: issueId, + action, + }); + // Roll back the dedup row so Linear's next retry (+1m / +1h / +6h) can + // try dispatch again. Without this, all retries would hit the dedup TTL + // (8h) and silently drop the task forever. + try { + await ddb.send(new DeleteCommand({ + TableName: DEDUP_TABLE_NAME, + Key: { dedup_key: dedupKey }, + })); + } catch (cleanupErr) { + logger.warn('Failed to roll back Linear webhook dedup row after invoke failure', { + error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr), + dedup_key: dedupKey, + }); + } + return jsonResponse(500, { error: 'Dispatch failed' }); + } + + return jsonResponse(200, { ok: true }); + } catch (err) { + logger.error('Linear webhook handler failed', { + error: err instanceof Error ? err.message : String(err), + }); + return jsonResponse(500, { error: 'Internal server error' }); + } +} + +function jsonResponse(statusCode: number, body: Record): APIGatewayProxyResult { + return { + statusCode, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }; +} diff --git a/cdk/src/handlers/shared/create-task-core.ts b/cdk/src/handlers/shared/create-task-core.ts index b65f898..2f1aa78 100644 --- a/cdk/src/handlers/shared/create-task-core.ts +++ b/cdk/src/handlers/shared/create-task-core.ts @@ -30,8 +30,8 @@ import { generateBranchName } from './gateway'; import { logger } from './logger'; import { checkRepoOnboarded } from './repo-config'; import { ErrorCode, errorResponse, successResponse } from './response'; -import { type CreateTaskRequest, isPrTaskType, type TaskRecord, type TaskType, toTaskDetail } from './types'; -import { computeTtlEpoch, hasTaskSpec, isValidIdempotencyKey, isValidRepo, isValidTaskDescriptionLength, isValidTaskType, MAX_TASK_DESCRIPTION_LENGTH, validateMaxBudgetUsd, validateMaxTurns, validatePrNumber } from './validation'; +import { type ChannelSource, type CreateTaskRequest, isPrTaskType, type TaskRecord, type TaskType, toTaskDetail } from './types'; +import { computeTtlEpoch, DEFAULT_MAX_TURNS, hasTaskSpec, isValidIdempotencyKey, isValidRepo, isValidTaskDescriptionLength, isValidTaskType, MAX_TASK_DESCRIPTION_LENGTH, validateMaxBudgetUsd, validateMaxTurns, validatePrNumber } from './validation'; import { TaskStatus } from '../../constructs/task-status'; /** @@ -39,7 +39,7 @@ import { TaskStatus } from '../../constructs/task-status'; */ export interface TaskCreationContext { readonly userId: string; - readonly channelSource: 'api' | 'webhook'; + readonly channelSource: ChannelSource; readonly channelMetadata: Record; readonly idempotencyKey?: string; } diff --git a/cdk/src/handlers/shared/linear-verify.ts b/cdk/src/handlers/shared/linear-verify.ts new file mode 100644 index 0000000..5199fcf --- /dev/null +++ b/cdk/src/handlers/shared/linear-verify.ts @@ -0,0 +1,152 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { logger } from './logger'; + +const sm = new SecretsManagerClient({}); + +/** Prefix for Linear-related secrets in Secrets Manager. */ +export const LINEAR_SECRET_PREFIX = 'bgagent/linear/'; + +// In-memory secret cache with 5-minute TTL (same pattern as slack-verify.ts). +const secretCache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; + +/** Maximum age of a Linear webhookTimestamp (ms) before it is rejected (replay protection). */ +export const MAX_WEBHOOK_TIMESTAMP_AGE_MS = 60 * 1000; + +/** + * Fetch a secret from Secrets Manager with in-memory caching. + * @param secretId - the full Secrets Manager secret ID or ARN. + * @param forceRefresh - bypass the cache and re-fetch from Secrets Manager. + * @returns the secret string, or null if not found. + */ +export async function getLinearSecret(secretId: string, forceRefresh = false): Promise { + const now = Date.now(); + if (!forceRefresh) { + const cached = secretCache.get(secretId); + if (cached && cached.expiresAt > now) { + return cached.secret; + } + } + + try { + const result = await sm.send(new GetSecretValueCommand({ SecretId: secretId })); + if (!result.SecretString) { + secretCache.delete(secretId); + return null; + } + secretCache.set(secretId, { secret: result.SecretString, expiresAt: now + CACHE_TTL_MS }); + return result.SecretString; + } catch (err) { + const errorName = (err as Error)?.name; + if (errorName === 'ResourceNotFoundException') { + logger.error('Linear secret not found in Secrets Manager', { secret_id: secretId }); + secretCache.delete(secretId); + return null; + } + logger.error('Failed to fetch Linear secret from Secrets Manager', { + secret_id: secretId, + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } +} + +/** + * Explicitly drop a cached secret. Called when rotation is suspected — + * e.g. signature verification fails with an otherwise valid-looking request. + * @param secretId - the Secrets Manager secret ID or ARN to evict. + */ +export function invalidateLinearSecretCache(secretId: string): void { + secretCache.delete(secretId); +} + +/** + * Verify a Linear webhook signature. + * + * Linear signs each webhook with HMAC-SHA256 over the raw request body, hex-encoded, + * delivered in the `Linear-Signature` header. Replay protection uses the + * `webhookTimestamp` field (UNIX milliseconds) inside the JSON payload, not a header. + * + * @param webhookSecret - the per-webhook signing secret. + * @param signature - the `Linear-Signature` header value. + * @param body - the raw request body string. + * @returns true if the signature matches. + */ +export function verifyLinearSignature( + webhookSecret: string, + signature: string, + body: string, +): boolean { + const expected = crypto.createHmac('sha256', webhookSecret).update(body).digest('hex'); + try { + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); + } catch (err) { + logger.warn('Linear signature comparison failed', { + error: err instanceof Error ? err.message : String(err), + expected_length: expected.length, + provided_length: signature.length, + }); + return false; + } +} + +/** + * Check that a Linear `webhookTimestamp` (ms since epoch, embedded in the payload) + * is within the acceptable replay window. + * @param webhookTimestamp - numeric timestamp from the parsed payload. + * @returns true if the timestamp is within MAX_WEBHOOK_TIMESTAMP_AGE_MS of now. + */ +export function isWebhookTimestampFresh(webhookTimestamp: number | undefined): boolean { + if (typeof webhookTimestamp !== 'number' || !isFinite(webhookTimestamp)) { + return false; + } + const age = Math.abs(Date.now() - webhookTimestamp); + return age <= MAX_WEBHOOK_TIMESTAMP_AGE_MS; +} + +/** + * Verify a Linear webhook request, transparently re-fetching the signing secret once + * if the cached copy is rejected. After rotation, warm Lambdas keep the old + * cached secret until their 5-minute TTL elapses — this forces an early refresh. + * + * @param secretId - Secrets Manager ARN/ID for the webhook secret. + * @param signature - the `Linear-Signature` header value. + * @param body - the raw request body string. + * @returns true if the signature is authentic (after at most one refresh retry). + */ +export async function verifyLinearRequest( + secretId: string, + signature: string, + body: string, +): Promise { + const cached = await getLinearSecret(secretId); + if (cached && verifyLinearSignature(cached, signature, body)) { + return true; + } + + invalidateLinearSecretCache(secretId); + const fresh = await getLinearSecret(secretId, true); + if (!fresh) return false; + if (fresh === cached) return false; + return verifyLinearSignature(fresh, signature, body); +} diff --git a/cdk/src/handlers/shared/orchestrator.ts b/cdk/src/handlers/shared/orchestrator.ts index c6a0bf8..f3c2146 100644 --- a/cdk/src/handlers/shared/orchestrator.ts +++ b/cdk/src/handlers/shared/orchestrator.ts @@ -351,6 +351,8 @@ export async function hydrateAndTransition(task: TaskRecord, blueprintConfig?: B prompt_version: promptVersion, ...(MEMORY_ID && { memory_id: MEMORY_ID }), hydrated_context: hydratedContext, + channel_source: task.channel_source, + ...(task.channel_metadata && Object.keys(task.channel_metadata).length > 0 && { channel_metadata: task.channel_metadata }), }; if (hydratedContext.fallback_error) { diff --git a/cdk/src/handlers/shared/slack-api.ts b/cdk/src/handlers/shared/slack-api.ts new file mode 100644 index 0000000..f76ba4c --- /dev/null +++ b/cdk/src/handlers/shared/slack-api.ts @@ -0,0 +1,72 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { logger } from './logger'; + +/** Slack API errors that should not count as failures for caller-side logging. */ +const BENIGN_SLACK_ERRORS = new Set(['already_reacted', 'no_reaction']); + +/** + * POST to a Slack Web API method with a bot token. + * + * Logs errors at warn level rather than throwing — Slack reactions, replies, and + * message cleanup are best-effort side-effects. Callers that need delivery to fail + * the whole request (e.g. stream dispatchers) should inspect the return value + * instead of relying on exceptions. + * + * @param botToken - xoxb-... bot token for the workspace. + * @param method - Slack Web API method, e.g. 'chat.postMessage'. + * @param body - JSON body to send. + * @returns true if the call succeeded; false if the request failed at any layer. + */ +export async function slackFetch( + botToken: string, + method: string, + body: Record, +): Promise { + try { + const response = await fetch(`https://slack.com/api/${method}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': `Bearer ${botToken}`, + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + logger.warn('Slack API returned non-2xx', { method, status: response.status }); + return false; + } + const result = await response.json() as { ok: boolean; error?: string }; + if (!result.ok) { + if (result.error && BENIGN_SLACK_ERRORS.has(result.error)) { + return true; + } + logger.warn('Slack API returned error', { method, error: result.error }); + return false; + } + return true; + } catch (err) { + logger.warn('Slack API fetch threw', { + method, + error: err instanceof Error ? err.message : String(err), + }); + return false; + } +} diff --git a/cdk/src/handlers/shared/slack-blocks.ts b/cdk/src/handlers/shared/slack-blocks.ts new file mode 100644 index 0000000..68a5e33 --- /dev/null +++ b/cdk/src/handlers/shared/slack-blocks.ts @@ -0,0 +1,242 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { formatDuration, truncate } from './slack-format'; +import type { TaskRecord } from './types'; + +/** A Slack Block Kit mrkdwn text object. */ +interface MrkdwnText { + readonly type: 'mrkdwn'; + readonly text: string; +} + +/** A Slack Block Kit plain_text text object. */ +interface PlainText { + readonly type: 'plain_text'; + readonly text: string; + readonly emoji?: boolean; +} + +/** Section block: a single line/paragraph of mrkdwn content. */ +export interface SectionBlock { + readonly type: 'section'; + readonly text: MrkdwnText; + readonly block_id?: string; +} + +/** Link-out button: opens a URL in a new tab. No action_id needed. */ +export interface LinkButtonElement { + readonly type: 'button'; + readonly text: PlainText; + readonly url: string; + readonly style?: 'primary' | 'danger'; +} + +/** Actionable button: triggers a Block Kit interaction callback via action_id. */ +export interface ActionButtonElement { + readonly type: 'button'; + readonly text: PlainText; + readonly action_id: string; + readonly style?: 'primary' | 'danger'; + readonly confirm?: { + readonly title: PlainText; + readonly text: MrkdwnText; + readonly confirm: PlainText; + readonly deny: PlainText; + }; +} + +export type ButtonElement = LinkButtonElement | ActionButtonElement; + +/** Actions block: a row of interactive elements (buttons, menus, etc.). */ +export interface ActionsBlock { + readonly type: 'actions'; + readonly block_id: string; + readonly elements: ReadonlyArray; +} + +/** Any Block Kit block this module renders. */ +export type SlackBlock = SectionBlock | ActionsBlock; + +/** A Slack message payload suitable for chat.postMessage. */ +export interface SlackMessage { + /** Fallback plain-text for notifications. */ + readonly text: string; + /** Block Kit blocks for rich rendering. */ + readonly blocks: SlackBlock[]; + /** If set, post as a threaded reply. */ + readonly thread_ts?: string; +} + +/** + * Render a task event as a Slack Block Kit message. + * + * @param eventType - the task event type (e.g. 'task_created', 'task_completed'). + * @param task - the task record with current state. + * @param eventMetadata - optional metadata from the event record. + * @returns a SlackMessage payload. + */ +export function renderSlackBlocks( + eventType: string, + task: Pick, + eventMetadata?: Record, +): SlackMessage { + switch (eventType) { + case 'task_created': + return taskCreatedMessage(task); + case 'session_started': + return sessionStartedMessage(task); + case 'task_completed': + return taskCompletedMessage(task); + case 'task_failed': + return taskFailedMessage(task, eventMetadata); + case 'task_cancelled': + return simpleStatusMessage(task, ':no_entry_sign: Task cancelled'); + case 'task_timed_out': + return taskTimedOutMessage(task); + default: + return simpleStatusMessage(task, `Event: ${eventType}`); + } +} + +function taskCreatedMessage( + task: Pick, +): SlackMessage { + const desc = task.task_description + ? `\n${truncate(task.task_description, 200)}` + : ''; + const text = `:rocket: *Task submitted* for \`${task.repo}\`${desc}\n_ID:_ \`${task.task_id}\``; + return { + text: `Task submitted for ${task.repo}`, + blocks: [section(text)], + }; +} + +function taskCompletedMessage( + task: Pick, +): SlackMessage { + const parts = [`:white_check_mark: *Task completed* for \`${task.repo}\``]; + const stats: string[] = []; + if (task.duration_s != null) stats.push(formatDuration(task.duration_s)); + if (task.cost_usd != null) stats.push(`$${Number(task.cost_usd).toFixed(2)}`); + if (stats.length > 0) parts.push(stats.join(' · ')); + const text = parts.join('\n'); + + const blocks: SlackBlock[] = [section(text)]; + + // "View PR" button — no inline link text, so Slack won't unfurl a big preview card. + if (task.pr_url) { + blocks.push(actions(task.task_id, [ + linkButton(`View PR ${prLabel(task.pr_url)}`, task.pr_url), + ])); + } + + return { + text: `Task completed for ${task.repo}`, + blocks, + }; +} + +function taskFailedMessage( + task: Pick, + eventMetadata?: Record, +): SlackMessage { + const reason = task.error_message + ?? (eventMetadata?.error as string | undefined) + ?? 'Unknown error'; + const text = `:x: *Task failed* for \`${task.repo}\`\n_Reason:_ ${truncate(reason, 300)}`; + return { + text: `Task failed for ${task.repo}`, + blocks: [section(text)], + }; +} + +function taskTimedOutMessage( + task: Pick, +): SlackMessage { + const duration = task.duration_s != null ? ` after ${formatDuration(task.duration_s)}` : ''; + const text = `:hourglass: *Task timed out* for \`${task.repo}\`${duration}`; + return { + text: `Task timed out for ${task.repo}`, + blocks: [section(text)], + }; +} + +function sessionStartedMessage( + task: Pick, +): SlackMessage { + const text = `:hourglass_flowing_sand: Agent started working on \`${task.repo}\``; + return { + text: `Agent started working on ${task.repo}`, + blocks: [ + section(text), + actions(task.task_id, [ + dangerButton('Cancel Task', `cancel_task:${task.task_id}`), + ]), + ], + }; +} + +function simpleStatusMessage( + task: Pick, + label: string, +): SlackMessage { + const text = `${label} for \`${task.repo}\`\n_ID:_ \`${task.task_id}\``; + return { + text: `${label} for ${task.repo}`, + blocks: [section(text)], + }; +} + +function section(text: string): SectionBlock { + return { type: 'section', text: { type: 'mrkdwn', text } }; +} + +function actions(blockId: string, elements: ReadonlyArray): ActionsBlock { + return { type: 'actions', block_id: blockId, elements }; +} + +function linkButton(label: string, url: string): LinkButtonElement { + return { + type: 'button', + text: { type: 'plain_text', text: label }, + url, + style: 'primary', + }; +} + +function dangerButton(label: string, actionId: string): ActionButtonElement { + return { + type: 'button', + text: { type: 'plain_text', text: label }, + action_id: actionId, + style: 'danger', + confirm: { + title: { type: 'plain_text', text: 'Cancel task?' }, + text: { type: 'mrkdwn', text: 'This will stop the running agent.' }, + confirm: { type: 'plain_text', text: 'Cancel' }, + deny: { type: 'plain_text', text: 'Keep running' }, + }, + }; +} + +function prLabel(prUrl: string): string { + const match = prUrl.match(/\/pull\/(\d+)$/); + return match ? `#${match[1]}` : 'Pull Request'; +} diff --git a/cdk/src/handlers/shared/slack-format.ts b/cdk/src/handlers/shared/slack-format.ts new file mode 100644 index 0000000..8b65e8e --- /dev/null +++ b/cdk/src/handlers/shared/slack-format.ts @@ -0,0 +1,45 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +/** + * Truncate a string to `maxLen`, appending "..." if it was cut. + * @param text - the string to truncate. + * @param maxLen - the maximum length of the returned string (including the "..."). + * @returns the truncated string. + */ +export function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) return text; + return text.slice(0, maxLen - 3) + '...'; +} + +/** + * Format a duration in seconds as a human-readable string (e.g. "45s", "2m 10s", "1h 5m"). + * @param seconds - the duration in seconds. Non-numeric values are coerced with Number(). + * @returns the formatted duration string. + */ +export function formatDuration(seconds: number): string { + const s = Number(seconds); + if (s < 60) return `${Math.round(s)}s`; + const minutes = Math.floor(s / 60); + const remS = Math.round(s % 60); + if (minutes < 60) return remS > 0 ? `${minutes}m ${remS}s` : `${minutes}m`; + const h = Math.floor(minutes / 60); + const remainM = minutes % 60; + return remainM > 0 ? `${h}h ${remainM}m` : `${h}h`; +} diff --git a/cdk/src/handlers/shared/slack-verify.ts b/cdk/src/handlers/shared/slack-verify.ts new file mode 100644 index 0000000..ee67855 --- /dev/null +++ b/cdk/src/handlers/shared/slack-verify.ts @@ -0,0 +1,157 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { logger } from './logger'; + +const sm = new SecretsManagerClient({}); + +/** Prefix for Slack-related secrets in Secrets Manager. */ +export const SLACK_SECRET_PREFIX = 'bgagent/slack/'; + +// In-memory secret cache with 5-minute TTL (same pattern as webhook handler). +const secretCache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; + +/** Maximum age of a Slack request timestamp before it is rejected (replay protection). */ +const MAX_TIMESTAMP_AGE_S = 5 * 60; + +/** + * Fetch a secret from Secrets Manager with in-memory caching. + * @param secretId - the full Secrets Manager secret ID or ARN. + * @param forceRefresh - bypass the cache and re-fetch from Secrets Manager. + * @returns the secret string, or null if not found. + */ +export async function getSlackSecret(secretId: string, forceRefresh = false): Promise { + const now = Date.now(); + if (!forceRefresh) { + const cached = secretCache.get(secretId); + if (cached && cached.expiresAt > now) { + return cached.secret; + } + } + + try { + const result = await sm.send(new GetSecretValueCommand({ SecretId: secretId })); + if (!result.SecretString) { + secretCache.delete(secretId); + return null; + } + secretCache.set(secretId, { secret: result.SecretString, expiresAt: now + CACHE_TTL_MS }); + return result.SecretString; + } catch (err) { + const errorName = (err as Error)?.name; + if (errorName === 'ResourceNotFoundException') { + logger.error('Slack secret not found in Secrets Manager', { secret_id: secretId }); + secretCache.delete(secretId); + return null; + } + logger.error('Failed to fetch Slack secret from Secrets Manager', { + secret_id: secretId, + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } +} + +/** + * Explicitly drop a cached secret. Called when rotation is suspected — + * e.g. signature verification fails with an otherwise valid-looking request. + * @param secretId - the Secrets Manager secret ID or ARN to evict. + */ +export function invalidateSlackSecretCache(secretId: string): void { + secretCache.delete(secretId); +} + +/** + * Verify a Slack request signature. + * + * Slack signs every request with HMAC-SHA256 using the app signing secret. + * Signature format: `v0={hex}` where the HMAC input is `v0:{timestamp}:{body}`. + * + * @param signingSecret - the Slack app signing secret. + * @param signature - the `X-Slack-Signature` header value. + * @param timestamp - the `X-Slack-Request-Timestamp` header value. + * @param body - the raw request body string. + * @returns true if the signature is valid and the timestamp is recent. + */ +export function verifySlackSignature( + signingSecret: string, + signature: string, + timestamp: string, + body: string, +): boolean { + // Reject requests with stale timestamps (replay protection). + const ts = parseInt(timestamp, 10); + if (isNaN(ts)) { + logger.warn('Invalid Slack request timestamp', { timestamp }); + return false; + } + const now = Math.floor(Date.now() / 1000); + if (Math.abs(now - ts) > MAX_TIMESTAMP_AGE_S) { + logger.warn('Slack request timestamp too old', { timestamp, now: String(now) }); + return false; + } + + // Compute expected signature: v0=HMAC-SHA256(signing_secret, "v0:{ts}:{body}") + const sigBasestring = `v0:${timestamp}:${body}`; + const expected = 'v0=' + crypto.createHmac('sha256', signingSecret).update(sigBasestring).digest('hex'); + + try { + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature)); + } catch (err) { + logger.warn('Slack signature comparison failed', { + error: err instanceof Error ? err.message : String(err), + expected_length: expected.length, + provided_length: signature.length, + }); + return false; + } +} + +/** + * Verify a Slack request, transparently re-fetching the signing secret once + * if the cached copy is rejected. After rotation, warm Lambdas keep the old + * cached secret until their 5-minute TTL elapses — this forces an early refresh. + * + * @param secretId - Secrets Manager ARN/ID for the signing secret. + * @param signature - the `X-Slack-Signature` header value. + * @param timestamp - the `X-Slack-Request-Timestamp` header value. + * @param body - the raw request body string. + * @returns true if the request is authentic (after at most one refresh retry). + */ +export async function verifySlackRequest( + secretId: string, + signature: string, + timestamp: string, + body: string, +): Promise { + const cached = await getSlackSecret(secretId); + if (cached && verifySlackSignature(cached, signature, timestamp, body)) { + return true; + } + + // Cache might be stale after a signing-secret rotation — evict and try once more. + invalidateSlackSecretCache(secretId); + const fresh = await getSlackSecret(secretId, true); + if (!fresh) return false; + if (fresh === cached) return false; // Same secret, still invalid — don't double-log. + return verifySlackSignature(fresh, signature, timestamp, body); +} diff --git a/cdk/src/handlers/shared/types.ts b/cdk/src/handlers/shared/types.ts index 094b8ba..9aff191 100644 --- a/cdk/src/handlers/shared/types.ts +++ b/cdk/src/handlers/shared/types.ts @@ -27,15 +27,18 @@ import type { TaskStatusType } from '../../constructs/task-status'; export type TaskType = 'new_task' | 'pr_iteration' | 'pr_review'; /** - * Provenance of a task's submission. ``api`` covers CLI / Cognito-authenticated - * submissions; ``webhook`` covers HMAC-signed inbound webhook submissions. + * Provenance of a task's submission. Shared across inbound adapters: + * - ``api``: CLI / Cognito-authenticated submissions + * - ``webhook``: HMAC-signed inbound webhook submissions (generic webhook endpoint) + * - ``slack``: Slack @mention / slash-command submissions (see SlackIntegration) + * - ``linear``: Linear label-triggered submissions (see LinearIntegration) * * Narrowed from ``string`` so switches and predicates that read * ``channel_source`` get exhaustiveness checking at compile time; matches the * internal ``CreateTaskContext.channelSource`` literal in ``create-task-core.ts``. * Keep in sync with ``cli/src/types.ts::ChannelSource``. */ -export type ChannelSource = 'api' | 'webhook'; +export type ChannelSource = 'api' | 'webhook' | 'slack' | 'linear'; /** Task types that operate on an existing pull request. */ export function isPrTaskType(taskType: TaskType): boolean { diff --git a/cdk/src/handlers/slack-command-processor.ts b/cdk/src/handlers/slack-command-processor.ts new file mode 100644 index 0000000..9163ee2 --- /dev/null +++ b/cdk/src/handlers/slack-command-processor.ts @@ -0,0 +1,403 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { createTaskCore } from './shared/create-task-core'; +import { logger } from './shared/logger'; +import { slackFetch } from './shared/slack-api'; +import { getSlackSecret, SLACK_SECRET_PREFIX } from './shared/slack-verify'; +import type { SlackCommandPayload } from './slack-commands'; + +/** + * Payload fields every inbound event carries, whether it came from a slash + * command or an @mention. + */ +interface BasePayload { + readonly text: string; + readonly user_id: string; + readonly team_id: string; + readonly channel_id: string; +} + +/** Slash-command invocation — has a usable response_url, no mention context. */ +export interface SlashCommandEvent extends BasePayload, SlackCommandPayload { + readonly source: 'slash'; +} + +/** @mention invocation — no response_url; reply via chat.postMessage in-thread. */ +export interface MentionEvent extends BasePayload { + readonly source: 'mention'; + readonly mention_thread_ts?: string; +} + +/** Discriminated union of the inbound events the processor accepts. */ +export type CommandProcessorEvent = SlashCommandEvent | MentionEvent; + +/** + * Legacy shape — the slash-command acknowledger (`slack-commands.ts`) forwards + * payloads without a `source` field. Normalize those into SlashCommandEvent so + * the handler body only has to reason about the discriminated union. + */ +type RawEvent = CommandProcessorEvent | SlackCommandPayload; + +function normalizeEvent(event: RawEvent): CommandProcessorEvent { + if ('source' in event && event.source) { + return event; + } + return { ...event, source: 'slash' }; +} + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +const USER_MAPPING_TABLE = process.env.SLACK_USER_MAPPING_TABLE_NAME!; +const INSTALLATION_TABLE = process.env.SLACK_INSTALLATION_TABLE_NAME!; + +/** Link code TTL. */ +const LINK_CODE_TTL_S = 10 * 60; // 10 minutes + +/** + * Async processor for Slack slash commands and @mention triggers. + * + * Invoked asynchronously by the slash command acknowledger or the events handler. + * Posts results back to Slack via `response_url` (slash commands) or + * `chat.postMessage` (@mentions). + */ +export async function handler(raw: RawEvent): Promise { + const event = normalizeEvent(raw); + const text = (event.text ?? '').trim(); + const parts = text.split(/\s+/); + const subcommand = parts[0]?.toLowerCase() ?? ''; + + // Build a reply function that handles both response_url and mention modes. + const reply = event.source === 'mention' + ? buildMentionReply(event) + : (msg: string) => postToSlack(event.response_url, msg); + + try { + switch (subcommand) { + case 'submit': + // Submit is only used via @mentions — slash commands show usage guidance. + if (event.source === 'mention') { + await handleSubmit(event, parts.slice(1), reply); + } else { + await reply('Use `@Shoof` to submit tasks — e.g. `@Shoof fix the bug in org/repo#42`\nFor private submissions, DM Shoof directly.'); + } + break; + case 'link': + await handleLink(event, reply); + break; + case 'help': + await reply( + '*Using Shoof*\n\n' + + '*Submit a task:* Mention `@Shoof` in any channel:\n' + + '> `@Shoof fix the login bug in org/repo#42`\n' + + '> `@Shoof update the README in org/repo`\n\n' + + '*Private submissions:* DM Shoof directly.\n\n' + + '*Cancel a task:* Use the Cancel button in the thread.\n\n' + + '*Link your account:* `/bgagent link` — one-time setup.\n\n' + + 'Reactions on your message show progress: :eyes: → :hourglass_flowing_sand: → :white_check_mark:', + ); + break; + default: + await reply('Use `@Shoof` to submit tasks, or `/bgagent link` to link your account.\nTry `/bgagent help` for more info.'); + } + } catch (err) { + logger.error('Slack command processing failed', { + subcommand, + error: err instanceof Error ? err.message : String(err), + team_id: event.team_id, + user_id: event.user_id, + }); + await reply(':warning: Something went wrong. Please try again.'); + } +} + +type ReplyFn = (text: string) => Promise; + +/** Build a reply function that posts in-thread via chat.postMessage for @mentions. */ +function buildMentionReply(event: MentionEvent): ReplyFn { + return async (text: string) => { + const botToken = await getBotToken(event.team_id); + if (!botToken) { + logger.warn('Cannot reply to mention: bot token not found', { team_id: event.team_id }); + return; + } + const response = await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': `Bearer ${botToken}`, + }, + body: JSON.stringify({ + channel: event.channel_id, + text, + thread_ts: event.mention_thread_ts, + }), + }); + const result = await response.json() as { ok: boolean; error?: string }; + if (!result.ok) { + logger.warn('Failed to post mention reply', { error: result.error, channel: event.channel_id }); + } + }; +} + +// ─── Submit ─────────────────────────────────────────────────────────────────── + +async function handleSubmit(event: MentionEvent, args: string[], reply: ReplyFn): Promise { + if (args.length === 0) { + await reply('Usage: `/bgagent submit org/repo#42 description`'); + return; + } + + // Resolve platform user. + const platformUserId = await lookupPlatformUser(event.team_id, event.user_id); + if (!platformUserId) { + await reply(':link: Your Slack account is not linked. Run `/bgagent link` first.'); + if (event.mention_thread_ts) { + await swapReaction(event.team_id, event.channel_id, event.mention_thread_ts, 'eyes', 'x'); + } + return; + } + + // Parse repo and optional issue number from first arg: "org/repo#42" or "org/repo". + const repoArg = args[0]; + const { repo, issueNumber } = parseRepoArg(repoArg); + if (!repo) { + await reply(`Invalid repo format: \`${repoArg}\`. Expected \`org/repo\` or \`org/repo#42\`.`); + if (event.mention_thread_ts) { + await swapReaction(event.team_id, event.channel_id, event.mention_thread_ts, 'eyes', 'x'); + } + return; + } + + // Check if the bot can post to this channel (private channels need an invite). + const channelCheck = await checkChannelAccess(event.team_id, event.channel_id); + if (!channelCheck.ok) { + await reply(channelCheck.error!); + return; + } + + // Remaining args are the task description. + const description = args.slice(1).join(' ') || undefined; + + // handleSubmit is only invoked for the mention path, so there's no response_url. + // Notifications thread under the user's @mention message using mention_thread_ts. + const channelMetadata: Record = { + slack_team_id: event.team_id, + slack_channel_id: event.channel_id, + slack_user_id: event.user_id, + }; + if (event.mention_thread_ts) { + channelMetadata.slack_thread_ts = event.mention_thread_ts; + } + + // Create the task through the shared core. + const result = await createTaskCore( + { + repo, + issue_number: issueNumber, + task_description: description, + }, + { + userId: platformUserId, + channelSource: 'slack', + channelMetadata, + }, + crypto.randomUUID(), + ); + + // Extract task info from the response. + const body = JSON.parse(result.body); + if (result.statusCode === 201 && body.data) { + // The notify handler posts the task_created message in-thread — don't + // duplicate it here on the mention path. + return; + } + + const errMsg = body.error?.message ?? 'Unknown error'; + await reply(`:x: Failed to create task: ${errMsg}`); + // Swap reaction to :x: on the mention message. + if (event.mention_thread_ts) { + await swapReaction(event.team_id, event.channel_id, event.mention_thread_ts, 'eyes', 'x'); + } +} + +function parseRepoArg(arg: string): { repo: string | null; issueNumber?: number } { + // Match "org/repo#42" or "org/repo" + const match = arg.match(/^([a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+)(?:#(\d+))?$/); + if (!match) return { repo: null }; + return { + repo: match[1], + issueNumber: match[2] ? parseInt(match[2], 10) : undefined, + }; +} + +// ─── Link ───────────────────────────────────────────────────────────────────── + +async function handleLink(event: CommandProcessorEvent, reply: ReplyFn): Promise { + // Generate a 6-character alphanumeric code. + const code = crypto.randomBytes(3).toString('hex').toUpperCase(); + const now = new Date().toISOString(); + const ttl = Math.floor(Date.now() / 1000) + LINK_CODE_TTL_S; + + // Store the pending link record. + await ddb.send(new PutCommand({ + TableName: USER_MAPPING_TABLE, + Item: { + slack_identity: `pending#${code}`, + slack_team_id: event.team_id, + slack_user_id: event.user_id, + link_method: 'slash_command', + linked_at: now, + status: 'pending', + ttl, + }, + })); + + await reply( + `:link: *Link your account*\n\nRun this command in your terminal:\n\`\`\`bgagent slack link ${code}\`\`\`\n_This code expires in 10 minutes._`, + ); +} + +// ─── Channel Access ────────────────────────────────────────────────────────── + +async function getBotToken(teamId: string): Promise { + const installation = await ddb.send(new GetCommand({ + TableName: INSTALLATION_TABLE, + Key: { team_id: teamId }, + })); + if (!installation.Item || installation.Item.status !== 'active') return null; + return getSlackSecret(`${SLACK_SECRET_PREFIX}${teamId}`); +} + +/** Slack error codes that definitively mean the bot cannot post in this channel. */ +const CHANNEL_ACCESS_HARD_FAILURES = new Set([ + 'channel_not_found', // private channel the bot hasn't been invited to + 'not_in_channel', // public channel the bot isn't in (some workspaces require join) + 'missing_scope', // bot lacks the scope it needs — admin must reinstall +]); + +async function checkChannelAccess(teamId: string, channelId: string): Promise<{ ok: boolean; error?: string }> { + // DM channels always work — notifications fall back to user ID. + if (channelId.startsWith('D')) return { ok: true }; + + const botToken = await getBotToken(teamId); + if (!botToken) { + logger.warn('Channel access check skipped: bot token missing', { team_id: teamId }); + return { + ok: false, + error: ':warning: The Slack integration is not fully configured (missing bot token). Ask your workspace admin to reinstall the app.', + }; + } + + try { + const response = await fetch(`https://slack.com/api/conversations.info?channel=${channelId}`, { + headers: { Authorization: `Bearer ${botToken}` }, + }); + const result = await response.json() as { ok: boolean; channel?: { is_private: boolean; is_member: boolean }; error?: string }; + + if (!result.ok) { + // Hard failures: the bot definitively cannot post here. Fail closed so the + // task isn't created silently into a dead-letter channel. + if (result.error && CHANNEL_ACCESS_HARD_FAILURES.has(result.error)) { + return { ok: false, error: ':lock: This is a private channel and the bot is not a member. Invite the bot first with `/invite @bgagent`, or submit from a public channel or DM.' }; + } + // Anything else (ratelimited, internal_error, fatal_error, network blip) is + // likely transient — fail open and let slack-notify surface any real delivery + // failure downstream. Blocking task submission on a 30-second Slack blip is + // a worse UX than creating a task that notifies late. + logger.warn('Channel access check: transient/unknown Slack error, failing open', { + error: result.error, + channel_id: channelId, + }); + return { ok: true }; + } + + if (result.channel?.is_private && !result.channel?.is_member) { + return { ok: false, error: ':lock: This is a private channel and the bot is not a member. Invite the bot first with `/invite @bgagent`, or submit from a public channel or DM.' }; + } + + return { ok: true }; + } catch (err) { + // Network-level failure — treat the same as a transient Slack error. + logger.warn('Channel access check network failure, failing open', { + error: err instanceof Error ? err.message : String(err), + }); + return { ok: true }; + } +} + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +async function lookupPlatformUser(teamId: string, userId: string): Promise { + const key = `${teamId}#${userId}`; + logger.info('Looking up platform user', { slack_identity: key, table: USER_MAPPING_TABLE }); + const result = await ddb.send(new GetCommand({ + TableName: USER_MAPPING_TABLE, + Key: { slack_identity: key }, + })); + + if (!result.Item) { + logger.warn('No user mapping found', { slack_identity: key }); + return null; + } + if (result.Item.status === 'pending') { + logger.warn('User mapping is pending', { slack_identity: key }); + return null; + } + logger.info('Found platform user', { slack_identity: key, platform_user_id: result.Item.platform_user_id }); + return (result.Item.platform_user_id as string) ?? null; +} + +async function postToSlack(responseUrl: string, text: string): Promise { + logger.info('Posting to Slack response_url', { + response_url: responseUrl.substring(0, 80), + text_length: text.length, + }); + try { + const response = await fetch(responseUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ response_type: 'ephemeral', text }), + }); + if (!response.ok) { + const body = await response.text().catch(() => ''); + logger.warn('Failed to post to Slack response_url', { + status: response.status, + response_url: responseUrl.substring(0, 80), + body, + }); + } else { + logger.info('Slack response_url post succeeded', { status: response.status }); + } + } catch (err) { + logger.warn('Error posting to Slack response_url', { + error: err instanceof Error ? err.message : String(err), + }); + } +} + +async function swapReaction(teamId: string, channelId: string, messageTs: string, remove: string, add: string): Promise { + const botToken = await getBotToken(teamId); + if (!botToken) return; + await slackFetch(botToken, 'reactions.remove', { channel: channelId, timestamp: messageTs, name: remove }); + await slackFetch(botToken, 'reactions.add', { channel: channelId, timestamp: messageTs, name: add }); +} diff --git a/cdk/src/handlers/slack-commands.ts b/cdk/src/handlers/slack-commands.ts new file mode 100644 index 0000000..f89b47c --- /dev/null +++ b/cdk/src/handlers/slack-commands.ts @@ -0,0 +1,146 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { logger } from './shared/logger'; +import { getSlackSecret, verifySlackRequest } from './shared/slack-verify'; + +const lambdaClient = new LambdaClient({}); + +const SIGNING_SECRET_ARN = process.env.SLACK_SIGNING_SECRET_ARN!; +const PROCESSOR_FUNCTION_NAME = process.env.SLACK_COMMAND_PROCESSOR_FUNCTION_NAME!; + +/** Parsed Slack slash command payload (URL-encoded form data). */ +export interface SlackCommandPayload { + readonly command: string; + readonly text: string; + readonly response_url: string; + readonly trigger_id: string; + readonly user_id: string; + readonly user_name: string; + readonly team_id: string; + readonly team_domain: string; + readonly channel_id: string; + readonly channel_name: string; +} + +/** + * POST /v1/slack/commands — Handle Slack slash commands. + * + * Must respond within 3 seconds. Verifies the signing secret, parses the + * command, acknowledges immediately, and async-invokes the processor Lambda. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + try { + if (!event.body) { + return slackResponse('Request body is required.'); + } + + // Verify Slack signing secret (re-fetches if the cached value was rotated out). + const signingSecret = await getSlackSecret(SIGNING_SECRET_ARN); + if (!signingSecret) { + logger.error('Slack signing secret not found'); + return slackResponse('Internal configuration error.'); + } + + const signature = event.headers['X-Slack-Signature'] ?? event.headers['x-slack-signature'] ?? ''; + const timestamp = event.headers['X-Slack-Request-Timestamp'] ?? event.headers['x-slack-request-timestamp'] ?? ''; + + if (!await verifySlackRequest(SIGNING_SECRET_ARN, signature, timestamp, event.body)) { + logger.warn('Invalid Slack command signature'); + return { statusCode: 401, headers: { 'Content-Type': 'text/plain' }, body: 'Invalid signature' }; + } + + // Parse URL-encoded form body. + const payload = parseFormBody(event.body); + const subcommand = (payload.text ?? '').trim().split(/\s+/)[0]?.toLowerCase() ?? ''; + + // For 'help' we can respond inline (no async processing needed). + if (subcommand === 'help' || subcommand === '') { + return slackResponse(HELP_TEXT); + } + + // Async-invoke the processor Lambda for all other subcommands. + try { + await lambdaClient.send(new InvokeCommand({ + FunctionName: PROCESSOR_FUNCTION_NAME, + InvocationType: 'Event', + Payload: new TextEncoder().encode(JSON.stringify(payload)), + })); + } catch (err) { + logger.error('Failed to invoke Slack command processor', { + error: err instanceof Error ? err.message : String(err), + subcommand, + }); + return slackResponse('Failed to process command. Please try again.'); + } + + // Acknowledge immediately — the processor will follow up via response_url. + const ackMessage = ACK_MESSAGES[subcommand] ?? `Processing \`${subcommand}\`...`; + return slackResponse(ackMessage); + } catch (err) { + logger.error('Slack command handler failed', { + error: err instanceof Error ? err.message : String(err), + }); + return slackResponse('An unexpected error occurred. Please try again.'); + } +} + +function parseFormBody(body: string): SlackCommandPayload { + const params = new URLSearchParams(body); + return { + command: params.get('command') ?? '', + text: params.get('text') ?? '', + response_url: params.get('response_url') ?? '', + trigger_id: params.get('trigger_id') ?? '', + user_id: params.get('user_id') ?? '', + user_name: params.get('user_name') ?? '', + team_id: params.get('team_id') ?? '', + team_domain: params.get('team_domain') ?? '', + channel_id: params.get('channel_id') ?? '', + channel_name: params.get('channel_name') ?? '', + }; +} + +function slackResponse(text: string): APIGatewayProxyResult { + return { + statusCode: 200, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ response_type: 'ephemeral', text }), + }; +} + +const ACK_MESSAGES: Record = { + link: ':link: Generating link code...', +}; + +const HELP_TEXT = `*Using Shoof* + +*Submit a task:* Mention \`@Shoof\` in any channel: +> \`@Shoof fix the login bug in org/repo#42\` +> \`@Shoof update the README in org/repo\` + +*Private submissions:* DM Shoof directly. + +*Cancel a task:* Use the Cancel button in the thread. + +*Link your account:* \`/bgagent link\` — one-time setup. + +Reactions on your message show progress: :eyes: → :hourglass_flowing_sand: → :white_check_mark:`; diff --git a/cdk/src/handlers/slack-events.ts b/cdk/src/handlers/slack-events.ts new file mode 100644 index 0000000..206ba32 --- /dev/null +++ b/cdk/src/handlers/slack-events.ts @@ -0,0 +1,300 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { DeleteSecretCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { DynamoDBDocumentClient, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { logger } from './shared/logger'; +import { slackFetch } from './shared/slack-api'; +import { getSlackSecret, SLACK_SECRET_PREFIX, verifySlackRequest } from './shared/slack-verify'; +import type { MentionEvent } from './slack-command-processor'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const sm = new SecretsManagerClient({}); +const lambdaClient = new LambdaClient({}); + +const TABLE_NAME = process.env.SLACK_INSTALLATION_TABLE_NAME!; +const SIGNING_SECRET_ARN = process.env.SLACK_SIGNING_SECRET_ARN!; +const PROCESSOR_FUNCTION_NAME = process.env.SLACK_COMMAND_PROCESSOR_FUNCTION_NAME; + +/** Secret recovery window for revoked installations. */ +const SECRET_RECOVERY_DAYS = 7; + +interface SlackEventPayload { + readonly type: string; + readonly challenge?: string; + readonly token?: string; + readonly team_id?: string; + readonly event?: { + readonly type: string; + readonly user?: string; + readonly text?: string; + readonly channel?: string; + readonly ts?: string; + readonly thread_ts?: string; + readonly [key: string]: unknown; + }; +} + +/** + * POST /v1/slack/events — Handle Slack Events API requests. + * + * Handles: + * - `url_verification` challenge (Slack sends this when the event URL is configured) + * - `app_uninstalled` event (mark installation revoked, delete bot token) + * - `tokens_revoked` event (same cleanup) + */ +/** Event types where retries are idempotent and must be re-processed. */ +const RETRY_ALLOWED_EVENT_TYPES = new Set(['app_uninstalled', 'tokens_revoked']); + +export async function handler(event: APIGatewayProxyEvent): Promise { + try { + if (!event.body) { + return jsonResponse(400, { error: 'Request body is required' }); + } + + // Verify Slack signing secret for every request — including url_verification. + // Slack signs all requests; skipping verification exposes the endpoint. + // The only reason to bypass is initial setup before the signing secret is populated. + const signature = event.headers['X-Slack-Signature'] ?? event.headers['x-slack-signature'] ?? ''; + const timestamp = event.headers['X-Slack-Request-Timestamp'] ?? event.headers['x-slack-request-timestamp'] ?? ''; + const signingSecret = await getSlackSecret(SIGNING_SECRET_ARN); + + if (!signingSecret) { + // Secret hasn't been populated yet — allow url_verification so the Slack App can be + // wired up during initial setup, but reject anything else. + logger.warn('Slack signing secret not populated — bypassing verification for url_verification only'); + const payload: SlackEventPayload = JSON.parse(event.body); + if (payload.type === 'url_verification' && payload.challenge) { + return jsonResponse(200, { challenge: payload.challenge }); + } + return jsonResponse(500, { error: 'Internal configuration error' }); + } + + if (!await verifySlackRequest(SIGNING_SECRET_ARN, signature, timestamp, event.body)) { + logger.warn('Invalid Slack event signature'); + return jsonResponse(401, { error: 'Invalid signature' }); + } + + const payload: SlackEventPayload = JSON.parse(event.body); + + // URL verification challenge — Slack sends this when configuring the event URL. + if (payload.type === 'url_verification' && payload.challenge) { + return jsonResponse(200, { challenge: payload.challenge }); + } + + // Slack retries events if we don't respond within 3 seconds. Ack retries + // immediately for user-facing events (mentions, DMs) to prevent duplicate task + // creation — the idempotency cost of processing the same app_mention twice is + // a double-submit. For security-critical revocation events, we MUST process + // retries so a transient failure on first delivery doesn't leave the workspace + // with a live bot token after uninstall. + const retryNum = event.headers['X-Slack-Retry-Num'] ?? event.headers['x-slack-retry-num']; + const eventType = payload.type === 'event_callback' ? payload.event?.type : undefined; + if (retryNum && !(eventType && RETRY_ALLOWED_EVENT_TYPES.has(eventType))) { + logger.info('Acknowledging Slack retry without reprocessing', { retry_num: retryNum, event_type: eventType }); + return jsonResponse(200, { ok: true }); + } + + // Dispatch by event type. + if (payload.type === 'event_callback' && payload.event) { + const teamId = payload.team_id; + + if ((eventType === 'app_uninstalled' || eventType === 'tokens_revoked') && teamId) { + await revokeInstallation(teamId); + } else if (eventType === 'app_mention' && teamId) { + await handleAppMention(payload.event, teamId); + } else if (eventType === 'message' && teamId && payload.event.channel_type === 'im') { + // DMs to the bot — skip bot's own messages to avoid loops. + if (!payload.event.bot_id) { + await handleAppMention(payload.event, teamId); + } + } else { + logger.info('Unhandled Slack event type', { event_type: eventType, team_id: teamId }); + } + } + + return jsonResponse(200, { ok: true }); + } catch (err) { + logger.error('Slack event handler failed', { + error: err instanceof Error ? err.message : String(err), + }); + return jsonResponse(500, { error: 'Internal server error' }); + } +} + +async function handleAppMention( + event: NonNullable, + teamId: string, +): Promise { + if (!PROCESSOR_FUNCTION_NAME) { + logger.warn('SLACK_COMMAND_PROCESSOR_FUNCTION_NAME not set, ignoring app_mention'); + return; + } + + const userId = event.user; + const channelId = event.channel; + const rawText = event.text ?? ''; + const messageTs = event.ts; + const threadTs = event.thread_ts; + + if (!userId || !channelId) { + logger.warn('app_mention missing user or channel', { event }); + return; + } + + // Strip the @mention prefix (e.g. "<@U12345> fix the bug" → "fix the bug"). + const text = rawText.replace(/<@[A-Z0-9]+>/g, '').trim(); + + if (!text) { + logger.info('app_mention with empty text after stripping mention, ignoring'); + return; + } + + // Build a payload compatible with the command processor. + // Use source: 'mention' so the processor knows there's no response_url — + // it should use chat.postMessage with the bot token instead. + // + // For natural language mentions like "@Shoof fix the bug in org/repo#42", + // extract the repo pattern and reorder so submit gets "org/repo#42 fix the bug". + // The submit handler expects: submit + const repoPattern = /\b([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+(?:#\d+)?)\b/; + const repoMatch = text.match(repoPattern); + if (!repoMatch) { + // No repo found — reply with a helpful error instead of a broken submit. + const botToken = await getSlackSecret(`${SLACK_SECRET_PREFIX}${teamId}`); + if (botToken) { + const mentionTs = threadTs ?? messageTs; + // Swap :eyes: to :x: on the mention + if (mentionTs) { + await slackFetch(botToken, 'reactions.remove', { channel: channelId, timestamp: mentionTs, name: 'eyes' }); + await slackFetch(botToken, 'reactions.add', { channel: channelId, timestamp: mentionTs, name: 'x' }); + } + await slackFetch(botToken, 'chat.postMessage', { + channel: channelId, + thread_ts: mentionTs, + text: ':x: Please include a repo — e.g. `@Shoof fix the bug in org/repo#42`', + }); + } + return; + } + + const repo = repoMatch[0]; + const description = text.replace(repo, '').replace(/\s+/g, ' ').trim(); + const commandText = `submit ${repo} ${description}`; + + const mentionPayload: MentionEvent = { + text: commandText, + user_id: userId, + team_id: teamId, + channel_id: channelId, + source: 'mention', + mention_thread_ts: threadTs ?? messageTs, + }; + + // React with :eyes: immediately so the user knows the bot saw their message. + const mentionTs = threadTs ?? messageTs; + if (mentionTs) { + const botToken = await getSlackSecret(`${SLACK_SECRET_PREFIX}${teamId}`); + if (botToken) { + await slackFetch(botToken, 'reactions.add', { channel: channelId, timestamp: mentionTs, name: 'eyes' }); + } + } + + try { + await lambdaClient.send(new InvokeCommand({ + FunctionName: PROCESSOR_FUNCTION_NAME, + InvocationType: 'Event', + Payload: new TextEncoder().encode(JSON.stringify(mentionPayload)), + })); + logger.info('app_mention forwarded to command processor', { + team_id: teamId, + user_id: userId, + channel_id: channelId, + text_length: text.length, + }); + } catch (err) { + logger.error('Failed to invoke command processor for app_mention', { + error: err instanceof Error ? err.message : String(err), + }); + // Mirror the no-repo-found failure UX: swap :eyes: to :x: and reply in thread + // so the user isn't left staring at a stuck :eyes: reaction forever. + const botToken = await getSlackSecret(`${SLACK_SECRET_PREFIX}${teamId}`); + if (botToken && mentionTs) { + await slackFetch(botToken, 'reactions.remove', { channel: channelId, timestamp: mentionTs, name: 'eyes' }); + await slackFetch(botToken, 'reactions.add', { channel: channelId, timestamp: mentionTs, name: 'x' }); + await slackFetch(botToken, 'chat.postMessage', { + channel: channelId, + thread_ts: mentionTs, + text: ':x: Something went wrong forwarding your request. Please try again.', + }); + } + } +} + +async function revokeInstallation(teamId: string): Promise { + const now = new Date().toISOString(); + + // Mark the installation record as revoked FIRST. If this fails we must not + // delete the bot token, or the DB will still show status=active while the + // token is gone — every subsequent Slack call would then fail with "secret + // not found." Let Slack retry the revocation event in that case. + try { + await ddb.send(new UpdateCommand({ + TableName: TABLE_NAME, + Key: { team_id: teamId }, + UpdateExpression: 'SET #s = :revoked, updated_at = :now, revoked_at = :now', + ExpressionAttributeNames: { '#s': 'status' }, + ExpressionAttributeValues: { ':revoked': 'revoked', ':now': now }, + })); + } catch (err) { + logger.error('Failed to mark Slack installation revoked — bot token left in place for retry', { + team_id: teamId, + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } + + // Schedule the bot token secret for deletion. Failure here is recoverable + // on retry (the DDB row is already revoked, so the next delivery just re-tries + // this step). + try { + await sm.send(new DeleteSecretCommand({ + SecretId: `${SLACK_SECRET_PREFIX}${teamId}`, + RecoveryWindowInDays: SECRET_RECOVERY_DAYS, + })); + logger.info('Slack installation revoked', { team_id: teamId }); + } catch (err) { + logger.warn('Failed to delete Slack bot token secret', { + team_id: teamId, + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } +} + +function jsonResponse(statusCode: number, body: Record): APIGatewayProxyResult { + return { + statusCode, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }; +} diff --git a/cdk/src/handlers/slack-interactions.ts b/cdk/src/handlers/slack-interactions.ts new file mode 100644 index 0000000..59b68d1 --- /dev/null +++ b/cdk/src/handlers/slack-interactions.ts @@ -0,0 +1,244 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { logger } from './shared/logger'; +import { getSlackSecret, SLACK_SECRET_PREFIX, verifySlackRequest } from './shared/slack-verify'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +const SIGNING_SECRET_ARN = process.env.SLACK_SIGNING_SECRET_ARN!; +const TASK_TABLE = process.env.TASK_TABLE_NAME!; +const USER_MAPPING_TABLE = process.env.SLACK_USER_MAPPING_TABLE_NAME!; + +interface SlackInteractionPayload { + readonly type: string; + readonly user: { readonly id: string; readonly username: string; readonly team_id: string }; + readonly actions?: ReadonlyArray<{ + readonly action_id: string; + readonly block_id: string; + readonly value?: string; + }>; + readonly response_url: string; + readonly trigger_id: string; + readonly channel?: { readonly id: string }; +} + +/** + * POST /v1/slack/interactions — Handle Slack Block Kit interactive actions. + * + * Slack sends interaction payloads as a URL-encoded `payload` field in the body. + * Currently handles: + * - `cancel_task:{task_id}` — Cancel a running task via the "Cancel Task" button. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + try { + if (!event.body) { + return jsonResponse(400, { error: 'Request body is required' }); + } + + // Verify Slack signing secret (re-fetches if the cached value was rotated out). + const signingSecret = await getSlackSecret(SIGNING_SECRET_ARN); + if (!signingSecret) { + logger.error('Slack signing secret not found'); + return jsonResponse(500, { error: 'Internal configuration error' }); + } + + const signature = event.headers['X-Slack-Signature'] ?? event.headers['x-slack-signature'] ?? ''; + const timestamp = event.headers['X-Slack-Request-Timestamp'] ?? event.headers['x-slack-request-timestamp'] ?? ''; + + if (!await verifySlackRequest(SIGNING_SECRET_ARN, signature, timestamp, event.body)) { + logger.warn('Invalid Slack interaction signature'); + return jsonResponse(401, { error: 'Invalid signature' }); + } + + // Parse the payload — Slack sends it as URL-encoded `payload=`. + const params = new URLSearchParams(event.body); + const payloadStr = params.get('payload'); + if (!payloadStr) { + return jsonResponse(400, { error: 'Missing payload' }); + } + + const payload: SlackInteractionPayload = JSON.parse(payloadStr); + + if (payload.type === 'block_actions' && payload.actions) { + for (const action of payload.actions) { + if (action.action_id.startsWith('cancel_task:')) { + await handleCancelAction(payload, action.action_id); + } + } + } + + // Slack expects a 200 response within 3 seconds. + return jsonResponse(200, {}); + } catch (err) { + logger.error('Slack interaction handler failed', { + error: err instanceof Error ? err.message : String(err), + }); + return jsonResponse(200, {}); // Still return 200 to avoid Slack retries. + } +} + +async function handleCancelAction(payload: SlackInteractionPayload, actionId: string): Promise { + const taskId = actionId.replace('cancel_task:', ''); + const teamId = payload.user.team_id; + const userId = payload.user.id; + + // Look up platform user. + const mappingResult = await ddb.send(new GetCommand({ + TableName: USER_MAPPING_TABLE, + Key: { slack_identity: `${teamId}#${userId}` }, + })); + + if (!mappingResult.Item || mappingResult.Item.status === 'pending') { + await postToResponseUrl(payload.response_url, ':link: Your Slack account is not linked.'); + return; + } + + const platformUserId = mappingResult.Item.platform_user_id as string; + + // Load the task. + const taskResult = await ddb.send(new GetCommand({ + TableName: TASK_TABLE, + Key: { task_id: taskId }, + })); + + if (!taskResult.Item) { + await postToResponseUrl(payload.response_url, `:mag: Task \`${taskId}\` not found.`); + return; + } + + if (taskResult.Item.user_id !== platformUserId) { + await postToResponseUrl(payload.response_url, ':no_entry: You can only cancel your own tasks.'); + return; + } + + // Attempt to cancel. + const ACTIVE_STATUSES = ['SUBMITTED', 'HYDRATING', 'RUNNING', 'FINALIZING']; + try { + await ddb.send(new UpdateCommand({ + TableName: TASK_TABLE, + Key: { task_id: taskId }, + UpdateExpression: 'SET #s = :cancelled, updated_at = :now', + ConditionExpression: '#s IN (:s1, :s2, :s3, :s4)', + ExpressionAttributeNames: { '#s': 'status' }, + ExpressionAttributeValues: { + ':cancelled': 'CANCELLED', + ':now': new Date().toISOString(), + ':s1': ACTIVE_STATUSES[0], + ':s2': ACTIVE_STATUSES[1], + ':s3': ACTIVE_STATUSES[2], + ':s4': ACTIVE_STATUSES[3], + }, + })); + + // Instant feedback: replace the Cancel button message with "Cancelling..." + // then clean up all intermediate messages. + const channelMeta = taskResult.Item.channel_metadata as Record | undefined; + const channelId = payload.channel?.id ?? channelMeta?.slack_channel_id; + if (channelMeta && channelId) { + const botToken = await getSlackSecret(`${SLACK_SECRET_PREFIX}${teamId}`); + if (botToken) { + if (channelMeta.slack_session_msg_ts) { + await updateSlackMessage(botToken, channelId, channelMeta.slack_session_msg_ts, + ':hourglass_flowing_sand: Cancelling...', channelMeta.slack_thread_ts); + } + const toDelete = [channelMeta.slack_created_msg_ts].filter(Boolean); + for (const ts of toDelete) { + await deleteSlackMessage(botToken, channelId, ts!); + } + } + } + } catch (err) { + if ((err as Error)?.name === 'ConditionalCheckFailedException') { + await postToResponseUrl(payload.response_url, ':warning: Task is already in a terminal state.'); + } else { + throw err; + } + } +} + +async function updateSlackMessage(botToken: string, channel: string, ts: string, text: string, threadTs?: string): Promise { + try { + const payload: Record = { + channel, + ts, + text, + blocks: [{ type: 'section', text: { type: 'mrkdwn', text } }], + }; + if (threadTs) payload.thread_ts = threadTs; + const response = await fetch('https://slack.com/api/chat.update', { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': `Bearer ${botToken}`, + }, + body: JSON.stringify(payload), + }); + const result = await response.json() as { ok: boolean; error?: string }; + if (!result.ok) { + logger.warn('Failed to update Slack message', { error: result.error, ts }); + } + } catch (err) { + logger.warn('Error updating Slack message', { error: err instanceof Error ? err.message : String(err) }); + } +} + +async function deleteSlackMessage(botToken: string, channel: string, ts: string): Promise { + try { + const response = await fetch('https://slack.com/api/chat.delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': `Bearer ${botToken}`, + }, + body: JSON.stringify({ channel, ts }), + }); + const result = await response.json() as { ok: boolean; error?: string }; + if (!result.ok) { + logger.warn('Failed to delete Slack message', { error: result.error, ts }); + } + } catch (err) { + logger.warn('Error deleting Slack message', { error: err instanceof Error ? err.message : String(err) }); + } +} + +async function postToResponseUrl(responseUrl: string, text: string): Promise { + try { + await fetch(responseUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ response_type: 'ephemeral', text, replace_original: false }), + }); + } catch (err) { + logger.warn('Failed to post to interaction response_url', { + error: err instanceof Error ? err.message : String(err), + }); + } +} + +function jsonResponse(statusCode: number, body: Record): APIGatewayProxyResult { + return { + statusCode, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }; +} diff --git a/cdk/src/handlers/slack-link.ts b/cdk/src/handlers/slack-link.ts new file mode 100644 index 0000000..60ba20d --- /dev/null +++ b/cdk/src/handlers/slack-link.ts @@ -0,0 +1,112 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand, PutCommand, DeleteCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { ulid } from 'ulid'; +import { extractUserId } from './shared/gateway'; +import { logger } from './shared/logger'; +import { ErrorCode, errorResponse, successResponse } from './shared/response'; +import { parseBody } from './shared/validation'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +const USER_MAPPING_TABLE = process.env.SLACK_USER_MAPPING_TABLE_NAME!; + +interface LinkRequest { + readonly code: string; +} + +/** + * POST /v1/slack/link — Complete Slack account linking. + * + * Called from the CLI (`bgagent slack link `) with a Cognito JWT. + * Looks up the pending link record, maps the Slack identity to the + * authenticated platform user, and cleans up the pending record. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + const requestId = ulid(); + + try { + const userId = extractUserId(event); + if (!userId) { + return errorResponse(401, ErrorCode.UNAUTHORIZED, 'Authentication required.', requestId); + } + + const body = parseBody(event.body ?? null); + if (!body?.code) { + return errorResponse(400, ErrorCode.VALIDATION_ERROR, 'Request body must include a "code" field.', requestId); + } + + const code = body.code.trim().toUpperCase(); + + // Look up the pending link record. + const pending = await ddb.send(new GetCommand({ + TableName: USER_MAPPING_TABLE, + Key: { slack_identity: `pending#${code}` }, + })); + + if (!pending.Item || pending.Item.status !== 'pending') { + return errorResponse(404, ErrorCode.VALIDATION_ERROR, 'Invalid or expired link code.', requestId); + } + + const teamId = pending.Item.slack_team_id as string; + const slackUserId = pending.Item.slack_user_id as string; + const now = new Date().toISOString(); + + // Write the confirmed mapping. + await ddb.send(new PutCommand({ + TableName: USER_MAPPING_TABLE, + Item: { + slack_identity: `${teamId}#${slackUserId}`, + platform_user_id: userId, + slack_team_id: teamId, + slack_user_id: slackUserId, + linked_at: now, + link_method: 'slash_command', + }, + })); + + // Clean up the pending record. + await ddb.send(new DeleteCommand({ + TableName: USER_MAPPING_TABLE, + Key: { slack_identity: `pending#${code}` }, + })); + + logger.info('Slack account linked', { + platform_user_id: userId, + slack_team_id: teamId, + slack_user_id: slackUserId, + }); + + return successResponse(200, { + message: 'Slack account linked successfully.', + slack_team_id: teamId, + slack_user_id: slackUserId, + linked_at: now, + }, requestId); + } catch (err) { + logger.error('Slack link handler failed', { + error: err instanceof Error ? err.message : String(err), + request_id: requestId, + }); + return errorResponse(500, ErrorCode.INTERNAL_ERROR, 'Internal server error.', requestId); + } +} diff --git a/cdk/src/handlers/slack-notify.ts b/cdk/src/handlers/slack-notify.ts new file mode 100644 index 0000000..553ecb9 --- /dev/null +++ b/cdk/src/handlers/slack-notify.ts @@ -0,0 +1,364 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, GetCommand, UpdateCommand } from '@aws-sdk/lib-dynamodb'; +import type { DynamoDBStreamEvent, DynamoDBRecord } from 'aws-lambda'; +import { logger } from './shared/logger'; +import { renderSlackBlocks } from './shared/slack-blocks'; +import { getSlackSecret, SLACK_SECRET_PREFIX } from './shared/slack-verify'; +import type { TaskRecord } from './shared/types'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +const TASK_TABLE = process.env.TASK_TABLE_NAME!; + +const TERMINAL_EVENTS = new Set(['task_completed', 'task_failed', 'task_cancelled', 'task_timed_out']); + +/** Event types that trigger Slack notifications. */ +const NOTIFIABLE_EVENTS = new Set([ + 'task_created', + 'session_started', + 'task_completed', + 'task_failed', + 'task_cancelled', + 'task_timed_out', +]); + +/** + * Slack notification handler triggered by DynamoDB Streams on TaskEventsTable. + * + * For each task event: + * 1. Load the task record to check channel_source and channel_metadata. + * 2. If channel_source is 'slack', render a Block Kit message and post to Slack. + * 3. Thread replies under the initial message using stored slack_thread_ts. + * + * Infrastructure errors (DynamoDB, Secrets Manager) are rethrown so Lambda's + * configured retry/bisect behavior can do its job. Slack API errors are + * treated as delivery failures and logged but never fail the batch. + */ +export async function handler(event: DynamoDBStreamEvent): Promise { + for (const record of event.Records) { + try { + await processRecord(record); + } catch (err) { + // Slack delivery errors are terminal — swallow after logging so the batch + // isn't retried for something a retry can't fix. + if (err instanceof SlackApiError) { + logger.warn('Slack delivery failed', { + error: err.message, + event_id: record.eventID, + }); + continue; + } + // Infrastructure errors (DynamoDB throttling, Secrets Manager outage, etc.) + // rethrow so Lambda retries the batch per the configured retryAttempts + + // bisectBatchOnError behavior. + logger.error('Infrastructure error processing Slack notification', { + error: err instanceof Error ? err.message : String(err), + event_id: record.eventID, + }); + throw err; + } + } +} + +/** + * Thrown when the Slack API returns an error after a successful HTTP call. + * Tagged so the batch handler can swallow it without retrying — Slack errors + * are not recoverable by retrying the stream record. + */ +class SlackApiError extends Error { + constructor(message: string) { + super(message); + this.name = 'SlackApiError'; + } +} + +async function processRecord(record: DynamoDBRecord): Promise { + if (record.eventName !== 'INSERT' || !record.dynamodb?.NewImage) return; + + const newImage = record.dynamodb.NewImage; + const eventType = newImage.event_type?.S; + const taskId = newImage.task_id?.S; + + if (!eventType || !taskId || !NOTIFIABLE_EVENTS.has(eventType)) return; + + // Load the task record first so we can skip non-Slack tasks before touching + // their DynamoDB row with dedup writes. + const taskResult = await ddb.send(new GetCommand({ + TableName: TASK_TABLE, + Key: { task_id: taskId }, + })); + + const task = taskResult.Item as TaskRecord | undefined; + if (!task || task.channel_source !== 'slack') return; + + // Deduplicate terminal notifications — the orchestrator may write multiple + // failure/completion events (retries). Use a conditional update to claim + // the right to send the terminal notification. + if (TERMINAL_EVENTS.has(eventType)) { + try { + await ddb.send(new UpdateCommand({ + TableName: TASK_TABLE, + Key: { task_id: taskId }, + UpdateExpression: 'SET channel_metadata.slack_notified_terminal = :t', + ConditionExpression: 'attribute_not_exists(channel_metadata.slack_notified_terminal)', + ExpressionAttributeValues: { ':t': true }, + })); + } catch (err) { + if ((err as Error)?.name === 'ConditionalCheckFailedException') { + logger.info('Terminal notification already sent, skipping duplicate', { task_id: taskId, event_type: eventType }); + return; + } + throw err; + } + } + + const channelMeta = task.channel_metadata; + if (!channelMeta?.slack_team_id || !channelMeta?.slack_channel_id) { + logger.warn('Slack task missing channel metadata', { task_id: taskId }); + return; + } + + // Fetch the bot token for this workspace. + const botToken = await getSlackSecret(`${SLACK_SECRET_PREFIX}${channelMeta.slack_team_id}`); + if (!botToken) { + logger.warn('Bot token not found for Slack workspace', { + team_id: channelMeta.slack_team_id, + task_id: taskId, + }); + return; + } + + // Parse event metadata if present. Failures are logged and treated as "no metadata" — + // the surfaced fallback reason is "Unknown error" which is user-hostile without a log. + const eventMetadata = newImage.metadata?.S + ? safeJsonParse(newImage.metadata.S, { task_id: taskId, event_type: eventType }) + : undefined; + + // Render the Slack message. + const message = renderSlackBlocks(eventType, task, eventMetadata ?? undefined); + + // For task_created, post a new message. For subsequent events, reply in thread. + const threadTs = channelMeta.slack_thread_ts; + + // For DM channels (prefix 'D'), post to the user ID instead — chat.postMessage + // opens a DM automatically when given a user ID, which avoids the channel_not_found + // error that occurs with ephemeral DM channel IDs from slash commands. + const channel = channelMeta.slack_channel_id.startsWith('D') && channelMeta.slack_user_id + ? channelMeta.slack_user_id + : channelMeta.slack_channel_id; + + const slackPayload: Record = { + channel, + text: message.text, + blocks: message.blocks, + }; + + // Thread all messages under the original. For @mentions, threadTs is set to the + // user's mention message by the command processor. For slash commands, threadTs + // is set to the task_created message after it's posted (see below). + if (threadTs) { + slackPayload.thread_ts = threadTs; + } + + // Suppress link unfurls — the View PR button is the clean way to access it. + slackPayload.unfurl_links = false; + + // Post to Slack. + const response = await fetch('https://slack.com/api/chat.postMessage', { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': `Bearer ${botToken}`, + }, + body: JSON.stringify(slackPayload), + }); + + const result = await response.json() as { ok: boolean; ts?: string; error?: string }; + + if (!result.ok) { + // Slack API errors are not retryable via the Lambda batch (re-processing the + // stream record won't make Slack start accepting the message), so throw a + // tagged error and let the batch handler swallow it after logging. + throw new SlackApiError(`slack chat.postMessage failed: ${result.error ?? 'unknown'} (task_id=${taskId} event_type=${eventType})`); + } + + // Emoji reaction on the root message — the user's @mention or the task_created message. + // Reactions always use the real channel ID (not user ID), even for DMs. + const reactionChannel = channelMeta.slack_channel_id; + const reactionTarget = threadTs ?? result.ts; + if (reactionTarget) { + await updateReaction(botToken, reactionChannel, reactionTarget, eventType); + } + + // Store message timestamps for later updates. + if (result.ts) { + if (eventType === 'task_created') { + const updates: string[] = ['channel_metadata.slack_created_msg_ts = :created_ts']; + const values: Record = { ':created_ts': result.ts }; + if (!threadTs) { + // Slash commands: also store thread_ts (mentions already have it). + updates.push('channel_metadata.slack_thread_ts = :created_ts'); + } + try { + await ddb.send(new UpdateCommand({ + TableName: TASK_TABLE, + Key: { task_id: taskId }, + UpdateExpression: `SET ${updates.join(', ')}`, + ExpressionAttributeValues: values, + })); + } catch (err) { + logger.warn('Failed to store task_created message ts', { + task_id: taskId, + error: err instanceof Error ? err.message : String(err), + }); + } + } else if (eventType === 'session_started') { + try { + await ddb.send(new UpdateCommand({ + TableName: TASK_TABLE, + Key: { task_id: taskId }, + UpdateExpression: 'SET channel_metadata.slack_session_msg_ts = :ts', + ExpressionAttributeValues: { ':ts': result.ts }, + })); + } catch (err) { + logger.warn('Failed to store session message ts', { + task_id: taskId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + + // On terminal events, clean up intermediate messages — only the final + // result message stays in the thread. + if (TERMINAL_EVENTS.has(eventType)) { + if (channelMeta.slack_session_msg_ts) { + await deleteMessage(botToken, channel, channelMeta.slack_session_msg_ts); + } + if (channelMeta.slack_created_msg_ts) { + await deleteMessage(botToken, channel, channelMeta.slack_created_msg_ts); + } + } + + logger.info('Slack notification sent', { + task_id: taskId, + event_type: eventType, + team_id: channelMeta.slack_team_id, + channel_id: channelMeta.slack_channel_id, + }); +} + +/** Map event types to the emoji reaction that should be on the original message. */ +const EVENT_REACTIONS: Record = { + task_created: 'eyes', + session_started: 'hourglass_flowing_sand', + task_completed: 'white_check_mark', + task_failed: 'x', + task_cancelled: 'no_entry_sign', + task_timed_out: 'hourglass', +}; + +/** Reactions to remove when transitioning to a new state. */ +const STALE_REACTIONS = ['eyes', 'hourglass_flowing_sand']; + +async function addReaction(botToken: string, channel: string, timestamp: string, emoji: string): Promise { + try { + const response = await fetch('https://slack.com/api/reactions.add', { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': `Bearer ${botToken}`, + }, + body: JSON.stringify({ channel, timestamp, name: emoji }), + }); + const result = await response.json() as { ok: boolean; error?: string }; + if (!result.ok && result.error !== 'already_reacted') { + logger.warn('Failed to add Slack reaction', { emoji, error: result.error }); + } + } catch (err) { + logger.warn('Error adding Slack reaction', { emoji, error: err instanceof Error ? err.message : String(err) }); + } +} + +async function removeReaction(botToken: string, channel: string, timestamp: string, emoji: string): Promise { + try { + const response = await fetch('https://slack.com/api/reactions.remove', { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': `Bearer ${botToken}`, + }, + body: JSON.stringify({ channel, timestamp, name: emoji }), + }); + const result = await response.json() as { ok: boolean; error?: string }; + if (!result.ok && result.error !== 'no_reaction') { + logger.warn('Failed to remove Slack reaction', { emoji, error: result.error }); + } + } catch (err) { + logger.warn('Error removing Slack reaction', { emoji, error: err instanceof Error ? err.message : String(err) }); + } +} + +async function updateReaction(botToken: string, channel: string, threadTs: string, eventType: string): Promise { + const newEmoji = EVENT_REACTIONS[eventType]; + if (!newEmoji) return; + + // Remove stale reactions first, then add the new one. + for (const stale of STALE_REACTIONS) { + if (stale !== newEmoji) { + await removeReaction(botToken, channel, threadTs, stale); + } + } + await addReaction(botToken, channel, threadTs, newEmoji); +} + +async function deleteMessage(botToken: string, channel: string, messageTs: string): Promise { + try { + const response = await fetch('https://slack.com/api/chat.delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'Authorization': `Bearer ${botToken}`, + }, + body: JSON.stringify({ channel, ts: messageTs }), + }); + const result = await response.json() as { ok: boolean; error?: string }; + if (!result.ok) { + logger.warn('Failed to delete session message', { error: result.error }); + } + } catch (err) { + logger.warn('Error deleting session message', { error: err instanceof Error ? err.message : String(err) }); + } +} + +function safeJsonParse(text: string, context?: Record): Record | null { + try { + return JSON.parse(text); + } catch (err) { + logger.warn('Failed to parse event metadata JSON', { + ...context, + error: err instanceof Error ? err.message : String(err), + preview: text.length > 200 ? `${text.slice(0, 200)}...` : text, + }); + return null; + } +} diff --git a/cdk/src/handlers/slack-oauth-callback.ts b/cdk/src/handlers/slack-oauth-callback.ts new file mode 100644 index 0000000..872d558 --- /dev/null +++ b/cdk/src/handlers/slack-oauth-callback.ts @@ -0,0 +1,205 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { CreateSecretCommand, RestoreSecretCommand, SecretsManagerClient, UpdateSecretCommand, ResourceNotFoundException, InvalidRequestException } from '@aws-sdk/client-secrets-manager'; +import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { logger } from './shared/logger'; +import { getSlackSecret, SLACK_SECRET_PREFIX } from './shared/slack-verify'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const sm = new SecretsManagerClient({}); + +const TABLE_NAME = process.env.SLACK_INSTALLATION_TABLE_NAME!; +const CLIENT_ID_SECRET_ARN = process.env.SLACK_CLIENT_ID_SECRET_ARN!; +const CLIENT_SECRET_ARN = process.env.SLACK_CLIENT_SECRET_ARN!; + +interface SlackOAuthResponse { + readonly ok: boolean; + readonly error?: string; + readonly app_id?: string; + readonly team?: { readonly id: string; readonly name: string }; + readonly bot_user_id?: string; + readonly access_token?: string; + readonly scope?: string; + readonly authed_user?: { readonly id: string }; +} + +/** + * GET /v1/slack/oauth/callback — Handle Slack OAuth V2 redirect. + * + * After a workspace admin authorizes the Slack App, Slack redirects here + * with a `code` query parameter. This handler exchanges the code for a + * bot token, stores it in Secrets Manager, and records the installation. + * + * **CSRF note:** this handler does not validate a `state` parameter + * (RFC 6749 §10.12). In OAuth V2, the `code` exchange at Slack also + * requires the app's `client_secret`, which is not available to an attacker — + * so a forged callback cannot complete the token exchange. The residual + * risk is a confused-deputy scenario where a workspace admin who clicked a + * legitimate install link on another tab is instead logged against an + * attacker-initiated install. Mitigating that requires per-session signed + * state storage and is tracked as a follow-up. For now, the `client_secret` + * requirement on Slack's side provides the primary defense. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + try { + const code = event.queryStringParameters?.code; + if (!code) { + return htmlResponse(400, 'Missing authorization code. Please try the install flow again.'); + } + + // Fetch the Slack App client ID and client secret from Secrets Manager. + const clientId = await getSlackSecret(CLIENT_ID_SECRET_ARN); + if (!clientId) { + logger.error('Slack client ID not found', { secret_arn: CLIENT_ID_SECRET_ARN }); + return htmlResponse(500, 'Slack client ID not configured. Populate the secret in Secrets Manager.'); + } + + const clientSecret = await getSlackSecret(CLIENT_SECRET_ARN); + if (!clientSecret) { + logger.error('Slack client secret not found', { secret_arn: CLIENT_SECRET_ARN }); + return htmlResponse(500, 'Slack client secret not configured. Populate the secret in Secrets Manager.'); + } + + // Exchange the code for an access token. + const redirectUri = buildRedirectUri(event); + const tokenResponse = await exchangeCode(code, clientId, clientSecret, redirectUri); + if (!tokenResponse.ok || !tokenResponse.access_token || !tokenResponse.team) { + logger.error('Slack OAuth token exchange failed', { + error: tokenResponse.error ?? 'unknown', + }); + return htmlResponse(400, `Slack authorization failed: ${tokenResponse.error ?? 'unknown error'}`); + } + + const teamId = tokenResponse.team.id; + const teamName = tokenResponse.team.name; + const botToken = tokenResponse.access_token; + const now = new Date().toISOString(); + + // Store the bot token in Secrets Manager. + const secretName = `${SLACK_SECRET_PREFIX}${teamId}`; + await upsertSecret(secretName, botToken, teamId); + + // Write installation record to DynamoDB. + await ddb.send(new PutCommand({ + TableName: TABLE_NAME, + Item: { + team_id: teamId, + team_name: teamName, + bot_token_secret_arn: secretName, + bot_user_id: tokenResponse.bot_user_id ?? '', + app_id: tokenResponse.app_id ?? '', + scope: tokenResponse.scope ?? '', + installed_by: tokenResponse.authed_user?.id ?? '', + installed_at: now, + updated_at: now, + status: 'active', + }, + })); + + logger.info('Slack workspace installed', { team_id: teamId, team_name: teamName }); + + return htmlResponse(200, ` +

Successfully installed!

+

ABCA Background Agent has been added to the ${escapeHtml(teamName)} workspace.

+

Team members can now link their accounts with /bgagent link and start submitting tasks.

+ `); + } catch (err) { + logger.error('Slack OAuth callback failed', { + error: err instanceof Error ? err.message : String(err), + }); + return htmlResponse(500, 'An unexpected error occurred. Please try again.'); + } +} + +async function exchangeCode( + code: string, + clientId: string, + clientSecret: string, + redirectUri: string, +): Promise { + const params = new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + redirect_uri: redirectUri, + }); + + const response = await fetch('https://slack.com/api/oauth.v2.access', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }); + + return await response.json() as SlackOAuthResponse; +} + +async function upsertSecret(secretName: string, secretValue: string, teamId: string): Promise { + try { + await sm.send(new UpdateSecretCommand({ + SecretId: secretName, + SecretString: secretValue, + })); + } catch (err) { + if (err instanceof ResourceNotFoundException) { + await sm.send(new CreateSecretCommand({ + Name: secretName, + SecretString: secretValue, + Description: `Slack bot token for workspace ${teamId}`, + Tags: [ + { Key: 'team_id', Value: teamId }, + { Key: 'service', Value: 'bgagent-slack' }, + ], + })); + } else if (err instanceof InvalidRequestException && String(err.message).includes('marked for deletion')) { + // Secret was scheduled for deletion during app uninstall — restore it and update. + await sm.send(new RestoreSecretCommand({ SecretId: secretName })); + await sm.send(new UpdateSecretCommand({ + SecretId: secretName, + SecretString: secretValue, + })); + } else { + throw err; + } + } +} + +function buildRedirectUri(event: APIGatewayProxyEvent): string { + const host = event.headers.Host ?? event.headers.host ?? ''; + const stage = event.requestContext.stage ?? ''; + return `https://${host}/${stage}/slack/oauth/callback`; +} + +function htmlResponse(statusCode: number, body: string): APIGatewayProxyResult { + const html = ` +ABCA Slack Integration + +${body}`; + return { + statusCode, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + body: html, + }; +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 98a3be1..74443e5 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -21,7 +21,7 @@ import * as path from 'path'; import * as agentcore from '@aws-cdk/aws-bedrock-agentcore-alpha'; import * as bedrock from '@aws-cdk/aws-bedrock-alpha'; import * as agentcoremixins from '@aws-cdk/mixins-preview/aws-bedrockagentcore'; -import { Stack, StackProps, RemovalPolicy, CfnOutput, CfnResource, Duration, Lazy } from 'aws-cdk-lib'; +import { Stack, StackProps, RemovalPolicy, CfnOutput, CfnResource, Duration, Fn, Lazy } from 'aws-cdk-lib'; import * as ec2 from 'aws-cdk-lib/aws-ec2'; // ecr_assets import is only needed when the ECS block below is uncommented // import * as ecr_assets from 'aws-cdk-lib/aws-ecr-assets'; @@ -37,7 +37,9 @@ import { Blueprint } from '../constructs/blueprint'; import { ConcurrencyReconciler } from '../constructs/concurrency-reconciler'; import { DnsFirewall } from '../constructs/dns-firewall'; import { FanOutConsumer } from '../constructs/fanout-consumer'; +import { LinearIntegration } from '../constructs/linear-integration'; import { RepoTable } from '../constructs/repo-table'; +import { SlackIntegration } from '../constructs/slack-integration'; import { StrandedTaskReconciler } from '../constructs/stranded-task-reconciler'; // import { EcsAgentCluster } from '../constructs/ecs-agent-cluster'; import { TaskApi } from '../constructs/task-api'; @@ -94,8 +96,9 @@ export class AgentStack extends Stack { ]); // --- Repository onboarding --- + const blueprintRepo = process.env.BLUEPRINT_REPO ?? this.node.tryGetContext('blueprintRepo') ?? 'awslabs/agent-plugins'; const agentPluginsBlueprint = new Blueprint(this, 'AgentPluginsBlueprint', { - repo: 'krokoko/agent-plugins', + repo: blueprintRepo, repoTable: repoTable.table, }); @@ -367,7 +370,9 @@ export class AgentStack extends Stack { inferenceProfile2.grantInvoke(runtime); runtime.with(agentcoremixins.mixins.CfnRuntimeLogsMixin.APPLICATION_LOGS.toLogGroup(applicationLogGroup)); - runtime.with(agentcoremixins.mixins.CfnRuntimeLogsMixin.TRACES.toXRay()); + // X-Ray tracing disabled — requires account-level UpdateTraceSegmentDestination + // which needs CloudWatch Logs resource policy propagation. Re-enable once resolved. + // runtime.with(agentcoremixins.mixins.CfnRuntimeLogsMixin.TRACES.toXRay()); runtime.with(agentcoremixins.mixins.CfnRuntimeLogsMixin.USAGE_LOGS.toLogGroup(usageLogGroup)); NagSuppressions.addResourceSuppressions(runtime, [ @@ -507,6 +512,110 @@ export class AgentStack extends Stack { runtimeArn: runtime.agentRuntimeArn, }); + // --- Slack integration (always deployed — secrets populated post-deploy) --- + const slackIntegration = new SlackIntegration(this, 'SlackIntegration', { + api: taskApi.api, + userPool: taskApi.userPool, + taskTable: taskTable.table, + taskEventsTable: taskEventsTable.table, + repoTable: repoTable.table, + orchestratorFunctionArn: orchestrator.alias.functionArn, + guardrailId: inputGuardrail.guardrailId, + guardrailVersion: inputGuardrail.guardrailVersion, + }); + + // --- Slack App setup outputs --- + // Pre-filled manifest URL: opens Slack's "Create New App" page with all + // URLs, scopes, and events pre-configured. User just clicks Create. + const apiHost = Fn.select(2, Fn.split('/', taskApi.api.url)); + const apiStage = Fn.select(3, Fn.split('/', taskApi.api.url)); + const apiBase = Fn.join('', ['https://', apiHost, '/', apiStage]); + + // Build the YAML manifest as a string using Fn.join (API URL tokens resolve at deploy time). + // Slack's ?new_app=1&manifest_json= endpoint accepts URL-encoded JSON. + const manifestJson = Fn.join('', [ + '{"_metadata":{"major_version":1,"minor_version":1},', + '"display_information":{"name":"Shoof","description":"Submit coding tasks to autonomous background agents","background_color":"#1a1a2e"},', + '"features":{"app_home":{"messages_tab_enabled":true,"messages_tab_read_only_enabled":false},"bot_user":{"display_name":"Shoof","always_online":true},', + '"slash_commands":[{"command":"/bgagent","url":"', apiBase, '/slack/commands","description":"Link your account or get help with Shoof","usage_hint":"link | help","should_escape":false}]},', + '"oauth_config":{"scopes":{"bot":["app_mentions:read","commands","chat:write","chat:write.public","channels:read","groups:read","im:history","im:write","users:read","reactions:write"]},', + '"redirect_urls":["', apiBase, '/slack/oauth/callback"]},', + '"settings":{"event_subscriptions":{"request_url":"', apiBase, '/slack/events","bot_events":["app_mention","message.im","app_uninstalled","tokens_revoked"]},', + '"interactivity":{"is_enabled":true,"request_url":"', apiBase, '/slack/interactions"},', + '"org_deploy_enabled":false,"socket_mode_enabled":false,"token_rotation_enabled":false}}', + ]); + + new CfnOutput(this, 'SlackAppManifestJson', { + value: manifestJson, + description: 'Slack App manifest JSON — the CLI URL-encodes this into the create URL', + }); + + new CfnOutput(this, 'SlackSigningSecretArn', { + value: slackIntegration.signingSecret.secretArn, + description: 'Secrets Manager ARN for the Slack signing secret — populate after creating the Slack App', + }); + + new CfnOutput(this, 'SlackClientSecretArn', { + value: slackIntegration.clientSecret.secretArn, + description: 'Secrets Manager ARN for the Slack client secret — populate after creating the Slack App', + }); + + new CfnOutput(this, 'SlackClientIdSecretArn', { + value: slackIntegration.clientIdSecret.secretArn, + description: 'Secrets Manager ARN for the Slack client ID — populate after creating the Slack App', + }); + + new CfnOutput(this, 'SlackInstallationTableName', { + value: slackIntegration.installationTable.tableName, + description: 'Name of the DynamoDB Slack installation table', + }); + + new CfnOutput(this, 'SlackUserMappingTableName', { + value: slackIntegration.userMappingTable.tableName, + description: 'Name of the DynamoDB Slack user mapping table', + }); + + // --- Linear integration (inbound webhook + agent-side MCP outbound) --- + const linearIntegration = new LinearIntegration(this, 'LinearIntegration', { + api: taskApi.api, + userPool: taskApi.userPool, + taskTable: taskTable.table, + taskEventsTable: taskEventsTable.table, + repoTable: repoTable.table, + orchestratorFunctionArn: orchestrator.alias.functionArn, + guardrailId: inputGuardrail.guardrailId, + guardrailVersion: inputGuardrail.guardrailVersion, + }); + + // Pipe the Linear API token secret into the AgentCore runtime so the + // agent's `resolve_linear_api_token()` can populate `LINEAR_API_TOKEN` + // for the Linear MCP's `${LINEAR_API_TOKEN}` placeholder. + linearIntegration.apiTokenSecret.grantRead(runtime); + cfnRuntime.addPropertyOverride( + 'EnvironmentVariables.LINEAR_API_TOKEN_SECRET_ARN', + linearIntegration.apiTokenSecret.secretArn, + ); + + new CfnOutput(this, 'LinearWebhookSecretArn', { + value: linearIntegration.webhookSecret.secretArn, + description: 'Secrets Manager ARN for the Linear webhook signing secret — populate via `bgagent linear setup`', + }); + + new CfnOutput(this, 'LinearApiTokenSecretArn', { + value: linearIntegration.apiTokenSecret.secretArn, + description: 'Secrets Manager ARN for the Linear personal API token (agent-side MCP) — populate via `bgagent linear setup`', + }); + + new CfnOutput(this, 'LinearProjectMappingTableName', { + value: linearIntegration.projectMappingTable.tableName, + description: 'Name of the DynamoDB Linear project → repo mapping table', + }); + + new CfnOutput(this, 'LinearUserMappingTableName', { + value: linearIntegration.userMappingTable.tableName, + description: 'Name of the DynamoDB Linear user mapping table', + }); + // --- Bedrock model invocation logging (account-level) --- const invocationLogGroup = new logs.LogGroup(this, 'ModelInvocationLogGroup', { logGroupName: '/aws/bedrock/model-invocation-logs', diff --git a/cdk/test/constructs/linear-integration.test.ts b/cdk/test/constructs/linear-integration.test.ts new file mode 100644 index 0000000..3444258 --- /dev/null +++ b/cdk/test/constructs/linear-integration.test.ts @@ -0,0 +1,114 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { App, Stack } from 'aws-cdk-lib'; +import { Template, Match } from 'aws-cdk-lib/assertions'; +import * as apigw from 'aws-cdk-lib/aws-apigateway'; +import * as cognito from 'aws-cdk-lib/aws-cognito'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { LinearIntegration } from '../../src/constructs/linear-integration'; + +describe('LinearIntegration construct', () => { + let template: Template; + + beforeAll(() => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + + const api = new apigw.RestApi(stack, 'TestApi'); + const userPool = new cognito.UserPool(stack, 'TestUserPool'); + const taskTable = new dynamodb.Table(stack, 'TaskTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + }); + const taskEventsTable = new dynamodb.Table(stack, 'TaskEventsTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'event_id', type: dynamodb.AttributeType.STRING }, + }); + + new LinearIntegration(stack, 'LinearIntegration', { + api, + userPool, + taskTable, + taskEventsTable, + }); + + template = Template.fromStack(stack); + }); + + test('creates three DynamoDB tables (project mapping + user mapping + dedup)', () => { + // TaskTable + TaskEventsTable + LinearProjectMapping + LinearUserMapping + LinearWebhookDedup = 5 + template.resourceCountIs('AWS::DynamoDB::Table', 5); + }); + + test('creates three Lambda functions (webhook, processor, link)', () => { + template.resourceCountIs('AWS::Lambda::Function', 3); + }); + + test('creates API Gateway resources under /linear', () => { + template.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: 'linear' }); + template.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: 'webhook' }); + template.hasResourceProperties('AWS::ApiGateway::Resource', { PathPart: 'link' }); + }); + + test('creates two Secrets Manager secrets (webhook + API token)', () => { + template.resourceCountIs('AWS::SecretsManager::Secret', 2); + template.hasResourceProperties('AWS::SecretsManager::Secret', { + Description: Match.stringLikeRegexp('Linear webhook signing secret'), + }); + template.hasResourceProperties('AWS::SecretsManager::Secret', { + Description: Match.stringLikeRegexp('Linear personal API token'), + }); + }); + + test('has NO DynamoDB Streams event-source mapping (outbound goes through MCP)', () => { + template.resourceCountIs('AWS::Lambda::EventSourceMapping', 0); + }); + + test('webhook handler env wires dedup table + processor + secret ARN', () => { + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + LINEAR_WEBHOOK_SECRET_ARN: Match.anyValue(), + LINEAR_WEBHOOK_DEDUP_TABLE_NAME: Match.anyValue(), + LINEAR_WEBHOOK_PROCESSOR_FUNCTION_NAME: Match.anyValue(), + }), + }, + }); + }); + + test('processor handler env wires both mapping tables + task table', () => { + template.hasResourceProperties('AWS::Lambda::Function', { + Environment: { + Variables: Match.objectLike({ + LINEAR_PROJECT_MAPPING_TABLE_NAME: Match.anyValue(), + LINEAR_USER_MAPPING_TABLE_NAME: Match.anyValue(), + TASK_TABLE_NAME: Match.anyValue(), + TASK_EVENTS_TABLE_NAME: Match.anyValue(), + }), + }, + }); + }); + + test('webhook dedup table has TTL attribute for 60s expiry', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + KeySchema: [{ AttributeName: 'dedup_key', KeyType: 'HASH' }], + TimeToLiveSpecification: { AttributeName: 'ttl', Enabled: true }, + }); + }); +}); diff --git a/cdk/test/constructs/slack-installation-table.test.ts b/cdk/test/constructs/slack-installation-table.test.ts new file mode 100644 index 0000000..a2cc546 --- /dev/null +++ b/cdk/test/constructs/slack-installation-table.test.ts @@ -0,0 +1,68 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { App, Stack } from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import { SlackInstallationTable } from '../../src/constructs/slack-installation-table'; + +describe('SlackInstallationTable construct', () => { + let template: Template; + + beforeAll(() => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new SlackInstallationTable(stack, 'SlackInstallationTable'); + template = Template.fromStack(stack); + }); + + test('creates a DynamoDB table', () => { + template.resourceCountIs('AWS::DynamoDB::Table', 1); + }); + + test('table has team_id as partition key', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + KeySchema: [ + { AttributeName: 'team_id', KeyType: 'HASH' }, + ], + }); + }); + + test('table uses PAY_PER_REQUEST billing', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + BillingMode: 'PAY_PER_REQUEST', + }); + }); + + test('table has point-in-time recovery enabled', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + PointInTimeRecoverySpecification: { + PointInTimeRecoveryEnabled: true, + }, + }); + }); + + test('enables TTL on ttl attribute', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + TimeToLiveSpecification: { + AttributeName: 'ttl', + Enabled: true, + }, + }); + }); +}); diff --git a/cdk/test/constructs/slack-integration.test.ts b/cdk/test/constructs/slack-integration.test.ts new file mode 100644 index 0000000..33f14a3 --- /dev/null +++ b/cdk/test/constructs/slack-integration.test.ts @@ -0,0 +1,135 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { App, Stack } from 'aws-cdk-lib'; +import { Template, Match } from 'aws-cdk-lib/assertions'; +import * as apigw from 'aws-cdk-lib/aws-apigateway'; +import * as cognito from 'aws-cdk-lib/aws-cognito'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import { SlackIntegration } from '../../src/constructs/slack-integration'; + +describe('SlackIntegration construct', () => { + let template: Template; + + beforeAll(() => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + + const api = new apigw.RestApi(stack, 'TestApi'); + const userPool = new cognito.UserPool(stack, 'TestUserPool'); + const taskTable = new dynamodb.Table(stack, 'TaskTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + }); + const taskEventsTable = new dynamodb.Table(stack, 'TaskEventsTable', { + partitionKey: { name: 'task_id', type: dynamodb.AttributeType.STRING }, + sortKey: { name: 'event_id', type: dynamodb.AttributeType.STRING }, + stream: dynamodb.StreamViewType.NEW_IMAGE, + }); + + new SlackIntegration(stack, 'SlackIntegration', { + api, + userPool, + taskTable, + taskEventsTable, + }); + + template = Template.fromStack(stack); + }); + + test('creates two DynamoDB tables (installation + user mapping)', () => { + // TaskTable + TaskEventsTable + SlackInstallation + SlackUserMapping = 4 + template.resourceCountIs('AWS::DynamoDB::Table', 4); + }); + + test('creates 7 Lambda functions', () => { + // oauth-callback, events, commands, command-processor, link, notify, interactions + template.resourceCountIs('AWS::Lambda::Function', 7); + }); + + test('creates API Gateway resources under /slack', () => { + // Verify /slack/* routes exist + template.hasResourceProperties('AWS::ApiGateway::Resource', { + PathPart: 'slack', + }); + template.hasResourceProperties('AWS::ApiGateway::Resource', { + PathPart: 'commands', + }); + template.hasResourceProperties('AWS::ApiGateway::Resource', { + PathPart: 'events', + }); + template.hasResourceProperties('AWS::ApiGateway::Resource', { + PathPart: 'link', + }); + template.hasResourceProperties('AWS::ApiGateway::Resource', { + PathPart: 'interactions', + }); + }); + + test('slash command handler has 3-second timeout', () => { + template.hasResourceProperties('AWS::Lambda::Function', { + Timeout: 3, + Environment: { + Variables: Match.objectLike({ + SLACK_SIGNING_SECRET_ARN: Match.anyValue(), + SLACK_COMMAND_PROCESSOR_FUNCTION_NAME: Match.anyValue(), + }), + }, + }); + }); + + test('notification handler has DynamoDB Streams event source', () => { + template.hasResourceProperties('AWS::Lambda::EventSourceMapping', { + EventSourceArn: Match.anyValue(), + StartingPosition: 'LATEST', + BatchSize: 10, + MaximumBatchingWindowInSeconds: 0, + MaximumRetryAttempts: 3, + BisectBatchOnFunctionError: true, + }); + }); + + test('creates 3 Secrets Manager secrets for Slack App credentials', () => { + template.resourceCountIs('AWS::SecretsManager::Secret', 3); + template.hasResourceProperties('AWS::SecretsManager::Secret', { + Description: Match.stringLikeRegexp('signing secret'), + }); + template.hasResourceProperties('AWS::SecretsManager::Secret', { + Description: Match.stringLikeRegexp('client secret'), + }); + template.hasResourceProperties('AWS::SecretsManager::Secret', { + Description: Match.stringLikeRegexp('client ID'), + }); + }); + + test('OAuth callback has Secrets Manager permissions', () => { + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: 'secretsmanager:CreateSecret', + Effect: 'Allow', + Condition: { + StringLike: { 'secretsmanager:Name': 'bgagent/slack/*' }, + }, + }), + ]), + }, + }); + }); +}); diff --git a/cdk/test/constructs/slack-user-mapping-table.test.ts b/cdk/test/constructs/slack-user-mapping-table.test.ts new file mode 100644 index 0000000..761ac51 --- /dev/null +++ b/cdk/test/constructs/slack-user-mapping-table.test.ts @@ -0,0 +1,83 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { App, Stack } from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import { SlackUserMappingTable } from '../../src/constructs/slack-user-mapping-table'; + +describe('SlackUserMappingTable construct', () => { + let template: Template; + + beforeAll(() => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new SlackUserMappingTable(stack, 'SlackUserMappingTable'); + template = Template.fromStack(stack); + }); + + test('creates a DynamoDB table', () => { + template.resourceCountIs('AWS::DynamoDB::Table', 1); + }); + + test('table has slack_identity as partition key', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + KeySchema: [ + { AttributeName: 'slack_identity', KeyType: 'HASH' }, + ], + }); + }); + + test('table uses PAY_PER_REQUEST billing', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + BillingMode: 'PAY_PER_REQUEST', + }); + }); + + test('table has point-in-time recovery enabled', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + PointInTimeRecoverySpecification: { + PointInTimeRecoveryEnabled: true, + }, + }); + }); + + test('enables TTL on ttl attribute', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + TimeToLiveSpecification: { + AttributeName: 'ttl', + Enabled: true, + }, + }); + }); + + test('table has PlatformUserIndex GSI', () => { + template.hasResourceProperties('AWS::DynamoDB::Table', { + GlobalSecondaryIndexes: [ + { + IndexName: 'PlatformUserIndex', + KeySchema: [ + { AttributeName: 'platform_user_id', KeyType: 'HASH' }, + { AttributeName: 'linked_at', KeyType: 'RANGE' }, + ], + Projection: { ProjectionType: 'ALL' }, + }, + ], + }); + }); +}); diff --git a/cdk/test/handlers/linear-link.test.ts b/cdk/test/handlers/linear-link.test.ts new file mode 100644 index 0000000..5fbbc33 --- /dev/null +++ b/cdk/test/handlers/linear-link.test.ts @@ -0,0 +1,126 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { APIGatewayProxyEvent } from 'aws-lambda'; + +const ddbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: ddbSend })) }, + GetCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), + DeleteCommand: jest.fn((input: unknown) => ({ _type: 'Delete', input })), +})); + +jest.mock('ulid', () => ({ ulid: jest.fn(() => 'REQ-ULID') })); + +process.env.LINEAR_USER_MAPPING_TABLE_NAME = 'LinearMap'; + +import { handler } from '../../src/handlers/linear-link'; + +function makeEvent(body: unknown, userId?: string): APIGatewayProxyEvent { + return { + body: body === null ? null : JSON.stringify(body), + headers: {}, + multiValueHeaders: {}, + httpMethod: 'POST', + isBase64Encoded: false, + path: '/v1/linear/link', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: userId + ? ({ authorizer: { claims: { sub: userId } } } as unknown as APIGatewayProxyEvent['requestContext']) + : ({} as APIGatewayProxyEvent['requestContext']), + resource: '', + }; +} + +describe('linear-link handler', () => { + beforeEach(() => { + ddbSend.mockReset(); + }); + + test('401s without a Cognito JWT', async () => { + const result = await handler(makeEvent({ code: 'ABC123' })); + expect(result.statusCode).toBe(401); + }); + + test('400s without a code in the body', async () => { + const result = await handler(makeEvent({}, 'cognito-user-1')); + expect(result.statusCode).toBe(400); + }); + + test('404s when code is not found', async () => { + ddbSend.mockResolvedValueOnce({ Item: undefined }); + const result = await handler(makeEvent({ code: 'XYZ123' }, 'cognito-user-1')); + expect(result.statusCode).toBe(404); + }); + + test('404s when code exists but status is not pending', async () => { + ddbSend.mockResolvedValueOnce({ Item: { linear_identity: 'pending#XYZ', status: 'consumed' } }); + const result = await handler(makeEvent({ code: 'XYZ123' }, 'cognito-user-1')); + expect(result.statusCode).toBe(404); + }); + + test('writes confirmed mapping and deletes pending record on success', async () => { + ddbSend + .mockResolvedValueOnce({ + Item: { + linear_identity: 'pending#ABC123', + status: 'pending', + linear_workspace_id: 'workspace-uuid-1', + linear_user_id: 'user-uuid-1', + }, + }) + .mockResolvedValueOnce({}) // Put (confirmed mapping) + .mockResolvedValueOnce({}); // Delete (pending) + + const result = await handler(makeEvent({ code: 'abc123' }, 'cognito-user-1')); + expect(result.statusCode).toBe(200); + const putCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Put'); + expect(putCall).toBeTruthy(); + expect(putCall![0].input.Item.linear_identity).toBe('workspace-uuid-1#user-uuid-1'); + expect(putCall![0].input.Item.platform_user_id).toBe('cognito-user-1'); + expect(putCall![0].input.Item.status).toBe('active'); + + const deleteCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Delete'); + expect(deleteCall).toBeTruthy(); + expect(deleteCall![0].input.Key.linear_identity).toBe('pending#ABC123'); + }); + + test('normalizes the code (uppercase, trimmed)', async () => { + ddbSend + .mockResolvedValueOnce({ + Item: { + linear_identity: 'pending#ABC123', + status: 'pending', + linear_workspace_id: 'w', + linear_user_id: 'u', + }, + }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + + await handler(makeEvent({ code: ' abc123 ' }, 'cognito-user-1')); + const getCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Get'); + expect(getCall![0].input.Key.linear_identity).toBe('pending#ABC123'); + }); +}); diff --git a/cdk/test/handlers/linear-webhook-processor.test.ts b/cdk/test/handlers/linear-webhook-processor.test.ts new file mode 100644 index 0000000..e02e823 --- /dev/null +++ b/cdk/test/handlers/linear-webhook-processor.test.ts @@ -0,0 +1,191 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const ddbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: ddbSend })) }, + GetCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), +})); + +const createTaskCoreMock = jest.fn(); +jest.mock('../../src/handlers/shared/create-task-core', () => ({ + createTaskCore: (...args: unknown[]) => createTaskCoreMock(...args), +})); + +process.env.LINEAR_PROJECT_MAPPING_TABLE_NAME = 'LinearProjects'; +process.env.LINEAR_USER_MAPPING_TABLE_NAME = 'LinearUsers'; + +import { handler } from '../../src/handlers/linear-webhook-processor'; + +function eventWith(payload: Record): { raw_body: string } { + return { raw_body: JSON.stringify(payload) }; +} + +function issue(overrides: Record = {}): Record { + return { + action: 'create', + type: 'Issue', + organizationId: 'org-1', + actor: { id: 'user-1' }, + data: { + id: 'issue-1', + identifier: 'ABC-42', + title: 'Fix the login bug', + description: 'Users cannot log in.', + projectId: 'project-1', + teamId: 'team-1', + labels: [{ id: 'lbl-bg', name: 'bgagent' }], + }, + ...overrides, + }; +} + +describe('linear-webhook-processor handler', () => { + beforeEach(() => { + ddbSend.mockReset(); + createTaskCoreMock.mockReset(); + }); + + test('skips missing raw_body', async () => { + await handler({ raw_body: '' }); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips malformed JSON', async () => { + await handler({ raw_body: 'not-json-{' }); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips non-Issue payloads', async () => { + await handler(eventWith({ type: 'Comment', data: { id: 'c-1' } })); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips when projectId is missing', async () => { + const payload = issue(); + const data = { ...(payload.data as Record) }; + delete data.projectId; + payload.data = data; + await handler(eventWith(payload)); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips when project is not onboarded', async () => { + ddbSend.mockResolvedValueOnce({ Item: undefined }); + await handler(eventWith(issue())); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips when project mapping is removed', async () => { + ddbSend.mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'removed' } }); + await handler(eventWith(issue())); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips when trigger label is absent on create', async () => { + ddbSend.mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }); + const payload = issue(); + (payload.data as Record).labels = [{ id: 'l2', name: 'other' }]; + await handler(eventWith(payload)); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips update when labelIds did not change', async () => { + ddbSend.mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }); + const payload = issue({ action: 'update', updatedFrom: { title: 'old' } }); + await handler(eventWith(payload)); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips update when label was previously already present', async () => { + ddbSend.mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }); + const payload = issue({ + action: 'update', + updatedFrom: { labelIds: ['lbl-bg', 'lbl-other'] }, + }); + await handler(eventWith(payload)); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('skips when actor has no linked platform user', async () => { + ddbSend + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }) + .mockResolvedValueOnce({ Item: undefined }); + await handler(eventWith(issue())); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('creates task with channel_source=linear and linear_* metadata', async () => { + ddbSend + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active', label_filter: 'bgagent' } }) + .mockResolvedValueOnce({ + Item: { + linear_identity: 'org-1#user-1', + platform_user_id: 'cognito-user-1', + status: 'active', + }, + }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 201, body: JSON.stringify({ data: { task_id: 'T1' } }) }); + + await handler(eventWith(issue())); + + expect(createTaskCoreMock).toHaveBeenCalledTimes(1); + const [reqBody, ctx] = createTaskCoreMock.mock.calls[0]; + expect(reqBody.repo).toBe('org/repo'); + expect(reqBody.task_description).toContain('ABC-42: Fix the login bug'); + expect(reqBody.task_description).toContain('Users cannot log in.'); + expect(ctx.userId).toBe('cognito-user-1'); + expect(ctx.channelSource).toBe('linear'); + expect(ctx.channelMetadata).toMatchObject({ + linear_issue_id: 'issue-1', + linear_issue_identifier: 'ABC-42', + linear_workspace_id: 'org-1', + linear_project_id: 'project-1', + linear_team_id: 'team-1', + }); + }); + + test('fires on update when labelIds newly include the trigger label', async () => { + ddbSend + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }) + .mockResolvedValueOnce({ Item: { platform_user_id: 'cognito-user-1', status: 'active' } }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 201, body: JSON.stringify({ data: { task_id: 'T1' } }) }); + + await handler(eventWith(issue({ + action: 'update', + updatedFrom: { labelIds: ['lbl-other'] }, + }))); + + expect(createTaskCoreMock).toHaveBeenCalledTimes(1); + }); + + test('honors a custom label_filter set on the project mapping', async () => { + ddbSend + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active', label_filter: 'triage' } }) + .mockResolvedValueOnce({ Item: { platform_user_id: 'cognito-user-1', status: 'active' } }); + createTaskCoreMock.mockResolvedValueOnce({ statusCode: 201, body: JSON.stringify({ data: { task_id: 'T1' } }) }); + + const payload = issue(); + (payload.data as Record).labels = [{ id: 'lbl-t', name: 'Triage' }]; + await handler(eventWith(payload)); + + expect(createTaskCoreMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/cdk/test/handlers/linear-webhook.test.ts b/cdk/test/handlers/linear-webhook.test.ts new file mode 100644 index 0000000..a0d1cd8 --- /dev/null +++ b/cdk/test/handlers/linear-webhook.test.ts @@ -0,0 +1,269 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import { ConditionalCheckFailedException } from '@aws-sdk/client-dynamodb'; +import type { APIGatewayProxyEvent } from 'aws-lambda'; + +const ddbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => { + class ConditionalCheckFailedExceptionMock extends Error { + constructor(opts: { message: string; $metadata?: unknown }) { + super(opts.message); + this.name = 'ConditionalCheckFailedException'; + } + } + return { + DynamoDBClient: jest.fn(() => ({})), + ConditionalCheckFailedException: ConditionalCheckFailedExceptionMock, + }; +}); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: ddbSend })) }, + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), + DeleteCommand: jest.fn((input: unknown) => ({ _type: 'Delete', input })), +})); + +const lambdaSend = jest.fn(); +jest.mock('@aws-sdk/client-lambda', () => ({ + LambdaClient: jest.fn(() => ({ send: lambdaSend })), + InvokeCommand: jest.fn((input: unknown) => ({ _type: 'Invoke', input })), +})); + +const smSend = jest.fn(); +jest.mock('@aws-sdk/client-secrets-manager', () => ({ + SecretsManagerClient: jest.fn(() => ({ send: smSend })), + GetSecretValueCommand: jest.fn((input: unknown) => ({ _type: 'GetSecretValue', input })), +})); + +process.env.LINEAR_WEBHOOK_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent/linear/webhook-XYZ'; +process.env.LINEAR_WEBHOOK_DEDUP_TABLE_NAME = 'LinearDedup'; +process.env.LINEAR_WEBHOOK_PROCESSOR_FUNCTION_NAME = 'linear-processor'; + +import { handler } from '../../src/handlers/linear-webhook'; +import { invalidateLinearSecretCache } from '../../src/handlers/shared/linear-verify'; + +const WEBHOOK_SECRET = 'test-linear-webhook-secret'; + +function sign(body: string): string { + return crypto.createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex'); +} + +function makeEvent(body: string, signature?: string): APIGatewayProxyEvent { + const headers: Record = {}; + if (signature !== undefined) headers['Linear-Signature'] = signature; + return { + body, + headers, + multiValueHeaders: {}, + httpMethod: 'POST', + isBase64Encoded: false, + path: '/v1/linear/webhook', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: {} as APIGatewayProxyEvent['requestContext'], + resource: '', + }; +} + +function issueCreatePayload(overrides: Record = {}): string { + return JSON.stringify({ + action: 'create', + type: 'Issue', + webhookTimestamp: Date.now(), + webhookId: 'wh-1', + organizationId: 'org-1', + data: { id: 'issue-1', labels: [{ id: 'lbl-1', name: 'bgagent' }] }, + ...overrides, + }); +} + +describe('linear-webhook handler', () => { + beforeEach(() => { + ddbSend.mockReset(); + lambdaSend.mockReset(); + smSend.mockReset(); + invalidateLinearSecretCache(process.env.LINEAR_WEBHOOK_SECRET_ARN!); + smSend.mockResolvedValue({ SecretString: WEBHOOK_SECRET }); + }); + + test('400s when body is missing', async () => { + const result = await handler(makeEvent('', sign(''))); + expect(result.statusCode).toBe(400); + }); + + test('401s when Linear-Signature header is missing', async () => { + const body = issueCreatePayload(); + const result = await handler(makeEvent(body)); + expect(result.statusCode).toBe(401); + }); + + test('401s when signature is invalid', async () => { + const body = issueCreatePayload(); + const result = await handler(makeEvent(body, 'deadbeef')); + expect(result.statusCode).toBe(401); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('401s when webhookTimestamp is stale', async () => { + // 5 minutes old — far outside the 60s replay window. + const body = JSON.stringify({ + action: 'create', + type: 'Issue', + webhookTimestamp: Date.now() - 5 * 60 * 1000, + webhookId: 'wh-1', + organizationId: 'org-1', + data: { id: 'issue-1', labels: [{ id: 'lbl-1', name: 'bgagent' }] }, + }); + const result = await handler(makeEvent(body, sign(body))); + expect(result.statusCode).toBe(401); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('ignores non-Issue event types with 200', async () => { + const body = JSON.stringify({ + action: 'create', + type: 'Comment', + webhookTimestamp: Date.now(), + webhookId: 'wh-2', + data: { id: 'cmt-1' }, + }); + const result = await handler(makeEvent(body, sign(body))); + expect(result.statusCode).toBe(200); + expect(ddbSend).not.toHaveBeenCalled(); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('400s when data.id is missing on an Issue event', async () => { + const body = JSON.stringify({ + action: 'create', + type: 'Issue', + webhookTimestamp: Date.now(), + webhookId: 'wh-3', + organizationId: 'org-1', + data: {}, + }); + const result = await handler(makeEvent(body, sign(body))); + expect(result.statusCode).toBe(400); + }); + + test('verified Issue event dedups and invokes processor', async () => { + const FRESH_TS = Date.now(); + const body = issueCreatePayload({ webhookTimestamp: FRESH_TS }); + ddbSend.mockResolvedValueOnce({}); // conditional Put succeeds + lambdaSend.mockResolvedValueOnce({}); + + const result = await handler(makeEvent(body, sign(body))); + + expect(result.statusCode).toBe(200); + const putCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Put'); + expect(putCall).toBeTruthy(); + expect(putCall![0].input.Item.dedup_key).toBe(`issue-1#create#${FRESH_TS}`); + expect(putCall![0].input.ConditionExpression).toContain('attribute_not_exists'); + + // The TTL must outlast Linear's full retry horizon (first at +1m, then + // +1h, then +6h — ~7h total). Anything shorter lets the +1h/+6h retries + // land after the dedup row expires and double-dispatch the task. + const nowSeconds = Math.floor(Date.now() / 1000); + const ttl = putCall![0].input.Item.ttl as number; + expect(ttl - nowSeconds).toBeGreaterThanOrEqual(7 * 60 * 60); + + expect(lambdaSend).toHaveBeenCalledTimes(1); + const invokeCall = lambdaSend.mock.calls[0][0]; + expect(invokeCall._type).toBe('Invoke'); + expect(invokeCall.input.FunctionName).toBe('linear-processor'); + expect(invokeCall.input.InvocationType).toBe('Event'); + const decoded = JSON.parse(new TextDecoder().decode(invokeCall.input.Payload)); + expect(decoded.raw_body).toBe(body); + }); + + test('distinct deliveries for the same issue both dispatch', async () => { + // Linear reuses `webhookId` across deliveries from the same webhook + // config, so two separate events (label-off-then-on) share the webhookId + // but differ in webhookTimestamp. The dedup primitive must include + // timestamp so distinct events are not collapsed. + const FRESH_TS = Date.now(); + const FRESH_TS_2 = FRESH_TS + 1000; + const body1 = issueCreatePayload({ webhookTimestamp: FRESH_TS }); + const body2 = issueCreatePayload({ webhookTimestamp: FRESH_TS_2 }); + ddbSend.mockResolvedValue({}); + lambdaSend.mockResolvedValue({}); + + await handler(makeEvent(body1, sign(body1))); + await handler(makeEvent(body2, sign(body2))); + + const putCalls = ddbSend.mock.calls.filter(([cmd]) => cmd._type === 'Put'); + expect(putCalls).toHaveLength(2); + expect(putCalls[0][0].input.Item.dedup_key).toBe(`issue-1#create#${FRESH_TS}`); + expect(putCalls[1][0].input.Item.dedup_key).toBe(`issue-1#create#${FRESH_TS_2}`); + expect(lambdaSend).toHaveBeenCalledTimes(2); + }); + + test('dedup hit returns 200 without re-invoking processor', async () => { + const body = issueCreatePayload(); + ddbSend.mockRejectedValueOnce(new ConditionalCheckFailedException({ + $metadata: {}, + message: 'Conditional check failed', + })); + + const result = await handler(makeEvent(body, sign(body))); + expect(result.statusCode).toBe(200); + const parsed = JSON.parse(result.body); + expect(parsed.deduped).toBe(true); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('returns 500 and rolls back dedup row if processor invoke fails', async () => { + const FRESH_TS = Date.now(); + const body = issueCreatePayload({ webhookTimestamp: FRESH_TS }); + // 1st ddbSend: PutCommand (dedup reservation) succeeds + // 2nd ddbSend: DeleteCommand (rollback after invoke failure) succeeds + ddbSend.mockResolvedValueOnce({}).mockResolvedValueOnce({}); + lambdaSend.mockRejectedValueOnce(new Error('Lambda throttle')); + + const result = await handler(makeEvent(body, sign(body))); + expect(result.statusCode).toBe(500); + + // Dedup row must be deleted so Linear's +1m/+1h/+6h retries can try again — + // otherwise a transient Lambda failure silently drops the task for 8h. + const deleteCalls = ddbSend.mock.calls.filter((c) => c[0]._type === 'Delete'); + expect(deleteCalls).toHaveLength(1); + expect(deleteCalls[0][0].input.TableName).toBe('LinearDedup'); + expect(deleteCalls[0][0].input.Key.dedup_key).toBe(`issue-1#create#${FRESH_TS}`); + }); + + test('returns 500 even if dedup rollback also fails (does not mask invoke error)', async () => { + const body = issueCreatePayload(); + ddbSend + .mockResolvedValueOnce({}) + .mockRejectedValueOnce(new Error('DDB unavailable')); + lambdaSend.mockRejectedValueOnce(new Error('Lambda throttle')); + + const result = await handler(makeEvent(body, sign(body))); + expect(result.statusCode).toBe(500); + }); + + test('400s on malformed JSON with a valid signature', async () => { + const body = 'not-json-{'; + const result = await handler(makeEvent(body, sign(body))); + expect(result.statusCode).toBe(400); + }); +}); diff --git a/cdk/test/handlers/shared/error-classifier.test.ts b/cdk/test/handlers/shared/error-classifier.test.ts index 4818c27..0c34168 100644 --- a/cdk/test/handlers/shared/error-classifier.test.ts +++ b/cdk/test/handlers/shared/error-classifier.test.ts @@ -497,11 +497,15 @@ describe('classifyError', () => { test('channel_source narrows to the literal union', () => { const apiRecord: TaskRecord = { ...baseRecord, channel_source: 'api' }; const webhookRecord: TaskRecord = { ...baseRecord, channel_source: 'webhook' }; + const slackRecord: TaskRecord = { ...baseRecord, channel_source: 'slack' }; + const linearRecord: TaskRecord = { ...baseRecord, channel_source: 'linear' }; expect(toTaskDetail(apiRecord).channel_source).toBe('api'); expect(toTaskDetail(webhookRecord).channel_source).toBe('webhook'); + expect(toTaskDetail(slackRecord).channel_source).toBe('slack'); + expect(toTaskDetail(linearRecord).channel_source).toBe('linear'); - // @ts-expect-error — 'slack' is not a valid ChannelSource - const invalid: TaskRecord = { ...baseRecord, channel_source: 'slack' }; + // @ts-expect-error — 'email' is not a valid ChannelSource + const invalid: TaskRecord = { ...baseRecord, channel_source: 'email' }; // Keep ``invalid`` used so the block doesn't get DCE'd and the // ``@ts-expect-error`` above remains anchored to a real assignment. expect(invalid.channel_source).toBeDefined(); diff --git a/cdk/test/handlers/shared/slack-blocks.test.ts b/cdk/test/handlers/shared/slack-blocks.test.ts new file mode 100644 index 0000000..194a18a --- /dev/null +++ b/cdk/test/handlers/shared/slack-blocks.test.ts @@ -0,0 +1,129 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { TaskStatus, type TaskStatusType } from '../../../src/constructs/task-status'; +import { type ActionsBlock, renderSlackBlocks, type SlackBlock } from '../../../src/handlers/shared/slack-blocks'; + +/** Narrow to a section block and return its text; throws if block isn't a section. */ +function sectionText(block: SlackBlock): string { + if (block.type !== 'section') { + throw new Error(`expected section block, got ${block.type}`); + } + return block.text.text; +} + +/** Narrow to an actions block; throws if block isn't one. */ +function actionsBlock(block: SlackBlock): ActionsBlock { + if (block.type !== 'actions') { + throw new Error(`expected actions block, got ${block.type}`); + } + return block; +} + +describe('renderSlackBlocks', () => { + const baseTask = { + task_id: '01HXYZ123', + repo: 'org/repo', + task_description: 'Fix the login bug', + pr_url: undefined as string | undefined, + error_message: undefined as string | undefined, + cost_usd: undefined as number | undefined, + duration_s: undefined as number | undefined, + status: TaskStatus.SUBMITTED as TaskStatusType, + }; + + test('renders task_created message', () => { + const msg = renderSlackBlocks('task_created', baseTask); + expect(msg.text).toContain('org/repo'); + expect(msg.blocks).toHaveLength(1); + const text = sectionText(msg.blocks[0]); + expect(text).toContain(':rocket:'); + expect(text).toContain('Fix the login bug'); + expect(text).toContain('01HXYZ123'); + }); + + test('renders task_completed message with PR URL', () => { + const task = { ...baseTask, status: TaskStatus.COMPLETED as TaskStatusType, pr_url: 'https://github.com/org/repo/pull/42', cost_usd: 0.47, duration_s: 272 }; + const msg = renderSlackBlocks('task_completed', task); + expect(msg.text).toContain('completed'); + const text = sectionText(msg.blocks[0]); + expect(text).toContain('$0.47'); + expect(text).toContain('4m 32s'); + // PR link is in the button, not inline text (avoids Slack unfurl cards) + const actions = actionsBlock(msg.blocks[1]); + const button = actions.elements[0]; + expect(button.text.text).toContain('#42'); + if (!('url' in button)) throw new Error('expected link button with url'); + expect(button.url).toBe('https://github.com/org/repo/pull/42'); + }); + + test('renders task_failed message with error', () => { + const task = { ...baseTask, status: TaskStatus.FAILED as TaskStatusType, error_message: 'Repo not found' }; + const msg = renderSlackBlocks('task_failed', task); + expect(msg.text).toContain('failed'); + expect(sectionText(msg.blocks[0])).toContain('Repo not found'); + }); + + test('renders task_failed message with metadata error', () => { + const task = { ...baseTask, status: TaskStatus.FAILED as TaskStatusType }; + const msg = renderSlackBlocks('task_failed', task, { error: 'timeout' }); + expect(sectionText(msg.blocks[0])).toContain('timeout'); + }); + + test('renders task_cancelled message', () => { + const msg = renderSlackBlocks('task_cancelled', baseTask); + expect(sectionText(msg.blocks[0])).toContain(':no_entry_sign:'); + }); + + test('renders task_timed_out message with duration', () => { + const task = { ...baseTask, duration_s: 28800 }; + const msg = renderSlackBlocks('task_timed_out', task); + expect(sectionText(msg.blocks[0])).toContain('8h'); + }); + + test('renders session_started message', () => { + const msg = renderSlackBlocks('session_started', baseTask); + expect(sectionText(msg.blocks[0])).toContain(':hourglass_flowing_sand:'); + }); + + test('renders unknown event type gracefully', () => { + const msg = renderSlackBlocks('hydration_complete', baseTask); + expect(sectionText(msg.blocks[0])).toContain('hydration_complete'); + }); + + test('truncates long descriptions', () => { + const task = { ...baseTask, task_description: 'A'.repeat(300) }; + const msg = renderSlackBlocks('task_created', task); + const text = sectionText(msg.blocks[0]); + expect(text.length).toBeLessThan(350); + expect(text).toContain('...'); + }); + + test('formats duration in hours', () => { + const task = { ...baseTask, status: TaskStatus.COMPLETED as TaskStatusType, duration_s: 3661 }; + const msg = renderSlackBlocks('task_completed', task); + expect(sectionText(msg.blocks[0])).toContain('1h 1m'); + }); + + test('formats duration in minutes and seconds', () => { + const task = { ...baseTask, status: TaskStatus.COMPLETED as TaskStatusType, duration_s: 125 }; + const msg = renderSlackBlocks('task_completed', task); + expect(sectionText(msg.blocks[0])).toContain('2m 5s'); + }); +}); diff --git a/cdk/test/handlers/shared/slack-format.test.ts b/cdk/test/handlers/shared/slack-format.test.ts new file mode 100644 index 0000000..4b74708 --- /dev/null +++ b/cdk/test/handlers/shared/slack-format.test.ts @@ -0,0 +1,60 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { formatDuration, truncate } from '../../../src/handlers/shared/slack-format'; + +describe('truncate', () => { + test('returns string unchanged when under limit', () => { + expect(truncate('hello', 10)).toBe('hello'); + }); + + test('truncates and appends ellipsis when over limit', () => { + expect(truncate('hello world', 8)).toBe('hello...'); + }); + + test('returns string unchanged when exactly at limit', () => { + expect(truncate('hello', 5)).toBe('hello'); + }); +}); + +describe('formatDuration', () => { + test('sub-minute rounds to whole seconds', () => { + expect(formatDuration(0)).toBe('0s'); + expect(formatDuration(45.4)).toBe('45s'); + expect(formatDuration(59)).toBe('59s'); + }); + + test('minute range uses m or m s format', () => { + expect(formatDuration(60)).toBe('1m'); + expect(formatDuration(65)).toBe('1m 5s'); + expect(formatDuration(3599)).toBe('59m 59s'); + }); + + test('hour range uses h or h m format', () => { + expect(formatDuration(3600)).toBe('1h'); + expect(formatDuration(3660)).toBe('1h 1m'); + expect(formatDuration(7320)).toBe('2h 2m'); + }); + + test('coerces DynamoDB numeric strings via Number()', () => { + // DynamoDB may round-trip numeric attributes as strings; Number() coerces them. + expect(formatDuration('90' as unknown as number)).toBe('1m 30s'); + expect(formatDuration('3600' as unknown as number)).toBe('1h'); + }); +}); diff --git a/cdk/test/handlers/shared/slack-verify.test.ts b/cdk/test/handlers/shared/slack-verify.test.ts new file mode 100644 index 0000000..c2edef0 --- /dev/null +++ b/cdk/test/handlers/shared/slack-verify.test.ts @@ -0,0 +1,148 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; + +const smSendMock = jest.fn(); +jest.mock('@aws-sdk/client-secrets-manager', () => ({ + SecretsManagerClient: jest.fn(() => ({ send: smSendMock })), + GetSecretValueCommand: jest.fn((input) => ({ input })), +})); + +// Imported after the mock is registered. +import { + invalidateSlackSecretCache, + verifySlackRequest, + verifySlackSignature, +} from '../../../src/handlers/shared/slack-verify'; + +describe('verifySlackSignature', () => { + const signingSecret = 'test-signing-secret-abc123'; + + function makeSignature(timestamp: string, body: string): string { + const basestring = `v0:${timestamp}:${body}`; + return 'v0=' + crypto.createHmac('sha256', signingSecret).update(basestring).digest('hex'); + } + + function currentTimestamp(): string { + return String(Math.floor(Date.now() / 1000)); + } + + test('accepts valid signature with current timestamp', () => { + const ts = currentTimestamp(); + const body = 'token=abc&command=/bgagent&text=help'; + const sig = makeSignature(ts, body); + + expect(verifySlackSignature(signingSecret, sig, ts, body)).toBe(true); + }); + + test('rejects invalid signature', () => { + const ts = currentTimestamp(); + const body = 'token=abc&command=/bgagent&text=help'; + const sig = 'v0=0000000000000000000000000000000000000000000000000000000000000000'; + + expect(verifySlackSignature(signingSecret, sig, ts, body)).toBe(false); + }); + + test('rejects stale timestamp (older than 5 minutes)', () => { + const staleTs = String(Math.floor(Date.now() / 1000) - 400); + const body = 'test-body'; + const sig = makeSignature(staleTs, body); + + expect(verifySlackSignature(signingSecret, sig, staleTs, body)).toBe(false); + }); + + test('rejects non-numeric timestamp', () => { + expect(verifySlackSignature(signingSecret, 'v0=abc', 'not-a-number', 'body')).toBe(false); + }); + + test('rejects signature with wrong length', () => { + const ts = currentTimestamp(); + expect(verifySlackSignature(signingSecret, 'v0=short', ts, 'body')).toBe(false); + }); + + test('rejects modified body', () => { + const ts = currentTimestamp(); + const body = 'original-body'; + const sig = makeSignature(ts, body); + + expect(verifySlackSignature(signingSecret, sig, ts, 'tampered-body')).toBe(false); + }); +}); + +describe('verifySlackRequest', () => { + const SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent/slack/signing-XYZ'; + + function signWith(secret: string, timestamp: string, body: string): string { + return 'v0=' + crypto.createHmac('sha256', secret).update(`v0:${timestamp}:${body}`).digest('hex'); + } + + beforeEach(() => { + smSendMock.mockReset(); + invalidateSlackSecretCache(SECRET_ARN); + }); + + test('verifies with cached secret on first call', async () => { + const secret = 'cached-secret'; + smSendMock.mockResolvedValueOnce({ SecretString: secret }); + + const ts = String(Math.floor(Date.now() / 1000)); + const body = 'token=abc'; + const sig = signWith(secret, ts, body); + + expect(await verifySlackRequest(SECRET_ARN, sig, ts, body)).toBe(true); + expect(smSendMock).toHaveBeenCalledTimes(1); + }); + + test('refetches and verifies when cached secret was rotated out', async () => { + // First fetch: stale secret (will fail verification). + // Second fetch (forced refresh): rotated secret (succeeds). + smSendMock + .mockResolvedValueOnce({ SecretString: 'stale-secret' }) + .mockResolvedValueOnce({ SecretString: 'rotated-secret' }); + + const ts = String(Math.floor(Date.now() / 1000)); + const body = 'token=abc'; + const sig = signWith('rotated-secret', ts, body); + + expect(await verifySlackRequest(SECRET_ARN, sig, ts, body)).toBe(true); + expect(smSendMock).toHaveBeenCalledTimes(2); + }); + + test('does not re-verify when refreshed secret is identical to cached one', async () => { + const secret = 'same-secret'; + smSendMock + .mockResolvedValueOnce({ SecretString: secret }) + .mockResolvedValueOnce({ SecretString: secret }); + + const ts = String(Math.floor(Date.now() / 1000)); + const body = 'token=abc'; + const sig = 'v0=deadbeef'; + + expect(await verifySlackRequest(SECRET_ARN, sig, ts, body)).toBe(false); + expect(smSendMock).toHaveBeenCalledTimes(2); + }); + + test('returns false when secret cannot be fetched', async () => { + smSendMock.mockRejectedValue(Object.assign(new Error('not found'), { name: 'ResourceNotFoundException' })); + + const ts = String(Math.floor(Date.now() / 1000)); + expect(await verifySlackRequest(SECRET_ARN, 'v0=whatever', ts, 'body')).toBe(false); + }); +}); diff --git a/cdk/test/handlers/slack-command-processor.test.ts b/cdk/test/handlers/slack-command-processor.test.ts new file mode 100644 index 0000000..5e35a79 --- /dev/null +++ b/cdk/test/handlers/slack-command-processor.test.ts @@ -0,0 +1,220 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +const ddbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: ddbSend })) }, + GetCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), +})); + +const smSend = jest.fn(); +jest.mock('@aws-sdk/client-secrets-manager', () => ({ + SecretsManagerClient: jest.fn(() => ({ send: smSend })), + GetSecretValueCommand: jest.fn((input: unknown) => ({ _type: 'GetSecretValue', input })), +})); + +const createTaskCoreMock = jest.fn(); +jest.mock('../../src/handlers/shared/create-task-core', () => ({ + createTaskCore: (...args: unknown[]) => createTaskCoreMock(...args), +})); + +const fetchMock = jest.fn(); +(global as unknown as { fetch: unknown }).fetch = fetchMock; + +process.env.SLACK_USER_MAPPING_TABLE_NAME = 'SlackMap'; +process.env.SLACK_INSTALLATION_TABLE_NAME = 'SlackInstall'; + +import { handler, type MentionEvent, type SlashCommandEvent } from '../../src/handlers/slack-command-processor'; + +function mention(overrides: Partial = {}): MentionEvent { + return { + source: 'mention', + text: 'submit org/repo fix the bug', + user_id: 'U1', + team_id: 'T1', + channel_id: 'C1', + mention_thread_ts: '1000.0001', + ...overrides, + }; +} + +function slashCommand(overrides: Partial = {}): SlashCommandEvent { + return { + source: 'slash', + text: 'help', + user_id: 'U1', + team_id: 'T1', + channel_id: 'C1', + command: '/bgagent', + user_name: 'u', + team_domain: 'acme', + channel_name: 'general', + trigger_id: 'T.1', + response_url: 'https://hooks.slack.com/cmd/X', + ...overrides, + }; +} + +describe('slack-command-processor handler', () => { + beforeEach(() => { + ddbSend.mockReset(); + smSend.mockReset(); + fetchMock.mockReset(); + createTaskCoreMock.mockReset(); + smSend.mockResolvedValue({ SecretString: 'xoxb-bot' }); + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ ok: true }), + }); + }); + + test('slash command without source flag defaults to slash and falls through to default branch', async () => { + // Legacy shape: no source field — slash ack lambda forwards raw SlackCommandPayload + const legacy = { + command: '/bgagent', + text: 'unknown_sub', + user_id: 'U1', + team_id: 'T1', + channel_id: 'C1', + user_name: '', + team_domain: '', + channel_name: '', + trigger_id: '', + response_url: 'https://hooks.slack.com/cmd/X', + }; + await handler(legacy); + // Posted the default "Use @Shoof" hint back to the response_url + const posted = fetchMock.mock.calls.find( + ([url, opts]) => String(url).startsWith('https://hooks.slack.com') && String((opts as { body: string }).body).includes('Use `@Shoof`'), + ); + expect(posted).toBeTruthy(); + }); + + test('slash submit tells user to use @mention', async () => { + await handler(slashCommand({ text: 'submit org/repo fix' })); + const posted = fetchMock.mock.calls.find( + ([url, opts]) => String((opts as { body: string }).body).includes('Use `@Shoof` to submit tasks'), + ); + expect(posted).toBeTruthy(); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('mention submit requires linked account (prompts /bgagent link)', async () => { + // 1. User mapping lookup: not found + ddbSend.mockResolvedValueOnce({ Item: undefined }); + // 2. swapReaction → getBotToken → installation lookup (for :x: swap) + ddbSend.mockResolvedValue({ Item: { status: 'active' } }); + await handler(mention({ text: 'submit org/repo fix' })); + const reply = fetchMock.mock.calls.find( + ([url, opts]) => String(url).includes('chat.postMessage') && String((opts as { body: string }).body).includes('not linked'), + ); + expect(reply).toBeTruthy(); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('mention submit rejects malformed repo', async () => { + ddbSend.mockResolvedValueOnce({ Item: { status: 'active', platform_user_id: 'cognito-1' } }); + // swapReaction → getBotToken → installation lookup (for :x: swap) + ddbSend.mockResolvedValue({ Item: { status: 'active' } }); + await handler(mention({ text: 'submit not-a-repo fix' })); + const reply = fetchMock.mock.calls.find( + ([url, opts]) => String(url).includes('chat.postMessage') && String((opts as { body: string }).body).includes('Invalid repo format'), + ); + expect(reply).toBeTruthy(); + }); + + test('mention submit creates task via createTaskCore', async () => { + ddbSend.mockResolvedValueOnce({ Item: { status: 'active', platform_user_id: 'cognito-1' } }); + // Installation lookup for bot token (checkChannelAccess) + bot token secret + ddbSend.mockResolvedValueOnce({ Item: { status: 'active' } }); + // fetch: conversations.info response (public channel with bot as member) + fetchMock.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ ok: true, channel: { is_private: false, is_member: true } }), + }); + createTaskCoreMock.mockResolvedValueOnce({ + statusCode: 201, + body: JSON.stringify({ data: { task_id: 'TASK123', repo: 'org/repo', status: 'SUBMITTED' } }), + }); + await handler(mention({ text: 'submit org/repo#42 add validation' })); + expect(createTaskCoreMock).toHaveBeenCalledTimes(1); + const [reqBody, ctx] = createTaskCoreMock.mock.calls[0]; + expect(reqBody.repo).toBe('org/repo'); + expect(reqBody.issue_number).toBe(42); + expect(reqBody.task_description).toBe('add validation'); + expect(ctx.channelSource).toBe('slack'); + expect(ctx.userId).toBe('cognito-1'); + // mention_thread_ts flows to channel_metadata + expect(ctx.channelMetadata.slack_thread_ts).toBe('1000.0001'); + }); + + test('mention submit in private channel bot is not in — replies with invite hint', async () => { + ddbSend.mockResolvedValueOnce({ Item: { status: 'active', platform_user_id: 'cognito-1' } }); + ddbSend.mockResolvedValue({ Item: { status: 'active' } }); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ ok: false, error: 'channel_not_found' }), + }); + await handler(mention({ text: 'submit org/repo fix' })); + const reply = fetchMock.mock.calls.find( + ([url, opts]) => String(url).includes('chat.postMessage') && String((opts as { body: string }).body).includes('private channel'), + ); + expect(reply).toBeTruthy(); + expect(createTaskCoreMock).not.toHaveBeenCalled(); + }); + + test('mention submit fails open on transient Slack errors (ratelimited, internal_error)', async () => { + ddbSend.mockResolvedValueOnce({ Item: { status: 'active', platform_user_id: 'cognito-1' } }); + ddbSend.mockResolvedValue({ Item: { status: 'active' } }); + // conversations.info returns a non-hard failure — task creation should proceed. + fetchMock.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ ok: false, error: 'ratelimited' }), + }); + createTaskCoreMock.mockResolvedValueOnce({ + statusCode: 201, + body: JSON.stringify({ data: { task_id: 'T1', repo: 'org/repo', status: 'SUBMITTED' } }), + }); + await handler(mention({ text: 'submit org/repo fix' })); + expect(createTaskCoreMock).toHaveBeenCalledTimes(1); + }); + + test('link subcommand persists pending mapping with a link code', async () => { + ddbSend.mockResolvedValueOnce({}); // Put pending mapping + await handler(slashCommand({ text: 'link' })); + const putCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Put'); + expect(putCall).toBeTruthy(); + expect(putCall![0].input.Item.slack_identity).toMatch(/^pending#/); + expect(putCall![0].input.Item.status).toBe('pending'); + const posted = fetchMock.mock.calls.find( + ([url, opts]) => String((opts as { body: string }).body).includes('bgagent slack link'), + ); + expect(posted).toBeTruthy(); + }); + + test('help subcommand replies with usage text', async () => { + await handler(slashCommand({ text: 'help' })); + const posted = fetchMock.mock.calls.find( + ([url, opts]) => String((opts as { body: string }).body).includes('Using Shoof'), + ); + expect(posted).toBeTruthy(); + }); +}); diff --git a/cdk/test/handlers/slack-commands.test.ts b/cdk/test/handlers/slack-commands.test.ts new file mode 100644 index 0000000..3ce1578 --- /dev/null +++ b/cdk/test/handlers/slack-commands.test.ts @@ -0,0 +1,113 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import type { APIGatewayProxyEvent } from 'aws-lambda'; + +const lambdaSend = jest.fn(); +jest.mock('@aws-sdk/client-lambda', () => ({ + LambdaClient: jest.fn(() => ({ send: lambdaSend })), + InvokeCommand: jest.fn((input: unknown) => ({ _type: 'Invoke', input })), +})); + +const smSend = jest.fn(); +jest.mock('@aws-sdk/client-secrets-manager', () => ({ + SecretsManagerClient: jest.fn(() => ({ send: smSend })), + GetSecretValueCommand: jest.fn((input: unknown) => ({ _type: 'GetSecretValue', input })), +})); + +process.env.SLACK_SIGNING_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent/slack/signing-ABC'; +process.env.SLACK_COMMAND_PROCESSOR_FUNCTION_NAME = 'cmd-processor'; + +import { invalidateSlackSecretCache } from '../../src/handlers/shared/slack-verify'; +import { handler } from '../../src/handlers/slack-commands'; + +const SIGNING_SECRET = 'test-signing'; + +function sign(body: string, ts: string): string { + return 'v0=' + crypto.createHmac('sha256', SIGNING_SECRET).update(`v0:${ts}:${body}`).digest('hex'); +} + +function makeEvent(body: string, ts: string, withSig = true): APIGatewayProxyEvent { + const headers: Record = { 'X-Slack-Request-Timestamp': ts }; + if (withSig) headers['X-Slack-Signature'] = sign(body, ts); + return { + body, + headers, + multiValueHeaders: {}, + httpMethod: 'POST', + isBase64Encoded: false, + path: '/v1/slack/commands', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: {} as APIGatewayProxyEvent['requestContext'], + resource: '', + }; +} + +describe('slack-commands handler', () => { + beforeEach(() => { + lambdaSend.mockReset(); + smSend.mockReset(); + invalidateSlackSecretCache(process.env.SLACK_SIGNING_SECRET_ARN!); + smSend.mockResolvedValue({ SecretString: SIGNING_SECRET }); + }); + + test('rejects invalid signature with 401', async () => { + const body = 'command=%2Fbgagent&text=link&user_id=U1'; + const ts = String(Math.floor(Date.now() / 1000)); + const event = makeEvent(body, ts, false); + event.headers['X-Slack-Signature'] = 'v0=deadbeef'; + const result = await handler(event); + expect(result.statusCode).toBe(401); + }); + + test('returns inline help for empty text or `help`', async () => { + const body = 'command=%2Fbgagent&text=help&user_id=U1&team_id=T1&channel_id=C1&response_url=https%3A%2F%2Fexample.com'; + const ts = String(Math.floor(Date.now() / 1000)); + const result = await handler(makeEvent(body, ts)); + expect(result.statusCode).toBe(200); + const payload = JSON.parse(result.body); + expect(payload.text).toContain('Using Shoof'); + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('forwards non-help subcommand to processor and acks inline', async () => { + lambdaSend.mockResolvedValueOnce({}); + const body = 'command=%2Fbgagent&text=link&user_id=U1&team_id=T1&channel_id=C1&response_url=https%3A%2F%2Fexample.com'; + const ts = String(Math.floor(Date.now() / 1000)); + const result = await handler(makeEvent(body, ts)); + expect(result.statusCode).toBe(200); + expect(lambdaSend).toHaveBeenCalledTimes(1); + const payload = JSON.parse(result.body); + expect(payload.text).toContain('link'); + }); + + test('handles processor invoke failure gracefully', async () => { + lambdaSend.mockRejectedValueOnce(new Error('lambda down')); + const body = 'command=%2Fbgagent&text=link&user_id=U1&team_id=T1&channel_id=C1&response_url=https%3A%2F%2Fexample.com'; + const ts = String(Math.floor(Date.now() / 1000)); + const result = await handler(makeEvent(body, ts)); + expect(result.statusCode).toBe(200); + const payload = JSON.parse(result.body); + expect(payload.text).toMatch(/Failed|try again/i); + }); +}); diff --git a/cdk/test/handlers/slack-events.test.ts b/cdk/test/handlers/slack-events.test.ts new file mode 100644 index 0000000..eedc412 --- /dev/null +++ b/cdk/test/handlers/slack-events.test.ts @@ -0,0 +1,300 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import type { APIGatewayProxyEvent } from 'aws-lambda'; + +// --- Mocks --- +const ddbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: ddbSend })) }, + UpdateCommand: jest.fn((input: unknown) => ({ _type: 'Update', input })), +})); + +const lambdaSend = jest.fn(); +jest.mock('@aws-sdk/client-lambda', () => ({ + LambdaClient: jest.fn(() => ({ send: lambdaSend })), + InvokeCommand: jest.fn((input: unknown) => ({ _type: 'Invoke', input })), +})); + +const smSend = jest.fn(); +jest.mock('@aws-sdk/client-secrets-manager', () => ({ + SecretsManagerClient: jest.fn(() => ({ send: smSend })), + GetSecretValueCommand: jest.fn((input: unknown) => ({ _type: 'GetSecretValue', input })), + DeleteSecretCommand: jest.fn((input: unknown) => ({ _type: 'DeleteSecret', input })), +})); + +const fetchMock = jest.fn(); +(global as unknown as { fetch: unknown }).fetch = fetchMock; + +process.env.SLACK_INSTALLATION_TABLE_NAME = 'SlackInstall'; +process.env.SLACK_SIGNING_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent/slack/signing-XYZ'; +process.env.SLACK_COMMAND_PROCESSOR_FUNCTION_NAME = 'cmd-processor'; + +import { invalidateSlackSecretCache } from '../../src/handlers/shared/slack-verify'; +import { handler } from '../../src/handlers/slack-events'; + +const SIGNING_SECRET = 'test-signing-secret'; + +function sign(body: string, timestamp: string): string { + return 'v0=' + crypto.createHmac('sha256', SIGNING_SECRET).update(`v0:${timestamp}:${body}`).digest('hex'); +} + +function currentTs(): string { + return String(Math.floor(Date.now() / 1000)); +} + +function makeEvent(body: string, headers: Record = {}): APIGatewayProxyEvent { + return { + body, + headers, + multiValueHeaders: {}, + httpMethod: 'POST', + isBase64Encoded: false, + path: '/v1/slack/events', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: {} as APIGatewayProxyEvent['requestContext'], + resource: '', + }; +} + +function signedEvent(body: string, retryNum?: string): APIGatewayProxyEvent { + const ts = currentTs(); + const headers: Record = { + 'X-Slack-Signature': sign(body, ts), + 'X-Slack-Request-Timestamp': ts, + }; + if (retryNum) headers['X-Slack-Retry-Num'] = retryNum; + return makeEvent(body, headers); +} + +describe('slack-events handler', () => { + beforeEach(() => { + ddbSend.mockReset(); + lambdaSend.mockReset(); + smSend.mockReset(); + fetchMock.mockReset(); + invalidateSlackSecretCache(process.env.SLACK_SIGNING_SECRET_ARN!); + // Default: signing secret fetched on demand + smSend.mockImplementation((cmd: { _type: string }) => { + if (cmd._type === 'GetSecretValue') return Promise.resolve({ SecretString: SIGNING_SECRET }); + return Promise.resolve({}); + }); + }); + + test('400s when body is missing', async () => { + const event = makeEvent(null as unknown as string); + const result = await handler(event); + expect(result.statusCode).toBe(400); + }); + + test('answers url_verification challenge with valid signature', async () => { + const body = JSON.stringify({ type: 'url_verification', challenge: 'abc123' }); + const result = await handler(signedEvent(body)); + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body)).toEqual({ challenge: 'abc123' }); + }); + + test('rejects url_verification with invalid signature', async () => { + const body = JSON.stringify({ type: 'url_verification', challenge: 'abc' }); + const ts = currentTs(); + const event = makeEvent(body, { + 'X-Slack-Signature': 'v0=0000000000000000000000000000000000000000000000000000000000000000', + 'X-Slack-Request-Timestamp': ts, + }); + const result = await handler(event); + expect(result.statusCode).toBe(401); + }); + + test('answers url_verification during initial setup when signing secret missing', async () => { + smSend.mockImplementation(() => Promise.resolve({ SecretString: undefined })); + invalidateSlackSecretCache(process.env.SLACK_SIGNING_SECRET_ARN!); + const body = JSON.stringify({ type: 'url_verification', challenge: 'initial' }); + // No signature provided — pre-setup flow + const result = await handler(makeEvent(body, {})); + expect(result.statusCode).toBe(200); + expect(JSON.parse(result.body)).toEqual({ challenge: 'initial' }); + }); + + test('rejects non-url_verification when signing secret missing', async () => { + smSend.mockImplementation(() => Promise.resolve({ SecretString: undefined })); + invalidateSlackSecretCache(process.env.SLACK_SIGNING_SECRET_ARN!); + const body = JSON.stringify({ type: 'event_callback', event: { type: 'app_mention' } }); + const result = await handler(makeEvent(body, {})); + expect(result.statusCode).toBe(500); + }); + + test('drops non-critical retries without reprocessing', async () => { + const body = JSON.stringify({ + type: 'event_callback', + team_id: 'T1', + event: { type: 'app_mention', user: 'U1', channel: 'C1', text: '<@BOT> hi', ts: '1.0' }, + }); + const result = await handler(signedEvent(body, '1')); + expect(result.statusCode).toBe(200); + // No lambda invocation because the retry is short-circuited + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('reprocesses retries for app_uninstalled (security-critical)', async () => { + ddbSend.mockResolvedValueOnce({}); // UpdateCommand + smSend.mockImplementation((cmd: { _type: string }) => { + if (cmd._type === 'GetSecretValue') return Promise.resolve({ SecretString: SIGNING_SECRET }); + if (cmd._type === 'DeleteSecret') return Promise.resolve({}); + return Promise.resolve({}); + }); + const body = JSON.stringify({ + type: 'event_callback', + team_id: 'T_revoke', + event: { type: 'app_uninstalled' }, + }); + const result = await handler(signedEvent(body, '2')); + expect(result.statusCode).toBe(200); + // DDB was updated (status→revoked) even though this is a retry + expect(ddbSend).toHaveBeenCalledTimes(1); + // And the secret deletion happened + const deleteCalled = smSend.mock.calls.some(([cmd]) => cmd._type === 'DeleteSecret'); + expect(deleteCalled).toBe(true); + }); + + test('does not delete bot token if DDB revocation update failed', async () => { + ddbSend.mockRejectedValueOnce(new Error('ddb throttle')); + const body = JSON.stringify({ + type: 'event_callback', + team_id: 'T_revoke', + event: { type: 'tokens_revoked' }, + }); + const result = await handler(signedEvent(body)); + expect(result.statusCode).toBe(500); + // Critical invariant: don't delete secret if install is still "active" in DDB + const deleteCalled = smSend.mock.calls.some(([cmd]) => cmd._type === 'DeleteSecret'); + expect(deleteCalled).toBe(false); + }); + + test('forwards app_mention to command processor with :eyes: reaction', async () => { + // First fetch is reactions.add for :eyes:, returns { ok: true } + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ ok: true }), + }); + lambdaSend.mockResolvedValueOnce({}); + smSend.mockImplementation((cmd: { _type: string; input?: { SecretId?: string } }) => { + if (cmd._type === 'GetSecretValue' && cmd.input?.SecretId === process.env.SLACK_SIGNING_SECRET_ARN) { + return Promise.resolve({ SecretString: SIGNING_SECRET }); + } + // Bot token lookup + return Promise.resolve({ SecretString: 'xoxb-bot' }); + }); + + const body = JSON.stringify({ + type: 'event_callback', + team_id: 'T1', + event: { + type: 'app_mention', + user: 'U1', + channel: 'C1', + text: '<@BOT> fix the bug in org/repo#42', + ts: '1234.0001', + }, + }); + const result = await handler(signedEvent(body)); + expect(result.statusCode).toBe(200); + expect(lambdaSend).toHaveBeenCalledTimes(1); + const [invokeCmd] = lambdaSend.mock.calls[0]; + const payload = JSON.parse(new TextDecoder().decode(invokeCmd.input.Payload)); + expect(payload.source).toBe('mention'); + expect(payload.text).toContain('submit'); + expect(payload.text).toContain('org/repo#42'); + expect(payload.channel_id).toBe('C1'); + // Reactions.add was called + const reactionCall = fetchMock.mock.calls.find(([url]) => String(url).includes('reactions.add')); + expect(reactionCall).toBeTruthy(); + }); + + test('app_mention without repo replies with :x: and helpful error', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ ok: true }), + }); + smSend.mockImplementation((cmd: { _type: string; input?: { SecretId?: string } }) => { + if (cmd._type === 'GetSecretValue' && cmd.input?.SecretId === process.env.SLACK_SIGNING_SECRET_ARN) { + return Promise.resolve({ SecretString: SIGNING_SECRET }); + } + return Promise.resolve({ SecretString: 'xoxb-bot' }); + }); + + const body = JSON.stringify({ + type: 'event_callback', + team_id: 'T1', + event: { type: 'app_mention', user: 'U1', channel: 'C1', text: '<@BOT> just a question', ts: '1.0' }, + }); + const result = await handler(signedEvent(body)); + expect(result.statusCode).toBe(200); + expect(lambdaSend).not.toHaveBeenCalled(); + const postedReply = fetchMock.mock.calls.find( + ([url, opts]) => String(url).includes('chat.postMessage') && String((opts as { body: string }).body).includes('Please include a repo'), + ); + expect(postedReply).toBeTruthy(); + }); + + test('app_mention with Lambda invoke failure swaps :eyes: to :x:', async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ ok: true }), + }); + lambdaSend.mockRejectedValueOnce(new Error('lambda outage')); + smSend.mockImplementation((cmd: { _type: string; input?: { SecretId?: string } }) => { + if (cmd._type === 'GetSecretValue' && cmd.input?.SecretId === process.env.SLACK_SIGNING_SECRET_ARN) { + return Promise.resolve({ SecretString: SIGNING_SECRET }); + } + return Promise.resolve({ SecretString: 'xoxb-bot' }); + }); + + const body = JSON.stringify({ + type: 'event_callback', + team_id: 'T1', + event: { + type: 'app_mention', + user: 'U1', + channel: 'C1', + text: '<@BOT> fix org/repo', + ts: '1.0', + }, + }); + const result = await handler(signedEvent(body)); + expect(result.statusCode).toBe(200); // Still 200 — Slack retries give a second chance + // Should have swapped reaction: remove :eyes:, add :x:, then post error message + const removeCall = fetchMock.mock.calls.find( + ([url, opts]) => String(url).includes('reactions.remove') && String((opts as { body: string }).body).includes('eyes'), + ); + const addCall = fetchMock.mock.calls.find( + ([url, opts]) => String(url).includes('reactions.add') && String((opts as { body: string }).body).includes('"name":"x"'), + ); + const errorReply = fetchMock.mock.calls.find( + ([url, opts]) => String(url).includes('chat.postMessage') && String((opts as { body: string }).body).includes('Something went wrong'), + ); + expect(removeCall).toBeTruthy(); + expect(addCall).toBeTruthy(); + expect(errorReply).toBeTruthy(); + }); +}); diff --git a/cdk/test/handlers/slack-interactions.test.ts b/cdk/test/handlers/slack-interactions.test.ts new file mode 100644 index 0000000..18bb85d --- /dev/null +++ b/cdk/test/handlers/slack-interactions.test.ts @@ -0,0 +1,184 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import type { APIGatewayProxyEvent } from 'aws-lambda'; + +const ddbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: ddbSend })) }, + GetCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), + UpdateCommand: jest.fn((input: unknown) => ({ _type: 'Update', input })), +})); + +const smSend = jest.fn(); +jest.mock('@aws-sdk/client-secrets-manager', () => ({ + SecretsManagerClient: jest.fn(() => ({ send: smSend })), + GetSecretValueCommand: jest.fn((input: unknown) => ({ _type: 'GetSecretValue', input })), +})); + +const fetchMock = jest.fn(); +(global as unknown as { fetch: unknown }).fetch = fetchMock; + +process.env.SLACK_SIGNING_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent/slack/signing-I'; +process.env.TASK_TABLE_NAME = 'Tasks'; +process.env.SLACK_USER_MAPPING_TABLE_NAME = 'SlackMap'; + +import { invalidateSlackSecretCache } from '../../src/handlers/shared/slack-verify'; +import { handler } from '../../src/handlers/slack-interactions'; + +const SIGNING_SECRET = 'test-signing'; + +function sign(body: string, ts: string): string { + return 'v0=' + crypto.createHmac('sha256', SIGNING_SECRET).update(`v0:${ts}:${body}`).digest('hex'); +} + +function makeInteractionEvent(payload: object): APIGatewayProxyEvent { + const body = `payload=${encodeURIComponent(JSON.stringify(payload))}`; + const ts = String(Math.floor(Date.now() / 1000)); + return { + body, + headers: { + 'X-Slack-Signature': sign(body, ts), + 'X-Slack-Request-Timestamp': ts, + }, + multiValueHeaders: {}, + httpMethod: 'POST', + isBase64Encoded: false, + path: '/v1/slack/interactions', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: {} as APIGatewayProxyEvent['requestContext'], + resource: '', + }; +} + +function interactionPayload(actionId: string, userId = 'U1', teamId = 'T1'): object { + return { + type: 'block_actions', + user: { id: userId, username: 'u', team_id: teamId }, + response_url: 'https://hooks.slack.com/response/xyz', + trigger_id: 't.1', + actions: [{ action_id: actionId, block_id: 'task-123' }], + channel: { id: 'C1' }, + }; +} + +describe('slack-interactions handler', () => { + beforeEach(() => { + ddbSend.mockReset(); + smSend.mockReset(); + fetchMock.mockReset(); + invalidateSlackSecretCache(process.env.SLACK_SIGNING_SECRET_ARN!); + smSend.mockResolvedValue({ SecretString: SIGNING_SECRET }); + fetchMock.mockResolvedValue({ ok: true, json: () => Promise.resolve({ ok: true }) }); + }); + + test('rejects invalid signature with 401', async () => { + const body = 'payload=%7B%22type%22%3A%22block_actions%22%7D'; + const ts = String(Math.floor(Date.now() / 1000)); + const event: APIGatewayProxyEvent = { + body, + headers: { 'X-Slack-Signature': 'v0=000', 'X-Slack-Request-Timestamp': ts }, + multiValueHeaders: {}, + httpMethod: 'POST', + isBase64Encoded: false, + path: '/v1/slack/interactions', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: {} as APIGatewayProxyEvent['requestContext'], + resource: '', + }; + const result = await handler(event); + expect(result.statusCode).toBe(401); + }); + + test('cancel_task transitions owned task to CANCELLED', async () => { + // 1. user mapping lookup → platform user id + ddbSend.mockResolvedValueOnce({ Item: { platform_user_id: 'user-42' } }); + // 2. task lookup → same owner + ddbSend.mockResolvedValueOnce({ Item: { task_id: 'task-42', user_id: 'user-42', channel_metadata: {} } }); + // 3. update → success + ddbSend.mockResolvedValueOnce({}); + + const event = makeInteractionEvent(interactionPayload('cancel_task:task-42', 'U1', 'T1')); + const result = await handler(event); + expect(result.statusCode).toBe(200); + const updateCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Update'); + expect(updateCall).toBeTruthy(); + expect(updateCall![0].input.ExpressionAttributeValues[':cancelled']).toBe('CANCELLED'); + }); + + test('cancel_task rejects when caller is not task owner', async () => { + ddbSend.mockResolvedValueOnce({ Item: { platform_user_id: 'attacker' } }); + ddbSend.mockResolvedValueOnce({ Item: { task_id: 'task-42', user_id: 'user-42' } }); + + const event = makeInteractionEvent(interactionPayload('cancel_task:task-42')); + const result = await handler(event); + expect(result.statusCode).toBe(200); + // No update attempted + const updateCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Update'); + expect(updateCall).toBeFalsy(); + // Posted to response_url with "own your own tasks" + const posted = fetchMock.mock.calls.find( + ([url, opts]) => String(url).startsWith('https://hooks.slack.com') && String((opts as { body: string }).body).includes('your own tasks'), + ); + expect(posted).toBeTruthy(); + }); + + test('cancel_task on already-terminal task warns the user', async () => { + ddbSend.mockResolvedValueOnce({ Item: { platform_user_id: 'user-42' } }); + ddbSend.mockResolvedValueOnce({ Item: { task_id: 'task-42', user_id: 'user-42' } }); + // ConditionalCheckFailedException => already in terminal state + const err = new Error('conditional failed'); + err.name = 'ConditionalCheckFailedException'; + ddbSend.mockRejectedValueOnce(err); + + const event = makeInteractionEvent(interactionPayload('cancel_task:task-42')); + const result = await handler(event); + expect(result.statusCode).toBe(200); + const posted = fetchMock.mock.calls.find( + ([url, opts]) => String(url).startsWith('https://hooks.slack.com') && String((opts as { body: string }).body).includes('terminal state'), + ); + expect(posted).toBeTruthy(); + }); + + test('unknown action_id is ignored silently', async () => { + const event = makeInteractionEvent(interactionPayload('other_action:xyz')); + const result = await handler(event); + expect(result.statusCode).toBe(200); + expect(ddbSend).not.toHaveBeenCalled(); + }); + + test('unlinked account receives :link: message instead of cancel', async () => { + ddbSend.mockResolvedValueOnce({ Item: { status: 'pending' } }); + const event = makeInteractionEvent(interactionPayload('cancel_task:task-42')); + const result = await handler(event); + expect(result.statusCode).toBe(200); + const posted = fetchMock.mock.calls.find( + ([url, opts]) => String(url).startsWith('https://hooks.slack.com') && String((opts as { body: string }).body).includes('not linked'), + ); + expect(posted).toBeTruthy(); + }); +}); diff --git a/cdk/test/handlers/slack-link.test.ts b/cdk/test/handlers/slack-link.test.ts new file mode 100644 index 0000000..1acd1c4 --- /dev/null +++ b/cdk/test/handlers/slack-link.test.ts @@ -0,0 +1,120 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { APIGatewayProxyEvent } from 'aws-lambda'; + +const ddbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: ddbSend })) }, + GetCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), + DeleteCommand: jest.fn((input: unknown) => ({ _type: 'Delete', input })), +})); + +jest.mock('ulid', () => ({ ulid: jest.fn(() => 'REQ-ULID') })); + +process.env.SLACK_USER_MAPPING_TABLE_NAME = 'SlackMap'; + +import { handler } from '../../src/handlers/slack-link'; + +function makeEvent(body: unknown, userId?: string): APIGatewayProxyEvent { + return { + body: body === null ? null : JSON.stringify(body), + headers: {}, + multiValueHeaders: {}, + httpMethod: 'POST', + isBase64Encoded: false, + path: '/v1/slack/link', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: userId + ? ({ authorizer: { claims: { sub: userId } } } as unknown as APIGatewayProxyEvent['requestContext']) + : ({} as APIGatewayProxyEvent['requestContext']), + resource: '', + }; +} + +describe('slack-link handler', () => { + beforeEach(() => { + ddbSend.mockReset(); + }); + + test('401s without a Cognito JWT', async () => { + const result = await handler(makeEvent({ code: 'ABC123' })); + expect(result.statusCode).toBe(401); + }); + + test('400s without a code in the body', async () => { + const result = await handler(makeEvent({}, 'cognito-user-1')); + expect(result.statusCode).toBe(400); + }); + + test('404s when code is not found', async () => { + ddbSend.mockResolvedValueOnce({ Item: undefined }); + const result = await handler(makeEvent({ code: 'XYZ123' }, 'cognito-user-1')); + expect(result.statusCode).toBe(404); + }); + + test('404s when code exists but status is not pending', async () => { + ddbSend.mockResolvedValueOnce({ Item: { slack_identity: 'pending#XYZ', status: 'consumed' } }); + const result = await handler(makeEvent({ code: 'XYZ123' }, 'cognito-user-1')); + expect(result.statusCode).toBe(404); + }); + + test('writes confirmed mapping and deletes pending record on success', async () => { + ddbSend + .mockResolvedValueOnce({ + Item: { + slack_identity: 'pending#ABC123', + status: 'pending', + slack_team_id: 'T1', + slack_user_id: 'U_slack', + }, + }) + .mockResolvedValueOnce({}) // Put (confirmed mapping) + .mockResolvedValueOnce({}); // Delete (pending) + + const result = await handler(makeEvent({ code: 'abc123' }, 'cognito-user-1')); + expect(result.statusCode).toBe(200); + const putCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Put'); + expect(putCall).toBeTruthy(); + expect(putCall![0].input.Item.slack_identity).toBe('T1#U_slack'); + expect(putCall![0].input.Item.platform_user_id).toBe('cognito-user-1'); + + const deleteCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Delete'); + expect(deleteCall).toBeTruthy(); + expect(deleteCall![0].input.Key.slack_identity).toBe('pending#ABC123'); + }); + + test('normalizes the code (uppercase, trimmed)', async () => { + ddbSend + .mockResolvedValueOnce({ + Item: { slack_identity: 'pending#ABC123', status: 'pending', slack_team_id: 'T1', slack_user_id: 'U1' }, + }) + .mockResolvedValueOnce({}) + .mockResolvedValueOnce({}); + + await handler(makeEvent({ code: ' abc123 ' }, 'cognito-user-1')); + const getCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Get'); + expect(getCall![0].input.Key.slack_identity).toBe('pending#ABC123'); + }); +}); diff --git a/cdk/test/handlers/slack-notify.test.ts b/cdk/test/handlers/slack-notify.test.ts new file mode 100644 index 0000000..f5a46b6 --- /dev/null +++ b/cdk/test/handlers/slack-notify.test.ts @@ -0,0 +1,197 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { DynamoDBStreamEvent, DynamoDBRecord } from 'aws-lambda'; + +const ddbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: ddbSend })) }, + GetCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), + UpdateCommand: jest.fn((input: unknown) => ({ _type: 'Update', input })), +})); + +const smSend = jest.fn(); +jest.mock('@aws-sdk/client-secrets-manager', () => ({ + SecretsManagerClient: jest.fn(() => ({ send: smSend })), + GetSecretValueCommand: jest.fn((input: unknown) => ({ _type: 'GetSecretValue', input })), +})); + +const fetchMock = jest.fn(); +(global as unknown as { fetch: unknown }).fetch = fetchMock; + +process.env.TASK_TABLE_NAME = 'Tasks'; + +import { handler } from '../../src/handlers/slack-notify'; + +function makeInsertRecord( + taskId: string, + eventType: string, + metadata?: Record, +): DynamoDBRecord { + return { + eventID: `evt-${Math.random()}`, + eventName: 'INSERT', + dynamodb: { + NewImage: { + task_id: { S: taskId }, + event_type: { S: eventType }, + ...(metadata && { metadata: { S: JSON.stringify(metadata) } }), + }, + }, + }; +} + +function withRecords(records: DynamoDBRecord[]): DynamoDBStreamEvent { + return { Records: records }; +} + +describe('slack-notify handler', () => { + beforeEach(() => { + ddbSend.mockReset(); + smSend.mockReset(); + fetchMock.mockReset(); + smSend.mockResolvedValue({ SecretString: 'xoxb-test' }); + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve({ ok: true, ts: '1234.0001' }), + }); + }); + + test('skips non-slack tasks without touching DDB beyond the task read', async () => { + ddbSend.mockResolvedValueOnce({ + Item: { task_id: 't1', channel_source: 'api', channel_metadata: {} }, + }); + + await handler(withRecords([makeInsertRecord('t1', 'task_completed')])); + + // Only the initial GetCommand ran — no dedup update, no Slack call. + expect(ddbSend).toHaveBeenCalledTimes(1); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('dedup write runs only after channel_source is confirmed slack', async () => { + ddbSend + .mockResolvedValueOnce({ + Item: { + task_id: 't1', + channel_source: 'slack', + channel_metadata: { slack_team_id: 'T1', slack_channel_id: 'C1' }, + repo: 'org/repo', + }, + }) + .mockResolvedValueOnce({}); // UpdateCommand for dedup + + await handler(withRecords([makeInsertRecord('t1', 'task_completed')])); + + // GetCommand first, then UpdateCommand (dedup). Order matters (item 17). + expect(ddbSend.mock.calls[0][0]._type).toBe('Get'); + expect(ddbSend.mock.calls[1][0]._type).toBe('Update'); + expect(fetchMock).toHaveBeenCalled(); + }); + + test('skips terminal notification when dedup marker already exists', async () => { + ddbSend + .mockResolvedValueOnce({ + Item: { + task_id: 't1', + channel_source: 'slack', + channel_metadata: { slack_team_id: 'T1', slack_channel_id: 'C1' }, + }, + }) + .mockRejectedValueOnce(Object.assign(new Error('exists'), { name: 'ConditionalCheckFailedException' })); + + await handler(withRecords([makeInsertRecord('t1', 'task_failed')])); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('swallows Slack API errors without failing the batch', async () => { + ddbSend.mockResolvedValue({ + Item: { + task_id: 't1', + channel_source: 'slack', + channel_metadata: { slack_team_id: 'T1', slack_channel_id: 'C1' }, + }, + }); + fetchMock.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ ok: false, error: 'channel_not_found' }), + }); + + await expect( + handler(withRecords([makeInsertRecord('t1', 'task_created')])), + ).resolves.toBeUndefined(); + }); + + test('rethrows infra errors so Lambda retries the batch (item 4)', async () => { + ddbSend.mockRejectedValueOnce(Object.assign(new Error('throttle'), { name: 'ProvisionedThroughputExceededException' })); + + await expect( + handler(withRecords([makeInsertRecord('t1', 'task_completed')])), + ).rejects.toThrow('throttle'); + }); + + test('ignores non-INSERT stream events', async () => { + const modifyRecord: DynamoDBRecord = { + eventID: 'evt-modify', + eventName: 'MODIFY', + dynamodb: { NewImage: { task_id: { S: 't1' }, event_type: { S: 'task_completed' } } }, + }; + await handler(withRecords([modifyRecord])); + expect(ddbSend).not.toHaveBeenCalled(); + }); + + test('ignores non-notifiable event types', async () => { + await handler(withRecords([makeInsertRecord('t1', 'agent_heartbeat')])); + expect(ddbSend).not.toHaveBeenCalled(); + }); + + test('logs and continues when event metadata JSON is malformed (item 20)', async () => { + const record: DynamoDBRecord = { + eventID: 'evt-bad-meta', + eventName: 'INSERT', + dynamodb: { + NewImage: { + task_id: { S: 't1' }, + event_type: { S: 'task_failed' }, + metadata: { S: 'not-json{' }, + }, + }, + }; + ddbSend + .mockResolvedValueOnce({ + Item: { + task_id: 't1', + channel_source: 'slack', + channel_metadata: { slack_team_id: 'T1', slack_channel_id: 'C1' }, + repo: 'org/repo', + error_message: 'agent crashed', + }, + }) + .mockResolvedValueOnce({}); // dedup + + await handler(withRecords([record])); + + // Still posts to Slack — bad metadata is not fatal. + expect(fetchMock).toHaveBeenCalled(); + const postBody = JSON.parse((fetchMock.mock.calls[0][1] as { body: string }).body); + expect(postBody.text).toContain('org/repo'); + }); +}); diff --git a/cdk/test/handlers/slack-oauth-callback.test.ts b/cdk/test/handlers/slack-oauth-callback.test.ts new file mode 100644 index 0000000..d9d24a6 --- /dev/null +++ b/cdk/test/handlers/slack-oauth-callback.test.ts @@ -0,0 +1,173 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import type { APIGatewayProxyEvent } from 'aws-lambda'; + +const ddbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => ({ DynamoDBClient: jest.fn(() => ({})) })); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: ddbSend })) }, + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), +})); + +class MockResourceNotFoundException extends Error { + constructor() { super('not found'); this.name = 'ResourceNotFoundException'; } +} +class MockInvalidRequestException extends Error { + constructor(message: string) { super(message); this.name = 'InvalidRequestException'; } +} + +const smSend = jest.fn(); +jest.mock('@aws-sdk/client-secrets-manager', () => ({ + SecretsManagerClient: jest.fn(() => ({ send: smSend })), + GetSecretValueCommand: jest.fn((input: unknown) => ({ _type: 'GetSecretValue', input })), + CreateSecretCommand: jest.fn((input: unknown) => ({ _type: 'CreateSecret', input })), + UpdateSecretCommand: jest.fn((input: unknown) => ({ _type: 'UpdateSecret', input })), + RestoreSecretCommand: jest.fn((input: unknown) => ({ _type: 'RestoreSecret', input })), + ResourceNotFoundException: MockResourceNotFoundException, + InvalidRequestException: MockInvalidRequestException, +})); + +const fetchMock = jest.fn(); +(global as unknown as { fetch: unknown }).fetch = fetchMock; + +process.env.SLACK_INSTALLATION_TABLE_NAME = 'SlackInstall'; +process.env.SLACK_CLIENT_ID_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent/slack/client_id-1'; +process.env.SLACK_CLIENT_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent/slack/client_secret-1'; + +import { invalidateSlackSecretCache } from '../../src/handlers/shared/slack-verify'; +import { handler } from '../../src/handlers/slack-oauth-callback'; + +function makeEvent(code?: string): APIGatewayProxyEvent { + return { + body: null, + headers: { Host: 'api.example.com' }, + multiValueHeaders: {}, + httpMethod: 'GET', + isBase64Encoded: false, + path: '/v1/slack/oauth/callback', + pathParameters: null, + queryStringParameters: code ? { code } : null, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: { stage: 'v1' } as APIGatewayProxyEvent['requestContext'], + resource: '', + }; +} + +describe('slack-oauth-callback handler', () => { + beforeEach(() => { + ddbSend.mockReset(); + smSend.mockReset(); + fetchMock.mockReset(); + invalidateSlackSecretCache(process.env.SLACK_CLIENT_ID_SECRET_ARN!); + invalidateSlackSecretCache(process.env.SLACK_CLIENT_SECRET_ARN!); + smSend.mockImplementation((cmd: { _type: string; input?: { SecretId?: string } }) => { + if (cmd._type === 'GetSecretValue') { + if (cmd.input?.SecretId === process.env.SLACK_CLIENT_ID_SECRET_ARN) { + return Promise.resolve({ SecretString: 'client-id-123' }); + } + if (cmd.input?.SecretId === process.env.SLACK_CLIENT_SECRET_ARN) { + return Promise.resolve({ SecretString: 'client-secret-xyz' }); + } + } + return Promise.resolve({}); + }); + }); + + test('400s when code is missing', async () => { + const result = await handler(makeEvent()); + expect(result.statusCode).toBe(400); + }); + + test('500s when client ID secret is not populated', async () => { + smSend.mockImplementation((cmd: { _type: string; input?: { SecretId?: string } }) => { + if (cmd._type === 'GetSecretValue' && cmd.input?.SecretId === process.env.SLACK_CLIENT_ID_SECRET_ARN) { + return Promise.resolve({ SecretString: undefined }); + } + return Promise.resolve({}); + }); + invalidateSlackSecretCache(process.env.SLACK_CLIENT_ID_SECRET_ARN!); + const result = await handler(makeEvent('code123')); + expect(result.statusCode).toBe(500); + }); + + test('400s when Slack rejects the token exchange', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ ok: false, error: 'invalid_code' }), + }); + const result = await handler(makeEvent('badcode')); + expect(result.statusCode).toBe(400); + expect(result.body).toContain('invalid_code'); + }); + + test('successful install creates secret + records installation', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + ok: true, + access_token: 'xoxb-new', + team: { id: 'T_new', name: 'New Team' }, + bot_user_id: 'B_1', + app_id: 'A_1', + scope: 'chat:write', + authed_user: { id: 'U_installer' }, + }), + }); + // First call: UpdateSecret → RNF; then CreateSecret succeeds + smSend.mockImplementationOnce(() => Promise.resolve({ SecretString: 'client-id-123' })); // GetSecretValue for CLIENT_ID + smSend.mockImplementationOnce(() => Promise.resolve({ SecretString: 'client-secret-xyz' })); // GetSecretValue for CLIENT_SECRET + smSend.mockImplementationOnce(() => Promise.reject(new MockResourceNotFoundException())); // UpdateSecret + smSend.mockImplementationOnce(() => Promise.resolve({})); // CreateSecret + ddbSend.mockResolvedValueOnce({}); // Put installation + + const result = await handler(makeEvent('goodcode')); + expect(result.statusCode).toBe(200); + expect(result.body).toContain('New Team'); + // Installation record was written + const putCall = ddbSend.mock.calls.find(([cmd]) => cmd._type === 'Put'); + expect(putCall).toBeTruthy(); + expect(putCall![0].input.Item.team_id).toBe('T_new'); + expect(putCall![0].input.Item.status).toBe('active'); + }); + + test('restores deleted secret before updating (re-install after uninstall)', async () => { + fetchMock.mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ + ok: true, + access_token: 'xoxb-reinstall', + team: { id: 'T_re', name: 'Re Team' }, + }), + }); + smSend.mockImplementationOnce(() => Promise.resolve({ SecretString: 'client-id-123' })); // CLIENT_ID + smSend.mockImplementationOnce(() => Promise.resolve({ SecretString: 'client-secret-xyz' })); // CLIENT_SECRET + // UpdateSecret throws InvalidRequestException with "marked for deletion" + smSend.mockImplementationOnce(() => Promise.reject(new MockInvalidRequestException('Secret is marked for deletion'))); + smSend.mockImplementationOnce(() => Promise.resolve({})); // RestoreSecret + smSend.mockImplementationOnce(() => Promise.resolve({})); // UpdateSecret (second try) + ddbSend.mockResolvedValueOnce({}); + + const result = await handler(makeEvent('code')); + expect(result.statusCode).toBe(200); + const restoreCalled = smSend.mock.calls.some(([cmd]) => cmd._type === 'RestoreSecret'); + expect(restoreCalled).toBe(true); + }); +}); diff --git a/cdk/test/stacks/agent.test.ts b/cdk/test/stacks/agent.test.ts index 54b5294..8bb8621 100644 --- a/cdk/test/stacks/agent.test.ts +++ b/cdk/test/stacks/agent.test.ts @@ -36,8 +36,11 @@ describe('AgentStack', () => { expect(template).toBeDefined(); }); - test('creates exactly 6 DynamoDB tables (including TaskNudgesTable for Phase 2)', () => { - template.resourceCountIs('AWS::DynamoDB::Table', 6); + test('creates exactly 11 DynamoDB tables', () => { + // task, task-events, repo, user-concurrency, webhook, task-nudges, + // slack-installation, slack-user-mapping, + // linear-project-mapping, linear-user-mapping, linear-webhook-dedup + template.resourceCountIs('AWS::DynamoDB::Table', 11); }); test('outputs TaskNudgesTableName', () => { diff --git a/cli/package.json b/cli/package.json index de5046f..b8b858f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -30,7 +30,11 @@ "typescript": "^5.9.3" }, "dependencies": { + "@aws-sdk/client-cloudformation": "3.1024.0", "@aws-sdk/client-cognito-identity-provider": "^3.1021.0", + "@aws-sdk/client-dynamodb": "3.1024.0", + "@aws-sdk/client-secrets-manager": "3.1024.0", + "@aws-sdk/lib-dynamodb": "3.1024.0", "commander": "^14.0.3" }, "resolutions": { diff --git a/cli/src/api-client.ts b/cli/src/api-client.ts index fec20f6..2277efc 100644 --- a/cli/src/api-client.ts +++ b/cli/src/api-client.ts @@ -27,8 +27,10 @@ import { CreateWebhookRequest, CreateWebhookResponse, ErrorResponse, + LinearLinkResponse, NudgeRequest, NudgeResponse, + SlackLinkResponse, PaginatedResponse, SuccessResponse, TaskDetail, @@ -327,4 +329,16 @@ export class ApiClient { const res = await this.request>('DELETE', `/webhooks/${encodeURIComponent(webhookId)}`); return res.data; } + + /** POST /slack/link — link a Slack account using a verification code. */ + async slackLink(code: string): Promise { + const res = await this.request>('POST', '/slack/link', { code }); + return res.data; + } + + /** POST /linear/link — link a Linear account using a verification code. */ + async linearLink(code: string): Promise { + const res = await this.request>('POST', '/linear/link', { code }); + return res.data; + } } diff --git a/cli/src/bin/bgagent.ts b/cli/src/bin/bgagent.ts index dd8a318..a1a8c9a 100644 --- a/cli/src/bin/bgagent.ts +++ b/cli/src/bin/bgagent.ts @@ -23,9 +23,11 @@ import { Command } from 'commander'; import { makeCancelCommand } from '../commands/cancel'; import { makeConfigureCommand } from '../commands/configure'; import { makeEventsCommand } from '../commands/events'; +import { makeLinearCommand } from '../commands/linear'; import { makeListCommand } from '../commands/list'; import { makeLoginCommand } from '../commands/login'; import { makeNudgeCommand } from '../commands/nudge'; +import { makeSlackCommand } from '../commands/slack'; import { makeStatusCommand } from '../commands/status'; import { makeSubmitCommand } from '../commands/submit'; import { makeTraceCommand } from '../commands/trace'; @@ -57,6 +59,8 @@ program.addCommand(makeStatusCommand()); program.addCommand(makeCancelCommand()); program.addCommand(makeNudgeCommand()); program.addCommand(makeEventsCommand()); +program.addCommand(makeSlackCommand()); +program.addCommand(makeLinearCommand()); program.addCommand(makeWatchCommand()); program.addCommand(makeTraceCommand()); program.addCommand(makeWebhookCommand()); diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts new file mode 100644 index 0000000..93d4eea --- /dev/null +++ b/cli/src/commands/linear.ts @@ -0,0 +1,427 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as readline from 'readline'; +import { CloudFormationClient, DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { GetSecretValueCommand, PutSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { Command } from 'commander'; +import { ApiClient } from '../api-client'; +import { loadConfig, loadCredentials } from '../config'; +import { formatJson } from '../format'; + +/** Default label that triggers an ABCA task when applied to a Linear issue. */ +const DEFAULT_LABEL_FILTER = 'bgagent'; + +/** Standard RFC 4122 UUID — Linear's `projects.nodes[].id` matches this shape. */ +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +export function makeLinearCommand(): Command { + const linear = new Command('linear') + .description('Manage Linear integration'); + + linear.addCommand( + new Command('link') + .description('Link your Linear account using a verification code') + .argument('', 'Verification code from Linear') + .option('--output ', 'Output format (text or json)', 'text') + .action(async (code: string, opts) => { + const client = new ApiClient(); + const result = await client.linearLink(code); + + if (opts.output === 'json') { + console.log(formatJson(result)); + } else { + console.log('Linear account linked successfully.'); + console.log(` Workspace: ${result.linear_workspace_id}`); + console.log(` User: ${result.linear_user_id}`); + console.log(` Linked at: ${result.linked_at}`); + } + }), + ); + + linear.addCommand( + new Command('setup') + .description('Populate Linear webhook secret + personal API token in Secrets Manager') + .option('--region ', 'AWS region (defaults to configured region)') + .option('--stack-name ', 'CloudFormation stack name', 'backgroundagent-dev') + .action(async (opts) => { + const config = loadConfig(); + const region = opts.region || config.region; + + const webhookSecretArn = await getStackOutput(region, opts.stackName, 'LinearWebhookSecretArn'); + const apiTokenSecretArn = await getStackOutput(region, opts.stackName, 'LinearApiTokenSecretArn'); + + if (!webhookSecretArn || !apiTokenSecretArn) { + console.error('Could not find Linear secret ARNs in stack outputs. Deploy the stack first.'); + process.exit(1); + } + + const apiBaseUrl = config.api_url.replace(/\/+$/, ''); + console.log('Linear setup — see docs/guides/LINEAR_SETUP_GUIDE.md for the full walkthrough.\n'); + console.log('Required Linear config:'); + console.log(' 1. Create a personal API key at https://linear.app/settings/account/security'); + console.log(` 2. Create a webhook at https://linear.app/settings/api — point it at: ${apiBaseUrl}/linear/webhook`); + console.log(' - Subscribe to: Issues'); + console.log(' - Copy the signing secret from the webhook detail page\n'); + + const webhookSecret = await promptSecret('Webhook signing secret: '); + const apiToken = await promptSecret('Personal API key (lin_api_…): '); + + if (!webhookSecret || !apiToken) { + console.error('\n✗ Both values are required. Try again.'); + process.exit(1); + } + if (!apiToken.startsWith('lin_api_')) { + console.error('\n✗ Personal API keys start with "lin_api_". Check https://linear.app/settings/account/security.'); + process.exit(1); + } + + const sm = new SecretsManagerClient({ region }); + await sm.send(new PutSecretValueCommand({ SecretId: webhookSecretArn, SecretString: webhookSecret })); + console.log(' ✓ Stored webhook signing secret'); + await sm.send(new PutSecretValueCommand({ SecretId: apiTokenSecretArn, SecretString: apiToken })); + console.log(' ✓ Stored personal API token'); + + const userMappingTable = await getStackOutput(region, opts.stackName, 'LinearUserMappingTableName'); + if (!userMappingTable) { + console.error('\n✗ Could not find LinearUserMappingTableName in stack outputs. Deploy the stack first.'); + process.exit(1); + } + await autoLinkTokenOwner({ region, apiToken, userMappingTable }); + + console.log('\nNext steps:'); + console.log(' 1. Onboard a Linear project:'); + console.log(' bgagent linear onboard-project --repo owner/repo'); + console.log(' 2. Add the "bgagent" label to a Linear issue in a mapped project — ABCA will pick it up.'); + console.log(' (To link additional Linear users, run `bgagent linear link ` after they generate a code.)'); + }), + ); + + linear.addCommand( + new Command('onboard-project') + .description('Map a Linear project to a GitHub repository (admin IAM required)') + .argument('', 'Linear project UUID') + .requiredOption('--repo ', 'GitHub repository the mapped project should route tasks to') + .option('--label