Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,7 @@ dist/
*.tgz
.env.*
.sonda
.next
.next
.blink
.next
*.tsbuildinfo
1 change: 1 addition & 0 deletions bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@
"blink-server": "dist/cli.js",
},
"devDependencies": {
"@blink.so/api": "workspace:*",
"@types/node": "^22.10.2",
"@types/pg": "^8.11.10",
"@types/ws": "^8.5.13",
Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/client.browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import ChatRuns from "./routes/chats/runs.client";
import Files from "./routes/files.client";
import Invites from "./routes/invites.client";
import Messages from "./routes/messages.client";
import Onboarding from "./routes/onboarding/onboarding.client";
import Organizations from "./routes/organizations/organizations.client";
import Users from "./routes/users.client";

Expand Down Expand Up @@ -34,6 +35,7 @@ export default class Client {
public readonly invites = new Invites(this);
public readonly users = new Users(this);
public readonly messages = new Messages(this);
public readonly onboarding = new Onboarding(this);

public constructor(options?: ClientOptions) {
this.baseURL = new URL(
Expand Down Expand Up @@ -101,5 +103,6 @@ export * from "./routes/agents/traces.client";
export * from "./routes/chats/chats.client";
export * from "./routes/invites.client";
export * from "./routes/messages.client";
export * from "./routes/onboarding/onboarding.client";
export * from "./routes/organizations/organizations.client";
export * from "./routes/users.client";
202 changes: 200 additions & 2 deletions packages/api/src/routes/agent-request.server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { BlinkInvocationTokenHeader } from "@blink.so/runtime/types";
import { createHmac, timingSafeEqual } from "node:crypto";
import type { Context } from "hono";

import type { Bindings } from "../server";
import { detectRequestLocation } from "../server-helper";
import { generateAgentInvocationToken } from "./agents/me/me.server";
Expand All @@ -23,7 +25,48 @@ export default async function handleAgentRequest(
}
return c.json({ message: "No agent exists for this webook" }, 404);
}

const incomingUrl = new URL(c.req.raw.url);

// Detect if this is a Slack request (works for both webhook and subdomain routing)
const isSlackPath =
(routing.mode === "webhook" && routing.subpath === "/slack") ||
(routing.mode === "subdomain" && incomingUrl.pathname === "/slack");

// Handle Slack verification tracking if active
const slackVerification = query.agent?.slack_verification;
let requestBodyText: string | undefined;

if (isSlackPath && slackVerification) {
// Read the body for verification processing
requestBodyText = await c.req.text();

const result = await processSlackVerificationTracking(
db,
{ id: query.agent.id, slack_verification: slackVerification },
requestBodyText,
c.req.header("x-slack-signature"),
c.req.header("x-slack-request-timestamp")
);

// URL verification challenge must be responded to immediately
if (result.challengeResponse) {
return c.json({ challenge: result.challengeResponse });
}

// Invalid signature - acknowledge but don't process further
if (!result.signatureValid) {
return c.json({ ok: true });
}

// Otherwise, continue to forward to agent (if deployed)
}

if (!query.agent_deployment) {
// No deployment - if this was a valid Slack request, we already tracked it
if (isSlackPath && slackVerification) {
return c.json({ ok: true }); // Acknowledge Slack event
}
return c.json(
{
message: `No deployment exists for this agent. Be sure to deploy your agent to receive webhook events`,
Expand All @@ -38,7 +81,6 @@ export default async function handleAgentRequest(
404
);
}
const incomingUrl = new URL(c.req.raw.url);

let url: URL;
if (routing.mode === "webhook") {
Expand Down Expand Up @@ -150,8 +192,11 @@ export default async function handleAgentRequest(
let response: Response | undefined;
let error: string | undefined;
try {
// Use the body we already read if it's a Slack request, otherwise use the stream
const bodyToSend =
requestBodyText !== undefined ? requestBodyText : c.req.raw.body;
response = await fetch(url, {
body: c.req.raw.body,
body: bodyToSend,
method: c.req.raw.method,
signal,
headers,
Expand Down Expand Up @@ -295,3 +340,156 @@ export default async function handleAgentRequest(
);
}
}

/**
* Verify Slack request signature using HMAC-SHA256.
*/
function verifySlackSignature(
signingSecret: string,
timestamp: string,
body: string,
signature: string
): boolean {
const time = Math.floor(Date.now() / 1000);
const requestTimestamp = Number.parseInt(timestamp, 10);

// Request is older than 5 minutes - reject to prevent replay attacks
if (Math.abs(time - requestTimestamp) > 60 * 5) {
return false;
}

const hmac = createHmac("sha256", signingSecret);
const sigBasestring = `v0:${timestamp}:${body}`;
hmac.update(sigBasestring);
const mySignature = `v0=${hmac.digest("hex")}`;

try {
return timingSafeEqual(Buffer.from(mySignature), Buffer.from(signature));
} catch {
return false;
}
}

/**
* Process Slack verification tracking without blocking the request flow.
* Returns tracking results so the caller can decide how to proceed.
*/
async function processSlackVerificationTracking(
db: Awaited<ReturnType<Bindings["database"]>>,
agent: {
id: string;
slack_verification: {
signingSecret: string;
botToken: string;
startedAt: string;
lastEventAt?: string;
dmReceivedAt?: string;
dmChannel?: string;
signatureFailedAt?: string;
};
},
body: string,
slackSignature: string | undefined,
slackTimestamp: string | undefined
): Promise<{
signatureValid: boolean;
challengeResponse?: string;
}> {
const verification = agent.slack_verification;

// Verify Slack signature if headers are present
if (slackSignature && slackTimestamp) {
if (
!verifySlackSignature(
verification.signingSecret,
slackTimestamp,
body,
slackSignature
)
) {
// Signature verification failed - record in database
await db.updateAgent({
id: agent.id,
slack_verification: {
...verification,
signatureFailedAt: new Date().toISOString(),
},
});
return { signatureValid: false };
}
}

// Parse the payload
let payload: {
type?: string;
challenge?: string;
event?: {
type?: string;
channel_type?: string;
channel?: string;
bot_id?: string;
ts?: string;
};
};

try {
payload = JSON.parse(body);
} catch {
// Can't parse - treat as invalid but not a security issue
return { signatureValid: true };
}

// Handle Slack URL verification challenge
if (payload.type === "url_verification" && payload.challenge) {
// Update lastEventAt since we received a valid event
await db.updateAgent({
id: agent.id,
slack_verification: {
...verification,
lastEventAt: new Date().toISOString(),
},
});
return { signatureValid: true, challengeResponse: payload.challenge };
}

// Track if we received a DM
const isDM =
payload.event?.type === "message" &&
payload.event.channel_type === "im" &&
!payload.event.bot_id; // Ignore bot's own messages

// If this is a DM and we haven't already recorded one, send a response to Slack
if (isDM && !verification.dmReceivedAt && payload.event?.channel) {
await fetch("https://slack.com/api/chat.postMessage", {
method: "POST",
headers: {
Authorization: `Bearer ${verification.botToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
channel: payload.event.channel,
thread_ts: payload.event.ts,
text: "Congrats, your Slack app is set up! You can now go back to the Blink dashboard.",
}),
}).catch(() => {
// Silent fail - user will see status in the UI
});
}

const updatedVerification = {
...verification,
lastEventAt: new Date().toISOString(),
...(isDM && {
dmReceivedAt: new Date().toISOString(),
dmChannel: payload.event?.channel,
}),
};

await db.updateAgent({
id: agent.id,
slack_verification: updatedVerification,
});

// Continue to agent - we've tracked the event
return { signatureValid: true };
}
6 changes: 6 additions & 0 deletions packages/api/src/routes/agents/agents.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import AgentEnv, { schemaCreateAgentEnv } from "./env.client";
import AgentLogs from "./logs.client";
import AgentMembers from "./members.client";
import AgentRuns from "./runs.client";
import AgentSetupGitHub from "./setup-github.client";
import AgentSetupSlack from "./setup-slack.client";
import AgentSteps from "./steps.client";
import AgentTraces from "./traces.client";

Expand Down Expand Up @@ -165,6 +167,8 @@ export default class Agents {
public readonly logs: AgentLogs;
public readonly traces: AgentTraces;
public readonly members: AgentMembers;
public readonly setupGitHub: AgentSetupGitHub;
public readonly setupSlack: AgentSetupSlack;

public constructor(client: Client) {
this.client = client;
Expand All @@ -175,6 +179,8 @@ export default class Agents {
this.logs = new AgentLogs(client);
this.traces = new AgentTraces(client);
this.members = new AgentMembers(client);
this.setupGitHub = new AgentSetupGitHub(client);
this.setupSlack = new AgentSetupSlack(client);
}

/**
Expand Down
4 changes: 4 additions & 0 deletions packages/api/src/routes/agents/agents.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ import mountLogs from "./logs.server";
import mountAgentsMe from "./me/me.server";
import mountAgentMembers from "./members.server";
import mountRuns from "./runs.server";
import mountSetupGitHub from "./setup-github.server";
import mountSetupSlack from "./setup-slack.server";
import mountSteps from "./steps.server";
import mountTraces from "./traces.server";

Expand Down Expand Up @@ -417,6 +419,8 @@ export default function mountAgents(app: Hono<{ Bindings: Bindings }>) {
mountLogs(app.basePath("/:agent_id/logs"));
mountTraces(app.basePath("/:agent_id/traces"));
mountAgentMembers(app.basePath("/:agent_id/members"));
mountSetupGitHub(app.basePath("/:agent_id/setup/github"));
mountSetupSlack(app.basePath("/:agent_id/setup/slack"));

// This is special - just for the agent invocation API.
// We don't like to do this, but we do because this API
Expand Down
Loading