---
phase: 02-claude-code-beachhead
plan: 02
subsystem: governance-channels
tags: [discord, approval, telegram-parity, ed25519, webhook, governance-channel]
type: execute
wave: 1
depends_on: []
files_modified:
  - __tests__/unit/discord-interactions-route.test.js
  - __tests__/unit/discord-approvals.test.js
  - __tests__/unit/discord-embed-payload.test.js
  - __tests__/unit/mcp-tools.test.js
  - app/api/discord/interactions/route.js
  - app/lib/discordApprovals.js
  - app/api/actions/route.js
  - middleware.js
  - .env.example
  - package.json
  - package-lock.json
  - docs/api-inventory.json
  - docs/api-inventory.md
  - docs/openapi/critical-stable.openapi.json
  - PROJECT_DETAILS.md
autonomous: true
requirements: [CCI-03]
user_setup:
  - service: discord
    why: "Receive approval requests as Discord DMs with Approve/Deny buttons (CCI-03). Requires a Discord bot app registered in Discord Developer Portal — Claude cannot register bots via API."
    env_vars:
      - name: DISCORD_BOT_TOKEN
        source: "Discord Developer Portal -> Applications -> (your app) -> Bot -> Reset Token"
      - name: DISCORD_PUBLIC_KEY
        source: "Discord Developer Portal -> Applications -> (your app) -> General Information -> Public Key"
      - name: DISCORD_APPROVER_USER_ID
        source: "Discord client -> your user profile (enable Developer Mode) -> Copy User ID"
      - name: DISCORD_APPROVER_ORG_ID
        source: "DashClaw instance -> /settings or your API key -> org_id field"
    dashboard_config:
      - task: "Create application, enable Bot, invite to a mutual test server with no privileged intents"
        location: "Discord Developer Portal (discord.com/developers/applications)"
      - task: "Set Interactions Endpoint URL to https://<deployment>/api/discord/interactions"
        location: "Discord Developer Portal -> Applications -> (your app) -> General Information -> Interactions Endpoint URL"

must_haves:
  truths:
    - "Discord sends a POST with a valid Ed25519 signature → /api/discord/interactions verifies, parses, and returns a 200 response within 3 seconds"
    - "Discord sends a POST with an invalid Ed25519 signature → /api/discord/interactions returns 401 without touching the database (Discord's security sweep requirement)"
    - "Discord PING (type 1) handshake returns {type: 1} so the Portal accepts the Interactions URL"
    - "An action entering status=pending_approval triggers a fire-and-forget Discord DM to DISCORD_APPROVER_USER_ID with an embed + Approve/Deny buttons"
    - "Tapping Approve on the DM causes action_records.status to transition from pending_approval → running with approved_by=discord:<user_id>"
    - "Tapping Deny on the DM causes action_records.status to transition to failed with decision=deny recorded"
    - "After button tap, the DM is edited in place to show APPROVED/DENIED with timestamp and buttons are removed"
    - "Discord bot surface is gated by DASHCLAW_ALERTS_DISCORD kill-switch and silent-no-op when DISCORD_BOT_TOKEN is unset"
  artifacts:
    - path: "app/api/discord/interactions/route.js"
      provides: "Discord interactions webhook handler with Ed25519 verify, PING handshake, MESSAGE_COMPONENT button handler"
      exports: ["POST"]
      contains: "verifyDiscordSignature"
    - path: "app/lib/discordApprovals.js"
      provides: "Outbound Discord DM emitter for pending_approval actions"
      exports: ["fireDiscordApproval"]
      contains: "buildEmbedPayload"
    - path: "__tests__/unit/discord-interactions-route.test.js"
      provides: "Unit tests for /api/discord/interactions covering signature verify, PING, MESSAGE_COMPONENT approve/deny, idempotency, misconfig"
      contains: "returns 401"
    - path: "__tests__/unit/discord-approvals.test.js"
      provides: "Unit tests for fireDiscordApproval isEnabled gating and fetch call shape"
    - path: "__tests__/unit/discord-embed-payload.test.js"
      provides: "Pure-function tests for buildEmbedPayload: 4 fields, 200-char goal truncation, brand color, button custom_id format"
  key_links:
    - from: "app/api/actions/route.js"
      to: "app/lib/discordApprovals.js"
      via: "after(() => fireDiscordApproval(createdAction, sql, orgId))"
      pattern: "fireDiscordApproval\\("
    - from: "app/api/discord/interactions/route.js"
      to: "app/lib/repositories/actions.repository.js"
      via: "getActionSummary + recordApproval (no direct SQL)"
      pattern: "recordApproval\\("
    - from: "middleware.js"
      to: "app/api/discord/interactions/route.js"
      via: "PUBLIC_ROUTES allowlist — route authenticates itself via Ed25519 signature"
      pattern: "/api/discord/interactions"
---

<objective>
Ship CCI-03: a Discord bot that DMs the approver with an embed + Approve/Deny buttons, receives button interactions at `/api/discord/interactions`, verifies Ed25519 signatures, and resolves the action in place via the same `recordApproval` repository path the Telegram bridge already uses. This is a mechanical 1:1 port of `app/api/telegram/webhook/route.js` + `app/lib/telegramApprovals.js` with auth (HMAC→Ed25519), ack (answerCallback→response type 6), and message-edit (editMessageText→PATCH @original) mechanisms swapped.

Purpose: Without Discord, the Claude Code beachhead has no phone-first approval story. This plan unlocks CCI-01's walkthrough (tracked in plan 02-01) and CCI-04's `/my-agent` denials rendering (plan 02-03) by producing real `action_records` rows with `approved_by=discord:*`.

Output:
- New route: `app/api/discord/interactions/route.js` (PING handshake, Ed25519 verify, MESSAGE_COMPONENT button handler, 401 discipline)
- New lib: `app/lib/discordApprovals.js` (`fireDiscordApproval` fire-and-forget emitter, mirrors `telegramApprovals.js`)
- Wiring: `app/api/actions/route.js` gains one `after(() => fireDiscordApproval(...))` line beside the existing Telegram emit
- Public-route allowlist: `/api/discord/interactions` added to `middleware.js` PUBLIC_ROUTES
- Env contract: 5 new `DISCORD_*` vars in `.env.example` mirroring the Telegram block
- Dep: `tweetnacl@1.0.3` added to `package.json` (Discord's own canonical Ed25519 verify path — per RESEARCH Open Question 1)
- Tests (Wave 0 + implementation): 3 new unit test files mirroring the Telegram tests
- Auto-regenerated artifacts: `docs/api-inventory.{json,md}`, `docs/openapi/critical-stable.openapi.json`, livingcode outputs under `app/lib/doctor/generated/`
- Docs delta: `PROJECT_DETAILS.md` route-list section grows by one row (the webhook is not SDK-exposed — mirror Telegram's treatment per RESEARCH §L3)
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/STATE.md
@.planning/phases/02-claude-code-beachhead/02-SPEC.md
@.planning/phases/02-claude-code-beachhead/02-CONTEXT.md
@.planning/phases/02-claude-code-beachhead/02-RESEARCH.md
@.planning/phases/02-claude-code-beachhead/02-VALIDATION.md
@.planning/codebase/CONVENTIONS.md
@.planning/codebase/INTEGRATIONS.md
@CLAUDE.md

# Direct blueprints (READ before writing the Discord files)
@app/api/telegram/webhook/route.js
@app/lib/telegramApprovals.js
@app/lib/repositories/actions.repository.js
@app/api/actions/route.js
@middleware.js
@.env.example
@__tests__/unit/telegram-webhook-route.test.js
@__tests__/unit/telegram-approvals.test.js

<interfaces>
<!-- Key contracts extracted from the codebase. Executor uses these directly; no repo spelunking needed. -->

From `app/lib/repositories/actions.repository.js`:
```javascript
// Read the pending action's display-relevant fields.
export async function getActionSummary(sql, orgId, actionId):
  Promise<{ action_id, agent_id, action_type, declared_goal, risk_score, status } | null>

// Atomic status transition with concurrency guard. Returns null if row was
// already resolved by another channel (the UPDATE WHERE status='pending_approval'
// matched zero rows). Never throws for concurrency — caller handles null.
export async function recordApproval(sql, orgId, actionId, {
  newStatus,      // 'running' for approve, 'failed' for deny
  errorMessage,   // null on approve, 'Denied via Discord' on deny
  decision,       // 'allow' | 'deny'
  userId,         // 'discord:<numeric_user_id>'
  safeReasoning,  // null on approve, 'Denied via Discord' on deny
}): Promise<ActionRow | null>
```

From `app/api/telegram/webhook/route.js` (structural blueprint — do NOT import, re-implement for Discord):
```javascript
// Shared constants shape to mirror:
const CALLBACK_DATA_RE = /^(ap|dn):(act_[a-z0-9_-]{1,57})$/;
const FETCH_TIMEOUT_MS = 1500;

// Shared helpers shape to mirror (buildResolvedText is portable — copy verbatim).
function buildResolvedText(action, decisionLabel, action_id): string

// Export signature — same as Telegram:
export async function POST(request: Request): Promise<NextResponse>
```

From `app/api/actions/route.js` lines 317-333 (wiring site):
```javascript
if (createdAction.status === 'pending_approval') {
  after(() => fireTelegramApproval(createdAction, sql, orgId));
  after(() => fireDiscordApproval(createdAction, sql, orgId));  // NEW — add this line
  after(() => fireWebhooksForApproval(orgId, 'approval_pending', { ... }, sql).catch(() => {}));
}
```

From `middleware.js` lines 27-41 — insertion site:
```javascript
const PUBLIC_ROUTES = [
  '/api/health',
  // ...
  '/api/telegram/webhook',  // auth: x-telegram-bot-api-secret-token + chat-id allowlist (in route)
  '/api/discord/interactions',  // NEW — auth: Ed25519 signature + user_id allowlist (in route)
  // ...
];
```

From Discord's interaction body (type 3 MESSAGE_COMPONENT) per RESEARCH §Code Examples:
```jsonc
{
  "type": 3,
  "id": "<snowflake>",
  "application_id": "<snowflake — USE FOR PATCH URL>",
  "token": "<15-min TTL interaction token — USE FOR PATCH URL>",
  "data": { "component_type": 2, "custom_id": "ap:act_abc12345" },
  "user":   { "id": "<snowflake>" },            // present in DM context
  "member": { "user": { "id": "<snowflake>" } } // present in guild context
}
```

Discord response callback types (RESEARCH §Standard Stack):
```
PING                          = 1  (incoming)
APPLICATION_COMMAND           = 2
MESSAGE_COMPONENT             = 3  (incoming — button clicks)
MODAL_SUBMIT                  = 5
PONG                          = 1  (response to type 1)
CHANNEL_MESSAGE_WITH_SOURCE   = 4
DEFERRED_UPDATE_MESSAGE       = 6  (use this — silent ack, no "Bot is thinking…")
UPDATE_MESSAGE                = 7
```

Discord REST endpoints (`https://discord.com/api/v10`):
```
POST /users/@me/channels                              # open DM channel (cache per-process)
POST /channels/{dm_channel_id}/messages               # send message with embed + components
PATCH /webhooks/{app_id}/{interaction.token}/messages/@original
                                                       # edit the DM after DEFERRED_UPDATE_MESSAGE ack
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Wave 0 — Scaffold failing tests for all three Discord surfaces</name>
  <files>See files_modified frontmatter for the full list; task-specific subset is documented in the action block.</files>
  <read_first>
    - __tests__/unit/telegram-webhook-route.test.js (447 lines, 16 test cases — structural mirror)
    - __tests__/unit/telegram-approvals.test.js (170 lines — outbound emitter test shape)
    - __tests__/helpers.js (whatever `makeRequest` exports — the Telegram tests import it)
    - app/api/telegram/webhook/route.js (to understand what the tests are covering)
    - app/lib/telegramApprovals.js (to understand isEnabled / buildMessage shape)
    - .planning/phases/02-claude-code-beachhead/02-RESEARCH.md §Validation Architecture (lines ~1100-1160) for the 8-10 test case list
    - .planning/phases/02-claude-code-beachhead/02-RESEARCH.md §Telegram → Discord Porting Map for per-file adaptation rules
  </read_first>
  <behavior>
    Each new test file must declare the expected behavior before the production code exists. Tests MUST FAIL on first run (RED state) because the production files don't exist yet. That's the TDD contract.

    File 1: `__tests__/unit/discord-interactions-route.test.js`
    Test cases (mirrors `telegram-webhook-route.test.js` 1:1):
    - `POST /api/discord/interactions — auth`:
      - Test 1: returns 401 when X-Signature-Ed25519 header is missing (per T-02-02-01: unsigned requests rejected)
      - Test 2: returns 401 when X-Signature-Timestamp header is missing
      - Test 3: returns 401 when signature does not verify against (timestamp + rawBody) (per T-02-02-01)
      - Test 4: returns 401 when signature is valid but body.user.id !== DISCORD_APPROVER_USER_ID — MUST be 401 (not 403) to avoid leaking sender identity (matches Telegram discipline)
    - `POST /api/discord/interactions — PING handshake`:
      - Test 5: valid signature + `{type: 1}` body returns JSON `{type: 1}` (PONG)
    - `POST /api/discord/interactions — callback_data validation`:
      - Test 6: valid signature + MESSAGE_COMPONENT + malformed custom_id returns type 6 (DEFERRED_UPDATE_MESSAGE), does not call repo
    - `POST /api/discord/interactions — approve path`:
      - Test 7: valid signature + `custom_id: "ap:act_abc12345"` → response is type 6 AND (after `after()` task) recordApproval called with `{newStatus:'running', decision:'allow', userId:'discord:<id>'}`
    - `POST /api/discord/interactions — deny path`:
      - Test 8: valid signature + `custom_id: "dn:act_xyz..."` → recordApproval called with `{newStatus:'failed', decision:'deny', errorMessage:'Denied via Discord'}`
    - `POST /api/discord/interactions — idempotency`:
      - Test 9: action already resolved (getActionSummary returns status !== 'pending_approval') → PATCH @original with "Already resolved" message, recordApproval NOT called
      - Test 10: recordApproval returns null (race with Telegram) → PATCH @original with "resolved by another channel" message
    - `POST /api/discord/interactions — misconfig`:
      - Test 11: DISCORD_APPROVER_ORG_ID unset → PATCH @original with "Server misconfigured" message, recordApproval NOT called

    Tests MUST use `vi.hoisted` pattern + `vi.mock('../../app/lib/repositories/actions.repository.js', ...)` pattern from `telegram-webhook-route.test.js` lines 1-25. Tests MUST sign synthetic payloads with a local `tweetnacl.sign.keyPair()` and set `DISCORD_PUBLIC_KEY` from the resulting public key hex in `beforeEach`. Use `tweetnacl.sign.detached(...)` to generate the X-Signature-Ed25519 header for valid-signature tests.

    File 2: `__tests__/unit/discord-approvals.test.js`
    Test cases (mirrors `telegram-approvals.test.js`):
    - `isEnabled` returns false when `DISCORD_BOT_TOKEN` is unset (no fetch call)
    - `isEnabled` returns false when `DISCORD_APPROVER_USER_ID` is unset
    - `isEnabled` returns false when `DASHCLAW_ALERTS_DISCORD === 'false'`
    - `fireDiscordApproval` with enabled env + `action.status === 'pending_approval'` → calls fetch exactly twice: once to `POST /users/@me/channels` then once to `POST /channels/{id}/messages`
    - `fireDiscordApproval` with `action.status !== 'pending_approval'` → no fetch calls (silent no-op)
    - `fireDiscordApproval` swallows fetch errors (fire-and-forget) — never rejects
    - Channel-id caching: two consecutive `fireDiscordApproval` calls open DM channel ONCE

    File 3: `__tests__/unit/discord-embed-payload.test.js`
    Test cases (pure-function tests on `buildEmbedPayload`):
    - returns embed with exactly 4 fields: Agent, Action, Risk score (inline=true for those 3), Goal (inline=false)
    - truncates `action.declared_goal` at 200 characters (per D-05 + T-02-02-03: bound untrusted input in embed)
    - uses color `0xf97316` (brand orange — the ONE permitted in-code hex, per .impeccable.md + RESEARCH §Pattern 2)
    - components has ACTION_ROW (type 1) with two buttons (type 2): Approve (style 3, custom_id `ap:<action_id>`) and Deny (style 4, custom_id `dn:<action_id>`)
    - custom_id preserves the full 57-char action_id limit (matches `CALLBACK_DATA_RE`)
    - footer.text contains `action.action_id`

    File 4 (ADD test case to existing file): `__tests__/unit/mcp-tools.test.js`
    Existing coverage: `describe("dashclaw_wait_for_approval")` already verifies polling + timeout (lines ~153-180). Add ONE new test case asserting the SPEC CCI-03 acceptance bullet 3: **MCP tool resolves within 2s of a status change** (per RESEARCH §Validation Architecture row CCI-03 — "verify existing coverage; may need new test").

    New test case inside the existing `describe("dashclaw_wait_for_approval")` block:
    - Test: "resolves within 2s of status flipping from pending_approval to completed"
      - `mockGet` returns `pending_approval` on first N calls, then `completed` on the (N+1)th
      - Use `poll_interval_seconds: 0.5` (matches real-world 3s default scaled to test time; the 2s boundary is the ACCEPTANCE criterion, not the poll interval — the resolver returns within one poll cycle of the flip)
      - Capture `Date.now()` before the flip is scheduled and after the call resolves
      - Assert: `(endTime - flipTime) <= 2000` ms (2s SPEC boundary)
      - Assert: response `approved === true`, `waited_seconds` is finite
    - Implementation hint: use `mockGet.mockImplementation` with a counter so the flip timestamp is captured the moment the mock starts returning `completed`, then assert resolution happens within 2000ms of that timestamp. Per A3 in RESEARCH (3s default poll + 0.5s PATCH ≈ 4s worst case), the test uses a tighter poll to prove the mechanism, not the default config.
  </behavior>
  <action>
    Create the three test files listed in `<behavior>`. Structural mirror of the Telegram counterparts — copy their `vi.hoisted` / `vi.stubGlobal('fetch', mockFetch)` / `vi.mock` scaffolding verbatim, adjust imports to point at the (not-yet-existing) Discord files.

    For the signature-verify tests: install the Ed25519 key helper inline at the top of the test file (per RESEARCH §Validation Architecture):

    ```javascript
    import nacl from 'tweetnacl';
    const KEYPAIR = nacl.sign.keyPair();
    const DISCORD_PUBLIC_KEY_HEX = Buffer.from(KEYPAIR.publicKey).toString('hex');
    function signDiscord(timestamp, rawBody) {
      const sig = nacl.sign.detached(Buffer.from(timestamp + rawBody), KEYPAIR.secretKey);
      return Buffer.from(sig).toString('hex');
    }
    ```

    Pre-requisite: install `tweetnacl` as a regular dependency (the Discord route in Task 2 uses it for signature verify, and the tests here need the same library to generate valid signatures):
    ```bash
    npm install tweetnacl@1.0.3
    ```

    **MCP <=2s test addition** (File 4): edit `__tests__/unit/mcp-tools.test.js` in-place to add the new test case inside the existing `describe("dashclaw_wait_for_approval")` block. The existing mock scaffolding (`mockGet`, `handlers`) is already present — just add one new `it("resolves within 2s of status flipping...")` case per the behavior spec above. This single-file edit is the second commit boundary of Wave 0.

    Commit tests + package.json + package-lock.json + mcp-tools.test.js edit. The three NEW Discord test files WILL fail (RED state — production modules don't exist). The mcp-tools new test case should PASS immediately (the 2s assertion is met by the existing poll mechanism). That is expected and verified below.

    Per T-02-02-01 mitigation: the auth tests MUST assert `res.status === 401` for missing sig, bad sig, and bad user_id (never 200, never 403). Discord's automated security sweeps will remove the Interactions URL if the endpoint returns anything other than 401 on a tampered signature.
  </action>
  <verify>
    <automated>npm test -- --run __tests__/unit/discord-interactions-route.test.js __tests__/unit/discord-approvals.test.js __tests__/unit/discord-embed-payload.test.js 2>&amp;1 | grep -E "FAIL|Cannot find module" &amp;&amp; echo "RED-STATE-CONFIRMED" &amp;&amp; npm test -- --run __tests__/unit/mcp-tools.test.js -t "resolves within 2s"</automated>
  </verify>
  <done>
    - Three NEW test files exist under `__tests__/unit/discord-*.test.js`
    - `tweetnacl@1.0.3` in package.json dependencies (not devDependencies)
    - `npm test -- --run __tests__/unit/discord-*` exits non-zero because production modules don't exist yet (expected RED state)
    - The test files compile (imports resolve syntactically — they fail at runtime because `app/api/discord/interactions/route.js` doesn't exist)
    - `__tests__/unit/mcp-tools.test.js` has one new test case matching `-t "resolves within 2s"` inside the existing `dashclaw_wait_for_approval` describe block
    - `npm test -- --run __tests__/unit/mcp-tools.test.js -t "resolves within 2s"` PASSES (this covers SPEC CCI-03 acceptance bullet 3)
    - No changes yet to `app/api/` or `app/lib/` — that's Task 2+
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Implement `/api/discord/interactions` route + `fireDiscordApproval` emitter + wire into actions route</name>
  <files>See files_modified frontmatter for the full list; task-specific subset is documented in the action block.</files>
  <read_first>
    - app/api/telegram/webhook/route.js (full, 188 lines — structural blueprint)
    - app/lib/telegramApprovals.js (full, 79 lines — emitter blueprint)
    - app/api/actions/route.js lines 315-333 (wiring site — where `after(() => fireTelegramApproval(...))` lives)
    - app/lib/repositories/actions.repository.js lines 1-59 (getActionSummary + recordApproval signatures)
    - middleware.js lines 26-41 (PUBLIC_ROUTES array)
    - .env.example lines 155-161 (Telegram block for shape reference)
    - .planning/phases/02-claude-code-beachhead/02-RESEARCH.md §Pattern 1 (route template, lines ~286-490)
    - .planning/phases/02-claude-code-beachhead/02-RESEARCH.md §Pattern 2 (emitter template, lines ~492-600)
    - .planning/phases/02-claude-code-beachhead/02-RESEARCH.md §Pitfalls 1-5 (signature/body/403 traps)
    - .planning/phases/02-claude-code-beachhead/02-RESEARCH.md §Security Domain (lines ~1160-1190 — STRIDE mitigations)
    - CLAUDE.md §Route SQL guardrail + §SDK Documentation Checklist
  </read_first>
  <action>
    Two production files + 3 small modifications. Follow the RESEARCH patterns verbatim. Discord's signed-body requirement (Pitfall 1) means this route MUST use `await request.text()` first, verify, then `JSON.parse`. Do NOT use `request.json()` — it destroys the signature.

    **File 2a: `app/api/discord/interactions/route.js`** (~200 lines — copy-adapt from Telegram webhook)

    Use the tweetnacl path (not native crypto) per RESEARCH Open Question 1 — tweetnacl matches Discord's own canonical example and avoids A1's unverified raw-key-format risk.

    ```javascript
    export const dynamic = 'force-dynamic';
    export const revalidate = 0;

    import { NextResponse, after } from 'next/server';
    import nacl from 'tweetnacl';
    import { getSql } from '../../../lib/db.js';
    import {
      getActionSummary,
      recordApproval,
    } from '../../../lib/repositories/actions.repository.js';

    const DISCORD_API = 'https://discord.com/api/v10';
    const CALLBACK_DATA_RE = /^(ap|dn):(act_[a-z0-9_-]{1,57})$/;
    const FETCH_TIMEOUT_MS = 1500;
    // Replay window: reject timestamps older than 5 minutes (T-02-02-02 mitigation)
    const TIMESTAMP_SKEW_SECONDS = 5 * 60;

    const PING = 1;
    const MESSAGE_COMPONENT = 3;
    const PONG = 1;
    const DEFERRED_UPDATE_MESSAGE = 6;

    function verifyDiscordSignature(rawBody, signatureHex, timestampStr, publicKeyHex) {
      if (!signatureHex || !timestampStr || !publicKeyHex) return false;
      // T-02-02-02: reject stale timestamps (anti-replay)
      const now = Math.floor(Date.now() / 1000);
      const ts = Number(timestampStr);
      if (!Number.isFinite(ts) || Math.abs(now - ts) > TIMESTAMP_SKEW_SECONDS) return false;
      try {
        return nacl.sign.detached.verify(
          Buffer.from(timestampStr + rawBody),
          Buffer.from(signatureHex, 'hex'),
          Buffer.from(publicKeyHex, 'hex'),
        );
      } catch {
        return false;
      }
    }

    export async function POST(request) {
      // Verify BEFORE JSON.parse (Pitfall 1 — re-serialization breaks signature)
      const rawBody = await request.text();
      const sig = request.headers.get('x-signature-ed25519');
      const ts = request.headers.get('x-signature-timestamp');

      if (!verifyDiscordSignature(rawBody, sig, ts, process.env.DISCORD_PUBLIC_KEY)) {
        // T-02-02-01: MUST return 401 (never 200/403) — Discord security sweeps
        return NextResponse.json({ error: 'invalid request signature' }, { status: 401 });
      }

      let body;
      try { body = JSON.parse(rawBody); } catch {
        return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
      }

      // PING handshake — Discord Developer Portal saves interactions URL only
      // if this returns {type: 1}
      if (body.type === PING) return NextResponse.json({ type: PONG });

      if (body.type !== MESSAGE_COMPONENT) return NextResponse.json({ type: PONG });

      // T-02-02-03: user identity check AFTER signature verify.
      // DM context uses body.user.id; guild context uses body.member.user.id.
      // Phase 2 is DM-only per D-04, so prefer body.user.id.
      const userId = String(body.user?.id ?? body.member?.user?.id ?? '');
      if (userId !== process.env.DISCORD_APPROVER_USER_ID) {
        return NextResponse.json({ error: 'invalid request signature' }, { status: 401 });
      }

      const customId = body.data?.custom_id ?? '';
      const match = customId.match(CALLBACK_DATA_RE);
      if (!match) return NextResponse.json({ type: DEFERRED_UPDATE_MESSAGE });
      const [, verb, actionId] = match;

      const appId = body.application_id;
      const interactionToken = body.token;

      // Ack inside the 3-second window; DB work happens in `after()`.
      after(() => resolveApproval(verb, actionId, userId, appId, interactionToken));

      return NextResponse.json({ type: DEFERRED_UPDATE_MESSAGE });
    }

    async function resolveApproval(verb, actionId, discordUserId, appId, interactionToken) {
      const orgId = process.env.DISCORD_APPROVER_ORG_ID;
      if (!orgId) {
        await editOriginal(appId, interactionToken,
          'Server misconfigured: DISCORD_APPROVER_ORG_ID is not set');
        return;
      }
      const sql = getSql();
      const action = await getActionSummary(sql, orgId, actionId);
      if (!action) { await editOriginal(appId, interactionToken, 'Action not found'); return; }
      if (action.status !== 'pending_approval') {
        await editOriginal(appId, interactionToken, `Already resolved — status: ${action.status}`);
        return;
      }
      const userId = `discord:${discordUserId}`;
      const isApprove = verb === 'ap';
      let updated;
      try {
        updated = await recordApproval(sql, orgId, actionId, {
          newStatus: isApprove ? 'running' : 'failed',
          errorMessage: isApprove ? null : 'Denied via Discord',
          decision: isApprove ? 'allow' : 'deny',
          userId,
          safeReasoning: isApprove ? null : 'Denied via Discord',
        });
      } catch (err) {
        console.warn('[DiscordInteractions] recordApproval failed:', err.message);
        await editOriginal(appId, interactionToken, 'Approval failed');
        return;
      }
      if (!updated) {
        await editOriginal(appId, interactionToken, 'Already resolved — resolved by another channel');
        return;
      }
      await editOriginal(appId, interactionToken,
        buildResolvedText(action, isApprove ? 'APPROVED' : 'DENIED', actionId));
    }

    async function editOriginal(appId, interactionToken, content) {
      try {
        await fetch(`${DISCORD_API}/webhooks/${appId}/${interactionToken}/messages/@original`, {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ content, components: [] }),
          signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
        });
      } catch (err) {
        console.warn('[DiscordInteractions] editOriginal failed:', err.message);
      }
    }

    function buildResolvedText(action, decisionLabel, actionId) {
      const ts = new Date().toTimeString().slice(0, 8);
      const goal = (action.declared_goal || '—').slice(0, 200);
      return [
        `${decisionLabel} — ${ts}`, '',
        `Agent:   ${action.agent_id || 'unknown'}`,
        `Action:  ${action.action_type || 'unknown'}`,
        `Goal: ${goal}`, '',
        actionId,
      ].join('\n');
    }
    ```

    **File 2b: `app/lib/discordApprovals.js`** (~110 lines — copy-adapt from `telegramApprovals.js`)

    Follow RESEARCH §Pattern 2 verbatim. Export `fireDiscordApproval` AND export `buildEmbedPayload` (the test file needs it). DM channel id cached per-process (cleared on cold start — that's fine).

    Critical: when the fetch to `POST /channels/{dm_id}/messages` returns 403, CLEAR the cached channel id (Pitfall 2 mitigation — user may have toggled DM permissions). Let the next call re-open. No retry loop — fire-and-forget.

    Embed color is `0xf97316` — this is the ONE permitted in-code hex per .impeccable.md + RESEARCH §Anti-Patterns, because the Discord `color` field demands a 24-bit int, not a CSS token.

    Export signature MUST match (tests assert):
    ```javascript
    export async function fireDiscordApproval(action, _sql, _orgId) { /* ... */ }
    export function buildEmbedPayload(action) { /* ... */ }
    ```

    **File 2c (MODIFY): `middleware.js`**

    Add `/api/discord/interactions` to the `PUBLIC_ROUTES` array directly beneath the `/api/telegram/webhook` entry. Mirror the inline comment style:

    ```javascript
    '/api/telegram/webhook',       // auth: x-telegram-bot-api-secret-token + chat-id allowlist (in route)
    '/api/discord/interactions',   // auth: Ed25519 signature + user_id allowlist (in route)
    ```

    Per T-02-02-01: this route is intentionally PUBLIC because it authenticates itself with Ed25519. DashClaw API-key auth would break Discord's callback model.

    **File 2d (MODIFY): `app/api/actions/route.js`**

    Import `fireDiscordApproval` at the top alongside the existing `fireTelegramApproval` import (grep for the latter to find the exact line). In the `if (createdAction.status === 'pending_approval')` block (lines ~326-333 per current file), add ONE new line directly beneath the Telegram emit:

    ```javascript
    after(() => fireTelegramApproval(createdAction, sql, orgId));
    after(() => fireDiscordApproval(createdAction, sql, orgId));  // NEW
    ```

    Both emitters are idempotent no-ops when their respective env vars are unset (Discord's `isEnabled` gates on `DISCORD_BOT_TOKEN`).

    **File 2e (MODIFY): `.env.example`**

    Append a Discord block directly after the Telegram block (after current line 161). Mirror Telegram's formatting exactly:

    ```
    # ─── OPTIONAL: Discord approval bridge ─────────────────────────────────────────
    # Feature is off when DISCORD_BOT_TOKEN is unset. Registration is a Developer Portal UI step.
    # DISCORD_BOT_TOKEN=                  # from Discord Developer Portal -> Bot -> Reset Token
    # DISCORD_PUBLIC_KEY=                 # 64-char hex from Developer Portal -> General Information
    # DISCORD_APPROVER_USER_ID=           # numeric Discord user ID allowed to approve
    # DISCORD_APPROVER_ORG_ID=            # org id — same value as TELEGRAM_APPROVER_ORG_ID in single-admin setups
    # DASHCLAW_ALERTS_DISCORD=false       # explicit kill-switch when token is present
    ```

    **GitNexus impact note** (per CLAUDE.md): `app/api/actions/route.js` is the only pre-existing symbol being modified. Run `gitnexus_impact({target: "POST", direction: "upstream"})` on that route before the commit. Expected blast radius: contained (callers of the actions POST endpoint are external API clients, no downstream code symbols impacted).

    **After all five writes commit:** the pre-commit hook (`run-pre-commit-checks.mjs`) will auto-regenerate `docs/api-inventory.{json,md}`, `docs/openapi/critical-stable.openapi.json`, and livingcode outputs. Do NOT hand-edit those files (L1 — livingcode generated artifacts).

    **SDK docs checklist audit** (per CLAUDE.md MEMORY + RESEARCH §L3): `/api/discord/interactions` is a webhook endpoint consumed by Discord, NOT by SDK callers. Mirror Telegram's treatment (grep `sdk/README.md` for "telegram" — absent → absent for Discord too). Required updates:
    - ✗ `app/docs/page.js` — webhook, not SDK surface → SKIP (mirrors Telegram)
    - ✗ `sdk/README.md`, `sdk-python/README.md` → SKIP (mirrors Telegram)
    - ✗ `docs/sdk-parity.md` → SKIP, but confirm via audit below (no-op expected)
    - ✓ `PROJECT_DETAILS.md` routes-list — add one row for `/api/discord/interactions`
    - ✓ `docs/api-inventory.md` — auto-regenerated by pre-commit hook
    - ✓ `docs/openapi/critical-stable.openapi.json` — auto-regenerated

    **Explicit sdk-parity.md no-op audit** (mandatory per user feedback 2026-04-22): the parity matrix tracks SDK method surfaces, not webhooks. Discord interactions is a webhook, so sdk-parity.md should not gain a row. Confirm both preconditions hold:
    ```bash
    # Precondition 1: Telegram is likewise absent from sdk-parity.md (baseline)
    grep -ic "telegram" docs/sdk-parity.md   # expect: 0
    # Precondition 2: No stale Discord SDK-method row exists
    grep -ic "discord" docs/sdk-parity.md    # expect: 0
    # If either returns non-zero, STOP and reconsider — the Discord route may be SDK-exposed after all.
    echo "sdk-parity.md unchanged (webhook, not SDK method — mirrors Telegram)"
    ```
    This single audit step closes the checker warning `sdk-parity-check-missing` (2026-04-22) without requiring an edit to sdk-parity.md itself.

    Run the doc-validation gates before commit:
    ```bash
    npm run route-sql:check     # must pass — Discord route uses repository, not raw SQL
    npm run openapi:check       # must pass — new route contract present
    npm run api:inventory:check # must pass — new route inventoried
    npm run docs:check          # must pass — no orphaned SDK documentation
    ```
  </action>
  <verify>
    <automated>npm test -- --run __tests__/unit/discord-interactions-route.test.js __tests__/unit/discord-approvals.test.js __tests__/unit/discord-embed-payload.test.js &amp;&amp; npm run route-sql:check &amp;&amp; npm run openapi:check &amp;&amp; npm run api:inventory:check &amp;&amp; npm run docs:check &amp;&amp; [ "$(grep -ic discord docs/sdk-parity.md)" = "0" ] &amp;&amp; [ "$(grep -ic telegram docs/sdk-parity.md)" = "0" ] &amp;&amp; echo "sdk-parity.md unchanged"</automated>
  </verify>
  <done>
    - `app/api/discord/interactions/route.js` exists, exports `POST`
    - `grep -E "verifyDiscordSignature|nacl.sign.detached.verify" app/api/discord/interactions/route.js` returns at least 1 match (T-02-02-01 mitigation in code)
    - `grep -E "TIMESTAMP_SKEW_SECONDS|5 \\* 60" app/api/discord/interactions/route.js` matches (T-02-02-02 mitigation in code)
    - `grep -E "CALLBACK_DATA_RE = /\\^\\(ap\\|dn\\):\\(act_" app/api/discord/interactions/route.js` matches (T-02-02-03 mitigation — custom_id strict regex)
    - `app/lib/discordApprovals.js` exists, exports `fireDiscordApproval` AND `buildEmbedPayload`
    - `grep "fireDiscordApproval" app/api/actions/route.js` returns exactly 2 matches (import + after() call)
    - `grep "'/api/discord/interactions'" middleware.js` returns 1 match (in PUBLIC_ROUTES array)
    - `.env.example` contains `DISCORD_BOT_TOKEN=` and `DASHCLAW_ALERTS_DISCORD=false`
    - No direct `sql\`...\`` literals in `app/api/discord/interactions/route.js` (route-sql:check passes)
    - All 11 tests in `discord-interactions-route.test.js` pass (GREEN state)
    - All tests in `discord-approvals.test.js` + `discord-embed-payload.test.js` pass
    - `npm run openapi:check` + `npm run api:inventory:check` + `npm run route-sql:check` + `npm run docs:check` all exit 0
    - `PROJECT_DETAILS.md` routes-list section contains `/api/discord/interactions` entry
    - `docs/sdk-parity.md` audit passes: `grep -ic discord docs/sdk-parity.md` returns 0 AND `grep -ic telegram docs/sdk-parity.md` returns 0 (webhook parity confirmed, no SDK-method row needed)
    - No hand-edits to `app/lib/doctor/generated/*` or `public/livingcode/*` — those regenerate
  </done>
</task>

<task type="auto" tdd="false">
  <name>Task 3: Full-suite regression gate + GitNexus change detection</name>
  <files>See files_modified frontmatter for the full list; task-specific subset is documented in the action block.</files>
  <read_first>
    - .planning/phases/02-claude-code-beachhead/02-VALIDATION.md §Sampling Rate (full `npm test` per plan wave is mandatory per feedback_full_test_suite_in_plan_verification.md)
    - CLAUDE.md §GitNexus "Self-Check Before Finishing"
  </read_first>
  <action>
    Phase-gate verification. No new code — prove nothing broke.

    1. Run the full 1648+ test suite:
       ```bash
       npm test
       ```
       Must exit 0. If any existing test fails (including `claude-code-starter-pack.test.js` — CCI-02 no-regression), stop and fix before closing the plan.

    2. Run the static guardrails:
       ```bash
       npm run lint
       npm run route-sql:check
       npm run openapi:check
       npm run api:inventory:check
       npm run docs:check
       ```

    3. GitNexus change detection (per CLAUDE.md Self-Check):
       ```
       gitnexus_detect_changes({scope: "staged"})
       ```
       Verify the affected symbol set matches the plan's `files_modified` frontmatter. Expected affected symbols:
       - NEW: `app/api/discord/interactions/route.js POST`, `resolveApproval`, `editOriginal`, `buildResolvedText`, `verifyDiscordSignature`
       - NEW: `app/lib/discordApprovals.js fireDiscordApproval`, `buildEmbedPayload`, `isEnabled`, `openDmChannel`, `sendApprovalMessage`
       - MODIFIED: `app/api/actions/route.js POST` (one-line addition — low-impact expected)
       - MODIFIED: `middleware.js` PUBLIC_ROUTES array

       If detect_changes flags any unexpected symbol change (e.g. you accidentally modified `fireTelegramApproval`), revert and re-commit with intent.

    4. Verify `app/lib/doctor/generated/` and `public/livingcode/` deltas ARE present in the diff — this confirms the pre-commit livingcode regeneration ran. Do NOT try to "clean up" those deltas; they're intentional.
  </action>
  <verify>
    <automated>npm test &amp;&amp; npm run lint &amp;&amp; npm run route-sql:check &amp;&amp; npm run openapi:check &amp;&amp; npm run api:inventory:check &amp;&amp; npm run docs:check</automated>
  </verify>
  <done>
    - `npm test` exits 0 across the full suite (1648+ tests)
    - `npm run lint` exits 0
    - `npm run route-sql:check` exits 0
    - `npm run openapi:check` exits 0 (new Discord route contract is present in `docs/openapi/critical-stable.openapi.json`)
    - `npm run api:inventory:check` exits 0
    - `npm run docs:check` exits 0
    - `__tests__/unit/claude-code-starter-pack.test.js` passes (CCI-02 untouched)
    - `__tests__/unit/telegram-webhook-route.test.js` passes (Telegram bridge untouched)
    - GitNexus detect_changes output matches `files_modified` frontmatter; no stray symbol modifications
    - Auto-regenerated files (`app/lib/doctor/generated/*`, `public/livingcode/index.html`, `docs/api-inventory.{json,md}`, `docs/openapi/critical-stable.openapi.json`) are staged alongside the feature commit
  </done>
</task>

</tasks>

<threat_model>
## Trust Boundaries

| Boundary | Description |
|----------|-------------|
| Discord → /api/discord/interactions | Untrusted: anyone can POST to this endpoint. Discord's Ed25519 signature is the only thing distinguishing legitimate interactions from attackers. |
| DashClaw server → discord.com/api/v10 | Trusted outbound (our bot token authenticates as us); TLS via Vercel. |
| Hook → /api/actions → fireDiscordApproval | Trusted in-process call chain, same org-scoped authentication path Telegram already uses. |

## STRIDE Threat Register

| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-02-02-01 | Spoofing | `POST /api/discord/interactions` — fake interactions without valid Ed25519 signature | mitigate | `verifyDiscordSignature` runs on raw bytes (`await request.text()` — NOT `request.json()`) BEFORE JSON.parse; returns 401 on any mismatch. Asserted by `discord-interactions-route.test.js` tests 1-3. Discord's automated security sweeps will disable the Interactions URL if this returns anything but 401 on bad signatures (RESEARCH §Pitfall 3). |
| T-02-02-02 | Tampering | Replay of captured signed interaction payload | mitigate | `TIMESTAMP_SKEW_SECONDS = 5 * 60` — reject any request where `abs(now - headers['x-signature-timestamp']) > 300`. Discord includes the timestamp in the signed data so an attacker can't tamper with it without breaking the signature. Implemented in `verifyDiscordSignature` before the nacl.verify call. |
| T-02-02-03 | Spoofing | custom_id injection — attacker crafts button callback with malicious action_id | mitigate | `CALLBACK_DATA_RE = /^(ap\|dn):(act_[a-z0-9_-]{1,57})$/` strict regex. Repository `getActionSummary(sql, orgId, actionId)` scopes by `DISCORD_APPROVER_ORG_ID` — an action_id from a different org returns null, route responds with "Action not found" via PATCH @original and never calls recordApproval. Asserted by test 11 (misconfig) + idempotency tests 9-10. |
| T-02-02-04 | Spoofing | Different Discord user impersonates the approver | mitigate | `body.user.id === DISCORD_APPROVER_USER_ID` check AFTER signature verify passes. 401 on mismatch (not 403 — avoids leaking "signature correct but sender wrong" distinguishability). Asserted by test 4. |
| T-02-02-05 | Information Disclosure | Bot token or signature headers leaked via logs | mitigate | `console.warn` sites log only `err.message` (never the raw headers or token). Mirrors Telegram discipline at `telegram/webhook/route.js:33,49`. No new log statements introduce env-var readback. |
| T-02-02-06 | Denial of Service | Attacker floods `/api/discord/interactions` with malformed bodies | accept | Signature verify is the first operation; bad-sig requests cost ~100µs to reject. JSON.parse failures die at 400 before touching repos. Vercel's per-lambda concurrency cap + Discord's own rate limits cap realistic damage. Low-value target (no PII, no state mutation without valid sig + correct user_id). |
| T-02-02-07 | Tampering | Race between Telegram + Discord approvals on the same action | mitigate | Repository `recordApproval` uses atomic `UPDATE WHERE status='pending_approval' RETURNING *`. Second caller gets null result → route responds "Already resolved — resolved by another channel". Asserted by test 10. Already proven for Telegram at `telegram-webhook-route.test.js:346-373`. |
</threat_model>

<verification>

### Full-plan verification gate (before calling this plan complete)

```bash
# All three new test files green
npm test -- --run __tests__/unit/discord-interactions-route.test.js \
                  __tests__/unit/discord-approvals.test.js \
                  __tests__/unit/discord-embed-payload.test.js

# Full suite — no regressions (per feedback_full_test_suite_in_plan_verification.md)
npm test

# Static guardrails
npm run lint
npm run route-sql:check
npm run openapi:check
npm run api:inventory:check
npm run docs:check

# Route plumbing spot-check (manual, 2 lines of shell):
grep -n 'fireDiscordApproval' app/api/actions/route.js   # expect 2 matches
grep -n '/api/discord/interactions' middleware.js         # expect 1 match in PUBLIC_ROUTES
```

### Manual smoke (post-deploy, logged in CCI-01 walkthrough — not gating THIS plan)

- Discord Developer Portal accepts the Interactions Endpoint URL (PING handshake succeeds)
- Triggering a `pending_approval` action via Claude Code sends a DM to the approver
- Tapping Approve flips DM to `APPROVED — HH:MM:SS`, `action_records.status = 'running'`, and `dashclaw_wait_for_approval` returns within ~3s
- Tapping Deny flips to `DENIED — HH:MM:SS`, status `failed`

These manual steps are owned by plan 02-01's walkthrough checkpoint, not this plan's verification.

</verification>

<success_criteria>

The plan is complete when:

1. `/api/discord/interactions` exists and passes signature verify, PING handshake, MESSAGE_COMPONENT approve, MESSAGE_COMPONENT deny, and idempotency tests (all 11 test cases in `discord-interactions-route.test.js` green)
2. `fireDiscordApproval` fire-and-forget semantics match Telegram (7 test cases in `discord-approvals.test.js` green)
3. `buildEmbedPayload` produces correct D-05 embed shape (6 test cases in `discord-embed-payload.test.js` green)
4. `app/api/actions/route.js` emits Discord alongside Telegram — one new `after()` line, zero behavior change to the response path
5. `middleware.js` PUBLIC_ROUTES allows `/api/discord/interactions` (parity with Telegram line)
6. `.env.example` documents all 5 `DISCORD_*` env vars
7. `PROJECT_DETAILS.md` routes-list section includes the new endpoint
8. Full `npm test` suite green; all static guardrails (lint/route-sql/openapi/api-inventory/docs) green
9. STRIDE threats T-02-02-01, -02-02-02, -02-02-03, -02-02-04 are mitigated in code and asserted by tests; -02-02-05 mitigation discipline maintained (no raw token/header logging); -02-02-06 documented as accepted; -02-02-07 inherited from repository atomic guard
10. Livingcode + api-inventory + openapi regenerated automatically by pre-commit hook and committed alongside feature code (no hand-edits)

</success_criteria>

<output>
After completion, create `.planning/phases/02-claude-code-beachhead/02-02-SUMMARY.md` capturing:
- What shipped (three files + three mods + 5 env vars + 1 new dep)
- STRIDE mitigation verification (link to passing tests)
- Final file inventory (actual vs planned files_modified)
- Any deltas to planned approach (e.g. tweetnacl chosen over native crypto per RESEARCH Open Q 1)
- Handoff notes for plan 02-01 walkthrough (what env vars the operator needs to set, the 3-step Developer Portal flow)
</output>
