Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
integrations-chat.ts9.1 KB · 292 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
/**
 * Chat-bot endpoints — Slack events, Discord interactions, install
 * callbacks. Mounted on `/api/v2/integrations/{slack,discord}/*`.
 *
 * Surface:
 *   POST /slack/events        — Slack Events API + slash commands
 *   POST /discord/interactions — Discord interactions (slash commands)
 *   POST /slack/install        — OAuth completion (placeholder)
 *   POST /discord/install      — OAuth completion (placeholder)
 *
 * Verification:
 *   - Slack: X-Slack-Signature HMAC-SHA256 over `v0:<ts>:<body>`. The
 *     signing secret comes from the chat_integrations row that matches the
 *     incoming team_id.
 *   - Discord: X-Signature-Ed25519 + X-Signature-Timestamp. The Application
 *     Public Key is stored in chat_integrations.signing_secret. We look it
 *     up by guild_id when present, otherwise we accept any row of
 *     kind='discord' that verifies (multi-tenancy is exact-match on the
 *     public key, so this is safe).
 *
 * Dispatch:
 *   - Both endpoints decode the slash-command, call handleBotCommand(), and
 *     return the formatted blocks/embeds inline. We never await long ops
 *     here — anything that would block returns a deep-link instead (see
 *     cmdSpecShip / cmdChat in chat-bot.ts).
 */

import { Hono } from "hono";
import { eq, and } from "drizzle-orm";
import { db } from "../db";
import { chatIntegrations } from "../db/schema";
import {
  parseSlackSlashCommand,
  parseDiscordSlashCommand,
  verifySlackSignature,
  verifyDiscordSignature,
  handleBotCommand,
  type DiscordInteractionLike,
} from "../lib/chat-bot";

const r = new Hono();

// ---------------------------------------------------------------------------
// Slack — Events API + slash commands hit the same endpoint.
// ---------------------------------------------------------------------------

r.post("/api/v2/integrations/slack/events", async (c) => {
  const body = await c.req.text();
  const sig = c.req.header("X-Slack-Signature") ?? "";
  const ts = c.req.header("X-Slack-Request-Timestamp") ?? "";

  // url_verification challenge arrives as JSON BEFORE the integration is
  // saved, so we can't reach into chat_integrations for a secret. We MAY
  // accept it without verification because the response (`challenge`) is
  // exactly what Slack sent us — it carries no privileged data. Slack's
  // own docs recommend skipping signature on url_verification.
  if (looksLikeJson(body)) {
    try {
      const parsed = JSON.parse(body) as {
        type?: string;
        challenge?: string;
      };
      if (parsed.type === "url_verification" && parsed.challenge) {
        return c.json({ challenge: parsed.challenge });
      }
    } catch {
      /* fall through */
    }
  }

  // Slash commands and event subscriptions are form-encoded; pull team_id
  // out to find the right signing secret.
  const params = new URLSearchParams(body);
  const teamId = params.get("team_id") ?? "";

  // Find every Slack integration that could possibly own this request — we
  // need the signing_secret to verify. We try each in turn (typical user
  // has one Slack workspace).
  const candidates = await db
    .select()
    .from(chatIntegrations)
    .where(
      and(
        eq(chatIntegrations.kind, "slack"),
        teamId
          ? eq(chatIntegrations.teamId, teamId)
          : eq(chatIntegrations.kind, "slack")
      )
    );

  let matched = null;
  for (const integ of candidates) {
    if (!integ.signingSecret) continue;
    const ok = await verifySlackSignature({
      signingSecret: integ.signingSecret,
      timestamp: ts,
      signature: sig,
      body,
    });
    if (ok) {
      matched = integ;
      break;
    }
  }
  if (!matched) {
    return c.json({ error: "invalid_signature" }, 401);
  }

  // Slash command path: dispatch + return blocks inline.
  const text = params.get("text") ?? "";
  const parsed = parseSlackSlashCommand(text);
  const response = await handleBotCommand({
    kind: "slack",
    userId: matched.ownerUserId,
    command: parsed.command,
    subcommand: parsed.subcommand,
    args: parsed.args,
  });
  return c.json(response);
});

// ---------------------------------------------------------------------------
// Discord — interactions endpoint (Ed25519-signed)
// ---------------------------------------------------------------------------

r.post("/api/v2/integrations/discord/interactions", async (c) => {
  const body = await c.req.text();
  const sig = c.req.header("X-Signature-Ed25519") ?? "";
  const ts = c.req.header("X-Signature-Timestamp") ?? "";

  if (!sig || !ts) {
    return c.json({ error: "missing_signature" }, 401);
  }

  // Look up by guild_id if present in body; else scan all discord rows
  // (typical install volume is small per user — the constant-time verify
  // ensures attackers can't enumerate).
  let guildId: string | null = null;
  try {
    const parsed = JSON.parse(body) as { guild_id?: string };
    guildId = parsed.guild_id ?? null;
  } catch {
    /* not JSON → fail below */
  }

  const candidates = guildId
    ? await db
        .select()
        .from(chatIntegrations)
        .where(
          and(
            eq(chatIntegrations.kind, "discord"),
            eq(chatIntegrations.teamId, guildId)
          )
        )
    : await db
        .select()
        .from(chatIntegrations)
        .where(eq(chatIntegrations.kind, "discord"));

  let matched = null;
  for (const integ of candidates) {
    if (!integ.signingSecret) continue;
    const ok = await verifyDiscordSignature({
      publicKeyHex: integ.signingSecret,
      signatureHex: sig,
      timestamp: ts,
      body,
    });
    if (ok) {
      matched = integ;
      break;
    }
  }
  if (!matched) return c.json({ error: "invalid_signature" }, 401);

  let interaction: DiscordInteractionLike;
  try {
    interaction = JSON.parse(body) as DiscordInteractionLike;
  } catch {
    return c.json({ error: "bad_json" }, 400);
  }

  // PING (type 1) → PONG (type 1). Discord uses this every few minutes.
  if (interaction.type === 1) {
    return c.json({ type: 1 });
  }

  const parsed = parseDiscordSlashCommand(interaction);
  const response = await handleBotCommand({
    kind: "discord",
    userId: matched.ownerUserId,
    command: parsed.command,
    subcommand: parsed.subcommand,
    args: parsed.args,
  });

  // CHANNEL_MESSAGE_WITH_SOURCE (type 4).
  if (response.kind === "discord") {
    return c.json({
      type: 4,
      data: {
        embeds: response.embeds,
        content: response.content,
        flags: response.ephemeral ? 1 << 6 : 0,
      },
    });
  }
  // Defensive — handleBotCommand should always return the matching kind.
  return c.json({ type: 4, data: { content: "ok" } });
});

// ---------------------------------------------------------------------------
// Install / OAuth completion stubs.
//
// These accept either:
//   * a JSON body with { team_id, channel_id, webhook_url, signing_secret }
//     for direct manual installs from /settings/integrations;
//   * an OAuth `code` exchange (when a real Slack/Discord app is wired up,
//     this code path is the one to extend).
//
// Auth: requires a Gluecron session cookie or PAT; we use the api-v2
// surface's auth (apiAuth runs at the basePath). For the stub we trust the
// caller's `ownerUserId` to be the session user.
// ---------------------------------------------------------------------------

interface InstallBody {
  team_id?: string;
  channel_id?: string;
  webhook_url?: string;
  signing_secret?: string;
}

async function handleInstall(
  kind: "slack" | "discord",
  c: import("hono").Context
) {
  // Minimal auth — read the session cookie / Bearer via api-v2 middleware
  // (we're mounted under the same prefix). The shared apiAuth middleware
  // populates c.get("user"); fall through to 401 if absent.
  // To avoid pulling in the middleware import cycle here, we check both.
  const user = (c.get as (k: string) => unknown)("user") as
    | { id: string }
    | undefined;
  if (!user?.id) return c.json({ error: "auth_required" }, 401);

  let body: InstallBody = {};
  try {
    body = (await c.req.json()) as InstallBody;
  } catch {
    /* allow empty body for placeholder */
  }
  const webhookUrl = (body.webhook_url ?? "").trim();
  if (!webhookUrl) {
    return c.json(
      { error: "missing_webhook_url", hint: "POST a JSON body with webhook_url" },
      400
    );
  }

  const [row] = await db
    .insert(chatIntegrations)
    .values({
      ownerUserId: user.id,
      kind,
      teamId: body.team_id?.trim() || null,
      channelId: body.channel_id?.trim() || null,
      webhookUrl,
      signingSecret: body.signing_secret?.trim() || null,
      enabled: true,
    })
    .onConflictDoNothing()
    .returning();

  return c.json({ ok: true, integration: row ?? null }, 201);
}

r.post("/api/v2/integrations/slack/install", (c) => handleInstall("slack", c));
r.post("/api/v2/integrations/discord/install", (c) =>
  handleInstall("discord", c)
);

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

function looksLikeJson(body: string): boolean {
  const t = body.trimStart();
  return t.startsWith("{") || t.startsWith("[");
}

export default r;