Skip to content

Commit 5fa160f

Browse files
committed
onboarding flow
1 parent b641e2d commit 5fa160f

79 files changed

Lines changed: 13337 additions & 33 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bun.lock

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

internal/api/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,14 @@
3030
"@blink-sdk/events": "workspace:*",
3131
"@bufbuild/protobuf": "^2.9.0",
3232
"@testing-library/react": "^16.3.0",
33+
"@types/tar-stream": "^3.1.3",
3334
"eventsource-parser": "^3.0.6",
3435
"happy-dom": "^18.0.1",
3536
"hono": "^4.9.7",
3637
"msw": "^2.12.1",
3738
"react": "19.1.2",
3839
"react-dom": "19.1.2",
40+
"tar-stream": "^3.1.7",
3941
"tsdown": "^0.15.1",
4042
"zod": "^4.1.9",
4143
"zod-validation-error": "^4.0.1"

internal/api/src/client.browser.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import ChatRuns from "./routes/chats/runs.client";
55
import Files from "./routes/files.client";
66
import Invites from "./routes/invites.client";
77
import Messages from "./routes/messages.client";
8+
import Onboarding from "./routes/onboarding/onboarding.client";
89
import Organizations from "./routes/organizations/organizations.client";
910
import Users from "./routes/users.client";
1011

@@ -34,6 +35,7 @@ export default class Client {
3435
public readonly invites = new Invites(this);
3536
public readonly users = new Users(this);
3637
public readonly messages = new Messages(this);
38+
public readonly onboarding = new Onboarding(this);
3739

3840
public constructor(options?: ClientOptions) {
3941
this.baseURL = new URL(
@@ -101,5 +103,6 @@ export * from "./routes/agents/traces.client";
101103
export * from "./routes/chats/chats.client";
102104
export * from "./routes/invites.client";
103105
export * from "./routes/messages.client";
106+
export * from "./routes/onboarding/onboarding.client";
104107
export * from "./routes/organizations/organizations.client";
105108
export * from "./routes/users.client";

internal/api/src/routes/agent-request.server.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { BlinkInvocationTokenHeader } from "@blink.so/runtime/types";
22
import type { Context } from "hono";
3+
34
import type { Bindings } from "../server";
45
import { detectRequestLocation } from "../server-helper";
56
import { generateAgentInvocationToken } from "./agents/me/me.server";
7+
import { handleSlackWebhook, isSlackRequest } from "./agents/slack-webhook";
68

79
export type AgentRequestRouting =
810
| { mode: "webhook"; subpath?: string }
@@ -23,6 +25,24 @@ export default async function handleAgentRequest(
2325
}
2426
return c.json({ message: "No agent exists for this webook" }, 404);
2527
}
28+
29+
const incomingUrl = new URL(c.req.raw.url);
30+
31+
// Handle Slack webhook requests during verification flow
32+
let requestBodyText: string | undefined;
33+
if (isSlackRequest(routing, incomingUrl.pathname) && query.agent) {
34+
const slackResult = await handleSlackWebhook(
35+
db,
36+
query.agent,
37+
c.req.raw,
38+
!!query.agent_deployment
39+
);
40+
if (slackResult.response) {
41+
return slackResult.response;
42+
}
43+
requestBodyText = slackResult.bodyText;
44+
}
45+
2646
if (!query.agent_deployment) {
2747
return c.json(
2848
{
@@ -38,7 +58,6 @@ export default async function handleAgentRequest(
3858
404
3959
);
4060
}
41-
const incomingUrl = new URL(c.req.raw.url);
4261

4362
let url: URL;
4463
if (routing.mode === "webhook") {
@@ -133,6 +152,17 @@ export default async function handleAgentRequest(
133152
c.req.raw.headers.forEach((value, key) => {
134153
headers.set(key, value);
135154
});
155+
156+
// If we read the body as text (for Slack verification), we need to recalculate
157+
// the Content-Length header. When fetch() sends a string body, it encodes it as
158+
// UTF-8, which may have a different byte length than the original Content-Length.
159+
// Note: Some runtimes (like Bun) auto-correct this, but Node.js throws an error
160+
// if Content-Length doesn't match the actual body length.
161+
if (requestBodyText !== undefined) {
162+
const encoder = new TextEncoder();
163+
const byteLength = encoder.encode(requestBodyText).length;
164+
headers.set("content-length", byteLength.toString());
165+
}
136166
// Strip cookies from webhook requests to prevent session leakage
137167
// Subdomain requests are on a different origin, so cookies won't be sent anyway
138168
if (routing.mode === "webhook") {
@@ -150,8 +180,11 @@ export default async function handleAgentRequest(
150180
let response: Response | undefined;
151181
let error: string | undefined;
152182
try {
183+
// Use the body we already read if it's a Slack request, otherwise use the stream
184+
const bodyToSend =
185+
requestBodyText !== undefined ? requestBodyText : c.req.raw.body;
153186
response = await fetch(url, {
154-
body: c.req.raw.body,
187+
body: bodyToSend,
155188
method: c.req.raw.method,
156189
signal,
157190
headers,

internal/api/src/routes/agent-request.test.ts

Lines changed: 191 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,30 @@
11
import { describe, expect, test } from "bun:test";
2+
import type { Agent } from "@blink.so/database/schema";
23
import type { Server } from "bun";
34
import { serve } from "../test";
45

56
interface SetupAgentOptions {
67
name: string;
7-
handler: (req: Request) => Response;
8+
handler: (req: Request) => Response | Promise<Response>;
9+
/** If provided, sets up slack_verification on the agent */
10+
slackVerification?: {
11+
signingSecret: string;
12+
botToken: string;
13+
/** Custom expiresAt timestamp (defaults to 24h from now) */
14+
expiresAt?: string;
15+
};
816
}
917

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

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

7688
const db = await bindings.database();
89+
90+
// Set up slack verification if provided
91+
if (options.slackVerification) {
92+
const now = new Date();
93+
const defaultExpiresAt = new Date(
94+
now.getTime() + 24 * 60 * 60 * 1000
95+
).toISOString();
96+
await db.updateAgent({
97+
id: agent.id,
98+
slack_verification: {
99+
signingSecret: options.slackVerification.signingSecret,
100+
botToken: options.slackVerification.botToken,
101+
startedAt: now.toISOString(),
102+
expiresAt: options.slackVerification.expiresAt ?? defaultExpiresAt,
103+
},
104+
});
105+
}
106+
77107
const target = await db.selectAgentDeploymentTargetByName(
78108
agent.id,
79109
"production"
@@ -85,6 +115,7 @@ async function setupAgent(
85115
const subdomainHost = `${requestId}.${parsedApiUrl.host}`;
86116

87117
return {
118+
agentId: agent.id,
88119
webhookUrl: `${apiUrl}/api/webhook/${target.request_id}`,
89120
getWebhookUrl: (subpath?: string) =>
90121
`${apiUrl}/api/webhook/${target.request_id}${subpath || ""}`,
@@ -95,6 +126,7 @@ async function setupAgent(
95126
headers.set("host", subdomainHost);
96127
return fetch(`${apiUrl}${path || "/"}`, { ...init, headers });
97128
},
129+
getAgent: () => db.selectAgentByID(agent.id),
98130
[Symbol.dispose]: () => mockServer?.stop(),
99131
};
100132
}
@@ -378,6 +410,57 @@ describe("webhook requests (/api/webhook/:id)", () => {
378410
});
379411
});
380412

413+
describe("Slack request Content-Length handling", () => {
414+
test("recalculates Content-Length when body is read as text for Slack verification", async () => {
415+
// Body with multi-byte UTF-8 characters
416+
// "Hello 世界" is 6 ASCII chars (6 bytes) + 1 space (1 byte) + 2 Chinese chars (6 bytes) = 13 bytes
417+
const bodyWithMultiByteChars = JSON.stringify({
418+
type: "event_callback",
419+
event: { type: "message", text: "Hello 世界" },
420+
});
421+
const expectedByteLength = new TextEncoder().encode(
422+
bodyWithMultiByteChars
423+
).length;
424+
425+
let receivedContentLength: string | null | undefined;
426+
let receivedBodyLength: number | undefined;
427+
428+
using agent = await setupAgent({
429+
name: "slack-content-length",
430+
handler: async (req) => {
431+
receivedContentLength = req.headers.get("content-length");
432+
const body = await req.text();
433+
receivedBodyLength = new TextEncoder().encode(body).length;
434+
return new Response("OK");
435+
},
436+
slackVerification: {
437+
signingSecret: "test-secret",
438+
botToken: "xoxb-test-token",
439+
},
440+
});
441+
442+
// Send request with a Content-Length that would be wrong if we used string length
443+
// instead of byte length (string length is different from byte length for multi-byte chars)
444+
const response = await fetch(agent.getWebhookUrl("/slack"), {
445+
method: "POST",
446+
headers: {
447+
"content-type": "application/json",
448+
// Intentionally set a wrong Content-Length to verify it gets corrected
449+
"content-length": String(bodyWithMultiByteChars.length),
450+
},
451+
body: bodyWithMultiByteChars,
452+
});
453+
454+
// The request should succeed (we won't have valid Slack signature, but that's OK for this test)
455+
// What we're testing is that the Content-Length was recalculated correctly
456+
expect(response.status).toBe(200);
457+
458+
// Verify the Content-Length header received by the agent matches actual byte length
459+
expect(receivedContentLength).toBe(String(expectedByteLength));
460+
expect(receivedBodyLength).toBe(expectedByteLength);
461+
});
462+
});
463+
381464
describe("subdomain requests", () => {
382465
test("basic request", async () => {
383466
using agent = await setupAgent({
@@ -474,3 +557,110 @@ describe("subdomain requests", () => {
474557
expect(response.headers.get("vary")).toBe("Origin, Accept-Encoding");
475558
});
476559
});
560+
561+
describe("Slack verification expiration", () => {
562+
test("clears expired slack_verification and skips verification processing", async () => {
563+
// Set expiresAt to 1 hour ago (already expired)
564+
const expiredAt = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString();
565+
566+
let handlerCalled = false;
567+
568+
using agent = await setupAgent({
569+
name: "slack-expired",
570+
handler: () => {
571+
handlerCalled = true;
572+
return new Response("OK");
573+
},
574+
slackVerification: {
575+
signingSecret: "test-secret",
576+
botToken: "xoxb-test-token",
577+
expiresAt: expiredAt,
578+
},
579+
});
580+
581+
// Verify slack_verification is set before request
582+
const beforeAgent = await agent.getAgent();
583+
expect(beforeAgent?.slack_verification).not.toBeNull();
584+
585+
// Make a request to /slack path
586+
const response = await fetch(agent.getWebhookUrl("/slack"), {
587+
method: "POST",
588+
headers: { "content-type": "application/json" },
589+
body: JSON.stringify({ type: "event_callback", event: { type: "test" } }),
590+
});
591+
592+
expect(response.status).toBe(200);
593+
expect(handlerCalled).toBe(true);
594+
595+
// Verify slack_verification was cleared
596+
const afterAgent = await agent.getAgent();
597+
expect(afterAgent?.slack_verification).toBeNull();
598+
});
599+
600+
test("does not clear non-expired slack_verification", async () => {
601+
// Set expiresAt to 1 hour from now (not expired yet)
602+
const futureExpiresAt = new Date(
603+
Date.now() + 1 * 60 * 60 * 1000
604+
).toISOString();
605+
606+
using agent = await setupAgent({
607+
name: "slack-not-expired",
608+
handler: () => new Response("OK"),
609+
slackVerification: {
610+
signingSecret: "test-secret",
611+
botToken: "xoxb-test-token",
612+
expiresAt: futureExpiresAt,
613+
},
614+
});
615+
616+
// Verify slack_verification is set before request
617+
const beforeAgent = await agent.getAgent();
618+
expect(beforeAgent?.slack_verification).not.toBeNull();
619+
620+
// Make a request to /slack path
621+
const response = await fetch(agent.getWebhookUrl("/slack"), {
622+
method: "POST",
623+
headers: { "content-type": "application/json" },
624+
body: JSON.stringify({ type: "event_callback", event: { type: "test" } }),
625+
});
626+
627+
expect(response.status).toBe(200);
628+
629+
// Verify slack_verification was NOT cleared (still active)
630+
const afterAgent = await agent.getAgent();
631+
expect(afterAgent?.slack_verification).not.toBeNull();
632+
});
633+
634+
test("does not run verification logic for expired slack_verification on non-slack paths", async () => {
635+
// Set expiresAt to 1 hour ago (already expired)
636+
const expiredAt = new Date(Date.now() - 1 * 60 * 60 * 1000).toISOString();
637+
638+
using agent = await setupAgent({
639+
name: "slack-expired-non-slack-path",
640+
handler: () => new Response("OK"),
641+
slackVerification: {
642+
signingSecret: "test-secret",
643+
botToken: "xoxb-test-token",
644+
expiresAt: expiredAt,
645+
},
646+
});
647+
648+
// Verify slack_verification is set before request
649+
const beforeAgent = await agent.getAgent();
650+
expect(beforeAgent?.slack_verification).not.toBeNull();
651+
652+
// Make a request to a non-slack path (e.g., /github)
653+
const response = await fetch(agent.getWebhookUrl("/github"), {
654+
method: "POST",
655+
headers: { "content-type": "application/json" },
656+
body: JSON.stringify({ action: "push" }),
657+
});
658+
659+
expect(response.status).toBe(200);
660+
661+
// slack_verification should NOT be cleared for non-slack paths
662+
// (expiration check only runs for /slack requests)
663+
const afterAgent = await agent.getAgent();
664+
expect(afterAgent?.slack_verification).not.toBeNull();
665+
});
666+
});

0 commit comments

Comments
 (0)