diff --git a/bun.lock b/bun.lock index df70f0de..f184a7a5 100644 --- a/bun.lock +++ b/bun.lock @@ -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", @@ -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", diff --git a/internal/api/package.json b/internal/api/package.json index 59f7f49f..78ba7682 100644 --- a/internal/api/package.json +++ b/internal/api/package.json @@ -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" diff --git a/internal/api/src/client.browser.ts b/internal/api/src/client.browser.ts index 1e513f9d..5cbeb063 100644 --- a/internal/api/src/client.browser.ts +++ b/internal/api/src/client.browser.ts @@ -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"; @@ -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( @@ -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"; diff --git a/internal/api/src/routes/agent-request.server.ts b/internal/api/src/routes/agent-request.server.ts index 809ccd64..0e9531e5 100644 --- a/internal/api/src/routes/agent-request.server.ts +++ b/internal/api/src/routes/agent-request.server.ts @@ -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 } @@ -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( { @@ -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") { @@ -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") { @@ -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, diff --git a/internal/api/src/routes/agent-request.test.ts b/internal/api/src/routes/agent-request.test.ts index c6352ff6..682dd9ef 100644 --- a/internal/api/src/routes/agent-request.test.ts +++ b/internal/api/src/routes/agent-request.test.ts @@ -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; + /** 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; + /** Get the current agent from the database */ + getAgent: () => Promise; } async function setupAgent( @@ -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" @@ -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 || ""}`, @@ -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(), }; } @@ -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({ @@ -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(); + }); +}); diff --git a/internal/api/src/routes/agents/agents.client.ts b/internal/api/src/routes/agents/agents.client.ts index 2d75ecb3..cb16b749 100644 --- a/internal/api/src/routes/agents/agents.client.ts +++ b/internal/api/src/routes/agents/agents.client.ts @@ -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"; @@ -28,6 +30,82 @@ export const schemaAgentVisibility = z.enum([ "organization", ]); +export const schemaOnboardingStep = z.enum([ + "welcome", + "llm-api-keys", + "github-setup", + "slack-setup", + "web-search", + "deploying", + "success", +]); + +export const schemaOnboardingState = z.object({ + currentStep: schemaOnboardingStep, + finished: z.boolean().optional(), + github: z + .object({ + appName: z.string(), + appUrl: z.string(), + installUrl: z.string(), + // Credentials (values) + appId: z.number().optional(), + clientId: z.string().optional(), + clientSecret: z.string().optional(), + webhookSecret: z.string().optional(), + privateKey: z.string().optional(), + // Env var names used + envVars: z + .object({ + appId: z.string(), + clientId: z.string(), + clientSecret: z.string(), + webhookSecret: z.string(), + privateKey: z.string(), + }) + .optional(), + }) + .optional(), + slack: z + .object({ + botToken: z.string(), + signingSecret: z.string(), + envVars: z + .object({ + botToken: z.string(), + signingSecret: z.string(), + }) + .optional(), + }) + .optional(), + llm: z + .object({ + provider: z.enum(["anthropic", "openai", "vercel"]).optional(), + apiKey: z.string().optional(), + envVar: z.string().optional(), + }) + .optional(), + webSearch: z + .object({ + provider: z.enum(["exa"]).optional(), + apiKey: z.string().optional(), + envVar: z.string().optional(), + }) + .optional(), +}); + +export type OnboardingState = z.infer; +export type OnboardingStep = z.infer; + +export const schemaIntegrationsState = z.object({ + llm: z.boolean().optional(), + github: z.boolean().optional(), + slack: z.boolean().optional(), + webSearch: z.boolean().optional(), +}); + +export type IntegrationsState = z.infer; + export const schemaCreateAgentRequest = z.object({ organization_id: z.uuid(), name: z.string().regex(nameFormat), @@ -48,6 +126,9 @@ export const schemaCreateAgentRequest = z.object({ // Optional: Specify the request_id for the production deployment target. // This is useful for setting up webhooks before the agent is fully deployed. request_id: z.uuid().optional(), + + // Optional: Initialize agent with onboarding state + onboarding_state: schemaOnboardingState.optional(), }); export type CreateAgentRequest = z.infer; @@ -70,6 +151,8 @@ export const schemaAgent = z.object({ .describe("The URL for the agent requests. Only visible to owners."), chat_expire_ttl: z.number().int().positive().nullable(), user_permission: z.enum(["read", "write", "admin"]).optional(), + onboarding_state: schemaOnboardingState.nullable(), + integrations_state: schemaIntegrationsState.nullable(), }); export const schemaUpdateAgentRequest = z.object({ @@ -165,6 +248,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; @@ -175,6 +260,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); } /** @@ -351,6 +438,59 @@ export default class Agents { await assertResponseStatus(resp, 200); return resp.json(); } + + /** + * Update onboarding state for an agent. + * + * @param id - The id of the agent. + * @param state - The partial onboarding state to merge. + * @returns The updated agent. + */ + public async updateOnboarding( + id: string, + state: Partial + ): Promise { + const resp = await this.client.request( + "PATCH", + `/api/agents/${id}/onboarding`, + JSON.stringify(state) + ); + await assertResponseStatus(resp, 200); + return resp.json(); + } + + /** + * Clear onboarding state for an agent (mark onboarding as complete). + * + * @param id - The id of the agent. + */ + public async clearOnboarding(id: string): Promise { + const resp = await this.client.request( + "DELETE", + `/api/agents/${id}/onboarding` + ); + await assertResponseStatus(resp, 204); + } + + /** + * Update integrations state for an agent. + * + * @param id - The id of the agent. + * @param state - The partial integrations state to merge. + * @returns The updated agent. + */ + public async updateIntegrationsState( + id: string, + state: Partial + ): Promise { + const resp = await this.client.request( + "PATCH", + `/api/agents/${id}/integrations`, + JSON.stringify(state) + ); + await assertResponseStatus(resp, 200); + return resp.json(); + } } export * from "./deployments.client"; diff --git a/internal/api/src/routes/agents/agents.server.ts b/internal/api/src/routes/agents/agents.server.ts index 9a5b86d7..3891f801 100644 --- a/internal/api/src/routes/agents/agents.server.ts +++ b/internal/api/src/routes/agents/agents.server.ts @@ -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"; @@ -52,6 +54,7 @@ export default function mountAgents(app: Hono<{ Bindings: Bindings }>) { description: req.description, visibility: req.visibility ?? "organization", chat_expire_ttl: req.chat_expire_ttl, + onboarding_state: req.onboarding_state, }); // Grant admin permission to the creator @@ -258,6 +261,87 @@ export default function mountAgents(app: Hono<{ Bindings: Bindings }>) { return c.body(null, 204); }); + // Update onboarding state for an agent. + app.patch( + "/:agent_id/onboarding", + withAuth, + withAgentURLParam, + withAgentPermission("write"), + async (c) => { + const agent = c.get("agent"); + const db = await c.env.database(); + const body = await c.req.json(); + + // Merge the new state with existing state + const currentState = agent.onboarding_state ?? { currentStep: "welcome" }; + const newState = { ...currentState, ...body }; + + const updated = await db.updateAgent({ + id: agent.id, + onboarding_state: newState, + }); + return c.json( + convert.agent( + updated, + await createAgentRequestURL(c, updated), + await getAgentUserPermission(c, updated) + ) + ); + } + ); + + // Clear onboarding state for an agent (mark onboarding complete). + app.delete( + "/:agent_id/onboarding", + withAuth, + withAgentURLParam, + withAgentPermission("write"), + async (c) => { + const agent = c.get("agent"); + const db = await c.env.database(); + + if (agent.onboarding_state) { + await db.updateAgent({ + id: agent.id, + onboarding_state: { + finished: true, + currentStep: agent.onboarding_state.currentStep, + }, + }); + } + return c.body(null, 204); + } + ); + + // Update integrations state for an agent. + app.patch( + "/:agent_id/integrations", + withAuth, + withAgentURLParam, + withAgentPermission("write"), + async (c) => { + const agent = c.get("agent"); + const db = await c.env.database(); + const body = await c.req.json(); + + // Merge the new state with existing state + const currentState = agent.integrations_state ?? {}; + const newState = { ...currentState, ...body }; + + const updated = await db.updateAgent({ + id: agent.id, + integrations_state: newState, + }); + return c.json( + convert.agent( + updated, + await createAgentRequestURL(c, updated), + await getAgentUserPermission(c, updated) + ) + ); + } + ); + // Delete an agent. app.delete( "/:agent_id", @@ -417,6 +501,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 diff --git a/internal/api/src/routes/agents/setup-github.client.ts b/internal/api/src/routes/agents/setup-github.client.ts new file mode 100644 index 00000000..f1666b13 --- /dev/null +++ b/internal/api/src/routes/agents/setup-github.client.ts @@ -0,0 +1,151 @@ +import { z } from "zod"; +import type Client from "../../client.browser"; +import { assertResponseStatus } from "../../client-helper"; + +// GitHub App data returned from GitHub after creation +export const schemaGitHubAppData = z.object({ + id: z.number(), + client_id: z.string(), + client_secret: z.string(), + webhook_secret: z.string(), + pem: z.string(), + name: z.string(), + html_url: z.string(), + slug: z.string(), +}); + +export type GitHubAppData = z.infer; + +// Start creation request/response +export const schemaStartGitHubAppCreationRequest = z.object({ + name: z.string().min(1).max(34), + organization: z.string().optional(), +}); + +export type StartGitHubAppCreationRequest = z.infer< + typeof schemaStartGitHubAppCreationRequest +>; + +export const schemaStartGitHubAppCreationResponse = z.object({ + manifest: z.string(), + github_url: z.string(), + session_id: z.string(), +}); + +export type StartGitHubAppCreationResponse = z.infer< + typeof schemaStartGitHubAppCreationResponse +>; + +// GitHub credentials returned when status is completed +// These should be saved as env vars by the client +export const schemaGitHubAppCredentials = z.object({ + app_id: z.number(), + client_id: z.string(), + client_secret: z.string(), + webhook_secret: z.string(), + private_key: z.string(), // base64-encoded PEM +}); + +export type GitHubAppCredentials = z.infer; + +// Creation status response +// Status flow: pending -> app_created -> completed +// - pending: waiting for user to create app on GitHub +// - app_created: app created, waiting for user to install it +// - completed: app created and installed +// - failed/expired: error states +export const schemaGitHubAppCreationStatusResponse = z.object({ + status: z.enum(["pending", "app_created", "completed", "failed", "expired"]), + error: z.string().optional(), + app_data: z + .object({ + id: z.number(), + name: z.string(), + html_url: z.string(), + slug: z.string(), + }) + .optional(), + // Credentials are only included when status is "completed" + credentials: schemaGitHubAppCredentials.optional(), +}); + +export type GitHubAppCreationStatusResponse = z.infer< + typeof schemaGitHubAppCreationStatusResponse +>; + +// Complete creation request +export const schemaCompleteGitHubAppCreationRequest = z.object({ + session_id: z.string(), +}); + +export type CompleteGitHubAppCreationRequest = z.infer< + typeof schemaCompleteGitHubAppCreationRequest +>; + +export const schemaCompleteGitHubAppCreationResponse = z.object({ + success: z.boolean(), + app_name: z.string().optional(), + app_url: z.string().optional(), + install_url: z.string().optional(), +}); + +export type CompleteGitHubAppCreationResponse = z.infer< + typeof schemaCompleteGitHubAppCreationResponse +>; + +export default class AgentSetupGitHub { + private readonly client: Client; + + public constructor(client: Client) { + this.client = client; + } + + /** + * Start GitHub App creation for an agent. + * Returns a URL to redirect the user to GitHub for app creation. + */ + public async startCreation( + agentId: string, + request: StartGitHubAppCreationRequest + ): Promise { + const resp = await this.client.request( + "POST", + `/api/agents/${agentId}/setup/github/start-creation`, + JSON.stringify(request) + ); + await assertResponseStatus(resp, 200); + return resp.json(); + } + + /** + * Get the current creation status. + * Poll this endpoint to check if the GitHub callback has been received. + */ + public async getCreationStatus( + agentId: string, + sessionId: string + ): Promise { + const resp = await this.client.request( + "GET", + `/api/agents/${agentId}/setup/github/creation-status/${sessionId}` + ); + await assertResponseStatus(resp, 200); + return resp.json(); + } + + /** + * Complete GitHub App creation. + */ + public async completeCreation( + agentId: string, + request: CompleteGitHubAppCreationRequest + ): Promise { + const resp = await this.client.request( + "POST", + `/api/agents/${agentId}/setup/github/complete-creation`, + JSON.stringify(request) + ); + await assertResponseStatus(resp, 200); + return resp.json(); + } +} diff --git a/internal/api/src/routes/agents/setup-github.server.ts b/internal/api/src/routes/agents/setup-github.server.ts new file mode 100644 index 00000000..3a0ce9c9 --- /dev/null +++ b/internal/api/src/routes/agents/setup-github.server.ts @@ -0,0 +1,493 @@ +import type { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { validator } from "hono/validator"; + +import { + withAgentPermission, + withAgentURLParam, + withAuth, +} from "../../middleware"; +import type { Bindings } from "../../server"; +import { createWebhookURL } from "../../server-helper"; +import { + type CompleteGitHubAppCreationResponse, + type GitHubAppCreationStatusResponse, + type StartGitHubAppCreationResponse, + schemaCompleteGitHubAppCreationRequest, + schemaGitHubAppData, + schemaStartGitHubAppCreationRequest, +} from "./setup-github.client"; + +// 24 hour expiry for GitHub App creation sessions +const SESSION_EXPIRY_MS = 24 * 60 * 60 * 1000; + +/** + * Create the GitHub App manifest for the manifest flow. + */ +function createGitHubAppManifest( + name: string, + webhookUrl: string, + callbackUrl: string, + setupUrl: string +) { + return { + name, + url: "https://github.com/coder/blink", + description: "A Blink agent for GitHub", + public: false, + redirect_url: callbackUrl, + setup_url: setupUrl, + setup_on_update: true, + hook_attributes: { + url: webhookUrl, + active: true, + }, + default_events: [ + "issues", + "issue_comment", + "pull_request", + "pull_request_review", + "pull_request_review_comment", + "push", + ], + default_permissions: { + contents: "write", + issues: "write", + pull_requests: "write", + metadata: "read", + }, + }; +} + +export default function mountSetupGitHub( + app: Hono<{ + Bindings: Bindings; + }> +) { + // Start GitHub App creation + app.post( + "/start-creation", + withAuth, + withAgentURLParam, + withAgentPermission("write"), + validator("json", (value) => { + return schemaStartGitHubAppCreationRequest.parse(value); + }), + async (c) => { + const agent = c.get("agent"); + const req = c.req.valid("json"); + const db = await c.env.database(); + + // Get the agent's production deployment target for webhook URL + const target = await db.selectAgentDeploymentTargetByName( + agent.id, + "production" + ); + if (!target) { + return c.json({ error: "No deployment target found" }, 400); + } + + const webhookUrl = createWebhookURL(c.env, target.request_id, "github"); + const sessionId = crypto.randomUUID(); + const now = new Date(); + const expiresAt = new Date(now.getTime() + SESSION_EXPIRY_MS); + + const apiOrigin = c.env.accessUrl.origin; + // Build the callback URL - this is where GitHub will redirect after app creation + const callbackUrl = `${apiOrigin}/api/agents/${agent.id}/setup/github/callback?session_id=${sessionId}`; + // Build the setup URL - this is where GitHub will redirect after app installation + const setupUrl = `${apiOrigin}/api/agents/${agent.id}/setup/github/setup-complete?session_id=${sessionId}`; + + const manifest = createGitHubAppManifest( + req.name, + webhookUrl, + callbackUrl, + setupUrl + ); + + await db.updateAgent({ + id: agent.id, + github_app_setup: { + sessionId, + startedAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + status: "pending", + }, + }); + + // Return the manifest and GitHub URL for the frontend to submit + const githubUrl = req.organization + ? `https://github.com/organizations/${req.organization}/settings/apps/new` + : `https://github.com/settings/apps/new`; + + const response: StartGitHubAppCreationResponse = { + manifest: JSON.stringify(manifest), + github_url: githubUrl, + session_id: sessionId, + }; + return c.json(response); + } + ); + + // GitHub callback - receives the code after app creation + // This endpoint is PUBLIC (no auth) because GitHub redirects the user's browser here + // Security is provided by validating the session_id + app.get("/callback", async (c) => { + const agentId = c.req.param("agent_id"); + if (!agentId) { + return c.html(createCallbackHtml("error", "Agent ID is required")); + } + + const db = await c.env.database(); + const agent = await db.selectAgentByID(agentId); + if (!agent) { + return c.html(createCallbackHtml("error", "Agent not found")); + } + + const sessionId = c.req.query("session_id"); + const code = c.req.query("code"); + + // Validate session - this provides security for this public endpoint + const setup = agent.github_app_setup; + if (!setup || setup.sessionId !== sessionId) { + return c.html( + createCallbackHtml( + "error", + "Invalid or expired session. Please restart the GitHub App setup." + ) + ); + } + + // Check expiry + if (new Date() > new Date(setup.expiresAt)) { + await db.updateAgent({ + id: agent.id, + github_app_setup: { + ...setup, + status: "failed", + error: "Session expired", + }, + }); + return c.html( + createCallbackHtml( + "error", + "Session expired. Please restart the GitHub App setup." + ) + ); + } + + if (!code) { + await db.updateAgent({ + id: agent.id, + github_app_setup: { + ...setup, + status: "failed", + error: "No code received from GitHub", + }, + }); + return c.html( + createCallbackHtml( + "error", + "No authorization code received from GitHub." + ) + ); + } + + try { + // Exchange the code for credentials + const res = await fetch( + `https://api.github.com/app-manifests/${code}/conversions`, + { + method: "POST", + headers: { + Accept: "application/vnd.github+json", + "User-Agent": `Blink-Server/${c.env.serverVersion}`, + "X-GitHub-Api-Version": "2022-11-28", + }, + } + ); + + if (!res.ok) { + const errorText = await res.text(); + throw new Error( + `GitHub API error: ${res.status} ${res.statusText}${errorText ? ` - ${errorText}` : ""}` + ); + } + + const rawData = await res.json(); + const data = schemaGitHubAppData.parse(rawData); + + // Store the app data in the session (status stays "pending" until installation) + await db.updateAgent({ + id: agent.id, + github_app_setup: { + ...setup, + status: "app_created", + appData: { + id: data.id, + clientId: data.client_id, + clientSecret: data.client_secret, + webhookSecret: data.webhook_secret, + pem: data.pem, + name: data.name, + htmlUrl: data.html_url, + slug: data.slug, + }, + }, + }); + + // Redirect to the app's installation page + const installUrl = `${data.html_url}/installations/new`; + return c.redirect(installUrl); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + + await db.updateAgent({ + id: agent.id, + github_app_setup: { + ...setup, + status: "failed", + error: errorMessage, + }, + }); + + return c.html( + createCallbackHtml( + "error", + `Failed to create GitHub App: ${errorMessage}` + ) + ); + } + }); + + // Setup complete - this is the Setup URL that GitHub redirects to after app installation + // This endpoint is PUBLIC (no auth) because GitHub redirects the user's browser here + app.get("/setup-complete", async (c) => { + const agentId = c.req.param("agent_id"); + if (!agentId) { + return c.html( + createCallbackHtml("error", "Agent ID is required", undefined) + ); + } + + const db = await c.env.database(); + const agent = await db.selectAgentByID(agentId); + if (!agent) { + return c.html(createCallbackHtml("error", "Agent not found", undefined)); + } + + const sessionId = c.req.query("session_id"); + const installationId = c.req.query("installation_id"); + const setup = agent.github_app_setup; + + // Check if there's an active setup session + const hasActiveSession = setup && setup.sessionId === sessionId; + + if (hasActiveSession && setup.appData) { + // Update the setup with installation info if provided + if (installationId) { + await db.updateAgent({ + id: agent.id, + github_app_setup: { + ...setup, + status: "completed", + installationId, + }, + }); + } else if (setup.status === "app_created") { + // Mark as completed even without installation_id (user might have installed) + await db.updateAgent({ + id: agent.id, + github_app_setup: { + ...setup, + status: "completed", + }, + }); + } + + return c.html( + createCallbackHtml( + "success", + `GitHub App "${setup.appData.name}" has been installed! Return to the setup wizard to continue.`, + true + ) + ); + } + + // No active session - just show a generic success message + return c.html( + createCallbackHtml("success", "GitHub App installation complete!", false) + ); + }); + + // Get creation status + app.get( + "/creation-status/:session_id", + withAuth, + withAgentURLParam, + withAgentPermission("read"), + async (c) => { + const agent = c.get("agent"); + const sessionId = c.req.param("session_id"); + const setup = agent.github_app_setup; + + if (!setup || setup.sessionId !== sessionId) { + return c.json({ status: "expired" as const }); + } + + // Check expiry for pending states + if ( + (setup.status === "pending" || setup.status === "app_created") && + new Date() > new Date(setup.expiresAt) + ) { + return c.json({ status: "expired" as const }); + } + + const response: GitHubAppCreationStatusResponse = { + status: setup.status, + error: setup.error, + app_data: setup.appData + ? { + id: setup.appData.id, + name: setup.appData.name, + html_url: setup.appData.htmlUrl, + slug: setup.appData.slug, + } + : undefined, + // Include full credentials only when status is completed + // so the client can save them as env vars + credentials: + setup.status === "completed" && setup.appData + ? { + app_id: setup.appData.id, + client_id: setup.appData.clientId, + client_secret: setup.appData.clientSecret, + webhook_secret: setup.appData.webhookSecret, + private_key: btoa(setup.appData.pem), + } + : undefined, + }; + return c.json(response); + } + ); + + // Complete creation - clear setup state + app.post( + "/complete-creation", + withAuth, + withAgentURLParam, + withAgentPermission("write"), + validator("json", (value) => { + return schemaCompleteGitHubAppCreationRequest.parse(value); + }), + async (c) => { + const agent = c.get("agent"); + const req = c.req.valid("json"); + const db = await c.env.database(); + + const setup = agent.github_app_setup; + if (!setup || setup.sessionId !== req.session_id) { + throw new HTTPException(400, { + message: "Invalid or expired session", + }); + } + + if (setup.status !== "completed" || !setup.appData) { + throw new HTTPException(400, { + message: "GitHub App creation not completed", + }); + } + + // Clear setup state + await db.updateAgent({ + id: agent.id, + github_app_setup: null, + }); + + const response: CompleteGitHubAppCreationResponse = { + success: true, + app_name: setup.appData.name, + app_url: setup.appData.htmlUrl, + install_url: `${setup.appData.htmlUrl}/installations/new`, + }; + return c.json(response); + } + ); +} + +/** + * Create HTML page for the callback response. + * @param showWizardHint - If true, shows a hint to return to the setup wizard + */ +function createCallbackHtml( + status: "success" | "error", + message: string, + showWizardHint?: boolean +): string { + const isSuccess = status === "success"; + const bgColor = isSuccess ? "#10b981" : "#ef4444"; + const icon = isSuccess + ? `` + : ``; + + const wizardHint = showWizardHint + ? `

You can close this window and return to the setup wizard.

` + : ""; + + return ` + + + + + GitHub App Setup - ${isSuccess ? "Success" : "Error"} + + + +
+
${icon}
+

${isSuccess ? "Success!" : "Something went wrong"}

+

${message}

+ ${wizardHint} +
+ +`; +} diff --git a/internal/api/src/routes/agents/setup-github.test.ts b/internal/api/src/routes/agents/setup-github.test.ts new file mode 100644 index 00000000..6e6e66c9 --- /dev/null +++ b/internal/api/src/routes/agents/setup-github.test.ts @@ -0,0 +1,327 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { HttpResponse, http } from "msw"; +import { type SetupServerApi, setupServer } from "msw/node"; +import { serve } from "../../test"; + +let mswServer: SetupServerApi; + +beforeEach(() => { + mswServer = setupServer(); + mswServer.listen({ onUnhandledRequest: "bypass" }); +}); + +afterEach(() => { + if (mswServer) { + mswServer.close(); + } +}); + +describe("GitHub App Setup", () => { + test("start-creation returns manifest and github URL", async () => { + const { helpers } = await serve({ + bindings: { + accessUrl: new URL("https://test.blink.so"), + }, + }); + const { client } = await helpers.createUser(); + const org = await client.organizations.create({ + name: "test-org", + }); + const agent = await client.agents.create({ + name: "test-agent", + organization_id: org.id, + }); + + const result = await client.agents.setupGitHub.startCreation(agent.id, { + name: "my-github-app", + }); + + expect(result.session_id).toBeDefined(); + expect(result.github_url).toBe("https://github.com/settings/apps/new"); + expect(result.manifest).toBeDefined(); + + const manifest = JSON.parse(result.manifest); + expect(manifest.name).toBe("my-github-app"); + expect(manifest.public).toBe(false); + expect(manifest.default_permissions).toEqual({ + contents: "write", + issues: "write", + pull_requests: "write", + metadata: "read", + }); + }); + + test("start-creation with organization returns organization github URL", async () => { + const { helpers } = await serve({ + bindings: { + accessUrl: new URL("https://test.blink.so"), + }, + }); + const { client } = await helpers.createUser(); + const org = await client.organizations.create({ + name: "test-org", + }); + const agent = await client.agents.create({ + name: "test-agent", + organization_id: org.id, + }); + + const result = await client.agents.setupGitHub.startCreation(agent.id, { + name: "my-github-app", + organization: "my-gh-org", + }); + + expect(result.github_url).toBe( + "https://github.com/organizations/my-gh-org/settings/apps/new" + ); + }); + + test("get-creation-status returns pending for new session", async () => { + const { helpers } = await serve({ + bindings: { + accessUrl: new URL("https://test.blink.so"), + }, + }); + const { client } = await helpers.createUser(); + const org = await client.organizations.create({ + name: "test-org", + }); + const agent = await client.agents.create({ + name: "test-agent", + organization_id: org.id, + }); + + const startResult = await client.agents.setupGitHub.startCreation( + agent.id, + { + name: "my-github-app", + } + ); + + const status = await client.agents.setupGitHub.getCreationStatus( + agent.id, + startResult.session_id + ); + + expect(status.status).toBe("pending"); + expect(status.app_data).toBeUndefined(); + expect(status.credentials).toBeUndefined(); + }); + + test("get-creation-status returns expired for invalid session", async () => { + const { helpers } = await serve({ + bindings: { + accessUrl: new URL("https://test.blink.so"), + }, + }); + const { client } = await helpers.createUser(); + const org = await client.organizations.create({ + name: "test-org", + }); + const agent = await client.agents.create({ + name: "test-agent", + organization_id: org.id, + }); + + const status = await client.agents.setupGitHub.getCreationStatus( + agent.id, + "invalid-session-id" + ); + + expect(status.status).toBe("expired"); + }); + + test("complete-creation fails for pending session", async () => { + const { helpers } = await serve({ + bindings: { + accessUrl: new URL("https://test.blink.so"), + }, + }); + const { client } = await helpers.createUser(); + const org = await client.organizations.create({ + name: "test-org", + }); + const agent = await client.agents.create({ + name: "test-agent", + organization_id: org.id, + }); + + const startResult = await client.agents.setupGitHub.startCreation( + agent.id, + { + name: "my-github-app", + } + ); + + await expect( + client.agents.setupGitHub.completeCreation(agent.id, { + session_id: startResult.session_id, + }) + ).rejects.toThrow("GitHub App creation not completed"); + }); + + test("get-creation-status returns credentials when completed", async () => { + const { helpers, url } = await serve({ + bindings: { + accessUrl: new URL("https://test.blink.so"), + }, + }); + const { client } = await helpers.createUser(); + const org = await client.organizations.create({ + name: "test-org", + }); + const agent = await client.agents.create({ + name: "test-agent", + organization_id: org.id, + }); + + const startResult = await client.agents.setupGitHub.startCreation( + agent.id, + { + name: "my-github-app", + } + ); + + // Mock the GitHub API endpoint that exchanges the code for app credentials + mswServer.use( + http.post( + "https://api.github.com/app-manifests/:code/conversions", + () => { + return HttpResponse.json({ + id: 12345, + client_id: "Iv1.abc123", + client_secret: "secret123", + webhook_secret: "webhook-secret", + pem: "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----", + name: "my-github-app", + html_url: "https://github.com/apps/my-github-app", + slug: "my-github-app", + }); + } + ) + ); + + // Call the callback endpoint as GitHub would (redirecting with a code) + const callbackRes = await fetch( + `${url}/api/agents/${agent.id}/setup/github/callback?session_id=${startResult.session_id}&code=mock-code`, + { redirect: "manual" } + ); + // Callback should redirect to the app's installation page + expect(callbackRes.status).toBe(302); + expect(callbackRes.headers.get("Location")).toBe( + "https://github.com/apps/my-github-app/installations/new" + ); + + // Call the setup-complete endpoint as GitHub would after app installation + const setupCompleteRes = await fetch( + `${url}/api/agents/${agent.id}/setup/github/setup-complete?session_id=${startResult.session_id}`, + { redirect: "manual" } + ); + expect(setupCompleteRes.status).toBe(200); + + // Now get status should return credentials + const status = await client.agents.setupGitHub.getCreationStatus( + agent.id, + startResult.session_id + ); + + expect(status.status).toBe("completed"); + expect(status.app_data).toEqual({ + id: 12345, + name: "my-github-app", + html_url: "https://github.com/apps/my-github-app", + slug: "my-github-app", + }); + expect(status.credentials).toBeDefined(); + if (!status.credentials) { + throw new Error("Credentials should be defined"); + } + expect(status.credentials.app_id).toBe(12345); + expect(status.credentials.client_id).toBe("Iv1.abc123"); + expect(status.credentials.client_secret).toBe("secret123"); + expect(status.credentials.webhook_secret).toBe("webhook-secret"); + // Private key should be base64 encoded + expect(status.credentials.private_key).toBe( + btoa( + "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----" + ) + ); + }); + + test("complete-creation clears setup state when completed", async () => { + const { helpers, url } = await serve({ + bindings: { + accessUrl: new URL("https://test.blink.so"), + }, + }); + const { client } = await helpers.createUser(); + const org = await client.organizations.create({ + name: "test-org", + }); + const agent = await client.agents.create({ + name: "test-agent", + organization_id: org.id, + }); + + const startResult = await client.agents.setupGitHub.startCreation( + agent.id, + { + name: "my-github-app", + } + ); + + // Mock the GitHub API endpoint that exchanges the code for app credentials + mswServer.use( + http.post( + "https://api.github.com/app-manifests/:code/conversions", + () => { + return HttpResponse.json({ + id: 12345, + client_id: "Iv1.abc123", + client_secret: "secret123", + webhook_secret: "webhook-secret", + pem: "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----", + name: "my-github-app", + html_url: "https://github.com/apps/my-github-app", + slug: "my-github-app", + }); + } + ) + ); + + // Call the callback endpoint as GitHub would (redirecting with a code) + await fetch( + `${url}/api/agents/${agent.id}/setup/github/callback?session_id=${startResult.session_id}&code=mock-code`, + { redirect: "manual" } + ); + + // Call the setup-complete endpoint as GitHub would after app installation + await fetch( + `${url}/api/agents/${agent.id}/setup/github/setup-complete?session_id=${startResult.session_id}`, + { redirect: "manual" } + ); + + // Complete the creation + const result = await client.agents.setupGitHub.completeCreation(agent.id, { + session_id: startResult.session_id, + }); + + expect(result.success).toBe(true); + expect(result.app_name).toBe("my-github-app"); + expect(result.app_url).toBe("https://github.com/apps/my-github-app"); + expect(result.install_url).toBe( + "https://github.com/apps/my-github-app/installations/new" + ); + + // Verify setup state is cleared + const status = await client.agents.setupGitHub.getCreationStatus( + agent.id, + startResult.session_id + ); + expect(status.status).toBe("expired"); + + // Verify no env vars were created (that's now the client's responsibility) + const envVars = await client.agents.env.list({ agent_id: agent.id }); + expect(envVars.length).toBe(0); + }); +}); diff --git a/internal/api/src/routes/agents/setup-slack.client.ts b/internal/api/src/routes/agents/setup-slack.client.ts new file mode 100644 index 00000000..f7176e38 --- /dev/null +++ b/internal/api/src/routes/agents/setup-slack.client.ts @@ -0,0 +1,194 @@ +import { z } from "zod"; +import type Client from "../../client.browser"; +import { assertResponseStatus } from "../../client-helper"; + +// Slack verification state stored on agent +export const schemaSlackVerification = z + .object({ + signingSecret: z.string(), + botToken: z.string(), + startedAt: z.string(), + lastEventAt: z.string().optional(), + dmReceivedAt: z.string().optional(), + dmChannel: z.string().optional(), + signatureFailedAt: z.string().optional(), + }) + .nullable(); + +export type SlackVerification = z.infer; + +// Start verification request/response +export const schemaStartSlackVerificationRequest = z.object({ + signing_secret: z.string().min(1), + bot_token: z.string().min(1), +}); + +export type StartSlackVerificationRequest = z.infer< + typeof schemaStartSlackVerificationRequest +>; + +export const schemaStartSlackVerificationResponse = z.object({ + webhook_url: z.string(), +}); + +export type StartSlackVerificationResponse = z.infer< + typeof schemaStartSlackVerificationResponse +>; + +// Verification status response +export const schemaSlackVerificationStatusResponse = z.object({ + active: z.boolean(), + started_at: z.string().optional(), + last_event_at: z.string().optional(), + dm_received: z.boolean(), + dm_channel: z.string().optional(), + signature_failed: z.boolean(), + signature_failed_at: z.string().optional(), +}); + +export type SlackVerificationStatusResponse = z.infer< + typeof schemaSlackVerificationStatusResponse +>; + +// Complete verification request +export const schemaCompleteSlackVerificationRequest = z.object({ + bot_token: z.string().min(1), + signing_secret: z.string().min(1), +}); + +export type CompleteSlackVerificationRequest = z.infer< + typeof schemaCompleteSlackVerificationRequest +>; + +export const schemaCompleteSlackVerificationResponse = z.object({ + success: z.boolean(), + bot_name: z.string().optional(), +}); + +export type CompleteSlackVerificationResponse = z.infer< + typeof schemaCompleteSlackVerificationResponse +>; + +// Webhook URL response +export const schemaSlackWebhookUrlResponse = z.object({ + webhook_url: z.string(), +}); + +export type SlackWebhookUrlResponse = z.infer< + typeof schemaSlackWebhookUrlResponse +>; + +// Validate token request/response +export const schemaValidateSlackTokenRequest = z.object({ + botToken: z.string(), +}); + +export type ValidateSlackTokenRequest = z.infer< + typeof schemaValidateSlackTokenRequest +>; + +export const schemaValidateSlackTokenResponse = z.object({ + valid: z.boolean(), + error: z.string().optional(), +}); + +export type ValidateSlackTokenResponse = z.infer< + typeof schemaValidateSlackTokenResponse +>; + +export default class AgentSetupSlack { + private readonly client: Client; + + public constructor(client: Client) { + this.client = client; + } + + /** + * Get the webhook URL for Slack integration. + * This doesn't require any credentials and can be called before setup. + */ + public async getWebhookUrl( + agentId: string + ): Promise { + const resp = await this.client.request( + "GET", + `/api/agents/${agentId}/setup/slack/webhook-url` + ); + await assertResponseStatus(resp, 200); + return resp.json(); + } + + /** + * Validate a Slack bot token by calling Slack's auth.test API. + */ + public async validateToken( + agentId: string, + request: ValidateSlackTokenRequest + ): Promise { + const resp = await this.client.request( + "POST", + `/api/agents/${agentId}/setup/slack/validate-token`, + JSON.stringify(request) + ); + await assertResponseStatus(resp, 200); + return resp.json(); + } + + /** + * Start Slack verification for an agent. + * This sets up the webhook to listen for Slack events. + */ + public async startVerification( + agentId: string, + request: StartSlackVerificationRequest + ): Promise { + const resp = await this.client.request( + "POST", + `/api/agents/${agentId}/setup/slack/start-verification`, + JSON.stringify(request) + ); + await assertResponseStatus(resp, 200); + return resp.json(); + } + + /** + * Get the current verification status. + */ + public async getVerificationStatus( + agentId: string + ): Promise { + const resp = await this.client.request( + "GET", + `/api/agents/${agentId}/setup/slack/verification-status` + ); + await assertResponseStatus(resp, 200); + return resp.json(); + } + + /** + * Complete Slack verification and save credentials. + */ + public async completeVerification( + agentId: string, + request: CompleteSlackVerificationRequest + ): Promise { + const resp = await this.client.request( + "POST", + `/api/agents/${agentId}/setup/slack/complete-verification`, + JSON.stringify(request) + ); + await assertResponseStatus(resp, 200); + return resp.json(); + } + + /** + * Cancel ongoing Slack verification. + */ + public async cancelVerification(agentId: string): Promise { + const resp = await this.client.request( + "POST", + `/api/agents/${agentId}/setup/slack/cancel-verification` + ); + await assertResponseStatus(resp, 204); + } +} diff --git a/internal/api/src/routes/agents/setup-slack.server.ts b/internal/api/src/routes/agents/setup-slack.server.ts new file mode 100644 index 00000000..1aa35ce9 --- /dev/null +++ b/internal/api/src/routes/agents/setup-slack.server.ts @@ -0,0 +1,222 @@ +import type { Hono } from "hono"; +import { validator } from "hono/validator"; + +import { + withAgentPermission, + withAgentURLParam, + withAuth, +} from "../../middleware"; +import type { Bindings } from "../../server"; +import { createWebhookURL } from "../../server-helper"; +import { + type CompleteSlackVerificationResponse, + type SlackVerificationStatusResponse, + type StartSlackVerificationResponse, + schemaCompleteSlackVerificationRequest, + schemaStartSlackVerificationRequest, + schemaValidateSlackTokenRequest, + type ValidateSlackTokenResponse, +} from "./setup-slack.client"; + +/** + * Verify Slack bot token by calling auth.test API. + */ +async function verifySlackBotToken( + botToken: string +): Promise<{ valid: boolean; error?: string; botName?: string }> { + try { + const resp = await fetch("https://slack.com/api/auth.test", { + method: "POST", + headers: { + Authorization: `Bearer ${botToken}`, + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + const data = (await resp.json()) as { + ok: boolean; + error?: string; + user?: string; + bot_id?: string; + }; + if (!data.ok) { + return { valid: false, error: data.error || "Invalid token" }; + } + return { valid: true, botName: data.user }; + } catch (error) { + return { + valid: false, + error: error instanceof Error ? error.message : "Verification failed", + }; + } +} + +export default function mountSetupSlack( + app: Hono<{ + Bindings: Bindings; + }> +) { + // Get webhook URL (no credentials required) + app.get( + "/webhook-url", + withAuth, + withAgentURLParam, + withAgentPermission("read"), + async (c) => { + const agent = c.get("agent"); + const db = await c.env.database(); + + // Get the agent's production deployment target for webhook URL + const target = await db.selectAgentDeploymentTargetByName( + agent.id, + "production" + ); + if (!target) { + return c.json({ error: "No deployment target found" }, 400); + } + + const webhookUrl = createWebhookURL(c.env, target.request_id, "slack"); + return c.json({ webhook_url: webhookUrl }); + } + ); + + // Validate Slack bot token + app.post( + "/validate-token", + withAuth, + withAgentURLParam, + withAgentPermission("read"), + validator("json", (value) => { + return schemaValidateSlackTokenRequest.parse(value); + }), + async (c) => { + const req = c.req.valid("json"); + const result = await verifySlackBotToken(req.botToken); + const response: ValidateSlackTokenResponse = { + valid: result.valid, + error: result.error, + }; + return c.json(response); + } + ); + + // Start Slack verification + app.post( + "/start-verification", + withAuth, + withAgentURLParam, + withAgentPermission("write"), + validator("json", (value) => { + return schemaStartSlackVerificationRequest.parse(value); + }), + async (c) => { + const agent = c.get("agent"); + const req = c.req.valid("json"); + const db = await c.env.database(); + + // Get the agent's production deployment target for webhook URL + const target = await db.selectAgentDeploymentTargetByName( + agent.id, + "production" + ); + if (!target) { + return c.json({ error: "No deployment target found" }, 400); + } + + const webhookUrl = createWebhookURL(c.env, target.request_id, "slack"); + + // Store verification state (expires after 24 hours) + const now = new Date(); + const expiresAt = new Date(now.getTime() + 24 * 60 * 60 * 1000); + await db.updateAgent({ + id: agent.id, + slack_verification: { + signingSecret: req.signing_secret, + botToken: req.bot_token, + startedAt: now.toISOString(), + expiresAt: expiresAt.toISOString(), + }, + }); + + const response: StartSlackVerificationResponse = { + webhook_url: webhookUrl, + }; + return c.json(response); + } + ); + + // Get verification status + app.get( + "/verification-status", + withAuth, + withAgentURLParam, + withAgentPermission("read"), + async (c) => { + const agent = c.get("agent"); + const verification = agent.slack_verification; + + const response: SlackVerificationStatusResponse = { + active: verification !== null, + started_at: verification?.startedAt, + last_event_at: verification?.lastEventAt, + dm_received: verification?.dmReceivedAt !== undefined, + dm_channel: verification?.dmChannel, + signature_failed: verification?.signatureFailedAt !== undefined, + signature_failed_at: verification?.signatureFailedAt, + }; + return c.json(response); + } + ); + + // Complete verification + app.post( + "/complete-verification", + withAuth, + withAgentURLParam, + withAgentPermission("write"), + validator("json", (value) => { + return schemaCompleteSlackVerificationRequest.parse(value); + }), + async (c) => { + const agent = c.get("agent"); + const req = c.req.valid("json"); + const db = await c.env.database(); + + // Verify the bot token + const verification = await verifySlackBotToken(req.bot_token); + if (!verification.valid) { + return c.json({ success: false, error: verification.error }, 400); + } + + await db.updateAgent({ + id: agent.id, + slack_verification: null, + }); + + const response: CompleteSlackVerificationResponse = { + success: true, + bot_name: verification.botName, + }; + return c.json(response); + } + ); + + // Cancel verification + app.post( + "/cancel-verification", + withAuth, + withAgentURLParam, + withAgentPermission("write"), + async (c) => { + const agent = c.get("agent"); + const db = await c.env.database(); + + // Clear verification state + await db.updateAgent({ + id: agent.id, + slack_verification: null, + }); + + return c.body(null, 204); + } + ); +} diff --git a/internal/api/src/routes/agents/setup-slack.test.ts b/internal/api/src/routes/agents/setup-slack.test.ts new file mode 100644 index 00000000..52c4d759 --- /dev/null +++ b/internal/api/src/routes/agents/setup-slack.test.ts @@ -0,0 +1,477 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createHmac } from "node:crypto"; +import { HttpResponse, http } from "msw"; +import { type SetupServerApi, setupServer } from "msw/node"; +import type Client from "../../client.node"; +import { serve as originalServe } from "../../test"; + +const serve = () => { + return originalServe({ + bindings: { + accessUrl: new URL("https://test.blink.so"), + matchRequestHost: undefined, + createRequestURL: undefined, + }, + }); +}; + +type ServeResult = Awaited>; + +/** + * Compute a valid Slack signature for testing. + */ +function computeSlackSignature( + signingSecret: string, + timestamp: string, + body: string +): string { + const hmac = createHmac("sha256", signingSecret); + const sigBasestring = `v0:${timestamp}:${body}`; + hmac.update(sigBasestring); + return `v0=${hmac.digest("hex")}`; +} + +/** + * Helper to create test fixtures (user, org, agent). + */ +async function setupAgent(helpers: ServeResult["helpers"]) { + const { client } = await helpers.createUser(); + const org = await client.organizations.create({ name: "test-org" }); + const agent = await client.agents.create({ + name: "test-agent", + organization_id: org.id, + }); + return { client, org, agent }; +} + +/** + * Helper to send a Slack webhook request with proper signature. + */ +async function sendSlackWebhook( + baseUrl: string, + webhookPath: string, + signingSecret: string, + payload: object +) { + const body = JSON.stringify(payload); + const timestamp = Math.floor(Date.now() / 1000).toString(); + const signature = computeSlackSignature(signingSecret, timestamp, body); + + return fetch(`${baseUrl}${webhookPath}`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-slack-signature": signature, + "x-slack-request-timestamp": timestamp, + }, + body, + }); +} + +/** + * Helper to start verification and get webhook path. + */ +async function startVerificationAndGetWebhookPath( + client: Client, + agentId: string, + signingSecret: string, + botToken = "xoxb-test-bot-token" +) { + const result = await client.agents.setupSlack.startVerification(agentId, { + signing_secret: signingSecret, + bot_token: botToken, + }); + const webhookPath = new URL(result.webhook_url).pathname; + return { webhookUrl: result.webhook_url, webhookPath }; +} + +/** + * Helper to mock Slack auth.test API response. + */ +function mockSlackAuthTest( + mswServer: SetupServerApi, + response: { ok: boolean; user?: string; bot_id?: string; error?: string } +) { + mswServer.use( + http.post("https://slack.com/api/auth.test", () => { + return HttpResponse.json(response); + }) + ); +} + +let mswServer: SetupServerApi; + +beforeEach(() => { + mswServer = setupServer(); + mswServer.listen({ onUnhandledRequest: "bypass" }); +}); + +afterEach(() => { + if (mswServer) { + mswServer.close(); + } +}); + +describe("Slack Setup", () => { + describe("GET /webhook-url", () => { + test("returns webhook URL when deployment target exists", async () => { + const { helpers } = await serve(); + const { client, agent } = await setupAgent(helpers); + + const result = await client.agents.setupSlack.getWebhookUrl(agent.id); + + expect(result.webhook_url).toMatch( + /^https:\/\/test\.blink\.so\/api\/webhook\/[a-z0-9-]+\/slack$/ + ); + }); + }); + + describe("POST /start-verification", () => { + test("stores verification state and returns webhook URL", async () => { + const { helpers } = await serve(); + const { client, agent } = await setupAgent(helpers); + + const result = await client.agents.setupSlack.startVerification( + agent.id, + { signing_secret: "test-signing-secret", bot_token: "xoxb-test-token" } + ); + + expect(result.webhook_url).toMatch( + /^https:\/\/test\.blink\.so\/api\/webhook\/[a-z0-9-]+\/slack$/ + ); + + const status = await client.agents.setupSlack.getVerificationStatus( + agent.id + ); + expect(status.active).toBe(true); + expect(status.started_at).toBeDefined(); + }); + }); + + describe("GET /verification-status", () => { + test("returns active: false when no verification in progress", async () => { + const { helpers } = await serve(); + const { client, agent } = await setupAgent(helpers); + + const status = await client.agents.setupSlack.getVerificationStatus( + agent.id + ); + + expect(status.active).toBe(false); + expect(status.started_at).toBeUndefined(); + expect(status.dm_received).toBe(false); + expect(status.signature_failed).toBe(false); + }); + + test("returns correct status fields when verification active", async () => { + const { helpers } = await serve(); + const { client, agent } = await setupAgent(helpers); + + await client.agents.setupSlack.startVerification(agent.id, { + signing_secret: "test-signing-secret", + bot_token: "xoxb-test-bot-token", + }); + + const status = await client.agents.setupSlack.getVerificationStatus( + agent.id + ); + + expect(status.active).toBe(true); + expect(status.started_at).toBeDefined(); + expect(status.dm_received).toBe(false); + expect(status.signature_failed).toBe(false); + }); + }); + + describe("POST /complete-verification", () => { + test("verifies token and returns bot name", async () => { + const { helpers } = await serve(); + const { client, agent } = await setupAgent(helpers); + + mockSlackAuthTest(mswServer, { + ok: true, + user: "test-bot", + bot_id: "B123", + }); + + const result = await client.agents.setupSlack.completeVerification( + agent.id, + { + bot_token: "xoxb-test-bot-token", + signing_secret: "test-signing-secret", + } + ); + + expect(result.success).toBe(true); + expect(result.bot_name).toBe("test-bot"); + }); + + test("returns 400 when bot token verification fails", async () => { + const { helpers } = await serve(); + const { client, agent } = await setupAgent(helpers); + + mockSlackAuthTest(mswServer, { ok: false, error: "invalid_auth" }); + + await expect( + client.agents.setupSlack.completeVerification(agent.id, { + bot_token: "xoxb-invalid-token", + signing_secret: "test-signing-secret", + }) + ).rejects.toThrow(); + }); + + test("clears verification state after completion", async () => { + const { helpers } = await serve(); + const { client, agent } = await setupAgent(helpers); + + await client.agents.setupSlack.startVerification(agent.id, { + signing_secret: "test-signing-secret", + bot_token: "xoxb-test-bot-token", + }); + + let status = await client.agents.setupSlack.getVerificationStatus( + agent.id + ); + expect(status.active).toBe(true); + + mockSlackAuthTest(mswServer, { + ok: true, + user: "test-bot", + bot_id: "B123", + }); + + await client.agents.setupSlack.completeVerification(agent.id, { + bot_token: "xoxb-test-bot-token", + signing_secret: "test-signing-secret", + }); + + status = await client.agents.setupSlack.getVerificationStatus(agent.id); + expect(status.active).toBe(false); + }); + }); + + describe("POST /cancel-verification", () => { + test("clears verification state and returns 204", async () => { + const { helpers } = await serve(); + const { client, agent } = await setupAgent(helpers); + + await client.agents.setupSlack.startVerification(agent.id, { + signing_secret: "test-signing-secret", + bot_token: "xoxb-test-bot-token", + }); + + let status = await client.agents.setupSlack.getVerificationStatus( + agent.id + ); + expect(status.active).toBe(true); + + await client.agents.setupSlack.cancelVerification(agent.id); + + status = await client.agents.setupSlack.getVerificationStatus(agent.id); + expect(status.active).toBe(false); + }); + }); + + describe("integration flow", () => { + test("full workflow: start -> poll status -> complete", async () => { + const { helpers } = await serve(); + const { client, agent } = await setupAgent(helpers); + + // Step 1: Get webhook URL + const webhookResult = await client.agents.setupSlack.getWebhookUrl( + agent.id + ); + expect(webhookResult.webhook_url).toBeDefined(); + + // Step 2: Start verification + const startResult = await client.agents.setupSlack.startVerification( + agent.id, + { signing_secret: "test-signing-secret", bot_token: "xoxb-test-token" } + ); + expect(startResult.webhook_url).toBe(webhookResult.webhook_url); + + // Step 3: Poll status (verification should be active) + const status = await client.agents.setupSlack.getVerificationStatus( + agent.id + ); + expect(status.active).toBe(true); + expect(status.dm_received).toBe(false); + + // Step 4: Complete verification + mockSlackAuthTest(mswServer, { + ok: true, + user: "integration-test-bot", + bot_id: "B789", + }); + + const completeResult = + await client.agents.setupSlack.completeVerification(agent.id, { + bot_token: "xoxb-test-token", + signing_secret: "test-signing-secret", + }); + expect(completeResult.success).toBe(true); + expect(completeResult.bot_name).toBe("integration-test-bot"); + + const finalStatus = await client.agents.setupSlack.getVerificationStatus( + agent.id + ); + expect(finalStatus.active).toBe(false); + }); + }); + + describe("Slack webhook during verification", () => { + test("handles URL verification challenge", async () => { + const { helpers, url } = await serve(); + const { client, agent } = await setupAgent(helpers); + const signingSecret = "test-signing-secret-for-webhook"; + + const { webhookPath } = await startVerificationAndGetWebhookPath( + client, + agent.id, + signingSecret + ); + + const response = await sendSlackWebhook( + url.toString(), + webhookPath, + signingSecret, + { + type: "url_verification", + challenge: "test-challenge-token-12345", + } + ); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + challenge: "test-challenge-token-12345", + }); + + const status = await client.agents.setupSlack.getVerificationStatus( + agent.id + ); + expect(status.active).toBe(true); + expect(status.last_event_at).toBeDefined(); + }); + + test("tracks DM receipt and updates verification status", async () => { + const { helpers, url } = await serve(); + const { client, agent } = await setupAgent(helpers); + const signingSecret = "test-signing-secret-for-dm"; + + // Mock the chat.postMessage endpoint (for the congratulations message) + mswServer.use( + http.post("https://slack.com/api/chat.postMessage", () => { + return HttpResponse.json({ ok: true }); + }) + ); + + const { webhookPath } = await startVerificationAndGetWebhookPath( + client, + agent.id, + signingSecret + ); + + const response = await sendSlackWebhook( + url.toString(), + webhookPath, + signingSecret, + { + type: "event_callback", + event: { + type: "message", + channel_type: "im", + channel: "D12345678", + user: "U12345678", + text: "Hello bot!", + ts: "1234567890.123456", + }, + } + ); + + expect(response.status).toBe(200); + + const status = await client.agents.setupSlack.getVerificationStatus( + agent.id + ); + expect(status.active).toBe(true); + expect(status.dm_received).toBe(true); + expect(status.dm_channel).toBe("D12345678"); + expect(status.last_event_at).toBeDefined(); + }); + + test("tracks signature failure when signature is invalid", async () => { + const { helpers, url } = await serve(); + const { client, agent } = await setupAgent(helpers); + + const { webhookPath } = await startVerificationAndGetWebhookPath( + client, + agent.id, + "correct-signing-secret" + ); + + // Send with WRONG signing secret + const response = await sendSlackWebhook( + url.toString(), + webhookPath, + "wrong-signing-secret", + { + type: "event_callback", + event: { type: "message", channel_type: "im", channel: "D12345678" }, + } + ); + + expect(response.status).toBe(200); + + const status = await client.agents.setupSlack.getVerificationStatus( + agent.id + ); + expect(status.active).toBe(true); + expect(status.signature_failed).toBe(true); + expect(status.signature_failed_at).toBeDefined(); + }); + + test("sends congratulations message on first DM", async () => { + const { helpers, url } = await serve(); + const { client, agent } = await setupAgent(helpers); + const signingSecret = "test-signing-secret-congrats"; + + // Track the chat.postMessage call + let postMessageCalled = false; + let postMessageBody: unknown = null; + mswServer.use( + http.post( + "https://slack.com/api/chat.postMessage", + async ({ request }) => { + postMessageCalled = true; + postMessageBody = await request.json(); + return HttpResponse.json({ ok: true }); + } + ) + ); + + const { webhookPath } = await startVerificationAndGetWebhookPath( + client, + agent.id, + signingSecret + ); + + await sendSlackWebhook(url.toString(), webhookPath, signingSecret, { + type: "event_callback", + event: { + type: "message", + channel_type: "im", + channel: "D99999999", + user: "U12345678", + text: "Test message", + ts: "1234567890.999999", + }, + }); + + expect(postMessageCalled).toBe(true); + expect(postMessageBody).toMatchObject({ + channel: "D99999999", + thread_ts: "1234567890.999999", + }); + }); + }); +}); diff --git a/internal/api/src/routes/agents/slack-webhook.ts b/internal/api/src/routes/agents/slack-webhook.ts new file mode 100644 index 00000000..33515aa3 --- /dev/null +++ b/internal/api/src/routes/agents/slack-webhook.ts @@ -0,0 +1,238 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +import type { Bindings } from "../../server"; +import type { AgentRequestRouting } from "../agent-request.server"; + +type SlackVerification = { + signingSecret: string; + botToken: string; + startedAt: string; + expiresAt: string; + lastEventAt?: string; + dmReceivedAt?: string; + dmChannel?: string; + signatureFailedAt?: string; +}; + +/** + * Check if a request is for the Slack webhook path. + */ +export function isSlackRequest( + routing: AgentRequestRouting, + pathname: string +): boolean { + return ( + (routing.mode === "webhook" && routing.subpath === "/slack") || + (routing.mode === "subdomain" && pathname === "/slack") + ); +} + +function isSlackVerificationExpired(expiresAt: string): boolean { + return Date.now() > new Date(expiresAt).getTime(); +} + +/** + * Handle Slack webhook requests during verification flow. + * Returns a Response if the request should be handled here, or null to continue to the agent. + * When continuing to the agent, bodyText contains the already-read request body. + */ +export async function handleSlackWebhook( + db: Awaited>, + agent: { id: string; slack_verification: SlackVerification | null }, + request: Request, + hasDeployment: boolean +): Promise< + | { response: Response; bodyText?: undefined } + | { response: null; bodyText?: string } +> { + const slackVerification = agent.slack_verification; + + if (!slackVerification) { + return { response: null }; + } + + // Check if verification has expired - if so, clear it and continue to agent + if (isSlackVerificationExpired(slackVerification.expiresAt)) { + await db.updateAgent({ + id: agent.id, + slack_verification: null, + }); + return { response: null }; + } + + // Read the body for verification processing + const bodyText = await request.text(); + + const result = await processSlackVerificationTracking( + db, + { id: agent.id, slack_verification: slackVerification }, + bodyText, + request.headers.get("x-slack-signature") ?? undefined, + request.headers.get("x-slack-request-timestamp") ?? undefined + ); + + // URL verification challenge must be responded to immediately + if (result.challengeResponse) { + return { + response: Response.json({ challenge: result.challengeResponse }), + }; + } + + // Invalid signature - acknowledge but don't process further + if (!result.signatureValid) { + return { response: Response.json({ ok: true }) }; + } + + // No deployment - we've tracked the event, just acknowledge + if (!hasDeployment) { + return { response: Response.json({ ok: true }) }; + } + + // Continue to forward to agent + return { response: null, bodyText }; +} + +/** + * Verify Slack request signature using HMAC-SHA256. + */ +export 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>, + agent: { + id: string; + slack_verification: SlackVerification; + }, + 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 }; +} diff --git a/internal/api/src/routes/onboarding/onboarding.client.ts b/internal/api/src/routes/onboarding/onboarding.client.ts new file mode 100644 index 00000000..4362614d --- /dev/null +++ b/internal/api/src/routes/onboarding/onboarding.client.ts @@ -0,0 +1,51 @@ +import { z } from "zod"; +import type Client from "../../client.browser"; +import { assertResponseStatus } from "../../client-helper"; + +export const schemaDownloadAgentRequest = z.object({ + organization_id: z.uuid(), +}); + +export type DownloadAgentRequest = z.infer; + +export const schemaDownloadAgentFile = z.object({ + path: z.string(), + id: z.uuid(), +}); + +export type DownloadAgentFile = z.infer; + +export const schemaDownloadAgentResponse = z.object({ + output_files: z.array(schemaDownloadAgentFile), + source_files: z.array(schemaDownloadAgentFile), + entrypoint: z.string(), + version: z.string().optional(), +}); + +export type DownloadAgentResponse = z.infer; + +export default class Onboarding { + private readonly client: Client; + + public constructor(client: Client) { + this.client = client; + } + + /** + * Download the pre-built onboarding agent from GitHub Releases. + * + * @param request - The request body containing organization_id. + * @returns The file ID and entrypoint of the downloaded agent. + */ + public async downloadAgent( + request: DownloadAgentRequest + ): Promise { + const resp = await this.client.request( + "POST", + "/api/onboarding/download-agent", + JSON.stringify(request) + ); + await assertResponseStatus(resp, 200); + return resp.json(); + } +} diff --git a/internal/api/src/routes/onboarding/onboarding.server.ts b/internal/api/src/routes/onboarding/onboarding.server.ts new file mode 100644 index 00000000..c71ef3be --- /dev/null +++ b/internal/api/src/routes/onboarding/onboarding.server.ts @@ -0,0 +1,147 @@ +import { Readable } from "node:stream"; +import { createGunzip } from "node:zlib"; +import type { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { validator } from "hono/validator"; +import * as tarStream from "tar-stream"; +import { authorizeOrganization, withAuth } from "../../middleware"; +import type { Bindings } from "../../server"; +import { schemaDownloadAgentRequest } from "./onboarding.client"; + +export default function mountOnboarding(app: Hono<{ Bindings: Bindings }>) { + // Download the onboarding agent bundle from artifacts server + app.post( + "/download-agent", + withAuth, + validator("json", (value) => { + return schemaDownloadAgentRequest.parse(value); + }), + async (c) => { + const req = c.req.valid("json"); + await authorizeOrganization(c, req.organization_id); + + const bundleUrl = c.env.ONBOARDING_AGENT_BUNDLE_URL; + + // Fetch the tar.gz bundle (follows redirects by default) + const bundleResp = await fetch(bundleUrl, { + headers: { + "User-Agent": `Blink-Server/${c.env.serverVersion}`, + }, + redirect: "follow", + }); + if (!bundleResp.ok) { + throw new HTTPException(502, { + message: `Failed to download bundle: ${bundleResp.status}`, + }); + } + + // Extract tar.gz and collect files + const files = await extractTarGz(bundleResp); + + // Categorize files + const outputFilesToUpload: Array<{ path: string; data: Buffer }> = []; + const sourceFilesToUpload: Array<{ path: string; data: Buffer }> = []; + + for (const file of files) { + // Skip the bundle.tar.gz if it's inside the archive + if (file.path === "bundle.tar.gz") { + continue; + } + + // Files under .blink/build/ are output files + if (file.path.startsWith(".blink/build/")) { + const outputPath = file.path.replace(".blink/build/", ""); + outputFilesToUpload.push({ path: outputPath, data: file.data }); + } + // Skip other .blink/ files (like .blink/config.json) + else if (file.path.startsWith(".blink/")) { + // Skip + } + // Everything else is a source file + else { + sourceFilesToUpload.push({ path: file.path, data: file.data }); + } + } + + // Upload all files in parallel + const userId = c.get("user_id"); + + const [outputFiles, sourceFiles] = await Promise.all([ + Promise.all( + outputFilesToUpload.map(async (file) => { + const { id } = await c.env.files.upload({ + user_id: userId, + organization_id: req.organization_id, + file: new File([new Uint8Array(file.data)], file.path), + }); + return { path: file.path, id }; + }) + ), + Promise.all( + sourceFilesToUpload.map(async (file) => { + const { id } = await c.env.files.upload({ + user_id: userId, + organization_id: req.organization_id, + file: new File([new Uint8Array(file.data)], file.path), + }); + return { path: file.path, id }; + }) + ), + ]); + + return c.json({ + output_files: outputFiles, + source_files: sourceFiles, + entrypoint: "agent.js", + }); + } + ); +} + +interface ExtractedFile { + path: string; + data: Buffer; +} + +async function extractTarGz(response: Response): Promise { + const files: ExtractedFile[] = []; + + // Get response body as array buffer and convert to Node stream + const arrayBuffer = await response.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + const nodeStream = Readable.from(buffer); + + // Create gunzip and tar extract streams + const gunzip = createGunzip(); + const extract = tarStream.extract(); + + return new Promise((resolve, reject) => { + extract.on("entry", (header, stream, next) => { + // Only process regular files + if (header.type !== "file") { + stream.resume(); + next(); + return; + } + + const chunks: Buffer[] = []; + stream.on("data", (chunk: Buffer) => chunks.push(chunk)); + stream.on("end", () => { + files.push({ + path: header.name, + data: Buffer.concat(chunks), + }); + next(); + }); + stream.on("error", reject); + }); + + extract.on("finish", () => resolve(files)); + extract.on("error", reject); + + gunzip.on("error", reject); + + // Pipe: buffer -> gunzip -> tar extract + nodeStream.pipe(gunzip).pipe(extract); + }); +} diff --git a/internal/api/src/routes/onboarding/onboarding.test.ts b/internal/api/src/routes/onboarding/onboarding.test.ts new file mode 100644 index 00000000..3380103b --- /dev/null +++ b/internal/api/src/routes/onboarding/onboarding.test.ts @@ -0,0 +1,230 @@ +import { afterAll, beforeAll, expect, test } from "bun:test"; +import { createGzip } from "node:zlib"; +import * as tarStream from "tar-stream"; +import { serve } from "../../test"; + +// Helper to create a tar.gz buffer in memory +async function createMockTarGz( + files: Array<{ path: string; content: string }> +): Promise { + const pack = tarStream.pack(); + + for (const file of files) { + pack.entry({ name: file.path }, file.content); + } + pack.finalize(); + + // Collect tar data + const tarChunks: Buffer[] = []; + for await (const chunk of pack as unknown as AsyncIterable) { + tarChunks.push(chunk); + } + const tarBuffer = Buffer.concat(tarChunks); + + // Gzip the tar data + return new Promise((resolve, reject) => { + const gzip = createGzip(); + const gzipChunks: Buffer[] = []; + + gzip.on("data", (chunk: Buffer) => gzipChunks.push(chunk)); + gzip.on("end", () => resolve(Buffer.concat(gzipChunks))); + gzip.on("error", reject); + + gzip.end(tarBuffer); + }); +} + +// Mock bundle server +let mockBundleServer: ReturnType | null = null; +let mockBundleUrl: string = ""; + +beforeAll(async () => { + // Create mock tar.gz with test files + const mockTarGz = await createMockTarGz([ + // Output files (in .blink/build/) + { path: ".blink/build/agent.js", content: 'console.log("Hello agent");' }, + { path: ".blink/build/package.json", content: '{"type": "module"}' }, + // Source files + { path: "agent.ts", content: 'export const agent = "test";' }, + { path: "package.json", content: '{"name": "test-agent"}' }, + { path: "tsconfig.json", content: '{"compilerOptions": {}}' }, + { path: "README.md", content: "# Test Agent" }, + // Files to skip + { path: ".blink/config.json", content: '{"agentId": "123"}' }, + ]); + + mockBundleServer = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + + // Simulate redirect like artifacts.blink.host does + if (url.pathname === "/redirect") { + return new Response(null, { + status: 302, + headers: { Location: `${mockBundleUrl}/bundle.tar.gz` }, + }); + } + + if (url.pathname === "/bundle.tar.gz") { + return new Response(new Uint8Array(mockTarGz), { + headers: { "Content-Type": "application/gzip" }, + }); + } + + return new Response("Not found", { status: 404 }); + }, + }); + + mockBundleUrl = mockBundleServer.url.toString().replace(/\/$/, ""); +}); + +afterAll(() => { + mockBundleServer?.stop(); +}); + +test("POST /api/onboarding/download-agent extracts tar.gz and categorizes files", async () => { + const { helpers, stop } = await serve({ + bindings: { + ONBOARDING_AGENT_BUNDLE_URL: `${mockBundleUrl}/bundle.tar.gz`, + }, + }); + + try { + const { client } = await helpers.createUser(); + + // Create an organization for the user + const org = await client.organizations.create({ + name: "test-org", + }); + + // Call the download-agent endpoint + const result = await client.onboarding.downloadAgent({ + organization_id: org.id, + }); + + // Verify output files (from .blink/build/) + expect(result.output_files).toBeArrayOfSize(2); + expect(result.output_files.map((f) => f.path).sort()).toEqual([ + "agent.js", + "package.json", + ]); + + // Verify source files (everything except .blink/) + expect(result.source_files).toBeArrayOfSize(4); + expect(result.source_files.map((f) => f.path).sort()).toEqual([ + "README.md", + "agent.ts", + "package.json", + "tsconfig.json", + ]); + + // Verify entrypoint + expect(result.entrypoint).toBe("agent.js"); + + // Verify all files have valid UUIDs + for (const file of [...result.output_files, ...result.source_files]) { + expect(file.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ + ); + } + } finally { + stop(); + } +}); + +test("POST /api/onboarding/download-agent follows redirects", async () => { + const { helpers, stop } = await serve({ + bindings: { + // Use the redirect endpoint + ONBOARDING_AGENT_BUNDLE_URL: `${mockBundleUrl}/redirect`, + }, + }); + + try { + const { client } = await helpers.createUser(); + + const org = await client.organizations.create({ + name: "test-org-redirect", + }); + + const result = await client.onboarding.downloadAgent({ + organization_id: org.id, + }); + + // Should still work after following redirect + expect(result.output_files.length).toBeGreaterThan(0); + expect(result.source_files.length).toBeGreaterThan(0); + expect(result.entrypoint).toBe("agent.js"); + } finally { + stop(); + } +}); + +test("POST /api/onboarding/download-agent returns 502 on download failure", async () => { + const { helpers, stop } = await serve({ + bindings: { + ONBOARDING_AGENT_BUNDLE_URL: `${mockBundleUrl}/nonexistent`, + }, + }); + + try { + const { client } = await helpers.createUser(); + + const org = await client.organizations.create({ + name: "test-org-error", + }); + + await expect( + client.onboarding.downloadAgent({ + organization_id: org.id, + }) + ).rejects.toThrow(); + } finally { + stop(); + } +}); + +test("POST /api/onboarding/download-agent includes server version in User-Agent", async () => { + const captured: { userAgent?: string } = {}; + + // Create a mock server that captures the User-Agent header + const mockTarGz = await createMockTarGz([ + { path: ".blink/build/agent.js", content: "test" }, + { path: "agent.ts", content: "test" }, + ]); + + const userAgentServer = Bun.serve({ + port: 0, + fetch(req) { + captured.userAgent = req.headers.get("User-Agent") ?? undefined; + return new Response(new Uint8Array(mockTarGz), { + headers: { "Content-Type": "application/gzip" }, + }); + }, + }); + + const { helpers, stop } = await serve({ + bindings: { + ONBOARDING_AGENT_BUNDLE_URL: `${userAgentServer.url}bundle.tar.gz`, + serverVersion: "custom-version", + }, + }); + + try { + const { client } = await helpers.createUser(); + + const org = await client.organizations.create({ + name: "test-org-user-agent", + }); + + await client.onboarding.downloadAgent({ + organization_id: org.id, + }); + + expect(captured.userAgent).toBe("Blink-Server/custom-version"); + } finally { + stop(); + userAgentServer.stop(); + } +}); diff --git a/internal/api/src/server-helper.ts b/internal/api/src/server-helper.ts index db852357..91fd5e34 100644 --- a/internal/api/src/server-helper.ts +++ b/internal/api/src/server-helper.ts @@ -1,5 +1,6 @@ import { DrizzleQueryError } from "drizzle-orm/errors"; import postgres from "postgres"; +import type { Bindings } from "./server"; export const isUniqueConstraintError = ( err: unknown, @@ -51,3 +52,38 @@ export const detectRequestLocation = (request: Request): string | undefined => { } return parts.length ? parts.join(", ") : undefined; }; + +/** + * Construct a webhook URL for an agent. + * Uses subdomain routing if matchRequestHost is configured, otherwise uses path-based routing. + * + * @param env - The bindings containing createRequestURL, matchRequestHost, and accessUrl + * @param requestId - The deployment target's request ID + * @param path - The webhook path (e.g., "github", "slack", or "/github") + * @returns The webhook URL + */ +export const createWebhookURL = ( + env: Bindings, + requestId: string, + path: string +): string => { + // Normalize path to always start with / + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + + if (env.createRequestURL) { + const baseUrl = env.createRequestURL(requestId); + return new URL(normalizedPath, baseUrl).toString(); + } + + // Use subdomain routing if configured + if (env.matchRequestHost) { + // Construct subdomain URL from accessUrl: https://{request_id}.{host}/{path} + const baseUrl = new URL(env.accessUrl); + baseUrl.host = `${requestId}.${baseUrl.host}`; + baseUrl.pathname = normalizedPath; + return baseUrl.toString(); + } + + // Path-based webhook mode: /api/webhook/{request_id}/{path} + return `${env.accessUrl.origin}/api/webhook/${requestId}${normalizedPath}`; +}; diff --git a/internal/api/src/server.ts b/internal/api/src/server.ts index deb7c217..698850cd 100644 --- a/internal/api/src/server.ts +++ b/internal/api/src/server.ts @@ -18,6 +18,7 @@ import mountDevhook from "./routes/devhook.server"; import mountFiles from "./routes/files.server"; import mountInvites from "./routes/invites.server"; import mountMessages from "./routes/messages.server"; +import mountOnboarding from "./routes/onboarding/onboarding.server"; import mountOrganizations from "./routes/organizations/organizations.server"; import type { OtelSpan } from "./routes/otlp/convert"; import mountOtlp from "./routes/otlp/otlp.server"; @@ -213,6 +214,11 @@ export interface Bindings { * Pathname will not be respected - /api is used. */ readonly apiBaseURL: URL; + /** + * accessUrl is the public URL used for external access (e.g., webhooks). + * This may differ from apiBaseURL when using tunnels or proxies. + */ + readonly accessUrl: URL; readonly matchRequestHost?: (host: string) => string | undefined; readonly createRequestURL?: (id: string) => URL; @@ -220,6 +226,7 @@ export interface Bindings { readonly NODE_ENV: string; readonly AI_GATEWAY_API_KEY?: string; readonly TOOLS_EXA_API_KEY?: string; + readonly ONBOARDING_AGENT_BUNDLE_URL: string; // OAuth provider credentials readonly GITHUB_CLIENT_ID?: string; @@ -227,6 +234,8 @@ export interface Bindings { readonly GOOGLE_CLIENT_ID?: string; readonly GOOGLE_CLIENT_SECRET?: string; + readonly serverVersion: string; + // Optional AWS credentials used by platform logging to CloudWatch readonly AWS_ACCESS_KEY_ID?: string; readonly AWS_SECRET_ACCESS_KEY?: string; @@ -311,6 +320,7 @@ mountMessages(api.basePath("/messages")); mountTools(api.basePath("/tools")); mountOtlp(api.basePath("/otlp")); mountDevhook(api.basePath("/devhook")); +mountOnboarding(api.basePath("/onboarding")); // Webhook route for proxying requests to agents // The wildcard route handles subpaths like /api/webhook/:id/github/events diff --git a/internal/api/src/test.ts b/internal/api/src/test.ts index 12206fea..2e4e481b 100644 --- a/internal/api/src/test.ts +++ b/internal/api/src/test.ts @@ -87,6 +87,7 @@ export const serve = async (options?: ServeOptions) => { const agentStore = new Map(); const bindings: Bindings = { apiBaseURL: srv.url, + accessUrl: srv.url, matchRequestHost: (hostname) => { const regex = new RegExp(`^(.*)\.${srv.url.host}$`); const exec = regex.exec(hostname); @@ -112,6 +113,9 @@ export const serve = async (options?: ServeOptions) => { }, AUTH_SECRET: authSecret, NODE_ENV: "development", + serverVersion: "test", + ONBOARDING_AGENT_BUNDLE_URL: + options?.bindings?.ONBOARDING_AGENT_BUNDLE_URL ?? "override-me-in-test", ...options?.bindings, agentStore: (targetID) => { let store = agentStore.get(targetID); diff --git a/internal/api/src/util/chat.ts b/internal/api/src/util/chat.ts index 99af199c..560ecc0e 100644 --- a/internal/api/src/util/chat.ts +++ b/internal/api/src/util/chat.ts @@ -147,6 +147,13 @@ export async function runChat({ }, }); + if (!env.AUTH_SECRET) { + // biome-ignore lint/suspicious/noConsole: we want to notify the admin if this happens. + console.error( + "runChat: AUTH_SECRET environment variable is not set. Unable to generate agent invocation token." + ); + throw new Error("Internal server error"); + } const { generateAgentInvocationToken } = await import( "@blink.so/api/agents/me/server" ); diff --git a/internal/database/migrations/0001_onboarding.sql b/internal/database/migrations/0001_onboarding.sql new file mode 100644 index 00000000..601a883d --- /dev/null +++ b/internal/database/migrations/0001_onboarding.sql @@ -0,0 +1,4 @@ +ALTER TABLE "agent" ADD COLUMN "slack_verification" jsonb;--> statement-breakpoint +ALTER TABLE "agent" ADD COLUMN "github_app_setup" jsonb;--> statement-breakpoint +ALTER TABLE "agent" ADD COLUMN "onboarding_state" jsonb;--> statement-breakpoint +ALTER TABLE "agent" ADD COLUMN "integrations_state" jsonb; \ No newline at end of file diff --git a/internal/database/migrations/meta/0001_snapshot.json b/internal/database/migrations/meta/0001_snapshot.json new file mode 100644 index 00000000..7a79ac9f --- /dev/null +++ b/internal/database/migrations/meta/0001_snapshot.json @@ -0,0 +1,3281 @@ +{ + "id": "83ed9112-fd97-4f5c-9d2e-02082cd5b929", + "prevId": "645fa823-648d-48c5-987d-c9d72bb0659e", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.agent": { + "name": "agent", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "name": { + "name": "name", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_file_id": { + "name": "avatar_file_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "active_deployment_id": { + "name": "active_deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_expire_ttl": { + "name": "chat_expire_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_deployment_number": { + "name": "last_deployment_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_number": { + "name": "last_run_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "slack_verification": { + "name": "slack_verification", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "github_app_setup": { + "name": "github_app_setup", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "onboarding_state": { + "name": "onboarding_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "integrations_state": { + "name": "integrations_state", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_name_unique": { + "name": "agent_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_organization_id_organization_id_fk": { + "name": "agent_organization_id_organization_id_fk", + "tableFrom": "agent", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "name_format": { + "name": "name_format", + "value": "\"agent\".\"name\" ~* '^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$'" + } + }, + "isRLSEnabled": false + }, + "public.agent_deployment": { + "name": "agent_deployment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_from": { + "name": "created_from", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "entrypoint": { + "name": "entrypoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "compatibility_version": { + "name": "compatibility_version", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'1'" + }, + "source_files": { + "name": "source_files", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_files": { + "name": "output_files", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "user_message": { + "name": "user_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "platform_memory_mb": { + "name": "platform_memory_mb", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "platform_region": { + "name": "platform_region", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform_metadata": { + "name": "platform_metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "direct_access_url": { + "name": "direct_access_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "agent_deployment_agent_id_number_unique": { + "name": "agent_deployment_agent_id_number_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_deployment_agent_id_agent_id_fk": { + "name": "agent_deployment_agent_id_agent_id_fk", + "tableFrom": "agent_deployment", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_deployment_target_id_agent_deployment_target_id_fk": { + "name": "agent_deployment_target_id_agent_deployment_target_id_fk", + "tableFrom": "agent_deployment", + "tableTo": "agent_deployment_target", + "columnsFrom": ["target_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_deployment_log": { + "name": "agent_deployment_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "deployment_id": { + "name": "deployment_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "agent_deployment_log_agent_id_agent_id_fk": { + "name": "agent_deployment_log_agent_id_agent_id_fk", + "tableFrom": "agent_deployment_log", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_deployment_target": { + "name": "agent_deployment_target", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "request_id": { + "name": "request_id", + "type": "uuid", + "primaryKey": false, + "notNull": true, + "default": "gen_random_uuid()" + }, + "target": { + "name": "target", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "agent_deployment_target_agent_id_target_unique": { + "name": "agent_deployment_target_agent_id_target_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_deployment_target_agent_id_agent_id_fk": { + "name": "agent_deployment_target_agent_id_agent_id_fk", + "tableFrom": "agent_deployment_target", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "agent_deployment_target_request_id_unique": { + "name": "agent_deployment_target_request_id_unique", + "nullsNotDistinct": false, + "columns": ["request_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_environment_variable": { + "name": "agent_environment_variable", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_value": { + "name": "encrypted_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_dek": { + "name": "encrypted_dek", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encryption_iv": { + "name": "encryption_iv", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encryption_auth_tag": { + "name": "encryption_auth_tag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "secret": { + "name": "secret", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "target": { + "name": "target", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{\"preview\",\"production\"}'" + } + }, + "indexes": { + "agent_environment_variable_agent_id_idx": { + "name": "agent_environment_variable_agent_id_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_env_key_prod_unique": { + "name": "agent_env_key_prod_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "'production' = ANY(\"agent_environment_variable\".\"target\")", + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_env_key_prev_unique": { + "name": "agent_env_key_prev_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "'preview' = ANY(\"agent_environment_variable\".\"target\")", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_environment_variable_agent_id_agent_id_fk": { + "name": "agent_environment_variable_agent_id_agent_id_fk", + "tableFrom": "agent_environment_variable", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_log": { + "name": "agent_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true, + "default": "'info'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "payload_str": { + "name": "payload_str", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "agent_log_agent_time_idx": { + "name": "agent_log_agent_time_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "timestamp", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_log_agent_id_agent_id_fk": { + "name": "agent_log_agent_id_agent_id_fk", + "tableFrom": "agent_log", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_permission": { + "name": "agent_permission", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "permission": { + "name": "permission", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "agent_permission_agent_id_user_id_unique": { + "name": "agent_permission_agent_id_user_id_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "agent_permission_agent_id_index": { + "name": "agent_permission_agent_id_index", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_permission_agent_id_agent_id_fk": { + "name": "agent_permission_agent_id_agent_id_fk", + "tableFrom": "agent_permission", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_permission_user_id_user_id_fk": { + "name": "agent_permission_user_id_user_id_fk", + "tableFrom": "agent_permission", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_permission_created_by_user_id_fk": { + "name": "agent_permission_created_by_user_id_fk", + "tableFrom": "agent_permission", + "tableTo": "user", + "columnsFrom": ["created_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_pin": { + "name": "agent_pin", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "agent_pin_agent_id_user_id_unique": { + "name": "agent_pin_agent_id_user_id_unique", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_pin_agent_id_agent_id_fk": { + "name": "agent_pin_agent_id_agent_id_fk", + "tableFrom": "agent_pin", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_pin_user_id_user_id_fk": { + "name": "agent_pin_user_id_user_id_fk", + "tableFrom": "agent_pin", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_storage_kv": { + "name": "agent_storage_kv", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_deployment_target_id": { + "name": "agent_deployment_target_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "agent_storage_kv_agent_deployment_target_id_key_unique": { + "name": "agent_storage_kv_agent_deployment_target_id_key_unique", + "columns": [ + { + "expression": "agent_deployment_target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_storage_kv_agent_id_agent_id_fk": { + "name": "agent_storage_kv_agent_id_agent_id_fk", + "tableFrom": "agent_storage_kv", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "agent_storage_kv_agent_deployment_target_id_agent_deployment_target_id_fk": { + "name": "agent_storage_kv_agent_deployment_target_id_agent_deployment_target_id_fk", + "tableFrom": "agent_storage_kv", + "tableTo": "agent_deployment_target", + "columnsFrom": ["agent_deployment_target_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.agent_trace": { + "name": "agent_trace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "start_time": { + "name": "start_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "end_time": { + "name": "end_time", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "payload_original": { + "name": "payload_original", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload_str": { + "name": "payload_str", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "agent_trace_agent_time_idx": { + "name": "agent_trace_agent_time_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "start_time", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "agent_trace_agent_id_agent_id_fk": { + "name": "agent_trace_agent_id_agent_id_fk", + "tableFrom": "agent_trace", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_lookup": { + "name": "key_lookup", + "type": "varchar(12)", + "primaryKey": false, + "notNull": true + }, + "key_prefix": { + "name": "key_prefix", + "type": "varchar(20)", + "primaryKey": false, + "notNull": true + }, + "key_suffix": { + "name": "key_suffix", + "type": "varchar(4)", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "revoked_by": { + "name": "revoked_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_user_idx": { + "name": "api_key_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_lookup_idx": { + "name": "api_key_lookup_idx", + "columns": [ + { + "expression": "key_lookup", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_revoked_by_user_id_fk": { + "name": "api_key_revoked_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": ["revoked_by"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_lookup_unique": { + "name": "api_key_key_lookup_unique", + "nullsNotDistinct": false, + "columns": ["key_lookup"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_deployment_id": { + "name": "agent_deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_deployment_target_id": { + "name": "agent_deployment_target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_key": { + "name": "agent_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "last_run_number": { + "name": "last_run_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expire_ttl": { + "name": "expire_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_organization_created_at_idx": { + "name": "chat_organization_created_at_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_organization_created_by": { + "name": "idx_chat_organization_created_by", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_visibility": { + "name": "idx_chat_visibility", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"visibility\" IN ('public', 'private', 'organization')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_expire_ttl": { + "name": "idx_chat_expire_ttl", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"expire_ttl\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_agent_deployment_target_id_key_unique": { + "name": "idx_chat_agent_deployment_target_id_key_unique", + "columns": [ + { + "expression": "agent_deployment_target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "agent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_organization_id_organization_id_fk": { + "name": "chat_organization_id_organization_id_fk", + "tableFrom": "chat", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_agent_id_agent_id_fk": { + "name": "chat_agent_id_agent_id_fk", + "tableFrom": "chat", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "chat_agent_deployment_id_agent_deployment_id_fk": { + "name": "chat_agent_deployment_id_agent_deployment_id_fk", + "tableFrom": "chat", + "tableTo": "agent_deployment", + "columnsFrom": ["agent_deployment_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "chat_agent_deployment_target_id_agent_deployment_target_id_fk": { + "name": "chat_agent_deployment_target_id_agent_deployment_target_id_fk", + "tableFrom": "chat", + "tableTo": "agent_deployment_target", + "columnsFrom": ["agent_deployment_target_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_run": { + "name": "chat_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_deployment_id": { + "name": "agent_deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_step_number": { + "name": "last_step_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "chat_run_chat_id_number_unique": { + "name": "chat_run_chat_id_number_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_run_chat_id_chat_id_fk": { + "name": "chat_run_chat_id_chat_id_fk", + "tableFrom": "chat_run", + "tableTo": "chat", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_run_agent_id_agent_id_fk": { + "name": "chat_run_agent_id_agent_id_fk", + "tableFrom": "chat_run", + "tableTo": "agent", + "columnsFrom": ["agent_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_run_step": { + "name": "chat_run_step", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chat_run_id": { + "name": "chat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_deployment_id": { + "name": "agent_deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "interrupted_at": { + "name": "interrupted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "first_message_id": { + "name": "first_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_message_id": { + "name": "last_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_headers": { + "name": "response_headers", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "response_headers_redacted": { + "name": "response_headers_redacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_body_redacted": { + "name": "response_body_redacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "continuation_reason": { + "name": "continuation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_to_first_token_micros": { + "name": "time_to_first_token_micros", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "tool_calls_total": { + "name": "tool_calls_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tool_calls_completed": { + "name": "tool_calls_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tool_calls_errored": { + "name": "tool_calls_errored", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_cost_usd": { + "name": "usage_cost_usd", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "usage_model": { + "name": "usage_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_total_input_tokens": { + "name": "usage_total_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "usage_total_output_tokens": { + "name": "usage_total_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "usage_total_tokens": { + "name": "usage_total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "usage_total_cached_input_tokens": { + "name": "usage_total_cached_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "chat_run_step_chat_run_id_id_unique": { + "name": "chat_run_step_chat_run_id_id_unique", + "columns": [ + { + "expression": "chat_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_run_step_single_streaming": { + "name": "chat_run_step_single_streaming", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat_run_step\".\"completed_at\" IS NULL AND \"chat_run_step\".\"error\" IS NULL AND \"chat_run_step\".\"interrupted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_run_step_agent_id_started_at_idx": { + "name": "chat_run_step_agent_id_started_at_idx", + "columns": [ + { + "expression": "agent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_run_step_agent_deployment_id_started_at_idx": { + "name": "chat_run_step_agent_deployment_id_started_at_idx", + "columns": [ + { + "expression": "agent_deployment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_run_step_chat_id_chat_id_fk": { + "name": "chat_run_step_chat_id_chat_id_fk", + "tableFrom": "chat_run_step", + "tableTo": "chat", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_run_step_chat_run_id_chat_run_id_fk": { + "name": "chat_run_step_chat_run_id_chat_run_id_fk", + "tableFrom": "chat_run_step", + "tableTo": "chat_run", + "columnsFrom": ["chat_run_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat_user_state": { + "name": "chat_user_state", + "schema": "", + "columns": { + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_read_at": { + "name": "last_read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "chat_user_state_chat_id_chat_id_fk": { + "name": "chat_user_state_chat_id_chat_id_fk", + "tableFrom": "chat_user_state", + "tableTo": "chat", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_state_user_id_user_id_fk": { + "name": "chat_user_state_user_id_user_id_fk", + "tableFrom": "chat_user_state", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "chat_user_state_chat_id_user_id_pk": { + "name": "chat_user_state_chat_id_user_id_pk", + "columns": ["chat_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.email_verification": { + "name": "email_verification", + "schema": "", + "columns": { + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "idx_email_verification_email_code": { + "name": "idx_email_verification_email_code", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.file": { + "name": "file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "byte_length": { + "name": "byte_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "pdf_page_count": { + "name": "pdf_page_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "bytea", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message": { + "name": "message", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chat_run_id": { + "name": "chat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_run_step_id": { + "name": "chat_run_step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true + }, + "parts": { + "name": "parts", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_deployment_id": { + "name": "agent_deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_message_chat_role_created": { + "name": "idx_message_chat_role_created", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"message\".\"role\" = 'user'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_chat_id_chat_id_fk": { + "name": "message_chat_id_chat_id_fk", + "tableFrom": "message", + "tableTo": "chat", + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "varchar(40)", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "varchar(2048)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "personal_owner_user_id": { + "name": "personal_owner_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "billing_tier": { + "name": "billing_tier", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "billing_interval": { + "name": "billing_interval", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'month'" + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metronome_customer_id": { + "name": "metronome_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metronome_contract_id": { + "name": "metronome_contract_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_billing_date": { + "name": "next_billing_date", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_entitled_at": { + "name": "billing_entitled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "personal_org_per_user": { + "name": "personal_org_per_user", + "columns": [ + { + "expression": "personal_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"organization\".\"kind\" = 'personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_personal_owner_user_id_user_id_fk": { + "name": "organization_personal_owner_user_id_user_id_fk", + "tableFrom": "organization", + "tableTo": "user", + "columnsFrom": ["personal_owner_user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_name_unique": { + "name": "organization_name_unique", + "nullsNotDistinct": false, + "columns": ["name"] + } + }, + "policies": {}, + "checkConstraints": { + "name_format": { + "name": "name_format", + "value": "\"organization\".\"name\" ~* '^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){0,38}$'" + }, + "name_not_reserved": { + "name": "name_not_reserved", + "value": "\"organization\".\"name\" NOT IN ($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)" + }, + "personal_owner_presence": { + "name": "personal_owner_presence", + "value": "(\"organization\".\"kind\" = 'personal' AND \"organization\".\"personal_owner_user_id\" IS NOT NULL)\n OR (\"organization\".\"kind\" = 'organization' AND \"organization\".\"personal_owner_user_id\" IS NULL)" + }, + "personal_created_by_matches_owner": { + "name": "personal_created_by_matches_owner", + "value": "\"organization\".\"kind\" != 'personal' OR \"organization\".\"created_by\" = \"organization\".\"personal_owner_user_id\"" + } + }, + "isRLSEnabled": false + }, + "public.organization_billing_usage_event": { + "name": "organization_billing_usage_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "transaction_id": { + "name": "transaction_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_usd": { + "name": "cost_usd", + "type": "numeric(32, 18)", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "organization_billing_usage_event_org_txn_unique": { + "name": "organization_billing_usage_event_org_txn_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "transaction_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_invite": { + "name": "organization_invite", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reusable": { + "name": "reusable", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_accepted_at": { + "name": "last_accepted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_invite_organization_id_organization_id_fk": { + "name": "organization_invite_organization_id_organization_id_fk", + "tableFrom": "organization_invite", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_invite_invited_by_membership_fk": { + "name": "organization_invite_invited_by_membership_fk", + "tableFrom": "organization_invite", + "tableTo": "organization_membership", + "columnsFrom": ["organization_id", "invited_by"], + "columnsTo": ["organization_id", "user_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "organization_invite_code_unique": { + "name": "organization_invite_code_unique", + "nullsNotDistinct": false, + "columns": ["code"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_membership": { + "name": "organization_membership", + "schema": "", + "columns": { + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "billing_emails_opt_out": { + "name": "billing_emails_opt_out", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "organization_membership_organization_id_organization_id_fk": { + "name": "organization_membership_organization_id_organization_id_fk", + "tableFrom": "organization_membership", + "tableTo": "organization", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_membership_user_id_user_id_fk": { + "name": "organization_membership_user_id_user_id_fk", + "tableFrom": "organization_membership", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "organization_membership_organization_id_user_id_pk": { + "name": "organization_membership_organization_id_user_id_pk", + "columns": ["organization_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_account": { + "name": "user_account", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_account_id": { + "name": "provider_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "user_account_user_id_user_id_fk": { + "name": "user_account_user_id_user_id_fk", + "tableFrom": "user_account", + "tableTo": "user", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_account_provider_provider_account_id_pk": { + "name": "user_account_provider_provider_account_id_pk", + "columns": ["provider", "provider_account_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": { + "public.chat_run_step_with_status": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "chat_run_id": { + "name": "chat_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_deployment_id": { + "name": "agent_deployment_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "heartbeat_at": { + "name": "heartbeat_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "interrupted_at": { + "name": "interrupted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "first_message_id": { + "name": "first_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "last_message_id": { + "name": "last_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_status": { + "name": "response_status", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "response_headers": { + "name": "response_headers", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "response_headers_redacted": { + "name": "response_headers_redacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "response_body": { + "name": "response_body", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_body_redacted": { + "name": "response_body_redacted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "continuation_reason": { + "name": "continuation_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "time_to_first_token_micros": { + "name": "time_to_first_token_micros", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "tool_calls_total": { + "name": "tool_calls_total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tool_calls_completed": { + "name": "tool_calls_completed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "tool_calls_errored": { + "name": "tool_calls_errored", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_cost_usd": { + "name": "usage_cost_usd", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "usage_model": { + "name": "usage_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "usage_total_input_tokens": { + "name": "usage_total_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "usage_total_output_tokens": { + "name": "usage_total_output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "usage_total_tokens": { + "name": "usage_total_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "usage_total_cached_input_tokens": { + "name": "usage_total_cached_input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "definition": "select \"id\", \"number\", \"chat_id\", \"chat_run_id\", \"agent_id\", \"agent_deployment_id\", \"started_at\", \"heartbeat_at\", \"completed_at\", \"interrupted_at\", \"first_message_id\", \"last_message_id\", \"error\", \"response_status\", \"response_headers\", \"response_headers_redacted\", \"response_body\", \"response_body_redacted\", \"response_message_id\", \"continuation_reason\", \"time_to_first_token_micros\", \"tool_calls_total\", \"tool_calls_completed\", \"tool_calls_errored\", \"usage_cost_usd\", \"usage_model\", \"usage_total_input_tokens\", \"usage_total_output_tokens\", \"usage_total_tokens\", \"usage_total_cached_input_tokens\", CASE\n WHEN \"error\" IS NOT NULL THEN 'error'\n WHEN \"interrupted_at\" IS NOT NULL THEN 'interrupted'\n WHEN \"completed_at\" IS NOT NULL THEN 'completed'\n WHEN \"continuation_reason\" IS NOT NULL THEN 'streaming'\n WHEN \"heartbeat_at\" < NOW() - INTERVAL '90 seconds' THEN 'stalled'\n ELSE 'streaming'\nEND as \"status\" from \"chat_run_step\"", + "name": "chat_run_step_with_status", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.chat_run_with_status": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_step_number": { + "name": "last_step_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "definition": "select \"chat_run\".\"id\", \"chat_run\".\"number\", \"chat_run\".\"chat_id\", COALESCE(\"chat_run_step_with_status\".\"agent_id\", \"chat_run\".\"agent_id\") as \"agent_id\", COALESCE(\"chat_run_step_with_status\".\"agent_deployment_id\", \"chat_run\".\"agent_deployment_id\") as \"agent_deployment_id\", \"chat_run\".\"created_at\", \"chat_run\".\"last_step_number\", COALESCE(\"chat_run_step_with_status\".\"completed_at\", \"chat_run_step_with_status\".\"interrupted_at\", \"chat_run_step_with_status\".\"heartbeat_at\", \"chat_run_step_with_status\".\"started_at\", \"chat_run\".\"created_at\") as \"updated_at\", \"chat_run_step_with_status\".\"error\", \"status\" from \"chat_run\" left join \"chat_run_step_with_status\" on (\"chat_run\".\"id\" = \"chat_run_step_with_status\".\"chat_run_id\" and \"chat_run_step_with_status\".\"number\" = \"chat_run\".\"last_step_number\")", + "name": "chat_run_with_status", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.chat_with_status": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "organization_id": { + "name": "organization_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "varchar", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "archived": { + "name": "archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "agent_deployment_target_id": { + "name": "agent_deployment_target_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_key": { + "name": "agent_key", + "type": "varchar(128)", + "primaryKey": false, + "notNull": true + }, + "last_run_number": { + "name": "last_run_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "expire_ttl": { + "name": "expire_ttl", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "definition": "select \"chat\".\"id\", \"chat\".\"created_at\", \"chat\".\"created_by\", \"chat\".\"organization_id\", \"chat\".\"visibility\", \"chat\".\"title\", \"chat\".\"metadata\", \"chat\".\"archived\", \"chat\".\"agent_id\", COALESCE(\"agent_deployment_id\", \"chat\".\"agent_deployment_id\") as \"agent_deployment_id\", \"chat\".\"agent_deployment_target_id\", \"chat\".\"agent_key\", \"chat\".\"last_run_number\", \"chat\".\"expire_ttl\", COALESCE(\"updated_at\", \"chat\".\"created_at\") as \"updated_at\", \"chat_run_with_status\".\"error\", CASE\n WHEN \"status\" IS NULL THEN 'idle'\n WHEN \"status\" IN ('error', 'stalled') THEN 'error'\n WHEN \"status\" = 'interrupted' THEN 'interrupted'\n WHEN \"status\" IN ('completed', 'idle') THEN 'idle'\n ELSE 'streaming'\n END as \"status\", CASE \n WHEN \"chat\".\"expire_ttl\" IS NULL THEN NULL\n ELSE COALESCE(\"updated_at\", \"chat\".\"created_at\") + (\"chat\".\"expire_ttl\" || ' seconds')::interval\n END as \"expires_at\" from \"chat\" left join \"chat_run_with_status\" on (\"chat\".\"id\" = \"chat_run_with_status\".\"chat_id\" and \"chat_run_with_status\".\"number\" = \"chat\".\"last_run_number\")", + "name": "chat_with_status", + "schema": "public", + "isExisting": false, + "materialized": false + }, + "public.user_with_personal_organization": { + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "definition": "select \"user\".\"id\", \"user\".\"created_at\", \"user\".\"updated_at\", \"user\".\"display_name\", \"user\".\"email\", \"user\".\"email_verified\", \"user\".\"password\", \"organization\".\"id\" as \"organization_id\", \"organization\".\"name\" as \"username\", \"organization\".\"avatar_url\" as \"avatar_url\" from \"user\" inner join \"organization\" on \"user\".\"id\" = \"organization\".\"personal_owner_user_id\"", + "name": "user_with_personal_organization", + "schema": "public", + "isExisting": false, + "materialized": false + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/internal/database/migrations/meta/_journal.json b/internal/database/migrations/meta/_journal.json index 6e98bbf4..196e3569 100644 --- a/internal/database/migrations/meta/_journal.json +++ b/internal/database/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1763742838403, "tag": "0000_initial", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1767894988953, + "tag": "0001_onboarding", + "breakpoints": true } ] } diff --git a/internal/database/src/convert.ts b/internal/database/src/convert.ts index c0005465..dae4ff58 100644 --- a/internal/database/src/convert.ts +++ b/internal/database/src/convert.ts @@ -71,6 +71,14 @@ export const agent = ( pinned: "pinned" in agent ? agent.pinned : false, chat_expire_ttl: agent.chat_expire_ttl, user_permission: userPermission, + onboarding_state: + userPermission === "admin" || userPermission === "write" + ? (agent.onboarding_state ?? null) + : null, + integrations_state: + userPermission === "admin" || userPermission === "write" + ? (agent.integrations_state ?? null) + : null, }; }; diff --git a/internal/database/src/schema.ts b/internal/database/src/schema.ts index d57edefe..8faf9aaf 100644 --- a/internal/database/src/schema.ts +++ b/internal/database/src/schema.ts @@ -488,6 +488,100 @@ export const agent = pgTable( .notNull() .default(0), last_run_number: integer("last_run_number").notNull().default(0), + + // Slack setup verification state (null when no verification in progress) + slack_verification: jsonb("slack_verification").$type<{ + signingSecret: string; + botToken: string; + startedAt: string; + expiresAt: string; + lastEventAt?: string; + dmReceivedAt?: string; + dmChannel?: string; + signatureFailedAt?: string; + }>(), + + // GitHub App setup state (null when no setup in progress) + // Status flow: pending -> app_created -> completed + // - pending: waiting for user to create app on GitHub + // - app_created: app created, waiting for user to install it + // - completed: app created and installed + // - failed: error occurred + github_app_setup: jsonb("github_app_setup").$type<{ + sessionId: string; + startedAt: string; + expiresAt: string; + status: "pending" | "app_created" | "completed" | "failed"; + error?: string; + installationId?: string; + appData?: { + id: number; + clientId: string; + clientSecret: string; + webhookSecret: string; + pem: string; + name: string; + htmlUrl: string; + slug: string; + }; + }>(), + + // Onboarding wizard state + onboarding_state: jsonb("onboarding_state").$type<{ + currentStep: + | "welcome" + | "llm-api-keys" + | "github-setup" + | "slack-setup" + | "web-search" + | "deploying" + | "success"; + finished?: boolean; + github?: { + appName: string; + appUrl: string; + installUrl: string; + appId?: number; + clientId?: string; + clientSecret?: string; + webhookSecret?: string; + privateKey?: string; + envVars?: { + appId: string; + clientId: string; + clientSecret: string; + webhookSecret: string; + privateKey: string; + }; + }; + slack?: { + botToken: string; + signingSecret: string; + envVars?: { + botToken: string; + signingSecret: string; + }; + }; + llm?: { + provider?: "anthropic" | "openai" | "vercel"; + apiKey?: string; + envVar?: string; + }; + webSearch?: { + provider?: "exa"; + apiKey?: string; + envVar?: string; + }; + }>(), + + // Integrations configuration state - tracks which integrations are configured + // This is separate from onboarding_state to persist after onboarding completes + integrations_state: jsonb("integrations_state").$type<{ + llm?: boolean; + github?: boolean; + slack?: boolean; + webSearch?: boolean; + }>(), }, (table) => [ check( diff --git a/internal/site/.storybook/main.ts b/internal/site/.storybook/main.ts index b9f97bf2..2d14d7ba 100644 --- a/internal/site/.storybook/main.ts +++ b/internal/site/.storybook/main.ts @@ -4,6 +4,7 @@ import path from "path"; import { fileURLToPath } from "url"; import Inspect from "vite-plugin-inspect"; import { nodePolyfills } from "vite-plugin-node-polyfills"; +import { mergeConfig } from "vite"; const config: StorybookConfig = { stories: ["../**/*.stories.@(js|jsx|mjs|ts|tsx)"], @@ -87,7 +88,9 @@ const config: StorybookConfig = { config.plugins.push(Inspect()); config.plugins.push(nodePolyfills()); - return config; + return mergeConfig(config, { + server: { watch: { usePolling: true, interval: 1000 } }, + }); }, }; export default config; diff --git a/internal/site/app/(app)/[organization]/[agent]/layout.tsx b/internal/site/app/(app)/[organization]/[agent]/layout.tsx index 2cdad99e..89d7ac20 100644 --- a/internal/site/app/(app)/[organization]/[agent]/layout.tsx +++ b/internal/site/app/(app)/[organization]/[agent]/layout.tsx @@ -21,6 +21,12 @@ export default async function AgentLayout({ getOrganization(session.user.id, organizationName), getAgent(organizationName, agentName), ]); + + // Redirect to onboarding if agent is being onboarded (finished === false) + if (agent.onboarding_state?.finished === false) { + return redirect(`/${organizationName}/~/onboarding/${agentName}`); + } + const user = await getUser(session.user.id); // Get organization kind from database for navigation diff --git a/internal/site/app/(app)/[organization]/[agent]/page.stories.tsx b/internal/site/app/(app)/[organization]/[agent]/page.stories.tsx index 44c995c1..89adabb0 100644 --- a/internal/site/app/(app)/[organization]/[agent]/page.stories.tsx +++ b/internal/site/app/(app)/[organization]/[agent]/page.stories.tsx @@ -1,13 +1,20 @@ -import { withFetch } from "@/.storybook/utils"; -import Layout from "@/app/(app)/layout"; -import { getQuerier } from "@/lib/database.mock"; import type { Meta, StoryObj } from "@storybook/react"; -import { mocked } from "storybook/test"; import { SessionProvider } from "next-auth/react"; +import { mocked } from "storybook/test"; +import Layout from "@/app/(app)/layout"; +import { type MockedClient, withMockClient } from "@/lib/api-client.mock"; +import { getQuerier } from "@/lib/database.mock"; import OrganizationLayout from "../layout"; import AgentLayout from "./layout"; import AgentPage from "./page"; +function configureMockClient(client: MockedClient) { + client.agents.steps.list.mockResolvedValue({ + items: [], + next_cursor: null, + }); +} + const meta: Meta = { title: "Page/Agent", component: AgentPage, @@ -64,24 +71,12 @@ export const Default: Story = { chat_expire_ttl: null, last_deployment_number: 0, last_run_number: 0, + slack_verification: null, + github_app_setup: null, + onboarding_state: null, + integrations_state: null, }), }); }, - decorators: [ - withFetch((url) => { - if (!url.pathname.endsWith("/steps")) { - return undefined; - } - - return new Response( - JSON.stringify({ - items: [], - has_more: false, - }), - { - status: 200, - } - ); - }), - ], + decorators: [withMockClient(configureMockClient)], }; diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/env-var-confirmation.stories.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/env-var-confirmation.stories.tsx new file mode 100644 index 00000000..d22ec035 --- /dev/null +++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/env-var-confirmation.stories.tsx @@ -0,0 +1,264 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Key, Search } from "lucide-react"; +import { useState } from "react"; +import { fn } from "storybook/test"; +import { GitHubIcon } from "@/components/icons/github"; +import { SlackIcon } from "@/components/slack-icon"; +import { type EnvVarConfig, EnvVarConfirmation } from "./env-var-confirmation"; + +// Wrapper component that manages state +function StatefulEnvVarConfirmation({ + initialEnvVars, + ...props +}: Omit< + React.ComponentProps, + "envVars" | "onEnvVarsChange" +> & { + initialEnvVars: EnvVarConfig[]; +}) { + const [envVars, setEnvVars] = useState(initialEnvVars); + return ( + + ); +} + +const meta: Meta = { + title: "Settings/Integrations/EnvVarConfirmation", + component: StatefulEnvVarConfirmation, + parameters: { + layout: "centered", + }, + args: { + onSave: fn(async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + }), + onCancel: fn(), + onBack: fn(), + saving: false, + }, + render: (args) => ( +
+ +
+ ), +}; + +export default meta; +type Story = StoryObj; + +export const LLM_SingleKey: Story = { + args: { + title: "LLM API Key", + description: "Save your LLM API key as an environment variable", + icon: , + iconBgColor: "bg-amber-500", + initialEnvVars: [ + { + defaultKey: "ANTHROPIC_API_KEY", + currentKey: "ANTHROPIC_API_KEY", + value: "sk-ant-api03-abcdefghijklmnopqrstuvwxyz123456789", + secret: true, + }, + ], + }, +}; +LLM_SingleKey.storyName = "LLM - Single API Key"; + +export const WebSearch_SingleKey: Story = { + args: { + title: "Web Search (Exa)", + description: "Save your Exa API key as an environment variable", + icon: , + iconBgColor: "bg-blue-500", + initialEnvVars: [ + { + defaultKey: "EXA_API_KEY", + currentKey: "EXA_API_KEY", + value: "exa-12345678-abcd-efgh-ijkl-mnopqrstuvwx", + secret: true, + }, + ], + }, +}; +WebSearch_SingleKey.storyName = "Web Search - Single API Key"; + +export const GitHub_MultipleKeys: Story = { + args: { + title: "GitHub App", + description: "Save your GitHub App credentials as environment variables", + icon: , + iconBgColor: "bg-[#24292f]", + initialEnvVars: [ + { + defaultKey: "GITHUB_APP_ID", + currentKey: "GITHUB_APP_ID", + value: "123456", + secret: false, + }, + { + defaultKey: "GITHUB_CLIENT_ID", + currentKey: "GITHUB_CLIENT_ID", + value: "Iv1.abc123def456ghi7", + secret: false, + }, + { + defaultKey: "GITHUB_CLIENT_SECRET", + currentKey: "GITHUB_CLIENT_SECRET", + value: "abcdef1234567890abcdef1234567890abcdef12", + secret: true, + }, + { + defaultKey: "GITHUB_WEBHOOK_SECRET", + currentKey: "GITHUB_WEBHOOK_SECRET", + value: "webhook-secret-12345", + secret: true, + }, + { + defaultKey: "GITHUB_PRIVATE_KEY", + currentKey: "GITHUB_PRIVATE_KEY", + value: + "LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQpNSUlFcEFJQkFBS0NBUUVBdGVzdC...", + secret: true, + }, + ], + }, +}; +GitHub_MultipleKeys.storyName = "GitHub - Multiple Credentials"; + +export const Slack_TwoKeys: Story = { + args: { + title: "Slack", + description: "Save your Slack credentials as environment variables", + icon: , + iconBgColor: "bg-[#4A154B]", + initialEnvVars: [ + { + defaultKey: "SLACK_BOT_TOKEN", + currentKey: "SLACK_BOT_TOKEN", + value: "xoxb-123456789012-123456789012-abcdefghijklmnopqrstuvwx", + secret: true, + }, + { + defaultKey: "SLACK_SIGNING_SECRET", + currentKey: "SLACK_SIGNING_SECRET", + value: "abcdef1234567890abcdef1234567890", + secret: true, + }, + ], + }, +}; +Slack_TwoKeys.storyName = "Slack - Two Credentials"; + +export const Saving: Story = { + args: { + title: "LLM API Key", + description: "Save your LLM API key as an environment variable", + icon: , + iconBgColor: "bg-amber-500", + saving: true, + initialEnvVars: [ + { + defaultKey: "ANTHROPIC_API_KEY", + currentKey: "ANTHROPIC_API_KEY", + value: "sk-ant-api03-abcdefghijklmnopqrstuvwxyz123456789", + secret: true, + }, + ], + }, +}; +Saving.storyName = "Saving State"; + +export const WithoutBackButton: Story = { + args: { + title: "LLM API Key", + description: "Save your LLM API key as an environment variable", + icon: , + iconBgColor: "bg-amber-500", + onBack: undefined, + initialEnvVars: [ + { + defaultKey: "ANTHROPIC_API_KEY", + currentKey: "ANTHROPIC_API_KEY", + value: "sk-ant-api03-abcdefghijklmnopqrstuvwxyz123456789", + secret: true, + }, + ], + }, +}; +WithoutBackButton.storyName = "Without Back Button"; + +export const EmptyKeyName: Story = { + args: { + title: "LLM API Key", + description: "Save your LLM API key as an environment variable", + icon: , + iconBgColor: "bg-amber-500", + initialEnvVars: [ + { + defaultKey: "ANTHROPIC_API_KEY", + currentKey: "", + value: "sk-ant-api03-abcdefghijklmnopqrstuvwxyz123456789", + secret: true, + }, + ], + }, +}; +EmptyKeyName.storyName = "Validation - Empty Key Name"; + +export const DuplicateKeyNames: Story = { + args: { + title: "Slack", + description: "Save your Slack credentials as environment variables", + icon: , + iconBgColor: "bg-[#4A154B]", + initialEnvVars: [ + { + defaultKey: "SLACK_BOT_TOKEN", + currentKey: "SLACK_TOKEN", + value: "xoxb-123456789012-123456789012-abcdefghijklmnopqrstuvwx", + secret: true, + }, + { + defaultKey: "SLACK_SIGNING_SECRET", + currentKey: "SLACK_TOKEN", + value: "abcdef1234567890abcdef1234567890", + secret: true, + }, + ], + }, +}; +DuplicateKeyNames.storyName = "Validation - Duplicate Key Names"; + +export const EditedKeyNames: Story = { + args: { + title: "GitHub App", + description: "Save your GitHub App credentials as environment variables", + icon: , + iconBgColor: "bg-[#24292f]", + initialEnvVars: [ + { + defaultKey: "GITHUB_APP_ID", + currentKey: "MY_GH_APP_ID", + value: "123456", + secret: false, + }, + { + defaultKey: "GITHUB_CLIENT_ID", + currentKey: "MY_GH_CLIENT_ID", + value: "Iv1.abc123def456ghi7", + secret: false, + }, + { + defaultKey: "GITHUB_CLIENT_SECRET", + currentKey: "MY_GH_CLIENT_SECRET", + value: "abcdef1234567890abcdef1234567890abcdef12", + secret: true, + }, + ], + }, +}; +EditedKeyNames.storyName = "Edited Key Names"; diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/env-var-confirmation.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/env-var-confirmation.tsx new file mode 100644 index 00000000..0711eb9b --- /dev/null +++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/env-var-confirmation.tsx @@ -0,0 +1,183 @@ +"use client"; + +import { ArrowLeft, Eye, EyeOff, Info, Loader2 } from "lucide-react"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; + +export interface EnvVarConfig { + defaultKey: string; // Original suggested name (e.g., "SLACK_BOT_TOKEN") + currentKey: string; // User-editable name + value: string; // Actual value (masked in UI for secrets) + secret: boolean; +} + +export interface EnvVarConfirmationProps { + title: string; + description: string; + icon: React.ReactNode; + iconBgColor: string; + envVars: EnvVarConfig[]; + onEnvVarsChange: (envVars: EnvVarConfig[]) => void; + onSave: () => Promise; + onCancel: () => void; + onBack?: () => void; + saving: boolean; +} + +export function EnvVarConfirmation({ + title, + description, + icon, + iconBgColor, + envVars, + onEnvVarsChange, + onSave, + onCancel, + onBack, + saving, +}: EnvVarConfirmationProps) { + const [revealedIndices, setRevealedIndices] = useState>( + new Set() + ); + + const handleKeyChange = (index: number, newKey: string) => { + const updated = [...envVars]; + updated[index] = { ...updated[index], currentKey: newKey }; + onEnvVarsChange(updated); + }; + + const toggleReveal = (index: number) => { + setRevealedIndices((prev) => { + const next = new Set(prev); + if (next.has(index)) { + next.delete(index); + } else { + next.add(index); + } + return next; + }); + }; + + const maskValue = (value: string) => { + if (value.length <= 8) { + return "••••••••"; + } + return `${value.slice(0, 4)}••••${value.slice(-4)}`; + }; + + const hasEmptyKeys = envVars.some((env) => !env.currentKey.trim()); + const hasDuplicateKeys = + new Set(envVars.map((env) => env.currentKey)).size !== envVars.length; + + return ( + + +
+ {onBack && ( + + )} +
+ {icon} +
+
+ {title} + {description} +
+
+
+ +
+ +

+ Review the environment variables that will be saved. You can edit + the variable names if needed. +

+
+ +
+ {envVars.map((envVar, index) => ( +
+ handleKeyChange(index, e.target.value)} + placeholder={envVar.defaultKey} + disabled={saving} + className="shrink-0 font-mono text-sm" + style={{ + width: `calc(${Math.max(envVar.currentKey.length, 12)}ch + 1.75rem)`, + }} + /> +
+ + {envVar.secret + ? revealedIndices.has(index) + ? envVar.value + : maskValue(envVar.value) + : envVar.value} + + {envVar.secret && ( + + )} +
+
+ ))} +
+ + {hasEmptyKeys && ( +

+ All variable names must be filled in. +

+ )} + {hasDuplicateKeys && ( +

+ Variable names must be unique. +

+ )} + +
+ + +
+
+
+ ); +} diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/github-integration.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/github-integration.tsx new file mode 100644 index 00000000..5566253f --- /dev/null +++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/github-integration.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { + type GitHubAppCredentials, + GitHubSetupWizard, +} from "@/components/github-setup-wizard"; +import { GitHubIcon } from "@/components/icons/github"; +import { EnvVarConfirmation } from "./env-var-confirmation"; +import { useIntegrationSetup } from "./use-integration-setup"; + +interface GitHubSetupResult { + appName: string; + appUrl: string; + installUrl: string; + credentials: GitHubAppCredentials; +} + +interface GitHubIntegrationProps { + agentId: string; + agentName: string; + onComplete: () => void; + onCancel: () => void; +} + +export function GitHubIntegration({ + agentId, + agentName, + onComplete, + onCancel, +}: GitHubIntegrationProps) { + const { + step, + setStep, + envVars, + setEnvVars, + saving, + handleSave, + handleSetupComplete, + } = useIntegrationSetup({ + agentId, + onComplete, + integrationKey: "github", + successMessage: "GitHub integration configured successfully", + }); + + const onSetupComplete = (result: { + appName: string; + appUrl: string; + installUrl: string; + credentials: GitHubAppCredentials; + }) => { + handleSetupComplete(result, [ + { + defaultKey: "GITHUB_APP_ID", + currentKey: "GITHUB_APP_ID", + value: String(result.credentials.appId), + secret: false, + }, + { + defaultKey: "GITHUB_CLIENT_ID", + currentKey: "GITHUB_CLIENT_ID", + value: result.credentials.clientId, + secret: false, + }, + { + defaultKey: "GITHUB_CLIENT_SECRET", + currentKey: "GITHUB_CLIENT_SECRET", + value: result.credentials.clientSecret, + secret: true, + }, + { + defaultKey: "GITHUB_WEBHOOK_SECRET", + currentKey: "GITHUB_WEBHOOK_SECRET", + value: result.credentials.webhookSecret, + secret: true, + }, + { + defaultKey: "GITHUB_PRIVATE_KEY", + currentKey: "GITHUB_PRIVATE_KEY", + value: result.credentials.privateKey, + secret: true, + }, + ]); + }; + + if (step === "confirm") { + return ( + } + iconBgColor="bg-[#24292f]" + envVars={envVars} + onEnvVarsChange={setEnvVars} + onSave={handleSave} + onCancel={onCancel} + onBack={() => setStep("setup")} + saving={saving} + /> + ); + } + + return ( + + ); +} diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integration-card.stories.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integration-card.stories.tsx new file mode 100644 index 00000000..e414ab62 --- /dev/null +++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integration-card.stories.tsx @@ -0,0 +1,113 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { Key, Search } from "lucide-react"; +import { fn } from "storybook/test"; +import { GitHubIcon } from "@/components/icons/github"; +import { SlackIcon } from "@/components/slack-icon"; +import { IntegrationCard } from "./integration-card"; + +const meta: Meta = { + title: "Settings/Integrations/IntegrationCard", + component: IntegrationCard, + parameters: { + layout: "centered", + }, + args: { + onConfigure: fn(), + }, + render: (args) => ( +
+ +
+ ), +}; + +export default meta; +type Story = StoryObj; + +export const LLM_NotConfigured: Story = { + args: { + title: "LLM API Key", + description: "Configure an API key for AI capabilities", + icon: , + iconBgColor: "bg-amber-500", + configured: false, + }, +}; +LLM_NotConfigured.storyName = "LLM - Not Configured"; + +export const WebSearch_NotConfigured: Story = { + args: { + title: "Web Search (Exa)", + description: "Enable web search capabilities", + icon: , + iconBgColor: "bg-blue-500", + configured: false, + }, +}; +WebSearch_NotConfigured.storyName = "Web Search - Not Configured"; + +export const GitHub_NotConfigured: Story = { + args: { + title: "GitHub", + description: "Connect to GitHub repositories", + icon: , + iconBgColor: "bg-[#24292f]", + configured: false, + }, +}; +GitHub_NotConfigured.storyName = "GitHub - Not Configured"; + +export const Slack_NotConfigured: Story = { + args: { + title: "Slack", + description: "Chat with your agent in Slack", + icon: , + iconBgColor: "bg-[#4A154B]", + configured: false, + }, +}; +Slack_NotConfigured.storyName = "Slack - Not Configured"; + +export const LLM_Configured: Story = { + args: { + title: "LLM API Key", + description: "Configure an API key for AI capabilities", + icon: , + iconBgColor: "bg-amber-500", + configured: true, + }, +}; +LLM_Configured.storyName = "LLM - Configured"; + +export const WebSearch_Configured: Story = { + args: { + title: "Web Search (Exa)", + description: "Enable web search capabilities", + icon: , + iconBgColor: "bg-blue-500", + configured: true, + }, +}; +WebSearch_Configured.storyName = "Web Search - Configured"; + +export const GitHub_Configured: Story = { + args: { + title: "GitHub", + description: "Connect to GitHub repositories", + icon: , + iconBgColor: "bg-[#24292f]", + configured: true, + }, +}; +GitHub_Configured.storyName = "GitHub - Configured"; + +export const Slack_Configured: Story = { + args: { + title: "Slack", + description: "Chat with your agent in Slack", + icon: , + iconBgColor: "bg-[#4A154B]", + configured: true, + }, +}; +Slack_Configured.storyName = "Slack - Configured"; diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integration-card.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integration-card.tsx new file mode 100644 index 00000000..c08f85fc --- /dev/null +++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integration-card.tsx @@ -0,0 +1,71 @@ +"use client"; + +import { Check, Plus, Settings } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; + +interface IntegrationCardProps { + title: string; + description: string; + icon: React.ReactNode; + iconBgColor: string; + configured: boolean; + onConfigure: () => void; +} + +export function IntegrationCard({ + title, + description, + icon, + iconBgColor, + configured, + onConfigure, +}: IntegrationCardProps) { + return ( + + +
+
+ {icon} +
+
+ {title} + + {description} + +
+
+ {configured ? ( + <> +
+ + Connected +
+ + + ) : ( + + )} +
+
+
+
+ ); +} diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integrations-manager.stories.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integrations-manager.stories.tsx new file mode 100644 index 00000000..304fae07 --- /dev/null +++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integrations-manager.stories.tsx @@ -0,0 +1,390 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { type MockedClient, withMockClient } from "@/lib/api-client.mock"; +import IntegrationsManager from "./integrations-manager"; + +const TEST_AGENT_ID = "test-agent-123"; +const TEST_WEBHOOK_URL = "https://api.blink.so/webhooks/test-webhook-id"; +const TEST_GITHUB_URL = "https://github.com/settings/apps/new"; +const TEST_MANIFEST = JSON.stringify({ + name: "Test App", + url: "https://blink.so", +}); +const TEST_SESSION_ID = "test-session-456"; + +// Configure mock client with default responses for all integrations +interface MockOptions { + // Slack options + slackValidationValid?: boolean; + slackDmReceived?: boolean; + slackSignatureFailed?: boolean; + // GitHub options + githubCreationStatus?: + | "pending" + | "app_created" + | "completed" + | "failed" + | "expired"; + githubAppData?: { + id: number; + name: string; + html_url: string; + slug: string; + }; +} + +// Configure mock client with default responses for all integrations +function configureMockClient(client: MockedClient, options?: MockOptions) { + const { + slackValidationValid = true, + slackDmReceived = false, + slackSignatureFailed = false, + githubCreationStatus = "pending", + githubAppData = { + id: 12345, + name: "Test GitHub App", + html_url: "https://github.com/apps/test-github-app", + slug: "test-github-app", + }, + } = options ?? {}; + + // Slack mocks + client.agents.setupSlack.getWebhookUrl.mockResolvedValue({ + webhook_url: TEST_WEBHOOK_URL, + }); + client.agents.setupSlack.startVerification.mockResolvedValue({ + webhook_url: TEST_WEBHOOK_URL, + }); + client.agents.setupSlack.getVerificationStatus.mockResolvedValue({ + active: true, + started_at: new Date().toISOString(), + last_event_at: slackDmReceived ? new Date().toISOString() : undefined, + dm_received: slackDmReceived, + dm_channel: slackDmReceived ? "D12345678" : undefined, + signature_failed: slackSignatureFailed, + }); + client.agents.setupSlack.completeVerification.mockResolvedValue({ + success: true, + bot_name: "Test Bot", + }); + client.agents.setupSlack.cancelVerification.mockResolvedValue(undefined); + client.agents.setupSlack.validateToken.mockResolvedValue({ + valid: slackValidationValid, + error: slackValidationValid ? undefined : "Invalid token", + }); + + // GitHub mocks + client.agents.setupGitHub.startCreation.mockResolvedValue({ + manifest: TEST_MANIFEST, + github_url: TEST_GITHUB_URL, + session_id: TEST_SESSION_ID, + }); + client.agents.setupGitHub.getCreationStatus.mockResolvedValue({ + status: githubCreationStatus, + app_data: + githubCreationStatus === "completed" || + githubCreationStatus === "app_created" + ? githubAppData + : undefined, + credentials: + githubCreationStatus === "completed" + ? { + app_id: githubAppData.id, + client_id: "Iv1.test123", + client_secret: "test-client-secret", + webhook_secret: "test-webhook-secret", + private_key: btoa("test-private-key"), + } + : undefined, + error: + githubCreationStatus === "failed" ? "Something went wrong" : undefined, + }); + client.agents.setupGitHub.completeCreation.mockResolvedValue({ + success: true, + app_name: githubAppData.name, + app_url: githubAppData.html_url, + install_url: `${githubAppData.html_url}/installations/new`, + }); + + // Environment variables mock + client.agents.env.create.mockResolvedValue({ + id: "env-123", + created_at: new Date(), + updated_at: new Date(), + created_by: "user-123", + updated_by: "user-123", + key: "TEST_KEY", + value: "test-value", + secret: true, + target: ["preview", "production"], + }); + client.agents.updateOnboarding.mockResolvedValue({ + id: TEST_AGENT_ID, + organization_id: "org-123", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + created_by: "user-123", + name: "Scout", + description: null, + avatar_url: null, + visibility: "organization", + active_deployment_id: null, + pinned: false, + request_url: null, + chat_expire_ttl: null, + onboarding_state: null, + integrations_state: null, + }); +} + +const meta: Meta = { + title: "Settings/Integrations/IntegrationsManager", + component: IntegrationsManager, + parameters: { + layout: "centered", + }, + args: { + agentId: TEST_AGENT_ID, + agentName: "Scout", + }, + render: (args) => ( +
+ +
+ ), + decorators: [withMockClient((client) => configureMockClient(client))], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; +Default.storyName = "Default (All Cards)"; + +// Settings that the mock client can read for dynamic behavior +const interactiveSettings = { + slackBotTokenValid: true, + slackSigningSecretValid: true, + slackPollCount: 0, + githubPollCount: 0, +}; + +// Configure interactive mock client with dynamic behavior +function configureInteractiveMockClient(client: MockedClient) { + // Slack mocks with dynamic behavior + client.agents.setupSlack.getWebhookUrl.mockResolvedValue({ + webhook_url: TEST_WEBHOOK_URL, + }); + client.agents.setupSlack.startVerification.mockImplementation(() => { + interactiveSettings.slackPollCount = 0; + return Promise.resolve({ webhook_url: TEST_WEBHOOK_URL }); + }); + client.agents.setupSlack.getVerificationStatus.mockImplementation(() => { + interactiveSettings.slackPollCount++; + const dmReceived = interactiveSettings.slackPollCount >= 3; + const signatureFailed = + dmReceived && !interactiveSettings.slackSigningSecretValid; + return Promise.resolve({ + active: true, + started_at: new Date().toISOString(), + last_event_at: + interactiveSettings.slackPollCount > 1 + ? new Date().toISOString() + : undefined, + dm_received: dmReceived, + dm_channel: dmReceived ? "D12345678" : undefined, + signature_failed: signatureFailed, + }); + }); + client.agents.setupSlack.completeVerification.mockResolvedValue({ + success: true, + bot_name: "Scout Bot", + }); + client.agents.setupSlack.cancelVerification.mockResolvedValue(undefined); + client.agents.setupSlack.validateToken.mockImplementation(() => + Promise.resolve({ + valid: interactiveSettings.slackBotTokenValid, + error: interactiveSettings.slackBotTokenValid + ? undefined + : "Invalid bot token", + }) + ); + + // GitHub mocks with auto-progression + client.agents.setupGitHub.startCreation.mockImplementation(() => { + interactiveSettings.githubPollCount = 0; + return Promise.resolve({ + manifest: TEST_MANIFEST, + github_url: TEST_GITHUB_URL, + session_id: TEST_SESSION_ID, + }); + }); + client.agents.setupGitHub.getCreationStatus.mockImplementation(() => { + interactiveSettings.githubPollCount++; + let status: "pending" | "app_created" | "completed" = "pending"; + if (interactiveSettings.githubPollCount >= 5) { + status = "completed"; + } else if (interactiveSettings.githubPollCount >= 3) { + status = "app_created"; + } + + const appData = { + id: 12345, + name: "my-org-Scout", + html_url: "https://github.com/apps/my-org-scout", + slug: "my-org-scout", + }; + + return Promise.resolve({ + status, + app_data: status !== "pending" ? appData : undefined, + credentials: + status === "completed" + ? { + app_id: appData.id, + client_id: "Iv1.test123", + client_secret: "test-client-secret-12345", + webhook_secret: "test-webhook-secret-67890", + private_key: btoa( + "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----" + ), + } + : undefined, + }); + }); + client.agents.setupGitHub.completeCreation.mockResolvedValue({ + success: true, + app_name: "my-org-Scout", + app_url: "https://github.com/apps/my-org-scout", + install_url: "https://github.com/apps/my-org-scout/installations/new", + }); + + // Environment variables mock + client.agents.env.create.mockResolvedValue({ + id: "env-123", + created_at: new Date(), + updated_at: new Date(), + created_by: "user-123", + updated_by: "user-123", + key: "TEST_KEY", + value: "test-value", + secret: true, + target: ["preview", "production"], + }); + client.agents.updateOnboarding.mockResolvedValue({ + id: TEST_AGENT_ID, + organization_id: "org-123", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + created_by: "user-123", + name: "Scout", + description: null, + avatar_url: null, + visibility: "organization", + active_deployment_id: null, + pinned: false, + request_url: null, + chat_expire_ttl: null, + onboarding_state: null, + integrations_state: null, + }); +} + +function InteractiveFlowWrapper() { + const [slackBotTokenValid, setSlackBotTokenValid] = useState(true); + const [slackSigningSecretValid, setSlackSigningSecretValid] = useState(true); + const [key, setKey] = useState(0); + + // Update global settings when state changes + interactiveSettings.slackBotTokenValid = slackBotTokenValid; + interactiveSettings.slackSigningSecretValid = slackSigningSecretValid; + + const resetIntegrations = () => { + interactiveSettings.slackPollCount = 0; + interactiveSettings.githubPollCount = 0; + setKey((k) => k + 1); + }; + + return ( +
+
+ +
+
+

Test Controls

+ +
+

Slack

+ + + +
+ +
+ + + +

+ Toggle checkboxes to simulate different API responses. GitHub will + auto-progress through stages after clicking "Create & install on + GitHub". +

+
+
+ ); +} + +export const InteractiveFlow: Story = { + render: () => , + decorators: [withMockClient(configureInteractiveMockClient)], +}; +InteractiveFlow.storyName = "Interactive Flow"; + +export const MixedStates: Story = { + args: { + integrationsState: { + llm: true, + github: true, + }, + }, +}; +MixedStates.storyName = "Mixed States (Partial Setup)"; + +export const AllConfigured: Story = { + args: { + integrationsState: { + llm: true, + webSearch: true, + github: true, + slack: true, + }, + }, +}; +AllConfigured.storyName = "All Configured"; diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integrations-manager.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integrations-manager.tsx new file mode 100644 index 00000000..a77a4f5f --- /dev/null +++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/integrations-manager.tsx @@ -0,0 +1,133 @@ +"use client"; + +import type { IntegrationsState } from "@blink.so/api"; +import { Key, Search } from "lucide-react"; +import { useState } from "react"; +import { GitHubIcon } from "@/components/icons/github"; +import { SlackIcon } from "@/components/slack-icon"; +import { GitHubIntegration } from "./github-integration"; +import { IntegrationCard } from "./integration-card"; +import { LlmIntegration } from "./llm-integration"; +import { SlackIntegration } from "./slack-integration"; +import { WebSearchIntegration } from "./web-search-integration"; + +type ActiveSetup = "llm" | "web-search" | "github" | "slack" | null; + +interface IntegrationsManagerProps { + agentId: string; + agentName: string; + integrationsState: IntegrationsState | null; +} + +export default function IntegrationsManager({ + agentId, + agentName, + integrationsState, +}: IntegrationsManagerProps) { + const [activeSetup, setActiveSetup] = useState(null); + // Derive initial configured status from integrationsState + const [configured, setConfigured] = useState({ + llm: !!integrationsState?.llm, + webSearch: !!integrationsState?.webSearch, + github: !!integrationsState?.github, + slack: !!integrationsState?.slack, + }); + + const handleComplete = (integration: keyof typeof configured) => { + setConfigured((prev) => ({ ...prev, [integration]: true })); + setActiveSetup(null); + }; + + const handleCancel = () => { + setActiveSetup(null); + }; + + // Render active setup wizard + if (activeSetup === "llm") { + return ( + handleComplete("llm")} + onCancel={handleCancel} + /> + ); + } + + if (activeSetup === "web-search") { + return ( + handleComplete("webSearch")} + onCancel={handleCancel} + /> + ); + } + + if (activeSetup === "github") { + return ( + handleComplete("github")} + onCancel={handleCancel} + /> + ); + } + + if (activeSetup === "slack") { + return ( + handleComplete("slack")} + onCancel={handleCancel} + /> + ); + } + + // Render integration cards grid + return ( +
+
+

Integrations

+

+ Connect your agent to external services to extend its capabilities. +

+
+
+ } + iconBgColor="bg-amber-500" + configured={configured.llm} + onConfigure={() => setActiveSetup("llm")} + /> + } + iconBgColor="bg-blue-500" + configured={configured.webSearch} + onConfigure={() => setActiveSetup("web-search")} + /> + } + iconBgColor="bg-[#24292f]" + configured={configured.github} + onConfigure={() => setActiveSetup("github")} + /> + } + iconBgColor="bg-[#4A154B]" + configured={configured.slack} + onConfigure={() => setActiveSetup("slack")} + /> +
+
+ ); +} diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/llm-integration.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/llm-integration.tsx new file mode 100644 index 00000000..262bb152 --- /dev/null +++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/llm-integration.tsx @@ -0,0 +1,79 @@ +"use client"; + +import { Key } from "lucide-react"; +import { + getEnvVarKeyForProvider, + type LlmApiKeysResult, + LlmApiKeysSetup, +} from "@/components/llm-api-keys-setup"; +import { EnvVarConfirmation } from "./env-var-confirmation"; +import { useIntegrationSetup } from "./use-integration-setup"; + +interface LlmIntegrationProps { + agentId: string; + onComplete: () => void; + onCancel: () => void; +} + +export function LlmIntegration({ + agentId, + onComplete, + onCancel, +}: LlmIntegrationProps) { + const { + step, + setStep, + setupResult, + envVars, + setEnvVars, + saving, + handleSave, + handleSetupComplete, + } = useIntegrationSetup({ + agentId, + onComplete, + integrationKey: "llm", + successMessage: "LLM API key configured successfully", + }); + + const onSetupComplete = (result: LlmApiKeysResult) => { + const envVarKey = getEnvVarKeyForProvider(result.provider); + handleSetupComplete(result, [ + { + defaultKey: envVarKey, + currentKey: envVarKey, + value: result.apiKey, + secret: true, + }, + ]); + }; + + if (step === "confirm") { + return ( + } + iconBgColor="bg-amber-500" + envVars={envVars} + onEnvVarsChange={setEnvVars} + onSave={handleSave} + onCancel={onCancel} + onBack={() => setStep("setup")} + saving={saving} + /> + ); + } + + return ( + + ); +} diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/page.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/page.tsx new file mode 100644 index 00000000..f3de8264 --- /dev/null +++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/page.tsx @@ -0,0 +1,33 @@ +import { notFound, redirect } from "next/navigation"; +import { auth } from "@/app/(auth)/auth"; +import { getAgent } from "../../../layout"; +import IntegrationsManager from "./integrations-manager"; + +export default async function IntegrationsSettingsPage({ + params, +}: { + params: Promise<{ organization: string; agent: string }>; +}) { + const session = await auth(); + if (!session || !session?.user?.id) { + return redirect("/login"); + } + + const { organization: organizationName, agent: agentName } = await params; + const agent = await getAgent(organizationName, agentName); + + const permission = agent.user_permission; + if (!(permission === "admin" || permission === "write")) { + return notFound(); + } + + return ( +
+ +
+ ); +} diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/slack-integration.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/slack-integration.tsx new file mode 100644 index 00000000..1d9a576a --- /dev/null +++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/slack-integration.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { SlackIcon } from "@/components/slack-icon"; +import { SlackSetupWizard } from "@/components/slack-setup-wizard"; +import { EnvVarConfirmation } from "./env-var-confirmation"; +import { useIntegrationSetup } from "./use-integration-setup"; + +interface SlackCredentials { + botToken: string; + signingSecret: string; +} + +interface SlackIntegrationProps { + agentId: string; + agentName: string; + onComplete: () => void; + onCancel: () => void; +} + +export function SlackIntegration({ + agentId, + agentName, + onComplete, + onCancel, +}: SlackIntegrationProps) { + const { + step, + setStep, + envVars, + setEnvVars, + saving, + handleSave, + handleSetupComplete, + } = useIntegrationSetup({ + agentId, + onComplete, + integrationKey: "slack", + successMessage: "Slack integration configured successfully", + }); + + const onSetupComplete = (result: SlackCredentials) => { + handleSetupComplete(result, [ + { + defaultKey: "SLACK_BOT_TOKEN", + currentKey: "SLACK_BOT_TOKEN", + value: result.botToken, + secret: true, + }, + { + defaultKey: "SLACK_SIGNING_SECRET", + currentKey: "SLACK_SIGNING_SECRET", + value: result.signingSecret, + secret: true, + }, + ]); + }; + + if (step === "confirm") { + return ( + } + iconBgColor="bg-[#4A154B]" + envVars={envVars} + onEnvVarsChange={setEnvVars} + onSave={handleSave} + onCancel={onCancel} + onBack={() => setStep("setup")} + saving={saving} + /> + ); + } + + return ( + + ); +} diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/use-integration-setup.ts b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/use-integration-setup.ts new file mode 100644 index 00000000..baefc2a2 --- /dev/null +++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/use-integration-setup.ts @@ -0,0 +1,112 @@ +"use client"; + +import type { IntegrationsState } from "@blink.so/api"; +import { useCallback, useState } from "react"; +import { toast } from "sonner"; +import { useAPIClient } from "@/lib/api-client"; +import type { EnvVarConfig } from "./env-var-confirmation"; + +type SetupStep = "setup" | "confirm"; + +interface UseIntegrationSetupOptions { + agentId: string; + onComplete: () => void; + + /** The integration key to set in integrations_state */ + integrationKey: keyof IntegrationsState; + + /** Success message to show */ + successMessage: string; +} + +interface UseIntegrationSetupReturn { + step: SetupStep; + setStep: (step: SetupStep) => void; + setupResult: TSetupResult | null; + setSetupResult: (result: TSetupResult | null) => void; + envVars: EnvVarConfig[]; + setEnvVars: (envVars: EnvVarConfig[]) => void; + saving: boolean; + handleSave: () => Promise; + handleSetupComplete: (result: TSetupResult, envVars: EnvVarConfig[]) => void; +} + +export function useIntegrationSetup({ + agentId, + onComplete, + integrationKey, + successMessage, +}: UseIntegrationSetupOptions): UseIntegrationSetupReturn { + const client = useAPIClient(); + + const [step, setStep] = useState("setup"); + const [setupResult, setSetupResult] = useState(null); + const [envVars, setEnvVars] = useState([]); + const [saving, setSaving] = useState(false); + + const handleSetupComplete = useCallback( + (result: TSetupResult, newEnvVars: EnvVarConfig[]) => { + setSetupResult(result); + setEnvVars(newEnvVars); + setStep("confirm"); + }, + [] + ); + + const handleSave = useCallback(async () => { + if (!setupResult) return; + + setSaving(true); + try { + // Save env vars + await Promise.all( + envVars.map((envVar) => + client.agents.env.create({ + agent_id: agentId, + key: envVar.currentKey, + value: envVar.value, + secret: envVar.secret, + target: ["preview", "production"], + upsert: true, + }) + ) + ); + + // Update integrations_state + await client.agents.updateIntegrationsState(agentId, { + [integrationKey]: true, + }); + + toast.success(successMessage); + onComplete(); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : "Failed to save environment variables" + ); + } finally { + setSaving(false); + } + }, [ + agentId, + client, + envVars, + onComplete, + setupResult, + integrationKey, + successMessage, + ]); + + return { + step, + setStep, + setupResult, + setSetupResult, + envVars, + setEnvVars, + saving, + handleSave, + handleSetupComplete, + }; +} diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/integrations/web-search-integration.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/web-search-integration.tsx new file mode 100644 index 00000000..ec754bea --- /dev/null +++ b/internal/site/app/(app)/[organization]/[agent]/settings/integrations/web-search-integration.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { Search } from "lucide-react"; +import { + EXA_ENV_VAR_KEY, + type WebSearchResult, + WebSearchSetup, +} from "@/components/web-search-setup"; +import { EnvVarConfirmation } from "./env-var-confirmation"; +import { useIntegrationSetup } from "./use-integration-setup"; + +interface WebSearchIntegrationProps { + agentId: string; + onComplete: () => void; + onCancel: () => void; +} + +export function WebSearchIntegration({ + agentId, + onComplete, + onCancel, +}: WebSearchIntegrationProps) { + const { + step, + setStep, + setupResult, + envVars, + setEnvVars, + saving, + handleSave, + handleSetupComplete, + } = useIntegrationSetup({ + agentId, + onComplete, + integrationKey: "webSearch", + successMessage: "Web search configured successfully", + }); + + const onSetupComplete = (result: WebSearchResult) => { + handleSetupComplete(result, [ + { + defaultKey: EXA_ENV_VAR_KEY, + currentKey: EXA_ENV_VAR_KEY, + value: result.exaApiKey, + secret: true, + }, + ]); + }; + + if (step === "confirm") { + return ( + } + iconBgColor="bg-blue-500" + envVars={envVars} + onEnvVarsChange={setEnvVars} + onSave={handleSave} + onCancel={onCancel} + onBack={() => setStep("setup")} + saving={saving} + /> + ); + } + + return ( + + ); +} diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/navigation.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/navigation.tsx index 8f35faad..ef18fd47 100644 --- a/internal/site/app/(app)/[organization]/[agent]/settings/navigation.tsx +++ b/internal/site/app/(app)/[organization]/[agent]/settings/navigation.tsx @@ -17,11 +17,17 @@ export function AgentSettingsNav() { label: "Environment Variables", href: `${baseHref}/env`, }, + { + value: "integrations", + label: "Integrations", + href: `${baseHref}/integrations`, + }, { value: "webhooks", label: "Webhooks", href: `${baseHref}/webhooks` }, ]; const getActiveTab = (pathname: string | null) => { if (pathname?.includes("/settings/env")) return "environment"; + if (pathname?.includes("/settings/integrations")) return "integrations"; if (pathname?.includes("/settings/webhooks")) return "webhooks"; return "general"; }; diff --git a/internal/site/app/(app)/[organization]/[agent]/settings/page.stories.tsx b/internal/site/app/(app)/[organization]/[agent]/settings/page.stories.tsx new file mode 100644 index 00000000..210bb9c3 --- /dev/null +++ b/internal/site/app/(app)/[organization]/[agent]/settings/page.stories.tsx @@ -0,0 +1,274 @@ +import type { Agent } from "@blink.so/api"; +import type { Meta, StoryObj } from "@storybook/react"; +import { PageContainer } from "@/components/page-header"; +import { + SettingsNavigation, + type SettingsTab, +} from "@/components/settings-navigation"; +import { type MockedClient, withMockClient } from "@/lib/api-client.mock"; +import { AgentDeleteForm } from "./agent-delete-form"; +import { AgentSettingsForm } from "./form"; +import IntegrationsManager from "./integrations/integrations-manager"; +import { WebhooksSection } from "./webhooks/webhooks-section"; + +const TEST_AGENT_ID = "test-agent-123"; +const TEST_ORGANIZATION_NAME = "acme"; +const TEST_AGENT_NAME = "scout"; +const TEST_WEBHOOK_URL = "https://api.blink.so/webhooks/test-webhook-id"; +const TEST_GITHUB_URL = "https://github.com/settings/apps/new"; +const TEST_MANIFEST = JSON.stringify({ + name: "Test App", + url: "https://blink.so", +}); +const TEST_SESSION_ID = "test-session-456"; + +// Mock agent data +const mockAgent: Agent = { + id: TEST_AGENT_ID, + organization_id: "org-123", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + created_by: "user-123", + name: TEST_AGENT_NAME, + description: "An AI-powered code review assistant.", + avatar_url: null, + visibility: "organization", + active_deployment_id: "deploy-123", + pinned: false, + request_url: "https://api.blink.so/agents/test-agent-123/request", + chat_expire_ttl: null, + onboarding_state: null, + integrations_state: null, +}; + +// Configure mock client for general settings +function configureMockClient(client: MockedClient) { + client.agents.update.mockResolvedValue({ + ...mockAgent, + updated_at: new Date().toISOString(), + }); + client.agents.delete.mockResolvedValue(undefined); +} + +// Configure mock client for integrations tab +function configureIntegrationsMockClient(client: MockedClient) { + // Slack mocks + client.agents.setupSlack.getWebhookUrl.mockResolvedValue({ + webhook_url: TEST_WEBHOOK_URL, + }); + client.agents.setupSlack.startVerification.mockResolvedValue({ + webhook_url: TEST_WEBHOOK_URL, + }); + client.agents.setupSlack.getVerificationStatus.mockResolvedValue({ + active: true, + started_at: new Date().toISOString(), + last_event_at: undefined, + dm_received: false, + dm_channel: undefined, + signature_failed: false, + }); + client.agents.setupSlack.completeVerification.mockResolvedValue({ + success: true, + bot_name: "Test Bot", + }); + client.agents.setupSlack.cancelVerification.mockResolvedValue(undefined); + client.agents.setupSlack.validateToken.mockResolvedValue({ + valid: true, + error: undefined, + }); + + // GitHub mocks + client.agents.setupGitHub.startCreation.mockResolvedValue({ + manifest: TEST_MANIFEST, + github_url: TEST_GITHUB_URL, + session_id: TEST_SESSION_ID, + }); + client.agents.setupGitHub.getCreationStatus.mockResolvedValue({ + status: "pending", + app_data: undefined, + credentials: undefined, + error: undefined, + }); + client.agents.setupGitHub.completeCreation.mockResolvedValue({ + success: true, + app_name: "Test GitHub App", + app_url: "https://github.com/apps/test-github-app", + install_url: "https://github.com/apps/test-github-app/installations/new", + }); + + // Environment variables mock + client.agents.env.create.mockResolvedValue({ + id: "env-123", + created_at: new Date(), + updated_at: new Date(), + created_by: "user-123", + updated_by: "user-123", + key: "TEST_KEY", + value: "test-value", + secret: true, + target: ["preview", "production"], + }); + client.agents.updateOnboarding.mockResolvedValue({ + id: TEST_AGENT_ID, + organization_id: "org-123", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + created_by: "user-123", + name: "Scout", + description: null, + avatar_url: null, + visibility: "organization", + active_deployment_id: null, + pinned: false, + request_url: null, + chat_expire_ttl: null, + onboarding_state: null, + integrations_state: null, + }); +} + +// Mock navigation component with configurable active tab +function MockSettingsNav({ activeTab }: { activeTab: string }) { + const baseHref = `/${TEST_ORGANIZATION_NAME}/${TEST_AGENT_NAME}/settings`; + + const tabs: SettingsTab[] = [ + { value: "general", label: "General", href: baseHref }, + { + value: "environment", + label: "Environment Variables", + href: `${baseHref}/env`, + }, + { + value: "integrations", + label: "Integrations", + href: `${baseHref}/integrations`, + }, + { value: "webhooks", label: "Webhooks", href: `${baseHref}/webhooks` }, + ]; + + return ( + activeTab} + /> + ); +} + +// General settings tab content +function GeneralTabContent({ agent }: { agent: Agent }) { + return ( + + +
+ + +
+
+ ); +} + +// Integrations tab content +function IntegrationsTabContent({ agent }: { agent: Agent }) { + return ( + + + + + ); +} + +// Webhooks tab content +function WebhooksTabContent({ agent }: { agent: Agent }) { + return ( + + + + + ); +} + +const meta: Meta = { + title: "Settings/AgentSettings", + parameters: { + layout: "fullscreen", + nextjs: { + appDirectory: true, + navigation: { + pathname: `/${TEST_ORGANIZATION_NAME}/${TEST_AGENT_NAME}/settings`, + }, + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const General: Story = { + render: () => , + decorators: [withMockClient(configureMockClient)], +}; + +export const Integrations: Story = { + render: () => , + decorators: [withMockClient(configureIntegrationsMockClient)], +}; + +export const IntegrationsConfigured: Story = { + render: () => ( + + ), + decorators: [withMockClient(configureIntegrationsMockClient)], +}; +IntegrationsConfigured.storyName = "Integrations (Configured)"; + +export const Webhooks: Story = { + render: () => , + decorators: [withMockClient(configureMockClient)], +}; + +export const WebhooksNotDeployed: Story = { + render: () => ( + + ), + decorators: [withMockClient(configureMockClient)], +}; +WebhooksNotDeployed.storyName = "Webhooks (Not Deployed)"; diff --git a/internal/site/app/(app)/[organization]/layout.tsx b/internal/site/app/(app)/[organization]/layout.tsx index cae5272c..4fee53aa 100644 --- a/internal/site/app/(app)/[organization]/layout.tsx +++ b/internal/site/app/(app)/[organization]/layout.tsx @@ -62,6 +62,16 @@ export const getUser = cache(async (userID: string) => { }); export const getAgent = cache( + async (organizationName: string, agentName: string) => { + const agent = await getAgentOrNull(organizationName, agentName); + if (!agent) { + return notFound(); + } + return agent; + } +); + +export const getAgentOrNull = cache( async (organizationName: string, agentName: string) => { const session = await auth(); const userID = session?.user?.id; @@ -72,7 +82,7 @@ export const getAgent = cache( userID, }); if (!agent) { - return notFound(); + return null; } // Get the production deployment target's request_id const productionTarget = await db.selectAgentDeploymentTargetByName( @@ -104,7 +114,7 @@ export const getAgent = cache( }); // If permission is undefined, user doesn't have access if (userPermission === undefined) { - return notFound(); + return null; } } } diff --git a/internal/site/app/(app)/[organization]/page.stories.tsx b/internal/site/app/(app)/[organization]/page.stories.tsx index ae3cbd3c..2ed4f26b 100644 --- a/internal/site/app/(app)/[organization]/page.stories.tsx +++ b/internal/site/app/(app)/[organization]/page.stories.tsx @@ -66,6 +66,10 @@ export const Default: Story = { chat_expire_ttl: null, last_deployment_number: 0, last_run_number: 0, + slack_verification: null, + github_app_setup: null, + onboarding_state: null, + integrations_state: null, active_deployment_created_by: "1", active_deployment_created_at: new Date(), }, @@ -84,6 +88,10 @@ export const Default: Story = { chat_expire_ttl: null, last_deployment_number: 0, last_run_number: 0, + slack_verification: null, + github_app_setup: null, + onboarding_state: null, + integrations_state: null, active_deployment_created_by: null, active_deployment_created_at: null, }, diff --git a/internal/site/app/(app)/[organization]/page.tsx b/internal/site/app/(app)/[organization]/page.tsx index d9fc8f82..91ca84e9 100644 --- a/internal/site/app/(app)/[organization]/page.tsx +++ b/internal/site/app/(app)/[organization]/page.tsx @@ -113,6 +113,25 @@ export default async function Page({ const isPersonal = organization.id === user.organization_id; + const DEFAULT_AGENT_NAME = "blink"; + + // Find an agent with onboarding in progress (finished === false) + const onboardingAgent = agents.find( + (a) => a.onboarding_state?.finished === false + ); + + // Redirect to onboarding if organization has no agents + if (agents.length === 0) { + return redirect(`/${organizationName}/~/onboarding/${DEFAULT_AGENT_NAME}`); + } + + // Redirect to agent onboarding if there's a one agent being onboarded + if (agents.length === 1 && onboardingAgent) { + return redirect( + `/${organizationName}/~/onboarding/${onboardingAgent.name}` + ); + } + return (
diff --git a/internal/site/app/(app)/[organization]/~/onboarding/[agent]/page.tsx b/internal/site/app/(app)/[organization]/~/onboarding/[agent]/page.tsx new file mode 100644 index 00000000..dea41129 --- /dev/null +++ b/internal/site/app/(app)/[organization]/~/onboarding/[agent]/page.tsx @@ -0,0 +1,62 @@ +import type { Metadata } from "next"; +import { redirect } from "next/navigation"; +import { auth } from "@/app/(auth)/auth"; +import Header from "@/components/header"; +import { getAgentOrNull, getOrganization, getUser } from "../../../layout"; +import { OrganizationNavigation } from "../../../navigation"; +import { AgentOnboardingWizard } from "./wizard"; + +export const metadata: Metadata = { + title: "Setup - Blink", +}; + +export default async function AgentOnboardingPage({ + params, +}: { + params: Promise<{ organization: string; agent: string }>; +}) { + const session = await auth(); + if (!session?.user?.id) { + return redirect("/login"); + } + + const { organization: organizationName, agent: agentName } = await params; + const [organization, agent] = await Promise.all([ + getOrganization(session.user.id, organizationName), + getAgentOrNull(organizationName, agentName), + ]); + const user = await getUser(session.user.id); + + // If agent exists but is not in onboarding, redirect to agent page + if (agent && agent.onboarding_state?.finished !== false) { + return redirect(`/${organizationName}/${agentName}`); + } + + const isPersonal = organization.id === user.organization_id; + + return ( +
+
+ +
+ +
+
+ ); +} diff --git a/internal/site/app/(app)/[organization]/~/onboarding/[agent]/wizard.tsx b/internal/site/app/(app)/[organization]/~/onboarding/[agent]/wizard.tsx new file mode 100644 index 00000000..e2c34b8f --- /dev/null +++ b/internal/site/app/(app)/[organization]/~/onboarding/[agent]/wizard.tsx @@ -0,0 +1,33 @@ +"use client"; + +import type { OnboardingState } from "@blink.so/api"; +import { useAPIClient } from "@/lib/api-client"; +import { type AgentInfo, WizardContent } from "../components/wizard-content"; + +export type { OnboardingState }; +export type OnboardingStep = OnboardingState["currentStep"]; + +export function AgentOnboardingWizard({ + organizationId, + organizationName, + agentName, + agent, +}: { + organizationId: string; + organizationName: string; + agentName: string; + /** Existing agent (for resuming onboarding) */ + agent?: AgentInfo; +}) { + const client = useAPIClient(); + + return ( + + ); +} diff --git a/internal/site/app/(app)/[organization]/~/onboarding/components/progress-indicator.tsx b/internal/site/app/(app)/[organization]/~/onboarding/components/progress-indicator.tsx new file mode 100644 index 00000000..80a8fcd4 --- /dev/null +++ b/internal/site/app/(app)/[organization]/~/onboarding/components/progress-indicator.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { Check } from "lucide-react"; +import { cn } from "@/lib/utils"; +import type { OnboardingStep } from "./wizard-content"; + +const stepLabels: Record = { + welcome: "Welcome", + "llm-api-keys": "LLM", + "github-setup": "GitHub", + "slack-setup": "Slack", + "web-search": "Web Search", + deploying: "Deploy", + success: "Success", +}; + +interface ProgressIndicatorProps { + steps: OnboardingStep[]; + currentStep: OnboardingStep; + onStepClick?: (step: OnboardingStep) => void; + /** When true, only the welcome step is clickable */ + welcomeOnly?: boolean; +} + +export function ProgressIndicator({ + steps, + currentStep, + onStepClick, + welcomeOnly = false, +}: ProgressIndicatorProps) { + const currentIndex = steps.indexOf(currentStep); + + return ( +
+ {steps.map((step, index) => { + const isComplete = index < currentIndex; + const isCurrent = index === currentIndex; + const isDisabled = welcomeOnly && step !== "welcome"; + + return ( +
+ + {index < steps.length - 1 && ( +
+ )} +
+ ); + })} +
+ ); +} diff --git a/internal/site/app/(app)/[organization]/~/onboarding/components/wizard-content.tsx b/internal/site/app/(app)/[organization]/~/onboarding/components/wizard-content.tsx new file mode 100644 index 00000000..592815b6 --- /dev/null +++ b/internal/site/app/(app)/[organization]/~/onboarding/components/wizard-content.tsx @@ -0,0 +1,224 @@ +"use client"; + +import type Client from "@blink.so/api"; +import type { IntegrationsState, OnboardingState } from "@blink.so/api"; +import { useRouter } from "next/navigation"; +import { useCallback, useState } from "react"; +import { DeployingStep } from "../steps/deploying"; +import { GitHubSetupStep } from "../steps/github-setup"; +import { LlmApiKeysStep } from "../steps/llm-api-keys"; +import { SlackSetupStep } from "../steps/slack-setup"; +import { SuccessStep } from "../steps/success"; +import { WebSearchStep } from "../steps/web-search"; +import { WelcomeStep } from "../steps/welcome"; +import { ProgressIndicator } from "./progress-indicator"; + +export type { OnboardingState }; +export type OnboardingStep = OnboardingState["currentStep"]; + +export interface AgentInfo { + id: string; + name: string; + onboarding_state: OnboardingState; +} + +interface WizardContentProps { + organizationId: string; + organizationName: string; + client: Client; + /** Agent with existing onboarding state (for resuming onboarding) */ + initialAgent?: AgentInfo; + /** Name for the agent to create (defaults to "blink") */ + agentName?: string; +} + +export function WizardContent({ + organizationId, + organizationName, + client, + initialAgent, + agentName = "blink", +}: WizardContentProps) { + const router = useRouter(); + + const [agentInfo, setAgentInfo] = useState( + initialAgent + ); + + const [state, setState] = useState(() => { + if (initialAgent) { + return initialAgent.onboarding_state; + } + return { currentStep: "welcome", finished: false }; + }); + + const updateOnboardingState = useCallback( + async (updates: Partial) => { + const newState = { ...state, ...updates }; + setState(newState); + + if (agentInfo) { + try { + await client.agents.updateOnboarding(agentInfo.id, newState); + } catch (error) { + // biome-ignore lint/suspicious/noConsole: useful for debugging + console.error("Failed to update onboarding state:", error); + } + } + }, + [state, agentInfo, client] + ); + + const goToStep = useCallback( + async (step: OnboardingStep) => { + await updateOnboardingState({ currentStep: step }); + }, + [updateOnboardingState] + ); + + const clearAndRedirect = useCallback(async () => { + if (agentInfo) { + const integrationsState: IntegrationsState = {}; + if (state.llm?.apiKey) integrationsState.llm = true; + if (state.github?.appId) integrationsState.github = true; + if (state.slack?.botToken) integrationsState.slack = true; + if (state.webSearch?.apiKey) integrationsState.webSearch = true; + + try { + await client.agents.updateIntegrationsState( + agentInfo.id, + integrationsState + ); + } catch (error) { + // biome-ignore lint/suspicious/noConsole: useful for debugging + console.error("Failed to update integrations state:", error); + } + + try { + await client.agents.clearOnboarding(agentInfo.id); + } catch (error) { + // biome-ignore lint/suspicious/noConsole: useful for debugging + console.error("Failed to clear onboarding state:", error); + } + } + const redirectName = agentInfo?.name ?? agentName; + router.push(`/${organizationName}/${redirectName}`); + }, [agentInfo, client, router, organizationName, agentName, state]); + + const handleAgentCreated = useCallback((agent: AgentInfo) => { + setAgentInfo(agent); + setState(agent.onboarding_state); + }, []); + + const steps: OnboardingStep[] = [ + "welcome", + "llm-api-keys", + "github-setup", + "slack-setup", + "web-search", + "deploying", + "success", + ]; + + // Effective agent ID and name - may be undefined if agent not yet created + const effectiveAgentId = agentInfo?.id; + const effectiveAgentName = agentInfo?.name ?? agentName; + + return ( +
+ goToStep(step as OnboardingStep)} + welcomeOnly={!effectiveAgentId} + /> + +
+ {state.currentStep === "welcome" && ( + goToStep("llm-api-keys")} + client={client} + organizationId={organizationId} + existingAgentId={effectiveAgentId} + onAgentCreated={handleAgentCreated} + agentName={agentName} + /> + )} + + {state.currentStep === "llm-api-keys" && ( + { + updateOnboardingState({ + llm: { ...state.llm, ...values }, + currentStep: "github-setup", + }); + }} + onSkip={() => goToStep("github-setup")} + onBack={() => goToStep("welcome")} + /> + )} + + {state.currentStep === "github-setup" && effectiveAgentId && ( + { + updateOnboardingState({ github, currentStep: "slack-setup" }); + }} + onSkip={() => goToStep("slack-setup")} + onBack={() => goToStep("llm-api-keys")} + /> + )} + + {state.currentStep === "slack-setup" && effectiveAgentId && ( + { + updateOnboardingState({ slack, currentStep: "web-search" }); + }} + onSkip={() => goToStep("web-search")} + onBack={() => goToStep("github-setup")} + /> + )} + + {state.currentStep === "web-search" && ( + { + updateOnboardingState({ + webSearch: { provider: "exa", apiKey }, + currentStep: "deploying", + }); + }} + onSkip={() => goToStep("deploying")} + onBack={() => goToStep("slack-setup")} + /> + )} + + {state.currentStep === "deploying" && effectiveAgentId && ( + { + goToStep("success"); + }} + /> + )} + + {state.currentStep === "success" && ( + + )} +
+
+ ); +} diff --git a/internal/site/app/(app)/[organization]/~/onboarding/llm-providers.ts b/internal/site/app/(app)/[organization]/~/onboarding/llm-providers.ts new file mode 100644 index 00000000..01c0855a --- /dev/null +++ b/internal/site/app/(app)/[organization]/~/onboarding/llm-providers.ts @@ -0,0 +1,46 @@ +export type AIProvider = "anthropic" | "openai" | "vercel"; + +export interface LlmProvider { + id: AIProvider; + name: string; + description: string; + placeholder: string; + helpUrl: string; + createKeyText: string; + envVarKey: string; +} + +export const LLM_PROVIDERS: LlmProvider[] = [ + { + id: "anthropic", + name: "Anthropic", + description: "Claude models", + placeholder: "sk-ant-...", + helpUrl: "https://console.anthropic.com/settings/keys", + createKeyText: "Create Anthropic API Key", + envVarKey: "ANTHROPIC_API_KEY", + }, + { + id: "openai", + name: "OpenAI", + description: "GPT models", + placeholder: "sk-...", + helpUrl: "https://platform.openai.com/api-keys", + createKeyText: "Create OpenAI API Key", + envVarKey: "OPENAI_API_KEY", + }, + { + id: "vercel", + name: "Vercel AI Gateway", + description: "Unified gateway for multiple AI providers", + placeholder: "vck_...", + helpUrl: "https://vercel.com/ai-gateway", + createKeyText: "Create Vercel AI Gateway API Key", + envVarKey: "VERCEL_AI_GATEWAY_API_KEY", + }, +]; + +export function getEnvVarKeyForProvider(provider: AIProvider): string { + const p = LLM_PROVIDERS.find((p) => p.id === provider); + return p?.envVarKey || "AI_API_KEY"; +} diff --git a/internal/site/app/(app)/[organization]/~/onboarding/steps/deploying.stories.tsx b/internal/site/app/(app)/[organization]/~/onboarding/steps/deploying.stories.tsx new file mode 100644 index 00000000..814b5bfc --- /dev/null +++ b/internal/site/app/(app)/[organization]/~/onboarding/steps/deploying.stories.tsx @@ -0,0 +1,86 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { withMockClient } from "@/lib/api-client.mock"; +import { DeployingStep } from "./deploying"; + +const noop = () => {}; + +const meta: Meta = { + title: "Onboarding/DeployingStep", + component: DeployingStep, + parameters: { + layout: "centered", + }, + args: { + organizationId: "org-123", + agentId: "agent-456", + goToStep: noop, + onSuccess: noop, + }, + decorators: [ + withMockClient(), + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +export const SummaryEmpty: Story = { + args: { + initialStatus: "summary", + }, +}; + +export const SummaryAllConfigured: Story = { + args: { + initialStatus: "summary", + llm: { + provider: "anthropic", + apiKey: "sk-ant-xxx", + }, + webSearch: { + provider: "exa", + apiKey: "exa-xxx", + }, + github: { + appName: "Scout", + appUrl: "https://github.com/apps/scout", + installUrl: "https://github.com/apps/scout/installations/new", + }, + slack: { + botToken: "xoxb-xxx", + signingSecret: "xxx", + }, + }, +}; + +export const SummaryPartial: Story = { + args: { + initialStatus: "summary", + llm: { + provider: "openai", + apiKey: "sk-xxx", + }, + github: { + appName: "MyApp", + appUrl: "https://github.com/apps/myapp", + installUrl: "https://github.com/apps/myapp/installations/new", + }, + }, +}; + +export const Deploying: Story = { + args: { + initialStatus: "deploying", + }, +}; + +export const ErrorState: Story = { + args: { + initialStatus: "error", + }, +}; diff --git a/internal/site/app/(app)/[organization]/~/onboarding/steps/deploying.tsx b/internal/site/app/(app)/[organization]/~/onboarding/steps/deploying.tsx new file mode 100644 index 00000000..4c62ef68 --- /dev/null +++ b/internal/site/app/(app)/[organization]/~/onboarding/steps/deploying.tsx @@ -0,0 +1,430 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; +import { + AlertCircle, + ArrowLeft, + Check, + Github, + Key, + Loader2, + Rocket, + Search, +} from "lucide-react"; +import type { ComponentType } from "react"; +import { useRef, useState } from "react"; +import { toast } from "sonner"; +import { OnboardingStepHeader } from "@/components/onboarding-step-header"; +import { SlackIcon } from "@/components/slack-icon"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { useAPIClient } from "@/lib/api-client"; +import type { OnboardingStep } from "../wizard"; + +type Status = "summary" | "deploying" | "error"; + +interface ConfigItem { + id: string; + label: string; + configured: boolean; + value?: string; + notConfiguredDescription: string; + icon?: LucideIcon; + IconComponent?: ComponentType<{ className?: string }>; + step: OnboardingStep; +} + +function DeployingInProgress() { + return ( +
+ + + +
+ +
+
+
+
+ ); +} + +function DeployingError({ + message, + onBack, +}: { + message?: string; + onBack: () => void; +}) { + return ( +
+ + + + {message || "Something went wrong during deployment."} Please + check the server logs. + + } + size="lg" + /> +
+ +
+
+
+
+ ); +} + +function DeployingSummary({ + configItems, + onDeploy, + onBack, + goToStep, +}: { + configItems: ConfigItem[]; + onDeploy: () => void; + onBack: () => void; + goToStep: (step: OnboardingStep) => void; +}) { + return ( +
+ + + +
+ {configItems.map((item) => { + const IconComponent = item.IconComponent; + const LucideIcon = item.icon; + return ( +
+
+ {item.configured ? ( +
+ +
+ ) : ( +
+ +
+ )} +
+
+ {IconComponent ? ( + + ) : LucideIcon ? ( + + ) : null} + {item.label} +
+ {!item.configured && ( + + {item.notConfiguredDescription} + + )} +
+
+
+ {item.configured ? ( + + {item.value} + + ) : ( + + Not configured + + )} + +
+
+ ); + })} +
+ + {configItems.some((item) => !item.configured) && ( +

+ You may still deploy the agent, but its functionality will be + limited. +

+ )} + +
+ + +
+
+
+
+ ); +} + +interface DeployingStepProps { + organizationId: string; + agentId: string; + slack?: { + botToken: string; + signingSecret: string; + }; + llm?: { + provider?: "anthropic" | "openai" | "vercel"; + apiKey?: string; + }; + webSearch?: { + provider?: "exa"; + apiKey?: string; + }; + github?: { + appName: string; + appUrl: string; + installUrl: string; + appId?: number; + clientId?: string; + clientSecret?: string; + webhookSecret?: string; + privateKey?: string; + }; + goToStep: (step: OnboardingStep) => void; + onSuccess: (agentId: string) => void; + /** Initial status for stories */ + initialStatus?: Status; +} + +const providerNames: Record = { + anthropic: "Anthropic", + openai: "OpenAI", + vercel: "Vercel AI Gateway", +}; + +export function DeployingStep({ + organizationId, + agentId, + slack, + llm, + webSearch, + github, + goToStep, + onSuccess, + initialStatus = "summary", +}: DeployingStepProps) { + const client = useAPIClient(); + const [status, setStatus] = useState(initialStatus); + const [errorMessage, setErrorMessage] = useState(null); + const hasStartedRef = useRef(false); + + const deploy = async () => { + if (hasStartedRef.current) return; + hasStartedRef.current = true; + setStatus("deploying"); + + try { + // Download the agent files + const downloadResult = await client.onboarding.downloadAgent({ + organization_id: organizationId, + }); + + // Build environment variables + const env: Array<{ key: string; value: string; secret: boolean }> = []; + + if (slack?.botToken) { + env.push({ + key: "SLACK_BOT_TOKEN", + value: slack.botToken, + secret: true, + }); + } + if (slack?.signingSecret) { + env.push({ + key: "SLACK_SIGNING_SECRET", + value: slack.signingSecret, + secret: true, + }); + } + if (webSearch?.apiKey) { + env.push({ + key: "EXA_API_KEY", + value: webSearch.apiKey, + secret: true, + }); + } + // Set the appropriate API key based on the selected provider + if (llm?.apiKey && llm?.provider) { + const envKeyMap: Record = { + anthropic: "ANTHROPIC_API_KEY", + openai: "OPENAI_API_KEY", + vercel: "AI_GATEWAY_API_KEY", + }; + env.push({ + key: envKeyMap[llm.provider], + value: llm.apiKey, + secret: true, + }); + } + if ( + github?.appId && + github.clientId && + github.clientSecret && + github.webhookSecret && + github.privateKey + ) { + env.push({ + key: "GITHUB_APP_ID", + value: github.appId.toString(), + secret: false, + }); + env.push({ + key: "GITHUB_CLIENT_ID", + value: github.clientId, + secret: false, + }); + env.push({ + key: "GITHUB_CLIENT_SECRET", + value: github.clientSecret, + secret: true, + }); + env.push({ + key: "GITHUB_WEBHOOK_SECRET", + value: github.webhookSecret, + secret: true, + }); + env.push({ + key: "GITHUB_PRIVATE_KEY", + value: github.privateKey, + secret: true, + }); + } + + // Set environment variables on the existing agent + const envResults = await Promise.allSettled( + env.map(async (variable) => { + await client.agents.env.create({ + agent_id: agentId, + key: variable.key, + value: variable.value, + secret: variable.secret, + upsert: true, + }); + }) + ); + const errEnvResults = envResults.filter( + (result) => result.status === "rejected" + ); + if (errEnvResults.length > 0) { + throw new Error( + `Failed to set environment variables: ${errEnvResults.map((result) => result.reason).join(", ")}` + ); + } + + // Deploy the agent with the downloaded files + await client.agents.deployments.create({ + agent_id: agentId, + output_files: downloadResult.output_files, + source_files: downloadResult.source_files, + entrypoint: downloadResult.entrypoint, + target: "production", + }); + + onSuccess(agentId); + } catch (error) { + setStatus("error"); + hasStartedRef.current = false; + const message = + error instanceof Error ? error.message : "Deployment failed"; + setErrorMessage(message); + toast.error(message); + } + }; + + // Configuration items for the summary + const configItems: ConfigItem[] = [ + { + id: "llm", + label: "LLM API Key", + configured: !!llm?.apiKey, + value: llm?.provider ? providerNames[llm.provider] : undefined, + notConfiguredDescription: "The agent will not be able to respond.", + icon: Key, + step: "llm-api-keys", + }, + { + id: "github", + label: "GitHub", + configured: !!github?.appName, + value: "Connected", + notConfiguredDescription: + "The agent will not be able to access GitHub repositories.", + icon: Github, + step: "github-setup", + }, + { + id: "slack", + label: "Slack", + configured: !!slack?.botToken, + value: "Connected", + notConfiguredDescription: "The agent will not be available in Slack.", + IconComponent: SlackIcon, + step: "slack-setup", + }, + { + id: "web-search", + label: "Web Search", + configured: !!webSearch?.apiKey, + value: "Exa", + notConfiguredDescription: "The agent will not be able to search the web.", + icon: Search, + step: "web-search", + }, + ]; + + if (status === "error") { + return ( + setStatus("summary")} + /> + ); + } + + if (status === "deploying") { + return ; + } + + return ( + goToStep("web-search")} + goToStep={goToStep} + /> + ); +} diff --git a/internal/site/app/(app)/[organization]/~/onboarding/steps/github-setup.tsx b/internal/site/app/(app)/[organization]/~/onboarding/steps/github-setup.tsx new file mode 100644 index 00000000..7f6ff7b2 --- /dev/null +++ b/internal/site/app/(app)/[organization]/~/onboarding/steps/github-setup.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { ArrowRight, Github } from "lucide-react"; +import { useState } from "react"; +import { + type GitHubAppCredentials, + GitHubSetupWizard, +} from "@/components/github-setup-wizard"; +import { OnboardingStepFooter } from "@/components/onboarding-step-footer"; +import { OnboardingStepHeader } from "@/components/onboarding-step-header"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +export interface GitHubSetupResult { + appName: string; + appUrl: string; + installUrl: string; + appId: number; + clientId: string; + clientSecret: string; + webhookSecret: string; + privateKey: string; +} + +interface GitHubSetupStepProps { + agentId: string; + agentName: string; + onComplete: (result: GitHubSetupResult) => void; + onSkip: () => void; + onBack?: () => void; +} + +export function GitHubSetupStep({ + agentId, + agentName, + onComplete, + onSkip, + onBack, +}: GitHubSetupStepProps) { + const [showWizard, setShowWizard] = useState(false); + + const handleWizardComplete = (result: { + appName: string; + appUrl: string; + installUrl: string; + credentials: GitHubAppCredentials; + }) => { + onComplete({ + appName: result.appName, + appUrl: result.appUrl, + installUrl: result.installUrl, + appId: result.credentials.appId, + clientId: result.credentials.clientId, + clientSecret: result.credentials.clientSecret, + webhookSecret: result.credentials.webhookSecret, + privateKey: result.credentials.privateKey, + }); + }; + + if (!showWizard) { + return ( + + + + +

+ You can also set this up later in Settings > Integrations +

+ +
+
+ ); + } + + return ( + setShowWizard(false)} + onSkip={onSkip} + /> + ); +} diff --git a/internal/site/app/(app)/[organization]/~/onboarding/steps/llm-api-keys.tsx b/internal/site/app/(app)/[organization]/~/onboarding/steps/llm-api-keys.tsx new file mode 100644 index 00000000..4ed76078 --- /dev/null +++ b/internal/site/app/(app)/[organization]/~/onboarding/steps/llm-api-keys.tsx @@ -0,0 +1,34 @@ +"use client"; + +import { + type AIProvider, + LlmApiKeysSetup, +} from "@/components/llm-api-keys-setup"; + +interface LlmApiKeysStepProps { + initialValues?: { + provider?: AIProvider; + apiKey?: string; + }; + onContinue: (values: { provider?: AIProvider; apiKey?: string }) => void; + onSkip: () => void; + onBack: () => void; +} + +export function LlmApiKeysStep({ + initialValues, + onContinue, + onSkip, + onBack, +}: LlmApiKeysStepProps) { + return ( + + onContinue({ provider: result.provider, apiKey: result.apiKey }) + } + onBack={onBack} + onSkip={onSkip} + /> + ); +} diff --git a/internal/site/app/(app)/[organization]/~/onboarding/steps/slack-setup.tsx b/internal/site/app/(app)/[organization]/~/onboarding/steps/slack-setup.tsx new file mode 100644 index 00000000..2010f8cf --- /dev/null +++ b/internal/site/app/(app)/[organization]/~/onboarding/steps/slack-setup.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { ArrowRight } from "lucide-react"; +import { useState } from "react"; +import { OnboardingStepFooter } from "@/components/onboarding-step-footer"; +import { OnboardingStepHeader } from "@/components/onboarding-step-header"; +import { SlackIcon } from "@/components/slack-icon"; +import { SlackSetupWizard } from "@/components/slack-setup-wizard"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +interface SlackSetupStepProps { + agentId: string; + agentName: string; + onComplete: (slack: { botToken: string; signingSecret: string }) => void; + onSkip: () => void; + onBack: () => void; +} + +export function SlackSetupStep({ + agentId, + agentName, + onComplete, + onSkip, + onBack, +}: SlackSetupStepProps) { + const [showWizard, setShowWizard] = useState(false); + + if (!showWizard) { + return ( + + + + +

+ You can also set this up later in Settings > Integrations +

+ +
+
+ ); + } + + return ( + setShowWizard(false)} + onSkip={onSkip} + /> + ); +} diff --git a/internal/site/app/(app)/[organization]/~/onboarding/steps/success.tsx b/internal/site/app/(app)/[organization]/~/onboarding/steps/success.tsx new file mode 100644 index 00000000..d37f137d --- /dev/null +++ b/internal/site/app/(app)/[organization]/~/onboarding/steps/success.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { ArrowRight, CheckCircle2 } from "lucide-react"; +import { OnboardingStepHeader } from "@/components/onboarding-step-header"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +interface SuccessStepProps { + agentName: string; + onFinish: () => void; +} + +export function SuccessStep({ agentName, onFinish }: SuccessStepProps) { + return ( +
+ + + + Your agent {agentName} has been successfully + deployed and is ready to use. + + } + size="lg" + /> +
+

Next Steps

+
    +
  • Start a chat with your agent to test it out
  • +
  • Configure additional environment variables in settings
  • +
  • Set up webhooks for GitHub and Slack integrations
  • +
+
+ + +
+
+
+ ); +} diff --git a/internal/site/app/(app)/[organization]/~/onboarding/steps/web-search.tsx b/internal/site/app/(app)/[organization]/~/onboarding/steps/web-search.tsx new file mode 100644 index 00000000..7fd933b5 --- /dev/null +++ b/internal/site/app/(app)/[organization]/~/onboarding/steps/web-search.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { WebSearchSetup } from "@/components/web-search-setup"; + +interface WebSearchStepProps { + initialValue?: string; + onContinue: (exaApiKey?: string) => void; + onSkip: () => void; + onBack: () => void; +} + +export function WebSearchStep({ + initialValue, + onContinue, + onSkip, + onBack, +}: WebSearchStepProps) { + return ( + onContinue(result.exaApiKey)} + onBack={onBack} + onSkip={onSkip} + /> + ); +} diff --git a/internal/site/app/(app)/[organization]/~/onboarding/steps/welcome.tsx b/internal/site/app/(app)/[organization]/~/onboarding/steps/welcome.tsx new file mode 100644 index 00000000..2b700456 --- /dev/null +++ b/internal/site/app/(app)/[organization]/~/onboarding/steps/welcome.tsx @@ -0,0 +1,138 @@ +"use client"; + +import type Client from "@blink.so/api"; +import type { OnboardingState } from "@blink.so/api"; +import { Bot, Github, Globe, Loader2, MessageSquare } from "lucide-react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { OnboardingStepHeader } from "@/components/onboarding-step-header"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; + +interface AgentInfo { + id: string; + name: string; + onboarding_state: OnboardingState; +} + +interface WelcomeStepProps { + onContinue: () => void; + client: Client; + organizationId: string; + existingAgentId?: string; + onAgentCreated: (agent: AgentInfo) => void; + /** Name for the agent to create (defaults to "blink") */ + agentName?: string; +} + +export function WelcomeStep({ + onContinue, + client, + organizationId, + existingAgentId, + onAgentCreated, + agentName = "blink", +}: WelcomeStepProps) { + const [loading, setLoading] = useState(false); + + const handleGetStarted = async () => { + // If agent already exists, just continue + if (existingAgentId) { + onContinue(); + return; + } + + setLoading(true); + try { + // Create agent + const initialOnboardingState: OnboardingState = { + currentStep: "welcome", + finished: false, + }; + + const agent = await client.agents.create({ + organization_id: organizationId, + name: agentName, + onboarding_state: initialOnboardingState, + }); + + if (!agent.onboarding_state) { + throw new Error( + "Onboarding state on new agent not found - this should never happen" + ); + } + + onAgentCreated({ + id: agent.id, + name: agent.name, + onboarding_state: agent.onboarding_state, + }); + + onContinue(); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to create agent" + ); + } finally { + setLoading(false); + } + }; + + return ( + + + +
+
+ +
+
GitHub Integration
+
+ Review PRs, respond to issues, and receive webhooks +
+
+
+
+ +
+
Slack Integration
+
+ Chat with your agent directly in Slack +
+
+
+
+ +
+
Web Search
+
+ Search the web for up-to-date information +
+
+
+
+ + +
+
+ ); +} diff --git a/internal/site/app/(app)/[organization]/~/onboarding/wizard.stories.tsx b/internal/site/app/(app)/[organization]/~/onboarding/wizard.stories.tsx new file mode 100644 index 00000000..4c1e553f --- /dev/null +++ b/internal/site/app/(app)/[organization]/~/onboarding/wizard.stories.tsx @@ -0,0 +1,342 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { type MockedClient, withMockClient } from "@/lib/api-client.mock"; +import { type AgentInfo, OnboardingWizard } from "./wizard"; + +const TEST_ORGANIZATION_ID = "org-123"; +const TEST_ORGANIZATION_NAME = "test-org"; +const TEST_AGENT_ID = "agent-456"; +const TEST_FILE_ID = "file-789"; +const TEST_WEBHOOK_URL = "https://api.blink.so/webhooks/slack/test-webhook-id"; +const TEST_GITHUB_SESSION_ID = "github-session-123"; +const TEST_GITHUB_MANIFEST_URL = + "https://github.com/settings/apps/new?manifest=..."; + +// Track state across mocked API calls +const mockState = { + pollCount: 0, + githubPollCount: 0, +}; + +function configureMockClient( + client: MockedClient, + options?: { hangDeployment?: boolean } +) { + const { hangDeployment = false } = options || {}; + + // Download agent + client.onboarding.downloadAgent.mockResolvedValue({ + output_files: [{ path: "main.js", id: TEST_FILE_ID }], + source_files: [{ path: "index.ts", id: "src-file-123" }], + entrypoint: "main.js", + }); + + // Create agent + client.agents.create.mockResolvedValue({ + id: TEST_AGENT_ID, + organization_id: TEST_ORGANIZATION_ID, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + created_by: "user-123", + name: "blink", + description: "AI agent with GitHub, Slack, and web search integrations", + avatar_url: null, + visibility: "organization", + active_deployment_id: null, + pinned: false, + request_url: null, + chat_expire_ttl: null, + onboarding_state: { currentStep: "welcome" }, + integrations_state: null, + }); + + // Validate Slack token + client.agents.setupSlack.validateToken.mockResolvedValue({ + valid: true, + }); + + // GitHub setup + client.agents.setupGitHub.startCreation.mockImplementation(() => { + mockState.githubPollCount = 0; + return Promise.resolve({ + manifest_url: TEST_GITHUB_MANIFEST_URL, + manifest: "{}", + github_url: TEST_GITHUB_MANIFEST_URL, + session_id: TEST_GITHUB_SESSION_ID, + }); + }); + + client.agents.setupGitHub.getCreationStatus.mockImplementation(() => { + mockState.githubPollCount++; + const completed = mockState.githubPollCount >= 3; + return Promise.resolve({ + status: completed ? "completed" : "pending", + app_data: completed + ? { + id: 12345, + name: "Scout", + html_url: "https://github.com/apps/scout", + slug: "scout", + } + : undefined, + credentials: completed + ? { + app_id: 12345, + client_id: "test-client-id", + client_secret: "test-client-secret", + webhook_secret: "test-webhook-secret", + private_key: btoa("test-private-key"), + } + : undefined, + }); + }); + + client.agents.setupGitHub.completeCreation.mockResolvedValue({ + success: true, + app_name: "Scout", + app_url: "https://github.com/apps/scout", + install_url: "https://github.com/apps/scout/installations/new", + }); + + // Slack setup + client.agents.setupSlack.getWebhookUrl.mockResolvedValue({ + webhook_url: TEST_WEBHOOK_URL, + }); + + client.agents.setupSlack.startVerification.mockImplementation(() => { + mockState.pollCount = 0; + return Promise.resolve({ webhook_url: TEST_WEBHOOK_URL }); + }); + + client.agents.setupSlack.getVerificationStatus.mockImplementation(() => { + mockState.pollCount++; + const dmReceived = mockState.pollCount >= 3; + return Promise.resolve({ + active: true, + started_at: new Date().toISOString(), + last_event_at: + mockState.pollCount > 1 ? new Date().toISOString() : undefined, + dm_received: dmReceived, + dm_channel: dmReceived ? "D12345678" : undefined, + signature_failed: false, + }); + }); + + client.agents.setupSlack.completeVerification.mockResolvedValue({ + success: true, + bot_name: "Scout Bot", + }); + + client.agents.setupSlack.cancelVerification.mockResolvedValue(undefined); + + // Environment variables + client.agents.env.create.mockResolvedValue({ + id: "env-123", + created_at: new Date(), + updated_at: new Date(), + created_by: "user-123", + updated_by: "user-123", + key: "TEST_KEY", + value: "test-value", + secret: false, + target: ["preview", "production"], + }); + + // Deployments + if (hangDeployment) { + client.agents.deployments.create.mockImplementation( + () => new Promise(() => {}) + ); + } else { + client.agents.deployments.create.mockResolvedValue({ + id: "deployment-123", + agent_id: TEST_AGENT_ID, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + created_by: "user-123", + created_from: "cli", + status: "success", + number: 1, + source_files: [{ path: "index.ts", id: "src-file-123" }], + output_files: [{ path: "main.js", id: TEST_FILE_ID }], + target: "production", + error_message: null, + user_message: null, + platform: "lambda", + platform_memory_mb: 512, + platform_region: null, + }); + } + + // Update onboarding + client.agents.updateOnboarding.mockResolvedValue({ + id: TEST_AGENT_ID, + organization_id: TEST_ORGANIZATION_ID, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + created_by: "user-123", + name: "blink", + description: null, + avatar_url: null, + visibility: "organization", + active_deployment_id: null, + pinned: false, + request_url: null, + chat_expire_ttl: null, + onboarding_state: null, + integrations_state: null, + }); +} + +const meta: Meta = { + title: "Onboarding/OnboardingWizard", + component: OnboardingWizard, + parameters: { + layout: "fullscreen", + nextjs: { + appDirectory: true, + navigation: { + push: () => {}, + }, + }, + }, + args: { + organizationId: TEST_ORGANIZATION_ID, + organizationName: TEST_ORGANIZATION_NAME, + }, + decorators: [ + withMockClient((client) => configureMockClient(client)), + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +// Base agent for steps that need an existing agent +const baseAgent: AgentInfo = { + id: TEST_AGENT_ID, + name: "blink", + onboarding_state: { currentStep: "welcome", finished: false }, +}; + +export const FullFlow: Story = {}; +FullFlow.storyName = "Full Flow (from Welcome)"; + +export const Step1_Welcome: Story = {}; +Step1_Welcome.storyName = "Step 1: Welcome"; + +export const Step2_LlmApiKeys: Story = { + args: { + agent: { + ...baseAgent, + onboarding_state: { currentStep: "llm-api-keys", finished: false }, + }, + }, +}; +Step2_LlmApiKeys.storyName = "Step 2: LLM API Keys"; + +export const Step3_GitHubSetup: Story = { + args: { + agent: { + ...baseAgent, + onboarding_state: { currentStep: "github-setup", finished: false }, + }, + }, +}; +Step3_GitHubSetup.storyName = "Step 3: GitHub Setup"; + +export const Step4_SlackSetup: Story = { + args: { + agent: { + ...baseAgent, + onboarding_state: { currentStep: "slack-setup", finished: false }, + }, + }, +}; +Step4_SlackSetup.storyName = "Step 4: Slack Setup"; + +export const Step5_WebSearch: Story = { + args: { + agent: { + ...baseAgent, + onboarding_state: { currentStep: "web-search", finished: false }, + }, + }, +}; +Step5_WebSearch.storyName = "Step 5: Web Search"; + +export const Step6_Summary_Empty: Story = { + args: { + agent: { + ...baseAgent, + onboarding_state: { currentStep: "deploying", finished: false }, + }, + }, +}; +Step6_Summary_Empty.storyName = "Step 6: Summary (Nothing Configured)"; + +export const Step6_Summary_AllConfigured: Story = { + args: { + agent: { + ...baseAgent, + onboarding_state: { + currentStep: "deploying", + finished: false, + llm: { + provider: "anthropic", + apiKey: "sk-ant-xxx", + }, + webSearch: { + provider: "exa", + apiKey: "exa-xxx", + }, + github: { + appName: "Scout", + appUrl: "https://github.com/apps/scout", + installUrl: "https://github.com/apps/scout/installations/new", + }, + slack: { + botToken: "xoxb-xxx", + signingSecret: "xxx", + }, + }, + }, + }, +}; +Step6_Summary_AllConfigured.storyName = "Step 6: Summary (All Configured)"; + +export const Step6_Summary_Partial: Story = { + args: { + agent: { + ...baseAgent, + onboarding_state: { + currentStep: "deploying", + finished: false, + llm: { + provider: "openai", + apiKey: "sk-xxx", + }, + github: { + appName: "MyApp", + appUrl: "https://github.com/apps/myapp", + installUrl: "https://github.com/apps/myapp/installations/new", + }, + }, + }, + }, +}; +Step6_Summary_Partial.storyName = "Step 6: Summary (Partial Config)"; + +export const Step7_Success: Story = { + args: { + agent: { + ...baseAgent, + onboarding_state: { currentStep: "success", finished: false }, + }, + }, +}; +Step7_Success.storyName = "Step 7: Success"; diff --git a/internal/site/app/(app)/[organization]/~/onboarding/wizard.tsx b/internal/site/app/(app)/[organization]/~/onboarding/wizard.tsx new file mode 100644 index 00000000..2b665c7e --- /dev/null +++ b/internal/site/app/(app)/[organization]/~/onboarding/wizard.tsx @@ -0,0 +1,30 @@ +"use client"; + +import type { OnboardingState } from "@blink.so/api"; +import { useAPIClient } from "@/lib/api-client"; +import { type AgentInfo, WizardContent } from "./components/wizard-content"; + +export type { AgentInfo, OnboardingState }; +export type OnboardingStep = OnboardingState["currentStep"]; + +export function OnboardingWizard({ + organizationId, + organizationName, + agent, +}: { + organizationId: string; + organizationName: string; + /** Agent with existing onboarding state (for resuming onboarding) */ + agent?: AgentInfo; +}) { + const client = useAPIClient(); + + return ( + + ); +} diff --git a/internal/site/components/app-name-words.ts b/internal/site/components/app-name-words.ts new file mode 100644 index 00000000..c531fb38 --- /dev/null +++ b/internal/site/components/app-name-words.ts @@ -0,0 +1,426 @@ +export const ADJECTIVES = [ + "agile", + "amber", + "arctic", + "azure", + "bold", + "brave", + "bright", + "calm", + "chrome", + "civic", + "clear", + "clever", + "coral", + "cosmic", + "crisp", + "crystal", + "cyber", + "dark", + "dawn", + "deep", + "delta", + "dusk", + "eager", + "early", + "echo", + "elite", + "ember", + "epic", + "fair", + "fast", + "fern", + "fiery", + "fleet", + "focal", + "fresh", + "frost", + "gentle", + "gilt", + "glad", + "gold", + "grand", + "green", + "grey", + "hale", + "happy", + "hazy", + "high", + "icy", + "idle", + "iron", + "ivory", + "jade", + "jazzy", + "keen", + "kind", + "lemon", + "light", + "lime", + "lively", + "lunar", + "magic", + "merry", + "mild", + "mint", + "misty", + "mocha", + "navy", + "neat", + "new", + "next", + "nice", + "noble", + "north", + "nova", + "oaken", + "olive", + "onyx", + "opal", + "open", + "pale", + "peach", + "pearl", + "pine", + "pink", + "plain", + "plum", + "polar", + "prime", + "pure", + "quick", + "quiet", + "rapid", + "rare", + "red", + "rich", + "ripe", + "rose", + "royal", + "ruby", + "rusty", + "safe", + "sage", + "salty", + "sandy", + "sharp", + "shiny", + "silent", + "silk", + "silver", + "sleek", + "slim", + "smart", + "smoky", + "snowy", + "soft", + "solar", + "solid", + "sonic", + "south", + "space", + "spicy", + "spring", + "stark", + "steel", + "stone", + "storm", + "sunny", + "super", + "sweet", + "swift", + "tall", + "teal", + "tidy", + "tiny", + "topaz", + "true", + "ultra", + "urban", + "vast", + "velvet", + "vital", + "vivid", + "warm", + "west", + "white", + "wild", + "windy", + "wise", + "young", + "zesty", + "zinc", +]; + +export const NOUNS = [ + "anchor", + "anvil", + "apex", + "arch", + "arrow", + "atlas", + "badge", + "basin", + "bay", + "beacon", + "beam", + "bear", + "bell", + "berry", + "birch", + "bird", + "blade", + "blaze", + "bloom", + "bolt", + "branch", + "breeze", + "brick", + "bridge", + "brook", + "bush", + "cairn", + "canopy", + "canyon", + "cape", + "cedar", + "chain", + "chapel", + "chart", + "chime", + "cliff", + "cloud", + "coast", + "comet", + "cone", + "coral", + "core", + "cosmos", + "cove", + "crane", + "creek", + "crest", + "crown", + "crystal", + "cube", + "cypress", + "dale", + "dawn", + "delta", + "den", + "dew", + "dome", + "door", + "dove", + "drift", + "drum", + "dune", + "dust", + "eagle", + "edge", + "elm", + "ember", + "fable", + "falcon", + "falls", + "feather", + "fern", + "field", + "finch", + "fjord", + "flame", + "flare", + "flash", + "flock", + "flora", + "flower", + "fog", + "ford", + "forge", + "fork", + "fort", + "fox", + "frost", + "gale", + "garden", + "gate", + "gem", + "glacier", + "glade", + "glen", + "globe", + "glow", + "gorge", + "grain", + "grass", + "grove", + "gulf", + "harbor", + "haven", + "hawk", + "haze", + "heath", + "hedge", + "helm", + "heron", + "hill", + "hive", + "hollow", + "hood", + "horn", + "inlet", + "iris", + "island", + "isle", + "ivy", + "jade", + "jasper", + "jetty", + "jewel", + "keep", + "kernel", + "key", + "kite", + "knoll", + "lake", + "lantern", + "lark", + "laurel", + "leaf", + "ledge", + "light", + "lily", + "link", + "lotus", + "lynx", + "manor", + "maple", + "marsh", + "meadow", + "mesa", + "mill", + "mist", + "moon", + "moss", + "moth", + "mount", + "mural", + "nest", + "nexus", + "north", + "oak", + "oasis", + "ocean", + "orbit", + "orchid", + "osprey", + "owl", + "palm", + "pass", + "path", + "peak", + "pearl", + "pebble", + "pier", + "pine", + "pixel", + "plain", + "planet", + "plaza", + "plum", + "point", + "pond", + "pool", + "poplar", + "port", + "prairie", + "prism", + "pulse", + "quartz", + "quest", + "quill", + "rain", + "range", + "rapids", + "raven", + "ray", + "reach", + "reef", + "ridge", + "rift", + "rim", + "ring", + "river", + "road", + "rock", + "root", + "rose", + "sage", + "sail", + "sand", + "scale", + "sea", + "seed", + "shade", + "shadow", + "shell", + "shield", + "shore", + "shrub", + "sky", + "slate", + "slope", + "snow", + "soil", + "south", + "space", + "spark", + "spire", + "spray", + "spring", + "spruce", + "spur", + "star", + "steam", + "stem", + "stone", + "storm", + "strait", + "stream", + "summit", + "sun", + "surf", + "swamp", + "swan", + "swift", + "temple", + "terra", + "thorn", + "thunder", + "tide", + "timber", + "tower", + "trail", + "tree", + "tulip", + "tundra", + "vale", + "valley", + "vault", + "veil", + "vertex", + "vine", + "void", + "wake", + "wave", + "well", + "west", + "willow", + "wind", + "wing", + "wood", + "wren", + "yard", + "zenith", + "zephyr", +]; diff --git a/internal/site/components/github-setup-wizard.stories.tsx b/internal/site/components/github-setup-wizard.stories.tsx new file mode 100644 index 00000000..7db8aa14 --- /dev/null +++ b/internal/site/components/github-setup-wizard.stories.tsx @@ -0,0 +1,231 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { fn } from "storybook/test"; +import { type MockedClient, withMockClient } from "@/lib/api-client.mock"; +import { + GitHubSetupWizard, + type GitHubSetupWizardInitialState, +} from "./github-setup-wizard"; + +const TEST_AGENT_ID = "test-agent-123"; +const TEST_GITHUB_URL = "https://github.com/settings/apps/new"; +const TEST_MANIFEST = JSON.stringify({ + name: "Test App", + url: "https://blink.so", +}); +const TEST_SESSION_ID = "test-session-456"; + +interface MockOptions { + creationStatus?: + | "pending" + | "app_created" + | "completed" + | "failed" + | "expired"; + completeSuccess?: boolean; + appData?: { + id: number; + name: string; + html_url: string; + slug: string; + }; +} + +function configureMockClient(client: MockedClient, options?: MockOptions) { + const { + creationStatus = "pending", + completeSuccess = true, + appData = { + id: 12345, + name: "Test GitHub App", + html_url: "https://github.com/apps/test-github-app", + slug: "test-github-app", + }, + } = options ?? {}; + + client.agents.setupGitHub.startCreation.mockResolvedValue({ + manifest: TEST_MANIFEST, + github_url: TEST_GITHUB_URL, + session_id: TEST_SESSION_ID, + }); + + client.agents.setupGitHub.getCreationStatus.mockResolvedValue({ + status: creationStatus, + app_data: + creationStatus === "completed" || creationStatus === "app_created" + ? appData + : undefined, + credentials: + creationStatus === "completed" + ? { + app_id: appData.id, + client_id: "Iv1.test123", + client_secret: "test-client-secret", + webhook_secret: "test-webhook-secret", + private_key: btoa("test-private-key"), + } + : undefined, + error: creationStatus === "failed" ? "Something went wrong" : undefined, + }); + + client.agents.setupGitHub.completeCreation.mockResolvedValue({ + success: completeSuccess, + app_name: appData.name, + app_url: appData.html_url, + install_url: `${appData.html_url}/installations/new`, + }); +} + +const meta: Meta = { + title: "Components/GitHubSetupWizard", + component: GitHubSetupWizard, + parameters: { + layout: "centered", + }, + args: { + agentId: TEST_AGENT_ID, + agentName: "Scout", + onComplete: fn(), + onBack: fn(), + onSkip: fn(), + }, + render: (args) => ( +
+ +
+ ), + decorators: [withMockClient((client) => configureMockClient(client))], +}; + +export default meta; +type Story = StoryObj; + +const withInitialState = (state: GitHubSetupWizardInitialState): Story => ({ + args: { + initialState: state, + }, +}); + +export const Initial: Story = withInitialState({}); +Initial.storyName = "Initial"; + +export const WithOrganization: Story = withInitialState({ + organization: "my-github-org", +}); +WithOrganization.storyName = "With Organization"; + +export const WaitingForAppCreation: Story = withInitialState({ + hasOpenedGitHub: true, + sessionId: TEST_SESSION_ID, + manifestData: { manifest: TEST_MANIFEST, github_url: TEST_GITHUB_URL }, + creationStatus: "pending", +}); +WaitingForAppCreation.storyName = "Waiting for App Creation"; + +export const WaitingForInstallation: Story = { + ...withInitialState({ + hasOpenedGitHub: true, + sessionId: TEST_SESSION_ID, + manifestData: { manifest: TEST_MANIFEST, github_url: TEST_GITHUB_URL }, + creationStatus: "app_created", + appData: { + id: 12345, + name: "my-org-Scout", + html_url: "https://github.com/apps/my-org-scout", + slug: "my-org-scout", + }, + }), + decorators: [ + withMockClient((client) => + configureMockClient(client, { creationStatus: "app_created" }) + ), + ], +}; +WaitingForInstallation.storyName = "Waiting for Installation"; + +export const Completed: Story = { + ...withInitialState({ + hasOpenedGitHub: true, + sessionId: TEST_SESSION_ID, + manifestData: { manifest: TEST_MANIFEST, github_url: TEST_GITHUB_URL }, + creationStatus: "completed", + appData: { + id: 12345, + name: "my-org-Scout", + html_url: "https://github.com/apps/my-org-scout", + slug: "my-org-scout", + }, + credentials: { + appId: 12345, + clientId: "Iv1.test123", + clientSecret: "test-client-secret", + webhookSecret: "test-webhook-secret", + privateKey: btoa("test-private-key"), + }, + }), + decorators: [ + withMockClient((client) => + configureMockClient(client, { creationStatus: "completed" }) + ), + ], +}; +Completed.storyName = "Completed"; + +export const Failed: Story = { + ...withInitialState({ + hasOpenedGitHub: true, + sessionId: TEST_SESSION_ID, + manifestData: { manifest: TEST_MANIFEST, github_url: TEST_GITHUB_URL }, + creationStatus: "failed", + error: "GitHub API error: 422 Unprocessable Entity", + }), + decorators: [ + withMockClient((client) => + configureMockClient(client, { creationStatus: "failed" }) + ), + ], +}; +Failed.storyName = "Failed"; + +export const Expired: Story = { + ...withInitialState({ + hasOpenedGitHub: true, + sessionId: TEST_SESSION_ID, + manifestData: { manifest: TEST_MANIFEST, github_url: TEST_GITHUB_URL }, + creationStatus: "expired", + }), + decorators: [ + withMockClient((client) => + configureMockClient(client, { creationStatus: "expired" }) + ), + ], +}; +Expired.storyName = "Expired"; + +export const Completing: Story = { + ...withInitialState({ + hasOpenedGitHub: true, + sessionId: TEST_SESSION_ID, + manifestData: { manifest: TEST_MANIFEST, github_url: TEST_GITHUB_URL }, + creationStatus: "completed", + appData: { + id: 12345, + name: "my-org-Scout", + html_url: "https://github.com/apps/my-org-scout", + slug: "my-org-scout", + }, + credentials: { + appId: 12345, + clientId: "Iv1.test123", + clientSecret: "test-client-secret", + webhookSecret: "test-webhook-secret", + privateKey: btoa("test-private-key"), + }, + completing: true, + }), + decorators: [ + withMockClient((client) => + configureMockClient(client, { creationStatus: "completed" }) + ), + ], +}; +Completing.storyName = "Completing"; diff --git a/internal/site/components/github-setup-wizard.tsx b/internal/site/components/github-setup-wizard.tsx new file mode 100644 index 00000000..44e4eb54 --- /dev/null +++ b/internal/site/components/github-setup-wizard.tsx @@ -0,0 +1,496 @@ +"use client"; + +import { AlertCircle, ExternalLink, Loader2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { OnboardingStepFooter } from "@/components/onboarding-step-footer"; +import { OnboardingStepHeader } from "@/components/onboarding-step-header"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { SetupStep } from "@/components/ui/setup-step"; +import { useAPIClient } from "@/lib/api-client"; +import { ADJECTIVES, NOUNS } from "./app-name-words"; +import { GitHubIcon } from "./icons/github"; + +function generateRandomAppName(agentName: string): string { + const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]; + const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]; + const randomNumber = Math.floor(Math.random() * 900) + 100; // 100-999 + return `${agentName}-${adjective}-${noun}-${randomNumber}`; +} + +export interface GitHubSetupWizardInitialState { + organization?: string; + sessionId?: string; + hasOpenedGitHub?: boolean; + manifestData?: { + manifest: string; + github_url: string; + }; + creationStatus?: + | "pending" + | "app_created" + | "completed" + | "failed" + | "expired"; + appData?: { + id: number; + name: string; + html_url: string; + slug: string; + }; + credentials?: GitHubAppCredentials; + completing?: boolean; + error?: string; +} + +export interface GitHubAppCredentials { + appId: number; + clientId: string; + clientSecret: string; + webhookSecret: string; + privateKey: string; // base64-encoded PEM +} + +interface GitHubSetupWizardProps { + agentId: string; + agentName: string; + onComplete: (result: { + appName: string; + appUrl: string; + installUrl: string; + credentials: GitHubAppCredentials; + }) => void; + onBack?: () => void; + onSkip?: () => void; + initialState?: GitHubSetupWizardInitialState; +} + +export function GitHubSetupWizard({ + agentId, + agentName, + onComplete, + onBack, + onSkip, + initialState, +}: GitHubSetupWizardProps) { + const client = useAPIClient(); + + // Generate app name (user can change on GitHub) + const [appName] = useState(() => generateRandomAppName(agentName)); + + // Form state + const [githubOrganization, setGithubOrganization] = useState( + initialState?.organization ?? "" + ); + + // Session state + const [sessionId, setSessionId] = useState( + initialState?.sessionId ?? null + ); + const [manifestData, setManifestData] = useState<{ + manifest: string; + github_url: string; + } | null>(initialState?.manifestData ?? null); + const [hasOpenedGitHub, setHasOpenedGitHub] = useState( + initialState?.hasOpenedGitHub ?? false + ); + + // Creation status + const [creationStatus, setCreationStatus] = useState< + "pending" | "app_created" | "completed" | "failed" | "expired" | null + >(initialState?.creationStatus ?? null); + const [appData, setAppData] = useState<{ + id: number; + name: string; + html_url: string; + slug: string; + } | null>(initialState?.appData ?? null); + const [credentials, setCredentials] = useState( + initialState?.credentials ?? null + ); + const [error, setError] = useState( + initialState?.error ?? null + ); + + // Completion state + const [completing, setCompleting] = useState( + initialState?.completing ?? false + ); + const [starting, setStarting] = useState(false); + + const pollingRef = useRef(null); + + // Determine current step + // Step 1 is completed only when user has clicked "Create GitHub App" (step 2 button) + const currentStep = useMemo(() => { + if (!hasOpenedGitHub) return 2; + if (creationStatus === "pending") return 3; + if (creationStatus === "app_created") return 3; // Still waiting for installation + if (creationStatus === "completed") return 4; + return 2; + }, [hasOpenedGitHub, creationStatus]); + + // Submit manifest to GitHub via form POST (opens in new tab) + const submitManifestForm = useCallback( + (githubUrl: string, manifest: string) => { + const form = document.createElement("form"); + form.method = "POST"; + form.action = githubUrl; + form.target = "_blank"; + + const input = document.createElement("input"); + input.type = "hidden"; + input.name = "manifest"; + input.value = manifest; + + form.appendChild(input); + document.body.appendChild(form); + form.submit(); + document.body.removeChild(form); + }, + [] + ); + + // Track the organization used for the current session + const [sessionOrganization, setSessionOrganization] = useState( + null + ); + + // Start creation flow and open GitHub in a new tab via form submission + const startCreation = useCallback(async () => { + if (starting) return; + setStarting(true); + setError(null); + + const orgToUse = githubOrganization.trim() || undefined; + + try { + const result = await client.agents.setupGitHub.startCreation(agentId, { + name: appName, + organization: orgToUse, + }); + setSessionId(result.session_id); + setSessionOrganization(githubOrganization); + setManifestData({ + manifest: result.manifest, + github_url: result.github_url, + }); + setCreationStatus("pending"); + + // Submit form to GitHub - this opens in a new tab and POSTs the manifest + submitManifestForm(result.github_url, result.manifest); + setHasOpenedGitHub(true); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to start creation" + ); + setError( + error instanceof Error ? error.message : "Failed to start creation" + ); + } finally { + setStarting(false); + } + }, [ + client, + agentId, + appName, + githubOrganization, + starting, + submitManifestForm, + ]); + + // Poll for creation status + const pollCreationStatus = useCallback(async () => { + if (!sessionId) return null; + + try { + const status = await client.agents.setupGitHub.getCreationStatus( + agentId, + sessionId + ); + setCreationStatus(status.status); + if (status.app_data) { + setAppData(status.app_data); + } + // Store credentials when status is completed + if (status.credentials) { + setCredentials({ + appId: status.credentials.app_id, + clientId: status.credentials.client_id, + clientSecret: status.credentials.client_secret, + webhookSecret: status.credentials.webhook_secret, + privateKey: status.credentials.private_key, + }); + } + if (status.error) { + setError(status.error); + } + return status; + } catch (error) { + // biome-ignore lint/suspicious/noConsole: useful for debugging polling failures + console.error("Failed to poll creation status:", error); + return null; + } + }, [client, agentId, sessionId]); + + // Start polling when in pending or app_created state + useEffect(() => { + if ( + (creationStatus === "pending" || creationStatus === "app_created") && + sessionId + ) { + const poll = async () => { + const status = await pollCreationStatus(); + if ( + status?.status === "completed" || + status?.status === "failed" || + status?.status === "expired" + ) { + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + } + }; + poll(); + pollingRef.current = setInterval(poll, 2000); + + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + }; + } + }, [creationStatus, sessionId, pollCreationStatus]); + + // Complete setup + const completeSetup = useCallback(async () => { + if (!sessionId || !credentials || !appData) return; + setCompleting(true); + + try { + // Call completeCreation to clear the server-side setup state + const result = await client.agents.setupGitHub.completeCreation(agentId, { + session_id: sessionId, + }); + + if (result.success) { + // Pass credentials to onComplete so the caller can save them as env vars + onComplete({ + appName: appData.name, + appUrl: appData.html_url, + installUrl: `${appData.html_url}/installations/new`, + credentials, + }); + } else { + toast.error("Failed to complete setup"); + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to complete setup" + ); + } finally { + setCompleting(false); + } + }, [client, agentId, sessionId, credentials, appData, onComplete]); + + // Reset to try again + const handleRetry = () => { + setSessionId(null); + setSessionOrganization(null); + setManifestData(null); + setHasOpenedGitHub(false); + setCreationStatus(null); + setAppData(null); + setCredentials(null); + setError(null); + }; + + return ( + + + + {/* Step 1: Organization (optional) - completed when Create button is clicked */} + + GitHub Organization (optional) + + } + > + setGithubOrganization(e.target.value)} + disabled={creationStatus === "completed"} + /> +

+ Enter a GitHub organization name to create the app under, or leave + blank for a personal app. +

+
+ + {/* Step 2: Create GitHub App */} + 2} + headline="Create and install the GitHub App" + > +

+ Click the button to open GitHub. You'll create the app and then + install it on your repositories. +

+ +
+ + {/* Step 3: Waiting for app creation */} + {creationStatus === "pending" && ( + +
+ + Complete the app creation on GitHub +
+
+ )} + + {/* Step 3: Waiting for installation */} + {creationStatus === "app_created" && ( + +
+ + + App created! Now{" "} + + install it to your repositories + + {" "} + and return here + +
+
+ )} + + {/* Step 3/4: Error state */} + {(creationStatus === "failed" || creationStatus === "expired") && ( + + +
+ } + headline={ +

+ {creationStatus === "expired" + ? "Session expired" + : "Creation failed"} +

+ } + > + {error &&

{error}

} + + + )} + + {/* Step 3: Success */} + {creationStatus === "completed" && appData && ( + + GitHub App created and installed! +

+ } + > +

+ Click Continue below to proceed. +

+ +
+ )} + + + + + ); +} diff --git a/internal/site/components/icons/github.tsx b/internal/site/components/icons/github.tsx new file mode 100644 index 00000000..2a276e04 --- /dev/null +++ b/internal/site/components/icons/github.tsx @@ -0,0 +1,13 @@ +export function GitHubIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/internal/site/components/llm-api-keys-setup.tsx b/internal/site/components/llm-api-keys-setup.tsx new file mode 100644 index 00000000..bfdf3724 --- /dev/null +++ b/internal/site/components/llm-api-keys-setup.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { ExternalLink, Key } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + type AIProvider, + getEnvVarKeyForProvider, + LLM_PROVIDERS, +} from "@/app/(app)/[organization]/~/onboarding/llm-providers"; +import { OnboardingStepFooter } from "@/components/onboarding-step-footer"; +import { OnboardingStepHeader } from "@/components/onboarding-step-header"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { SetupStep } from "@/components/ui/setup-step"; +import { cn } from "@/lib/utils"; + +export type { AIProvider }; +export { getEnvVarKeyForProvider }; + +export interface LlmApiKeysResult { + provider: AIProvider; + apiKey: string; +} + +export interface LlmApiKeysSetupProps { + initialValues?: { + provider?: AIProvider; + apiKey?: string; + }; + onComplete: (result: LlmApiKeysResult) => void; + onBack?: () => void; + onSkip?: () => void; + completing?: boolean; +} + +export function LlmApiKeysSetup({ + initialValues, + onComplete, + onBack, + onSkip, + completing, +}: LlmApiKeysSetupProps) { + const [aiProvider, setAIProvider] = useState( + initialValues?.provider + ); + const [aiApiKey, setAIApiKey] = useState(initialValues?.apiKey || ""); + const [hasOpenedKeyPage, setHasOpenedKeyPage] = useState(false); + + const selectedProvider = LLM_PROVIDERS.find((p) => p.id === aiProvider); + + const currentStep = useMemo(() => { + if (!aiProvider) return 1; + if (!hasOpenedKeyPage) return 2; + return 3; + }, [aiProvider, hasOpenedKeyPage]); + + const handleComplete = () => { + if (aiProvider && aiApiKey.trim()) { + onComplete({ + provider: aiProvider, + apiKey: aiApiKey.trim(), + }); + } + }; + + const canComplete = aiProvider && aiApiKey.trim(); + + return ( + + + + {/* Step 1: Select Provider */} + 1} + headline={ + + } + > +
+ {LLM_PROVIDERS.map((provider) => ( + + ))} +
+
+ + {/* Step 2: Create API Key */} + + + + + {/* Step 3: Enter API Key */} + = 2} + completed={!!aiApiKey.trim()} + headline={ + + } + > + setAIApiKey(e.target.value)} + disabled={!aiProvider || completing} + data-1p-ignore + autoComplete="off" + /> + + + +
+
+ ); +} diff --git a/internal/site/components/onboarding-step-footer.tsx b/internal/site/components/onboarding-step-footer.tsx new file mode 100644 index 00000000..b99593a2 --- /dev/null +++ b/internal/site/components/onboarding-step-footer.tsx @@ -0,0 +1,68 @@ +"use client"; + +import { ArrowLeft, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +export interface OnboardingStepFooterProps { + onBack?: () => void; + onSkip?: () => void; + /** Primary action. When not provided, only shows back/skip buttons */ + onContinue?: () => void; + continueDisabled?: boolean; + disabled?: boolean; + continueText?: string; + loadingText?: string; + loading?: boolean; + /** Additional class names for the container */ + className?: string; +} + +export function OnboardingStepFooter({ + onBack, + onSkip, + onContinue, + continueDisabled, + disabled, + continueText = "Continue", + loadingText = "Saving...", + loading, + className, +}: OnboardingStepFooterProps) { + return ( +
+ {onBack && ( + + )} +
+ {onSkip && ( + + )} + {onContinue && ( + + )} +
+
+ ); +} diff --git a/internal/site/components/onboarding-step-header.tsx b/internal/site/components/onboarding-step-header.tsx new file mode 100644 index 00000000..0eb3e775 --- /dev/null +++ b/internal/site/components/onboarding-step-header.tsx @@ -0,0 +1,87 @@ +"use client"; + +import type { ComponentType, ReactNode } from "react"; +import { CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; + +export interface OnboardingStepHeaderProps { + /** Icon component (LucideIcon or custom component like SlackIcon) */ + icon?: ComponentType<{ className?: string }>; + /** Background color class for the icon circle (e.g., "bg-primary/10", "bg-[#24292f]") */ + iconBgClassName?: string; + /** Icon color class (e.g., "text-primary", "text-white") */ + iconClassName?: string; + /** Title text */ + title: string; + /** Description text or ReactNode */ + description: ReactNode; + /** Size variant - "lg" uses larger icon container (only applies to centered layout) */ + size?: "default" | "lg"; + /** Layout variant - "centered" for vertical centered, "inline" for horizontal with CardHeader */ + layout?: "centered" | "inline"; + /** Additional class names for the container */ + className?: string; +} + +export function OnboardingStepHeader({ + icon: Icon, + iconBgClassName = "bg-primary/10", + iconClassName = "text-primary", + title, + description, + size = "default", + layout = "centered", + className, +}: OnboardingStepHeaderProps) { + if (layout === "inline") { + return ( + +
+ {Icon && ( +
+ +
+ )} + {title} +
+ {description} +
+ ); + } + + const isLarge = size === "lg"; + + return ( +
+ {Icon && ( +
+ +
+ )} +

+ {title} +

+

+ {description} +

+
+ ); +} diff --git a/internal/site/components/slack-icon.tsx b/internal/site/components/slack-icon.tsx new file mode 100644 index 00000000..0beecf62 --- /dev/null +++ b/internal/site/components/slack-icon.tsx @@ -0,0 +1,14 @@ +export function SlackIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/internal/site/components/slack-setup-wizard.stories.tsx b/internal/site/components/slack-setup-wizard.stories.tsx new file mode 100644 index 00000000..0d814640 --- /dev/null +++ b/internal/site/components/slack-setup-wizard.stories.tsx @@ -0,0 +1,415 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "react"; +import { fn } from "storybook/test"; +import { type MockedClient, withMockClient } from "@/lib/api-client.mock"; +import { + SlackSetupWizard, + type SlackSetupWizardInitialState, +} from "./slack-setup-wizard"; + +const TEST_AGENT_ID = "test-agent-123"; +const TEST_WEBHOOK_URL = "https://api.blink.so/webhooks/slack/test-webhook-id"; + +interface MockOptions { + validationValid?: boolean; + validationError?: string; + dmReceived?: boolean; + signatureFailed?: boolean; + completeSuccess?: boolean; +} + +function configureMockClient(client: MockedClient, options?: MockOptions) { + const { + validationValid = true, + validationError, + dmReceived = false, + signatureFailed = false, + completeSuccess = true, + } = options ?? {}; + + client.agents.setupSlack.getWebhookUrl.mockResolvedValue({ + webhook_url: TEST_WEBHOOK_URL, + }); + + client.agents.setupSlack.startVerification.mockResolvedValue({ + webhook_url: TEST_WEBHOOK_URL, + }); + + client.agents.setupSlack.getVerificationStatus.mockResolvedValue({ + active: true, + started_at: new Date().toISOString(), + last_event_at: dmReceived ? new Date().toISOString() : undefined, + dm_received: dmReceived, + dm_channel: dmReceived ? "D12345678" : undefined, + signature_failed: signatureFailed, + signature_failed_at: signatureFailed ? new Date().toISOString() : undefined, + }); + + client.agents.setupSlack.validateToken.mockResolvedValue({ + valid: validationValid, + error: validationError, + }); + + client.agents.setupSlack.completeVerification.mockResolvedValue({ + success: completeSuccess, + bot_name: "Test Bot", + }); + + client.agents.setupSlack.cancelVerification.mockResolvedValue(undefined); +} + +const meta: Meta = { + title: "Components/SlackSetupWizard", + component: SlackSetupWizard, + parameters: { + layout: "centered", + }, + args: { + agentId: TEST_AGENT_ID, + agentName: "Scout", + onComplete: fn(), + onBack: fn(), + onSkip: fn(), + }, + render: (args) => ( +
+ +
+ ), + decorators: [withMockClient((client) => configureMockClient(client))], +}; + +export default meta; +type Story = StoryObj; + +// Helper to create stories with specific initial state +const withInitialState = (state: SlackSetupWizardInitialState): Story => ({ + args: { + initialState: state, + }, +}); + +export const Step1_AppName: Story = withInitialState({ + webhookUrl: TEST_WEBHOOK_URL, + loadingWebhookUrl: false, + appName: "", +}); +Step1_AppName.storyName = "Step 1: App Name (empty)"; + +export const Step1_AppNameFilled: Story = withInitialState({ + webhookUrl: TEST_WEBHOOK_URL, + loadingWebhookUrl: false, + appName: "Scout", +}); +Step1_AppNameFilled.storyName = "Step 1: App Name (filled)"; + +export const Step2_LoadingWebhookUrl: Story = withInitialState({ + webhookUrl: null, + loadingWebhookUrl: true, + appName: "Scout", +}); +Step2_LoadingWebhookUrl.storyName = "Step 2: Loading Webhook URL"; + +export const Step2_CreateSlackApp: Story = withInitialState({ + webhookUrl: TEST_WEBHOOK_URL, + loadingWebhookUrl: false, + appName: "Scout", + hasOpenedSlack: false, +}); +Step2_CreateSlackApp.storyName = "Step 2: Create Slack App"; + +export const Step2_SlackOpened: Story = withInitialState({ + webhookUrl: TEST_WEBHOOK_URL, + loadingWebhookUrl: false, + appName: "Scout", + hasOpenedSlack: true, +}); +Step2_SlackOpened.storyName = "Step 2: Slack Opened (can open again)"; + +export const Step3_AppId: Story = withInitialState({ + webhookUrl: TEST_WEBHOOK_URL, + loadingWebhookUrl: false, + appName: "Scout", + hasOpenedSlack: true, + appId: "", +}); +Step3_AppId.storyName = "Step 3: App ID (empty)"; + +export const Step3_AppIdFilled: Story = withInitialState({ + webhookUrl: TEST_WEBHOOK_URL, + loadingWebhookUrl: false, + appName: "Scout", + hasOpenedSlack: true, + appId: "A0123456789", +}); +Step3_AppIdFilled.storyName = "Step 3: App ID (filled)"; + +export const Step4_SigningSecret: Story = withInitialState({ + webhookUrl: TEST_WEBHOOK_URL, + loadingWebhookUrl: false, + appName: "Scout", + hasOpenedSlack: true, + appId: "A0123456789", + signingSecret: "", +}); +Step4_SigningSecret.storyName = "Step 4: Signing Secret (empty)"; + +export const Step4_SigningSecretFilled: Story = withInitialState({ + webhookUrl: TEST_WEBHOOK_URL, + loadingWebhookUrl: false, + appName: "Scout", + hasOpenedSlack: true, + appId: "A0123456789", + signingSecret: "abc123secret", +}); +Step4_SigningSecretFilled.storyName = "Step 4: Signing Secret (filled)"; + +export const Step5_BotToken: Story = withInitialState({ + webhookUrl: TEST_WEBHOOK_URL, + loadingWebhookUrl: false, + appName: "Scout", + hasOpenedSlack: true, + appId: "A0123456789", + signingSecret: "abc123secret", + botToken: "", +}); +Step5_BotToken.storyName = "Step 5: Bot Token (empty)"; + +export const Step5_BotTokenFilled: Story = withInitialState({ + webhookUrl: TEST_WEBHOOK_URL, + loadingWebhookUrl: false, + appName: "Scout", + hasOpenedSlack: true, + appId: "A0123456789", + signingSecret: "abc123secret", + botToken: "xoxb-123456789-abcdefghijklmnop", +}); +Step5_BotTokenFilled.storyName = "Step 5: Bot Token (filled, not validated)"; + +export const Step5_BotTokenValidating: Story = withInitialState({ + webhookUrl: TEST_WEBHOOK_URL, + loadingWebhookUrl: false, + appName: "Scout", + hasOpenedSlack: true, + appId: "A0123456789", + signingSecret: "abc123secret", + botToken: "xoxb-123456789-abcdefghijklmnop", + validatingToken: true, +}); +Step5_BotTokenValidating.storyName = "Step 5: Bot Token (validating)"; + +export const Step5_BotTokenValidated: Story = withInitialState({ + webhookUrl: TEST_WEBHOOK_URL, + loadingWebhookUrl: false, + appName: "Scout", + hasOpenedSlack: true, + appId: "A0123456789", + signingSecret: "abc123secret", + botToken: "xoxb-123456789-abcdefghijklmnop", + tokenValidated: true, +}); +Step5_BotTokenValidated.storyName = "Step 5: Bot Token (validated)"; + +export const Step6_WaitingForDM: Story = withInitialState({ + webhookUrl: TEST_WEBHOOK_URL, + loadingWebhookUrl: false, + appName: "Scout", + hasOpenedSlack: true, + appId: "A0123456789", + signingSecret: "abc123secret", + botToken: "xoxb-123456789-abcdefghijklmnop", + tokenValidated: true, + verificationStarted: true, + dmReceived: false, +}); +Step6_WaitingForDM.storyName = "Step 6: Waiting for DM"; + +export const Step6_DMReceived: Story = withInitialState({ + webhookUrl: TEST_WEBHOOK_URL, + loadingWebhookUrl: false, + appName: "Scout", + hasOpenedSlack: true, + appId: "A0123456789", + signingSecret: "abc123secret", + botToken: "xoxb-123456789-abcdefghijklmnop", + tokenValidated: true, + verificationStarted: true, + dmReceived: true, +}); +Step6_DMReceived.storyName = "Step 6: DM Received (ready to complete)"; + +export const Step6_Completing: Story = withInitialState({ + webhookUrl: TEST_WEBHOOK_URL, + loadingWebhookUrl: false, + appName: "Scout", + hasOpenedSlack: true, + appId: "A0123456789", + signingSecret: "abc123secret", + botToken: "xoxb-123456789-abcdefghijklmnop", + tokenValidated: true, + verificationStarted: true, + dmReceived: true, + completing: true, +}); +Step6_Completing.storyName = "Step 6: Completing Setup"; + +export const Step6_SigningSecretError: Story = withInitialState({ + webhookUrl: TEST_WEBHOOK_URL, + loadingWebhookUrl: false, + appName: "Scout", + hasOpenedSlack: true, + appId: "A0123456789", + signingSecret: "wrong-secret", + signingSecretError: true, + botToken: "xoxb-123456789-abcdefghijklmnop", + tokenValidated: true, + verificationStarted: true, + dmReceived: true, +}); +Step6_SigningSecretError.storyName = "Step 6: Signing Secret Error"; + +export const WithoutBackButton: Story = { + args: { + onBack: undefined, + initialState: { + webhookUrl: TEST_WEBHOOK_URL, + loadingWebhookUrl: false, + appName: "Scout", + }, + }, +}; +WithoutBackButton.storyName = "Without Back Button"; + +// Global settings that the mock client can read +const interactiveSettings = { + botTokenValid: true, + signingSecretValid: true, + pollCount: 0, +}; + +// Interactive wrapper component with controls +function InteractiveFlowWrapper() { + const [botTokenValid, setBotTokenValid] = useState(true); + const [signingSecretValid, setSigningSecretValid] = useState(true); + const [key, setKey] = useState(0); + + // Update global settings when state changes + interactiveSettings.botTokenValid = botTokenValid; + interactiveSettings.signingSecretValid = signingSecretValid; + + const resetWizard = () => { + interactiveSettings.pollCount = 0; + setKey((k) => k + 1); + }; + + return ( +
+
+ +
+
+

Test Controls

+ +
+ + + +
+ +
+ + + +

+ Toggle the checkboxes to simulate different API responses. The wizard + will use these settings for validation and verification. +

+
+
+ ); +} + +// Configure interactive mock client with dynamic behavior +function configureInteractiveMockClient(client: MockedClient) { + client.agents.setupSlack.getWebhookUrl.mockResolvedValue({ + webhook_url: TEST_WEBHOOK_URL, + }); + + client.agents.setupSlack.startVerification.mockImplementation(() => { + interactiveSettings.pollCount = 0; + return Promise.resolve({ webhook_url: TEST_WEBHOOK_URL }); + }); + + client.agents.setupSlack.getVerificationStatus.mockImplementation(() => { + interactiveSettings.pollCount++; + const dmReceived = interactiveSettings.pollCount >= 3; + const signatureFailed = + dmReceived && !interactiveSettings.signingSecretValid; + return Promise.resolve({ + active: true, + started_at: new Date().toISOString(), + last_event_at: + interactiveSettings.pollCount > 1 + ? new Date().toISOString() + : undefined, + dm_received: dmReceived, + dm_channel: dmReceived ? "D12345678" : undefined, + signature_failed: signatureFailed, + signature_failed_at: signatureFailed + ? new Date().toISOString() + : undefined, + }); + }); + + client.agents.setupSlack.validateToken.mockImplementation(() => + Promise.resolve({ + valid: interactiveSettings.botTokenValid, + error: interactiveSettings.botTokenValid + ? undefined + : "Invalid bot token", + }) + ); + + client.agents.setupSlack.completeVerification.mockResolvedValue({ + success: true, + bot_name: "Scout Bot", + }); + + client.agents.setupSlack.cancelVerification.mockResolvedValue(undefined); +} + +// Interactive story that simulates the full flow with controls +export const InteractiveFlow: Story = { + render: () => , + decorators: [withMockClient(configureInteractiveMockClient)], +}; +InteractiveFlow.storyName = "Interactive Flow"; diff --git a/internal/site/components/slack-setup-wizard.tsx b/internal/site/components/slack-setup-wizard.tsx new file mode 100644 index 00000000..725c17e9 --- /dev/null +++ b/internal/site/components/slack-setup-wizard.tsx @@ -0,0 +1,642 @@ +"use client"; + +import { AlertCircle, Check, ExternalLink, Loader2 } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { toast } from "sonner"; +import { OnboardingStepFooter } from "@/components/onboarding-step-footer"; +import { OnboardingStepHeader } from "@/components/onboarding-step-header"; +import { SlackIcon } from "@/components/slack-icon"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { SetupStep } from "@/components/ui/setup-step"; +import { useAPIClient } from "@/lib/api-client"; +import { + createAgentSlackManifest, + createSlackAppUrl, +} from "@/lib/slack-manifest"; + +export interface SlackSetupWizardInitialState { + webhookUrl?: string | null; + loadingWebhookUrl?: boolean; + appName?: string; + hasOpenedSlack?: boolean; + appId?: string; + signingSecret?: string; + botToken?: string; + validatingToken?: boolean; + tokenValidated?: boolean; + botTokenError?: boolean; + signingSecretError?: boolean; + verificationStarted?: boolean; + dmReceived?: boolean; + completing?: boolean; +} + +interface SlackSetupWizardProps { + agentId: string; + agentName: string; + onComplete: (credentials: { + botToken: string; + signingSecret: string; + }) => void; + onBack?: () => void; + onSkip?: () => void; + /** Initial state for stories/testing - allows rendering in specific states */ + initialState?: SlackSetupWizardInitialState; +} + +export function SlackSetupWizard({ + agentId, + agentName, + onComplete, + onBack, + onSkip, + initialState, +}: SlackSetupWizardProps) { + const client = useAPIClient(); + + // Webhook URL state (fetched from backend unless provided in initialState) + const [webhookUrl, setWebhookUrl] = useState( + initialState?.webhookUrl ?? null + ); + const [loadingWebhookUrl, setLoadingWebhookUrl] = useState( + initialState?.loadingWebhookUrl ?? initialState?.webhookUrl === undefined + ); + + // Form state + const [appName, setAppName] = useState(initialState?.appName ?? agentName); + const [hasOpenedSlack, setHasOpenedSlack] = useState( + initialState?.hasOpenedSlack ?? false + ); + const [appId, setAppId] = useState(initialState?.appId ?? ""); + const [signingSecret, setSigningSecret] = useState( + initialState?.signingSecret ?? "" + ); + const [botToken, setBotToken] = useState(initialState?.botToken ?? ""); + + // Validation state + const [validatingToken, setValidatingToken] = useState( + initialState?.validatingToken ?? false + ); + const [tokenValidated, setTokenValidated] = useState( + initialState?.tokenValidated ?? false + ); + const [botTokenError, setBotTokenError] = useState( + initialState?.botTokenError ?? false + ); + const [signingSecretError, setSigningSecretError] = useState( + initialState?.signingSecretError ?? false + ); + + // Verification state + const [startingVerification, setStartingVerification] = useState(false); + const [verificationStarted, setVerificationStarted] = useState( + initialState?.verificationStarted ?? false + ); + const [verificationStatus, setVerificationStatus] = useState<{ + active: boolean; + lastEventAt?: string; + dmReceived: boolean; + signatureFailed: boolean; + }>({ + active: initialState?.verificationStarted ?? false, + dmReceived: initialState?.dmReceived ?? false, + signatureFailed: false, + }); + const [completing, setCompleting] = useState( + initialState?.completing ?? false + ); + const pollingRef = useRef(null); + const autoValidationTriggeredRef = useRef(false); + + // Fetch webhook URL on mount (skip if provided in initialState) + useEffect(() => { + if (initialState?.webhookUrl !== undefined) return; + + async function fetchWebhookUrl() { + try { + const result = await client.agents.setupSlack.getWebhookUrl(agentId); + setWebhookUrl(result.webhook_url); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to load webhook URL" + ); + } finally { + setLoadingWebhookUrl(false); + } + } + fetchWebhookUrl(); + }, [client, agentId, initialState?.webhookUrl]); + + // Determine which step is active (1-indexed for display) + const currentStep = useMemo(() => { + if (!appName.trim()) return 1; + if (!hasOpenedSlack) return 2; + if (!appId.trim()) return 3; + if (!signingSecret.trim()) return 4; + if (!tokenValidated) return 5; + return 6; + }, [appName, hasOpenedSlack, appId, signingSecret, tokenValidated]); + + // Generate manifest URL client-side + const manifestUrl = useMemo(() => { + if (!webhookUrl) return null; + const manifest = createAgentSlackManifest(appName, webhookUrl); + return createSlackAppUrl(manifest); + }, [appName, webhookUrl]); + + // Generate install URL from app ID + const installUrl = useMemo(() => { + if (!appId.trim()) return ""; + return `https://api.slack.com/apps/${appId}/install-on-team`; + }, [appId]); + + // Generate app home page URL (for signing secret) + const appHomeUrl = useMemo(() => { + if (!appId.trim()) return ""; + return `https://api.slack.com/apps/${appId}`; + }, [appId]); + + // Validate bot token on blur + const validateBotToken = useCallback(async () => { + if (!botToken.trim()) return; + + setValidatingToken(true); + setBotTokenError(false); + try { + const result = await client.agents.setupSlack.validateToken(agentId, { + botToken, + }); + + if (result.valid) { + setTokenValidated(true); + setBotTokenError(false); + } else { + toast.error(result.error || "Invalid bot token"); + setTokenValidated(false); + setBotTokenError(true); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : "Validation failed"); + setTokenValidated(false); + setBotTokenError(true); + } finally { + setValidatingToken(false); + } + }, [client, agentId, botToken]); + + // Auto-validate when bot token reaches 8+ characters + useEffect(() => { + if (botToken.length < 8) { + autoValidationTriggeredRef.current = false; + return; + } + if ( + !autoValidationTriggeredRef.current && + !validatingToken && + !tokenValidated + ) { + autoValidationTriggeredRef.current = true; + validateBotToken(); + } + }, [botToken, validatingToken, tokenValidated, validateBotToken]); + + // Start verification (called when step 6 becomes active) + const startVerification = useCallback(async () => { + if (verificationStarted || startingVerification) return; + + setStartingVerification(true); + try { + await client.agents.setupSlack.startVerification(agentId, { + signing_secret: signingSecret, + bot_token: botToken, + }); + setVerificationStarted(true); + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to start verification" + ); + } finally { + setStartingVerification(false); + } + }, [ + client, + agentId, + signingSecret, + botToken, + verificationStarted, + startingVerification, + ]); + + // Poll for verification status + const pollVerificationStatus = useCallback(async () => { + try { + const status = + await client.agents.setupSlack.getVerificationStatus(agentId); + setVerificationStatus({ + active: status.active, + lastEventAt: status.last_event_at, + dmReceived: status.dm_received, + signatureFailed: status.signature_failed, + }); + return status; + } catch (error) { + // biome-ignore lint/suspicious/noConsole: useful for debugging polling failures + console.error("Failed to poll verification status:", error); + return null; + } + }, [client, agentId]); + + // Start verification when step 6 is reached (not if there's a signing secret error) + useEffect(() => { + if ( + currentStep === 6 && + tokenValidated && + !verificationStarted && + !signingSecretError + ) { + startVerification(); + } + }, [ + currentStep, + tokenValidated, + verificationStarted, + signingSecretError, + startVerification, + ]); + + // Start polling when verification is active + useEffect(() => { + if (verificationStarted && !verificationStatus.dmReceived) { + const poll = async () => { + const status = await pollVerificationStatus(); + if (status?.dm_received) { + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + } + // Detect signature failure + if (status?.signature_failed) { + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + setSigningSecretError(true); + setVerificationStarted(false); + toast.error( + "Invalid signing secret. Please check and re-enter your signing secret." + ); + } + }; + poll(); + pollingRef.current = setInterval(poll, 2000); + + return () => { + if (pollingRef.current) { + clearInterval(pollingRef.current); + } + }; + } + }, [ + verificationStarted, + verificationStatus.dmReceived, + pollVerificationStatus, + ]); + + // Complete setup + const completeSetup = async () => { + setCompleting(true); + try { + const result = await client.agents.setupSlack.completeVerification( + agentId, + { + bot_token: botToken, + signing_secret: signingSecret, + } + ); + + if (result.success) { + onComplete({ botToken, signingSecret }); + } else { + toast.error("Failed to complete setup"); + } + } catch (error) { + toast.error( + error instanceof Error ? error.message : "Failed to complete setup" + ); + } finally { + setCompleting(false); + } + }; + + // Cancel verification and go back + const handleBack = async () => { + try { + await client.agents.setupSlack.cancelVerification(agentId); + } catch { + // Ignore errors when canceling + } + onBack?.(); + }; + + return ( + + + + {/* Step 1: App Name */} + + What would you like to call the agent in Slack? + + } + > + setAppName(e.target.value)} + /> + + + {/* Step 2: Create Slack App */} + 2} + headline="Create the Slack app" + > + + + + {/* Step 3: App ID */} + 3} + headline={ + + } + > + setAppId(e.target.value)} + disabled={currentStep < 3} + data-1p-ignore + autoComplete="off" + /> + + + {/* Step 4: Signing Secret */} + 4} + indicator={ + signingSecretError ? ( +
+ +
+ ) : undefined + } + headline={ + + } + > + { + setSigningSecret(e.target.value); + if (signingSecretError) { + // Clear error and reset verification when user fixes signing secret + setSigningSecretError(false); + setVerificationStarted(false); + setVerificationStatus({ + active: false, + dmReceived: false, + signatureFailed: false, + }); + } + }} + disabled={currentStep < 4} + data-1p-ignore + autoComplete="off" + className={ + signingSecretError + ? "border-yellow-500 focus-visible:ring-yellow-500" + : "" + } + /> + {signingSecretError && ( +

+ Signing secret verification failed. Did you enter it correctly? +

+ )} +
+ + {/* Step 5: Bot Token */} + + +
+ ) : undefined + } + headline={ + + } + > +
+ { + setBotToken(e.target.value); + setTokenValidated(false); + setBotTokenError(false); + }} + onBlur={validateBotToken} + disabled={currentStep < 5} + data-1p-ignore + autoComplete="off" + className={`${tokenValidated ? "pr-10" : ""} ${botTokenError ? "border-yellow-500 focus-visible:ring-yellow-500" : ""}`} + /> + {validatingToken && ( +
+ +
+ )} + {tokenValidated && !validatingToken && ( +
+ +
+ )} +
+ {botTokenError && ( +

+ Bot token validation failed. Did you enter it correctly? +

+ )} +
+ + {/* Step 6: DM Verification */} + + {currentStep === 6 && ( +
+ {/* DM Status */} +
+ {verificationStatus.dmReceived && !signingSecretError ? ( + + ) : ( + + )} + + {verificationStatus.dmReceived && !signingSecretError + ? "Message received!" + : signingSecretError + ? "Message received..." + : "Waiting for message..."} + +
+ + {/* Subtitle */} +

+ {signingSecretError + ? `There was a problem with the signing secret. Please fix it and DM ${appName} again.` + : `Find "${appName}" in the Slack search bar and DM it.`} +

+
+ )} +
+ + + + + ); +} diff --git a/internal/site/components/ui/setup-step.tsx b/internal/site/components/ui/setup-step.tsx new file mode 100644 index 00000000..7b48fe64 --- /dev/null +++ b/internal/site/components/ui/setup-step.tsx @@ -0,0 +1,43 @@ +import type { ReactNode } from "react"; +import { StepNumber } from "./step-number"; + +interface SetupStepProps { + num: number; + active: boolean; + completed: boolean; + headline: ReactNode; + children: ReactNode; + /** Optional custom indicator to replace the StepNumber (e.g., for error states) */ + indicator?: ReactNode; +} + +export function SetupStep({ + num, + active, + completed, + headline, + children, + indicator, +}: SetupStepProps) { + return ( +
+ {indicator ?? ( + + )} +
+ {typeof headline === "string" ? ( +

+ {headline} +

+ ) : ( + headline + )} + {children} +
+
+ ); +} diff --git a/internal/site/components/ui/step-number.tsx b/internal/site/components/ui/step-number.tsx new file mode 100644 index 00000000..f21ba4f9 --- /dev/null +++ b/internal/site/components/ui/step-number.tsx @@ -0,0 +1,23 @@ +import { Check } from "lucide-react"; + +interface StepNumberProps { + num: number; + active: boolean; + completed: boolean; +} + +export function StepNumber({ num, active, completed }: StepNumberProps) { + return ( +
+ {completed ? : num} +
+ ); +} diff --git a/internal/site/components/web-search-setup.tsx b/internal/site/components/web-search-setup.tsx new file mode 100644 index 00000000..6576bd0c --- /dev/null +++ b/internal/site/components/web-search-setup.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { ExternalLink, Info, Search } from "lucide-react"; +import { useState } from "react"; +import { OnboardingStepFooter } from "@/components/onboarding-step-footer"; +import { OnboardingStepHeader } from "@/components/onboarding-step-header"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { SetupStep } from "@/components/ui/setup-step"; + +export interface WebSearchResult { + exaApiKey: string; +} + +export interface WebSearchSetupProps { + initialValue?: string; + onComplete: (result: WebSearchResult) => void; + onBack?: () => void; + onSkip?: () => void; + completing?: boolean; +} + +export const EXA_ENV_VAR_KEY = "EXA_API_KEY"; + +export function WebSearchSetup({ + initialValue, + onComplete, + onBack, + onSkip, + completing, +}: WebSearchSetupProps) { + const [exaApiKey, setExaApiKey] = useState(initialValue || ""); + const [hasOpenedKeyPage, setHasOpenedKeyPage] = useState(false); + + const handleComplete = () => { + if (exaApiKey.trim()) { + onComplete({ exaApiKey: exaApiKey.trim() }); + } + }; + + return ( + + + +
+ +

+ Exa is a web search provider for AI agents.{" "} + + Learn more + +

+
+ + {/* Step 1: Create API Key */} + + + + + {/* Step 2: Enter API Key */} + + Paste your API key + + } + > + setExaApiKey(e.target.value)} + disabled={completing} + data-1p-ignore + autoComplete="off" + /> + + + +
+
+ ); +} diff --git a/internal/site/lib/api-client.mock.tsx b/internal/site/lib/api-client.mock.tsx new file mode 100644 index 00000000..dff3fd0d --- /dev/null +++ b/internal/site/lib/api-client.mock.tsx @@ -0,0 +1,107 @@ +import Client from "@blink.so/api"; +import type { StoryFn } from "@storybook/react"; +import { fn } from "storybook/test"; + +// Type-safe mock function that preserves the original function's types +// biome-ignore lint/suspicious/noExplicitAny: generic function type +type TypedMockFn any> = T & { + mockResolvedValue: (value: Awaited>) => TypedMockFn; + mockRejectedValue: (value: unknown) => TypedMockFn; + mockImplementation: ( + impl: (...args: Parameters) => ReturnType + ) => TypedMockFn; + mockReturnValue: (value: ReturnType) => TypedMockFn; + mockClear: () => TypedMockFn; + mockReset: () => TypedMockFn; +}; + +// Type that converts all methods in an object to typed mocks +type MockedMethods = { + // biome-ignore lint/suspicious/noExplicitAny: recursive type needs any + [K in keyof T]: T[K] extends (...args: any[]) => any + ? TypedMockFn + : T[K] extends object + ? MockedMethods + : T[K]; +}; + +// Mocked Client type where all methods are type-safe Storybook mocks +export type MockedClient = MockedMethods; + +/** + * Recursively replace all methods with mocks that reject by default. + * Stories must explicitly mock methods they use with .mockResolvedValue(). + */ +function mockAllMethods( + obj: object, + path = "", + visited = new WeakSet() +): void { + if (visited.has(obj)) return; + visited.add(obj); + + for (const key of Object.keys(obj)) { + // biome-ignore lint/suspicious/noExplicitAny: easier that way + const value = (obj as any)[key]; + const newPath = path ? `${path}.${key}` : key; + + if (value && typeof value === "object" && value.constructor !== Object) { + // Nested class instance - recurse into it and also mock its prototype methods + mockAllMethods(value, newPath, visited); + for (const method of Object.getOwnPropertyNames( + Object.getPrototypeOf(value) + )) { + if (method !== "constructor" && typeof value[method] === "function") { + value[method] = fn().mockRejectedValue( + new Error(`${newPath}.${method} not mocked`) + ); + } + } + } + } +} + +/** + * Create a mock API client where all methods reject by default. + * Stories must explicitly mock methods they use with .mockResolvedValue(). + */ +export function createMockClient(): MockedClient { + const client = new Client({ baseURL: "http://mock" }); + mockAllMethods(client); + return client as unknown as MockedClient; +} + +// Current mock client for the active story +let currentMockClient: MockedClient | null = null; + +/** + * Storybook decorator that creates a fresh mock client for each story. + * Pass a configure function to set up mock responses. + * + * @example + * export const MyStory: Story = { + * decorators: [ + * withMockClient((client) => { + * client.agents.setupSlack.getWebhookUrl.mockResolvedValue({ webhook_url: "..." }); + * }), + * ], + * }; + */ +export function withMockClient(configure?: (client: MockedClient) => void) { + return (Story: StoryFn) => { + // Create fresh mock client for this story + currentMockClient = createMockClient(); + if (configure) { + configure(currentMockClient); + } + // @ts-expect-error StoryFn typing + return ; + }; +} + +export const useAPIClient = fn(() => { + if (!currentMockClient) { + currentMockClient = createMockClient(); + } + return currentMockClient as unknown as Client; +}); diff --git a/internal/site/lib/api-client.ts b/internal/site/lib/api-client.ts index 91f22365..a1f1c90f 100644 --- a/internal/site/lib/api-client.ts +++ b/internal/site/lib/api-client.ts @@ -7,7 +7,8 @@ export function useAPIClient() { return useMemo( () => new Client({ - baseURL: typeof window !== "undefined" ? window.location.origin : "", + baseURL: + typeof window !== "undefined" ? window.location.origin : undefined, }), [] ); diff --git a/internal/site/lib/slack-manifest.ts b/internal/site/lib/slack-manifest.ts new file mode 100644 index 00000000..b81e4fa7 --- /dev/null +++ b/internal/site/lib/slack-manifest.ts @@ -0,0 +1,107 @@ +/** + * Creates a Slack manifest URL for app creation. + */ + +export interface SlackManifest { + display_information: { + name: string; + description?: string; + }; + features?: { + bot_user?: { + display_name: string; + always_online?: boolean; + }; + app_home?: { + home_tab_enabled?: boolean; + messages_tab_enabled?: boolean; + messages_tab_read_only_enabled?: boolean; + }; + }; + oauth_config: { + scopes: { + bot?: string[]; + user?: string[]; + }; + }; + settings?: { + event_subscriptions?: { + request_url: string; + bot_events?: string[]; + }; + interactivity?: { + is_enabled: boolean; + request_url: string; + }; + org_deploy_enabled?: boolean; + socket_mode_enabled?: boolean; + token_rotation_enabled?: boolean; + }; +} + +/** + * Creates a URL to initialize Slack App creation with a manifest. + * @param manifest The Slack App manifest configuration + * @returns URL to create the Slack app with the provided manifest + */ +export function createSlackAppUrl(manifest: SlackManifest): string { + const manifestJson = encodeURIComponent(JSON.stringify(manifest)); + return `https://api.slack.com/apps?new_app=1&manifest_json=${manifestJson}`; +} + +/** + * Creates a default Slack manifest for a Blink agent. + * @param appName The display name for the Slack app + * @param webhookUrl The webhook URL for event subscriptions + * @returns A Slack manifest configured for the agent + */ +export function createAgentSlackManifest( + appName: string, + webhookUrl: string +): SlackManifest { + return { + display_information: { + name: appName, + description: `Chat with ${appName}`, + }, + features: { + bot_user: { + display_name: appName, + always_online: true, + }, + app_home: { + home_tab_enabled: false, + messages_tab_enabled: true, + messages_tab_read_only_enabled: false, + }, + }, + oauth_config: { + scopes: { + bot: [ + "assistant:write", + "chat:write", + "im:history", + "im:read", + "im:write", + ], + }, + }, + settings: { + event_subscriptions: { + request_url: webhookUrl, + bot_events: [ + "assistant_thread_context_changed", + "assistant_thread_started", + "message.im", + ], + }, + interactivity: { + is_enabled: true, + request_url: webhookUrl, + }, + org_deploy_enabled: false, + socket_mode_enabled: false, + token_rotation_enabled: false, + }, + }; +} diff --git a/internal/site/middleware.ts b/internal/site/middleware.ts index 84e59f68..6fec3319 100644 --- a/internal/site/middleware.ts +++ b/internal/site/middleware.ts @@ -79,12 +79,17 @@ export async function middleware(request: NextRequest) { currentCookie?.value || (response ? response.cookies.get(SESSION_COOKIE_NAME)?.value : undefined); + const authSecret = process.env.AUTH_SECRET; + if (!authSecret) { + throw new Error("AUTH_SECRET environment variable is not set"); + } + let token = null; if (tokenValue) { try { token = await decode({ token: tokenValue, - secret: process.env.AUTH_SECRET!, + secret: authSecret, salt: SESSION_COOKIE_NAME, }); } catch { diff --git a/internal/worker/src/new-api.ts b/internal/worker/src/new-api.ts index 1f5e35f9..dba2fa9a 100644 --- a/internal/worker/src/new-api.ts +++ b/internal/worker/src/new-api.ts @@ -47,6 +47,7 @@ export default function handleNewAPI( GOOGLE_CLIENT_SECRET: env.GOOGLE_CLIENT_SECRET, apiBaseURL, + accessUrl: apiBaseURL, matchRequestHost: (host) => { // These two are for backwards compatibility. let exec = devRequestHostRegex.exec(host); @@ -425,6 +426,9 @@ export default function handleNewAPI( NODE_ENV: env.NODE_ENV, AI_GATEWAY_API_KEY: env.AI_GATEWAY_API_KEY, TOOLS_EXA_API_KEY: env.EXA_API_KEY, + serverVersion: "worker", + ONBOARDING_AGENT_BUNDLE_URL: + "https://artifacts.blink.host/starter-agent/bundle.tar.gz", }, ctx ); diff --git a/packages/server/package.json b/packages/server/package.json index adb6910a..b58b5c9c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -27,12 +27,12 @@ "dev": "bun src/cli.ts --dev" }, "devDependencies": { - "blink": "workspace:*", - "@blink.so/api": "workspace:*", "@blink-sdk/tunnel": "workspace:*", + "@blink.so/api": "workspace:*", "@types/node": "^22.10.2", "@types/pg": "^8.11.10", "@types/ws": "^8.5.13", + "blink": "workspace:*", "boxen": "^8.0.1", "chalk": "^5.4.1", "commander": "^12.1.0", diff --git a/packages/server/src/cli.ts b/packages/server/src/cli.ts index a375b10b..acb93f1c 100644 --- a/packages/server/src/cli.ts +++ b/packages/server/src/cli.ts @@ -88,6 +88,7 @@ async function runServer(options: { port: string; dev?: boolean | string }) { authSecret, baseUrl, devProxy, + accessUrl, }); const box = boxen( diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index fafe1bf0..75856bd3 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -1,6 +1,7 @@ import api from "@blink.so/api/server"; import connectToPostgres from "@blink.so/database/postgres"; import Querier from "@blink.so/database/querier"; +import pkg from "../package.json" with { type: "json" }; import { migrate } from "drizzle-orm/node-postgres/migrator"; import { existsSync } from "fs"; import { readFile } from "fs/promises"; @@ -24,12 +25,14 @@ interface ServerOptions { authSecret: string; baseUrl: string; devProxy?: string; // e.g. "localhost:3000" + accessUrl: string; } // Files are now stored in the database instead of in-memory export async function startServer(options: ServerOptions) { - const { port, postgresUrl, authSecret, baseUrl, devProxy } = options; + const { port, postgresUrl, authSecret, baseUrl, accessUrl, devProxy } = + options; const db = await connectToPostgres(postgresUrl); const querier = new Querier(db); @@ -127,6 +130,9 @@ export async function startServer(options: ServerOptions) { { AUTH_SECRET: authSecret, NODE_ENV: "development", + serverVersion: pkg.version, + ONBOARDING_AGENT_BUNDLE_URL: + "https://artifacts.blink.host/starter-agent/bundle.tar.gz", agentStore: (deploymentTargetID) => { return { delete: async (key) => { @@ -181,6 +187,7 @@ export async function startServer(options: ServerOptions) { return querier; }, apiBaseURL: url, + accessUrl: new URL(accessUrl), auth: { handleWebSocketTokenRequest: async (id, request) => { // WebSocket upgrades are handled in the 'upgrade' event