Skip to content
Merged
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
4 changes: 3 additions & 1 deletion bun.lock
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@
"@blink-sdk/events": "workspace:*",
"@bufbuild/protobuf": "^2.9.0",
"@testing-library/react": "^16.3.0",
"@types/tar-stream": "^3.1.3",
"eventsource-parser": "^3.0.6",
"happy-dom": "^18.0.1",
"hono": "^4.9.7",
"msw": "^2.12.1",
"react": "19.1.2",
"react-dom": "19.1.2",
"tar-stream": "^3.1.7",
"tsdown": "^0.15.1",
"zod": "^4.1.9",
"zod-validation-error": "^4.0.1",
Expand Down Expand Up @@ -352,7 +354,7 @@
},
"packages/scout-agent": {
"name": "@blink-sdk/scout-agent",
"version": "0.0.14",
"version": "0.0.15",
"dependencies": {
"@blink-sdk/compute": "^0.0.15",
"@blink-sdk/github": "^0.0.23",
Expand Down
2 changes: 2 additions & 0 deletions internal/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,14 @@
"@blink-sdk/events": "workspace:*",
"@bufbuild/protobuf": "^2.9.0",
"@testing-library/react": "^16.3.0",
"@types/tar-stream": "^3.1.3",
"eventsource-parser": "^3.0.6",
"happy-dom": "^18.0.1",
"hono": "^4.9.7",
"msw": "^2.12.1",
"react": "19.1.2",
"react-dom": "19.1.2",
"tar-stream": "^3.1.7",
"tsdown": "^0.15.1",
"zod": "^4.1.9",
"zod-validation-error": "^4.0.1"
Expand Down
3 changes: 3 additions & 0 deletions internal/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";
37 changes: 35 additions & 2 deletions internal/api/src/routes/agent-request.server.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { BlinkInvocationTokenHeader } from "@blink.so/runtime/types";
import type { Context } from "hono";

import type { Bindings } from "../server";
import { detectRequestLocation } from "../server-helper";
import { generateAgentInvocationToken } from "./agents/me/me.server";
import { handleSlackWebhook, isSlackRequest } from "./agents/slack-webhook";

export type AgentRequestRouting =
| { mode: "webhook"; subpath?: string }
Expand All @@ -23,6 +25,24 @@ export default async function handleAgentRequest(
}
return c.json({ message: "No agent exists for this webook" }, 404);
}

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

// Handle Slack webhook requests during verification flow
let requestBodyText: string | undefined;
if (isSlackRequest(routing, incomingUrl.pathname) && query.agent) {
const slackResult = await handleSlackWebhook(
db,
query.agent,
c.req.raw,
!!query.agent_deployment
);
if (slackResult.response) {
return slackResult.response;
}
requestBodyText = slackResult.bodyText;
}

if (!query.agent_deployment) {
return c.json(
{
Expand All @@ -38,7 +58,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 @@ -133,6 +152,17 @@ export default async function handleAgentRequest(
c.req.raw.headers.forEach((value, key) => {
headers.set(key, value);
});

// If we read the body as text (for Slack verification), we need to recalculate
// the Content-Length header. When fetch() sends a string body, it encodes it as
// UTF-8, which may have a different byte length than the original Content-Length.
// Note: Some runtimes (like Bun) auto-correct this, but Node.js throws an error
// if Content-Length doesn't match the actual body length.
if (requestBodyText !== undefined) {
const encoder = new TextEncoder();
const byteLength = encoder.encode(requestBodyText).length;
headers.set("content-length", byteLength.toString());
}
// Strip cookies from webhook requests to prevent session leakage
// Subdomain requests are on a different origin, so cookies won't be sent anyway
if (routing.mode === "webhook") {
Expand All @@ -150,8 +180,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
192 changes: 191 additions & 1 deletion internal/api/src/routes/agent-request.test.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,30 @@
import { describe, expect, test } from "bun:test";
import type { Agent } from "@blink.so/database/schema";
import type { Server } from "bun";
import { serve } from "../test";

interface SetupAgentOptions {
name: string;
handler: (req: Request) => Response;
handler: (req: Request) => Response | Promise<Response>;
/** If provided, sets up slack_verification on the agent */
slackVerification?: {
signingSecret: string;
botToken: string;
/** Custom expiresAt timestamp (defaults to 24h from now) */
expiresAt?: string;
};
}

interface SetupAgentResult extends Disposable {
/** The agent ID */
agentId: string;
/** Subpath webhook URL (/api/webhook/:id) - cookies are stripped */
webhookUrl: string;
getWebhookUrl: (subpath?: string) => string;
/** Fetch via subdomain (uses Host header) - cookies pass through */
fetchSubdomain: (path?: string, init?: RequestInit) => Promise<Response>;
/** Get the current agent from the database */
getAgent: () => Promise<Agent | undefined>;
}

async function setupAgent(
Expand Down Expand Up @@ -74,6 +86,24 @@ async function setupAgent(
if (!agent.request_url) throw new Error("No webhook route");

const db = await bindings.database();

// Set up slack verification if provided
if (options.slackVerification) {
const now = new Date();
const defaultExpiresAt = new Date(
now.getTime() + 24 * 60 * 60 * 1000
).toISOString();
await db.updateAgent({
id: agent.id,
slack_verification: {
signingSecret: options.slackVerification.signingSecret,
botToken: options.slackVerification.botToken,
startedAt: now.toISOString(),
expiresAt: options.slackVerification.expiresAt ?? defaultExpiresAt,
},
});
}

const target = await db.selectAgentDeploymentTargetByName(
agent.id,
"production"
Expand All @@ -85,6 +115,7 @@ async function setupAgent(
const subdomainHost = `${requestId}.${parsedApiUrl.host}`;

return {
agentId: agent.id,
webhookUrl: `${apiUrl}/api/webhook/${target.request_id}`,
getWebhookUrl: (subpath?: string) =>
`${apiUrl}/api/webhook/${target.request_id}${subpath || ""}`,
Expand All @@ -95,6 +126,7 @@ async function setupAgent(
headers.set("host", subdomainHost);
return fetch(`${apiUrl}${path || "/"}`, { ...init, headers });
},
getAgent: () => db.selectAgentByID(agent.id),
[Symbol.dispose]: () => mockServer?.stop(),
};
}
Expand Down Expand Up @@ -378,6 +410,57 @@ describe("webhook requests (/api/webhook/:id)", () => {
});
});

describe("Slack request Content-Length handling", () => {
test("recalculates Content-Length when body is read as text for Slack verification", async () => {
// Body with multi-byte UTF-8 characters
// "Hello 世界" is 6 ASCII chars (6 bytes) + 1 space (1 byte) + 2 Chinese chars (6 bytes) = 13 bytes
const bodyWithMultiByteChars = JSON.stringify({
type: "event_callback",
event: { type: "message", text: "Hello 世界" },
});
const expectedByteLength = new TextEncoder().encode(
bodyWithMultiByteChars
).length;

let receivedContentLength: string | null | undefined;
let receivedBodyLength: number | undefined;

using agent = await setupAgent({
name: "slack-content-length",
handler: async (req) => {
receivedContentLength = req.headers.get("content-length");
const body = await req.text();
receivedBodyLength = new TextEncoder().encode(body).length;
return new Response("OK");
},
slackVerification: {
signingSecret: "test-secret",
botToken: "xoxb-test-token",
},
});

// Send request with a Content-Length that would be wrong if we used string length
// instead of byte length (string length is different from byte length for multi-byte chars)
const response = await fetch(agent.getWebhookUrl("/slack"), {
method: "POST",
headers: {
"content-type": "application/json",
// Intentionally set a wrong Content-Length to verify it gets corrected
"content-length": String(bodyWithMultiByteChars.length),
},
body: bodyWithMultiByteChars,
});

// The request should succeed (we won't have valid Slack signature, but that's OK for this test)
// What we're testing is that the Content-Length was recalculated correctly
expect(response.status).toBe(200);

// Verify the Content-Length header received by the agent matches actual byte length
expect(receivedContentLength).toBe(String(expectedByteLength));
expect(receivedBodyLength).toBe(expectedByteLength);
});
});

describe("subdomain requests", () => {
test("basic request", async () => {
using agent = await setupAgent({
Expand Down Expand Up @@ -474,3 +557,110 @@ describe("subdomain requests", () => {
expect(response.headers.get("vary")).toBe("Origin, Accept-Encoding");
});
});

describe("Slack verification expiration", () => {
test("clears expired slack_verification and skips verification processing", async () => {
// Set expiresAt to 1 hour ago (already expired)
const expiredAt = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString();

let handlerCalled = false;

using agent = await setupAgent({
name: "slack-expired",
handler: () => {
handlerCalled = true;
return new Response("OK");
},
slackVerification: {
signingSecret: "test-secret",
botToken: "xoxb-test-token",
expiresAt: expiredAt,
},
});

// Verify slack_verification is set before request
const beforeAgent = await agent.getAgent();
expect(beforeAgent?.slack_verification).not.toBeNull();

// Make a request to /slack path
const response = await fetch(agent.getWebhookUrl("/slack"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "event_callback", event: { type: "test" } }),
});

expect(response.status).toBe(200);
expect(handlerCalled).toBe(true);

// Verify slack_verification was cleared
const afterAgent = await agent.getAgent();
expect(afterAgent?.slack_verification).toBeNull();
});

test("does not clear non-expired slack_verification", async () => {
// Set expiresAt to 1 hour from now (not expired yet)
const futureExpiresAt = new Date(
Date.now() + 1 * 60 * 60 * 1000
).toISOString();

using agent = await setupAgent({
name: "slack-not-expired",
handler: () => new Response("OK"),
slackVerification: {
signingSecret: "test-secret",
botToken: "xoxb-test-token",
expiresAt: futureExpiresAt,
},
});

// Verify slack_verification is set before request
const beforeAgent = await agent.getAgent();
expect(beforeAgent?.slack_verification).not.toBeNull();

// Make a request to /slack path
const response = await fetch(agent.getWebhookUrl("/slack"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ type: "event_callback", event: { type: "test" } }),
});

expect(response.status).toBe(200);

// Verify slack_verification was NOT cleared (still active)
const afterAgent = await agent.getAgent();
expect(afterAgent?.slack_verification).not.toBeNull();
});

test("does not run verification logic for expired slack_verification on non-slack paths", async () => {
// Set expiresAt to 1 hour ago (already expired)
const expiredAt = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString();

using agent = await setupAgent({
name: "slack-expired-non-slack-path",
handler: () => new Response("OK"),
slackVerification: {
signingSecret: "test-secret",
botToken: "xoxb-test-token",
expiresAt: expiredAt,
},
});

// Verify slack_verification is set before request
const beforeAgent = await agent.getAgent();
expect(beforeAgent?.slack_verification).not.toBeNull();

// Make a request to a non-slack path (e.g., /github)
const response = await fetch(agent.getWebhookUrl("/github"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ action: "push" }),
});

expect(response.status).toBe(200);

// slack_verification should NOT be cleared for non-slack paths
// (expiration check only runs for /slack requests)
const afterAgent = await agent.getAgent();
expect(afterAgent?.slack_verification).not.toBeNull();
});
});
Loading