diff --git a/app/api/chat/generate/route.ts b/app/api/chat/generate/route.ts deleted file mode 100644 index 0176d0eee..000000000 --- a/app/api/chat/generate/route.ts +++ /dev/null @@ -1,49 +0,0 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { handleChatGenerate } from "@/lib/chat/handleChatGenerate"; - -/** - * OPTIONS handler for CORS preflight requests. - * - * @returns A NextResponse with CORS headers. - */ -export async function OPTIONS() { - return new NextResponse(null, { - status: 200, - headers: getCorsHeaders(), - }); -} - -/** - * POST /api/chat/generate - * - * Non-streaming chat endpoint that processes messages and returns a JSON response. - * - * Authentication: x-api-key header required. - * The account ID is inferred from the API key. - * - * Request body: - * - messages: Array of chat messages (mutually exclusive with prompt) - * - prompt: String prompt (mutually exclusive with messages) - * - roomId: Optional UUID of the chat room - * - topic: Optional topic for new chat room (ignored if room already exists) - * - artistId: Optional UUID of the artist account - * - model: Optional model ID override - * - excludeTools: Optional array of tool names to exclude - * - accountId: Optional accountId override (requires org API key) - * - * Response body: - * - text: The generated text response - * - reasoningText: Optional reasoning text (for models that support it) - * - sources: Array of sources used in generation - * - finishReason: The reason generation finished - * - usage: Token usage information - * - response: Additional response metadata - * - * @param request - The request object - * @returns A JSON response with the generated text or error - */ -export async function POST(request: NextRequest): Promise { - return handleChatGenerate(request); -} diff --git a/app/api/chat/runs/[runId]/route.ts b/app/api/chat/runs/[runId]/route.ts new file mode 100644 index 000000000..725e06593 --- /dev/null +++ b/app/api/chat/runs/[runId]/route.ts @@ -0,0 +1,42 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleChatRunStatus } from "@/lib/chat/runs/handleChatRunStatus"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/chat/runs/{runId} + * + * Point-in-time status of an asynchronous run started via `POST /api/chat/runs` + * (recoupable/chat#1813). Returns `{ runId, status }` — a snapshot ("is it + * done?"), not the generated content. Read the content via the chat (`chatId` + * from the start response): `GET /api/chat/{chatId}/stream`, or the persisted + * messages. + * + * Authentication: x-api-key header required. + * + * @param request - The request object. + * @param ctx - Route context with the `runId` path param. + * @param ctx.params - Promise resolving to the `{ runId }` path params. + * @returns 200 `{ runId, status }`, 401/403 on auth, or 404 if the run is unknown. + */ +export async function GET( + request: NextRequest, + ctx: { params: Promise<{ runId: string }> }, +): Promise { + const { runId } = await ctx.params; + return handleChatRunStatus(request, runId); +} + +export const dynamic = "force-dynamic"; diff --git a/app/api/chat/runs/route.ts b/app/api/chat/runs/route.ts new file mode 100644 index 000000000..0f667665b --- /dev/null +++ b/app/api/chat/runs/route.ts @@ -0,0 +1,55 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { handleStartChatRun } from "@/lib/chat/runs/handleStartChatRun"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A NextResponse with CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * POST /api/chat/runs + * + * Start an asynchronous, headless chat-generation run on the durable + * `runAgentWorkflow` (recoupable/chat#1813). Provisions a session + sandbox, + * starts a workflow run, and returns `{ runId, chatId, sessionId }` with **202** + * immediately (plus a `Location` header at the run-status resource) — generation, + * assistant-message persistence, and side effects happen server-side after the + * response. + * + * Authentication: x-api-key header required (account inferred from the key; + * org keys may override via body `accountId`). + * + * Request body: + * - prompt: String prompt (mutually exclusive with messages) + * - messages: Array of UIMessages (mutually exclusive with prompt) + * - artistId: Optional UUID of the artist account + * - model: Optional model ID override (default anthropic/claude-haiku-4.5) + * - topic: Optional session title + * - accountId: Optional accountId override (requires org API key) + * + * Response body (202): `{ runId, chatId, sessionId }`. Read the result later via + * `GET /api/chat/{chatId}/stream` (watch the stream) or the chat's persisted + * messages; poll `GET /api/chat/runs/{runId}` for status (status route lands in + * a follow-up). + * + * @param request - The request object + * @returns 202 `{ runId, chatId, sessionId }`, or a 4xx/5xx error + */ +export async function POST(request: NextRequest): Promise { + return handleStartChatRun(request); +} + +// Provisioning (repo + session + sandbox) runs before the 202 returns, so give +// the function headroom beyond the default. The workflow itself runs durably +// outside this request. +export const maxDuration = 120; +export const dynamic = "force-dynamic"; diff --git a/app/lib/workflows/__tests__/runAgentWorkflow.test.ts b/app/lib/workflows/__tests__/runAgentWorkflow.test.ts index c49adfe6f..be1adf5d0 100644 --- a/app/lib/workflows/__tests__/runAgentWorkflow.test.ts +++ b/app/lib/workflows/__tests__/runAgentWorkflow.test.ts @@ -6,7 +6,11 @@ import { closeChatStream } from "@/app/lib/workflows/closeChatStream"; import { generateAssistantMessageId } from "@/app/lib/workflows/generateAssistantMessageId"; import { handleChatCredits } from "@/lib/credits/handleChatCredits"; import { autoCommitChatTurn } from "@/lib/chat/auto-commit/autoCommitChatTurn"; +import { deleteEphemeralKeyStep } from "@/app/lib/workflows/deleteEphemeralKeyStep"; +vi.mock("@/app/lib/workflows/deleteEphemeralKeyStep", () => ({ + deleteEphemeralKeyStep: vi.fn(), +})); vi.mock("@/app/lib/workflows/runAgentStep", () => ({ runAgentStep: vi.fn(), })); @@ -90,6 +94,37 @@ describe("runAgentWorkflow", () => { expect(clearChatActiveStream).toHaveBeenCalledWith("chat-1", "wrun_from_metadata"); }); + it("deletes the ephemeral key on run end when agentContext.ephemeralKeyId is set (headless)", async () => { + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + aborted: false, + responseMessage: undefined, + }); + + await runAgentWorkflow({ + ...baseInput, + agentContext: { + sandbox: { state: { type: "vercel" }, workingDirectory: "/sandbox/mono" }, + ephemeralKeyId: "ephem-key-1", + } as never, + }); + + expect(deleteEphemeralKeyStep).toHaveBeenCalledTimes(1); + expect(deleteEphemeralKeyStep).toHaveBeenCalledWith("ephem-key-1"); + }); + + it("does NOT delete a key for the interactive path (no ephemeralKeyId)", async () => { + vi.mocked(runAgentStep).mockResolvedValue({ + finishReason: "stop", + aborted: false, + responseMessage: undefined, + }); + + await runAgentWorkflow(baseInput); + + expect(deleteEphemeralKeyStep).not.toHaveBeenCalled(); + }); + it("explicitly closes the chat writable after a successful run so SSE ends promptly", async () => { vi.mocked(runAgentStep).mockResolvedValue({ finishReason: "stop", diff --git a/app/lib/workflows/deleteEphemeralKeyStep.ts b/app/lib/workflows/deleteEphemeralKeyStep.ts new file mode 100644 index 000000000..614323a82 --- /dev/null +++ b/app/lib/workflows/deleteEphemeralKeyStep.ts @@ -0,0 +1,25 @@ +import { deleteApiKey } from "@/lib/supabase/account_api_keys/deleteApiKey"; + +/** + * Vercel Workflow `"use step"` that deletes the ephemeral, account-scoped + * `recoup_sk_…` key minted for a headless `/api/chat/runs` run + * (recoupable/chat#1813). Called from `runAgentWorkflow`'s `finally` so the + * credential is revoked the moment the run ends — the key's ~15m `expires_at` + * TTL (enforced in `getApiKeyAccountId`) is only the backstop if this is missed. + * + * Defensively swallows its own errors: a cleanup hiccup must not fail the run, + * and the TTL still guarantees the key can't outlive its window. + * + * @param keyId - `account_api_keys.id` of the ephemeral key to delete. + */ +export async function deleteEphemeralKeyStep(keyId: string): Promise { + "use step"; + try { + const { error } = await deleteApiKey(keyId); + if (error) { + console.error(`[deleteEphemeralKeyStep] failed to delete key ${keyId}:`, error); + } + } catch (error) { + console.error(`[deleteEphemeralKeyStep] unhandled error deleting key ${keyId}:`, error); + } +} diff --git a/app/lib/workflows/runAgentWorkflow.ts b/app/lib/workflows/runAgentWorkflow.ts index c6642161a..c0cdec7ff 100644 --- a/app/lib/workflows/runAgentWorkflow.ts +++ b/app/lib/workflows/runAgentWorkflow.ts @@ -4,6 +4,7 @@ import { closeChatStream } from "@/app/lib/workflows/closeChatStream"; import { generateAssistantMessageId } from "@/app/lib/workflows/generateAssistantMessageId"; import { runAgentStep } from "@/app/lib/workflows/runAgentStep"; import { clearChatActiveStream } from "@/lib/chat/clearChatActiveStream"; +import { deleteEphemeralKeyStep } from "@/app/lib/workflows/deleteEphemeralKeyStep"; import { handleChatCredits } from "@/lib/credits/handleChatCredits"; import { autoCommitChatTurn } from "@/lib/chat/auto-commit/autoCommitChatTurn"; import type { AgentMessageMetadata } from "@/lib/agent/messageMetadata/AgentMessageMetadata"; @@ -151,11 +152,19 @@ export async function runAgentWorkflow(input: RunAgentWorkflowInput): Promise ({ - ensureCreditsOrShortCircuit: vi.fn().mockResolvedValue(null), -})); - -// Mock all dependencies before importing the module under test -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: vi.fn(), -})); - -vi.mock("@/lib/chat/setupChatRequest", () => ({ - setupChatRequest: vi.fn(), -})); - -vi.mock("@/lib/chat/saveChatCompletion", () => ({ - saveChatCompletion: vi.fn(), -})); - -vi.mock("@/lib/uuid/generateUUID", () => { - const mockFn = vi.fn(() => "auto-generated-room-id"); - return { - generateUUID: mockFn, - default: mockFn, - }; -}); - -vi.mock("@/lib/chat/createNewRoom", () => ({ - createNewRoom: vi.fn(), -})); - -vi.mock("@/lib/supabase/memories/insertMemories", () => ({ - default: vi.fn(), -})); - -vi.mock("@/lib/messages/filterMessageContentForMemories", () => ({ - default: vi.fn((msg: unknown) => msg), -})); - -vi.mock("@/lib/chat/setupConversation", () => ({ - setupConversation: vi.fn(), -})); - -const mockValidateAuthContext = vi.mocked(validateAuthContext); -const mockSetupChatRequest = vi.mocked(setupChatRequest); -const mockSaveChatCompletion = vi.mocked(saveChatCompletion); -const mockSetupConversation = vi.mocked(setupConversation); - -// Helper to create a mock agent with .generate() -/** - * - * @param generateResult - */ -function createMockAgent(generateResult: Record) { - return { - generate: vi.fn().mockResolvedValue(generateResult), - stream: vi.fn(), - tools: {}, - }; -} - -// Helper to create mock NextRequest -/** - * - * @param body - * @param headers - */ -function createMockRequest(body: unknown, headers: Record = {}): Request { - return { - json: () => Promise.resolve(body), - headers: { - get: (key: string) => headers[key.toLowerCase()] || null, - has: (key: string) => key.toLowerCase() in headers, - }, - } as unknown as Request; -} - -describe("handleChatGenerate", () => { - beforeEach(() => { - vi.clearAllMocks(); - // Default mock for setupConversation - mockSetupConversation.mockResolvedValue({ - roomId: "auto-generated-room-id", - memoryId: "auto-generated-memory-id", - }); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe("validation", () => { - it("returns 400 error when neither messages nor prompt is provided", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - - const request = createMockRequest({ roomId: "room-123" }, { "x-api-key": "test-key" }); - - const result = await handleChatGenerate(request as any); - - expect(result).toBeInstanceOf(NextResponse); - expect(result.status).toBe(400); - const json = await result.json(); - expect(json.status).toBe("error"); - }); - - it("returns 401 error when no auth header is provided", async () => { - mockValidateAuthContext.mockResolvedValue( - NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), - ); - const request = createMockRequest({ prompt: "Hello" }, {}); - - const result = await handleChatGenerate(request as any); - - expect(result).toBeInstanceOf(NextResponse); - expect(result.status).toBe(401); - }); - }); - - describe("text generation", () => { - it("returns generated text using agent.generate() for valid requests", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - - const mockAgent = createMockAgent({ - text: "Hello! How can I help you?", - reasoningText: undefined, - sources: [], - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { - messages: [], - headers: {}, - body: null, - }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - const request = createMockRequest({ prompt: "Hello, world!" }, { "x-api-key": "valid-key" }); - - const result = await handleChatGenerate(request as any); - - expect(mockAgent.generate).toHaveBeenCalled(); - expect(result.status).toBe(200); - const json = await result.json(); - expect(json.text).toBe("Hello! How can I help you?"); - expect(json.finishReason).toBe("stop"); - expect(json.usage).toEqual({ promptTokens: 10, completionTokens: 20 }); - }); - - it("uses messages array when provided", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - const messages = [{ role: "user", content: "Hello" }]; - const request = createMockRequest({ messages }, { "x-api-key": "valid-key" }); - - await handleChatGenerate(request as any); - - expect(mockSetupChatRequest).toHaveBeenCalledWith( - expect.objectContaining({ - messages, - accountId: "account-123", - }), - ); - }); - - it("passes through optional parameters", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "room-xyz", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - const request = createMockRequest( - { - prompt: "Hello", - roomId: "room-xyz", - artistId: "artist-abc", - model: "claude-3-opus", - excludeTools: ["tool1"], - }, - { "x-api-key": "valid-key" }, - ); - - await handleChatGenerate(request as any); - - expect(mockSetupChatRequest).toHaveBeenCalledWith( - expect.objectContaining({ - roomId: "room-xyz", - artistId: "artist-abc", - model: "claude-3-opus", - excludeTools: ["tool1"], - }), - ); - }); - - it("includes reasoningText when present", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - - const mockAgent = createMockAgent({ - text: "Response", - reasoningText: "Let me think about this...", - sources: [{ url: "https://example.com" }], - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); - - const result = await handleChatGenerate(request as any); - - expect(result.status).toBe(200); - const json = await result.json(); - expect(json.reasoningText).toBe("Let me think about this..."); - expect(json.sources).toEqual([{ url: "https://example.com" }]); - }); - }); - - describe("error handling", () => { - it("returns 500 error when setupChatRequest fails", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupChatRequest.mockRejectedValue(new Error("Setup failed")); - - const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); - - const result = await handleChatGenerate(request as any); - - expect(result).toBeInstanceOf(NextResponse); - expect(result.status).toBe(500); - const json = await result.json(); - expect(json.status).toBe("error"); - }); - - it("returns 500 error when agent.generate() fails", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - - const mockAgent = { - generate: vi.fn().mockRejectedValue(new Error("Generation failed")), - stream: vi.fn(), - tools: {}, - }; - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); - - const result = await handleChatGenerate(request as any); - - expect(result).toBeInstanceOf(NextResponse); - expect(result.status).toBe(500); - const json = await result.json(); - expect(json.status).toBe("error"); - }); - }); - - describe("accountId override", () => { - it("allows accountId override", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "target-account-456", - orgId: null, - authToken: "token", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - const request = createMockRequest( - { prompt: "Hello", accountId: "target-account-456" }, - { "x-api-key": "org-api-key" }, - ); - - await handleChatGenerate(request as any); - - expect(mockSetupChatRequest).toHaveBeenCalledWith( - expect.objectContaining({ - accountId: "target-account-456", - }), - ); - }); - }); - - describe("message persistence", () => { - it("saves assistant message to database when roomId is provided", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "room-abc-123", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "Hello! How can I help you?", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - mockSaveChatCompletion.mockResolvedValue(null); - - const request = createMockRequest( - { prompt: "Hello", roomId: "room-abc-123" }, - { "x-api-key": "valid-key" }, - ); - - await handleChatGenerate(request as any); - - expect(mockSaveChatCompletion).toHaveBeenCalledWith({ - text: "Hello! How can I help you?", - roomId: "room-abc-123", - }); - }); - - it("saves message with auto-generated roomId when roomId is not provided", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "auto-generated-room-id", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - mockSaveChatCompletion.mockResolvedValue(null); - - const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); - - await handleChatGenerate(request as any); - - // Since roomId is auto-created, saveChatCompletion should be called - expect(mockSaveChatCompletion).toHaveBeenCalledWith({ - text: "Response", - roomId: "auto-generated-room-id", - }); - }); - - it("includes roomId in HTTP response when provided by client", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "client-provided-room-id", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - mockSaveChatCompletion.mockResolvedValue(null); - - const request = createMockRequest( - { prompt: "Hello", roomId: "client-provided-room-id" }, - { "x-api-key": "valid-key" }, - ); - - const result = await handleChatGenerate(request as any); - - expect(result.status).toBe(200); - const json = await result.json(); - expect(json.roomId).toBe("client-provided-room-id"); - }); - - it("includes auto-generated roomId in HTTP response when not provided", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "auto-generated-room-456", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - mockSaveChatCompletion.mockResolvedValue(null); - - const request = createMockRequest({ prompt: "Hello" }, { "x-api-key": "valid-key" }); - - const result = await handleChatGenerate(request as any); - - expect(result.status).toBe(200); - const json = await result.json(); - expect(json.roomId).toBe("auto-generated-room-456"); - }); - - it("passes correct text to saveChatCompletion", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "room-xyz", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "This is the assistant response text", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - mockSaveChatCompletion.mockResolvedValue(null); - - const request = createMockRequest( - { prompt: "Hello", roomId: "room-xyz" }, - { "x-api-key": "valid-key" }, - ); - - await handleChatGenerate(request as any); - - expect(mockSaveChatCompletion).toHaveBeenCalledWith({ - text: "This is the assistant response text", - roomId: "room-xyz", - }); - }); - - it("still returns success response even if saveChatCompletion fails", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "token", - }); - mockSetupConversation.mockResolvedValue({ - roomId: "room-abc", - memoryId: "memory-id", - }); - - const mockAgent = createMockAgent({ - text: "Response", - finishReason: "stop", - usage: { promptTokens: 10, completionTokens: 20 }, - response: { messages: [], headers: {}, body: null }, - }); - - mockSetupChatRequest.mockResolvedValue({ - agent: mockAgent, - messages: [], - } as any); - - mockSaveChatCompletion.mockRejectedValue(new Error("Database error")); - - const request = createMockRequest( - { prompt: "Hello", roomId: "room-abc" }, - { "x-api-key": "valid-key" }, - ); - - const result = await handleChatGenerate(request as any); - - expect(result.status).toBe(200); - const json = await result.json(); - expect(json.text).toBe("Response"); - }); - }); -}); diff --git a/lib/chat/buildRunAgentInput.ts b/lib/chat/buildRunAgentInput.ts index 95b0df423..0630bf562 100644 --- a/lib/chat/buildRunAgentInput.ts +++ b/lib/chat/buildRunAgentInput.ts @@ -20,9 +20,14 @@ export type BuildRunAgentInputParams = { /** * Short-lived bearer for in-sandbox recoup-api calls: the user's Privy JWT * (interactive `/api/chat/workflow`) or an ephemeral account key (headless - * `/api/chat/generate`). Omitted when absent so the service key never leaks. + * `/api/chat/runs`). Omitted when absent so the service key never leaks. */ recoupAccessToken?: string; + /** + * Row id of an ephemeral key minted for a headless run, so the workflow can + * delete it on run end (recoupable/chat#1813). Interactive callers omit it. + */ + ephemeralKeyId?: string; }; /** @@ -43,6 +48,7 @@ export function buildRunAgentInput({ workingDirectory, skills, recoupAccessToken, + ephemeralKeyId, }: BuildRunAgentInputParams): RunAgentWorkflowInput { const repoIds = parseGitHubRepoIdentifiers(cloneUrl); const recoupOrgId = cloneUrl ? (extractOrgId(cloneUrl) ?? undefined) : undefined; @@ -61,6 +67,7 @@ export function buildRunAgentInput({ recoupOrgId, skills, ...(recoupAccessToken ? { recoupAccessToken } : {}), + ...(ephemeralKeyId ? { ephemeralKeyId } : {}), }, }; } diff --git a/lib/chat/handleChatGenerate.ts b/lib/chat/handleChatGenerate.ts deleted file mode 100644 index 1f0a4a970..000000000 --- a/lib/chat/handleChatGenerate.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { validateChatRequest } from "./validateChatRequest"; -import { setupChatRequest } from "./setupChatRequest"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { saveChatCompletion } from "./saveChatCompletion"; - -/** - * Handles a non-streaming chat generate request. - * - * This function: - * 1. Validates the request (auth, body schema) - * 2. Sets up the chat configuration (agent, model, tools) - * 3. Generates text using the AI SDK's generateText - * 4. Persists the assistant message to the database (if roomId is provided) - * 5. Returns a JSON response with text, reasoning, sources, etc. - * - * @param request - The incoming NextRequest - * @returns A JSON response or error NextResponse - */ -export async function handleChatGenerate(request: NextRequest): Promise { - const validatedBodyOrError = await validateChatRequest(request); - if (validatedBodyOrError instanceof NextResponse) { - return validatedBodyOrError; - } - const body = validatedBodyOrError; - - try { - const chatConfig = await setupChatRequest(body); - const { agent } = chatConfig; - - const result = await agent.generate(chatConfig); - - // Save assistant message to database - // Note: roomId is always defined after validateChatRequest (auto-created if not provided) - try { - await saveChatCompletion({ - text: result.text, - roomId: body.roomId, - }); - } catch (error) { - // Log error but don't fail the request - message persistence is non-critical - console.error("Failed to persist assistant message:", error); - } - - return NextResponse.json( - { - text: result.text, - roomId: body.roomId, - reasoningText: result.reasoningText, - sources: result.sources, - finishReason: result.finishReason, - usage: result.usage, - response: { - messages: result.response.messages, - headers: result.response.headers, - body: result.response.body, - }, - }, - { - status: 200, - headers: getCorsHeaders(), - }, - ); - } catch (e) { - console.error("/api/chat/generate Global error:", e); - return NextResponse.json( - { - status: "error", - message: e instanceof Error ? e.message : "Unknown error", - }, - { - status: 500, - headers: getCorsHeaders(), - }, - ); - } -} diff --git a/lib/chat/runs/__tests__/handleChatRunStatus.test.ts b/lib/chat/runs/__tests__/handleChatRunStatus.test.ts new file mode 100644 index 000000000..d1f4169e8 --- /dev/null +++ b/lib/chat/runs/__tests__/handleChatRunStatus.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { handleChatRunStatus } from "@/lib/chat/runs/handleChatRunStatus"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { getRun } from "workflow/api"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); +vi.mock("workflow/api", () => ({ + getRun: vi.fn(), +})); + +const req = () => + new NextRequest("https://x.test/api/chat/runs/wrun_abc", { + headers: { "x-api-key": "recoup_sk_test" }, + }); + +const okAuth = { accountId: "acc-1", orgId: null, authToken: "recoup_sk_test" }; + +describe("handleChatRunStatus", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + }); + + it("returns 200 { runId, status } mapping the workflow status", async () => { + vi.mocked(getRun).mockReturnValue({ status: Promise.resolve("running") } as never); + const res = await handleChatRunStatus(req(), "wrun_abc"); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ runId: "wrun_abc", status: "running" }); + }); + + it("normalizes pending → running and completed/failed/cancelled through", async () => { + for (const [raw, want] of [ + ["pending", "running"], + ["completed", "completed"], + ["failed", "failed"], + ["cancelled", "cancelled"], + ] as const) { + vi.mocked(getRun).mockReturnValue({ status: Promise.resolve(raw) } as never); + const res = await handleChatRunStatus(req(), "wrun_abc"); + expect((await res.json()).status).toBe(want); + } + }); + + it("returns the auth error short-circuit", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error" }, { status: 401 }), + ); + const res = await handleChatRunStatus(req(), "wrun_abc"); + expect(res.status).toBe(401); + expect(getRun).not.toHaveBeenCalled(); + }); + + it("404s when the run is not found (getRun throws)", async () => { + vi.mocked(getRun).mockImplementation(() => { + throw new Error("run not found"); + }); + const res = await handleChatRunStatus(req(), "wrun_missing"); + expect(res.status).toBe(404); + }); +}); diff --git a/lib/chat/runs/__tests__/handleStartChatRun.test.ts b/lib/chat/runs/__tests__/handleStartChatRun.test.ts new file mode 100644 index 000000000..3f1c8555b --- /dev/null +++ b/lib/chat/runs/__tests__/handleStartChatRun.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { handleStartChatRun } from "@/lib/chat/runs/handleStartChatRun"; +import { validateChatRunRequest } from "@/lib/chat/runs/validateChatRunRequest"; +import { provisionRunSession } from "@/lib/chat/runs/provisionRunSession"; +import { mintEphemeralAccountKey } from "@/lib/keys/mintEphemeralAccountKey"; +import { deleteApiKey } from "@/lib/supabase/account_api_keys/deleteApiKey"; +import { buildRunAgentInput } from "@/lib/chat/buildRunAgentInput"; +import { start } from "workflow/api"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); +vi.mock("@/lib/chat/runs/validateChatRunRequest", () => ({ + validateChatRunRequest: vi.fn(), +})); +vi.mock("@/lib/chat/runs/provisionRunSession", () => ({ + provisionRunSession: vi.fn(), +})); +vi.mock("@/lib/keys/mintEphemeralAccountKey", () => ({ + mintEphemeralAccountKey: vi.fn(), +})); +vi.mock("@/lib/supabase/account_api_keys/deleteApiKey", () => ({ + deleteApiKey: vi.fn(async () => ({ error: null })), +})); +vi.mock("@/lib/chat/buildRunAgentInput", () => ({ + buildRunAgentInput: vi.fn(x => ({ built: true, ...x })), +})); +vi.mock("workflow/api", () => ({ + start: vi.fn(), +})); +vi.mock("@/app/lib/workflows/runAgentWorkflow", () => ({ runAgentWorkflow: vi.fn() })); + +const req = () => + new NextRequest("https://x.test/api/chat/generate", { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": "recoup_sk_test" }, + body: JSON.stringify({ prompt: "go" }), + }); + +const validated = { + accountId: "acc-1", + orgId: null, + messages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "go" }] }], + artistId: undefined, + modelId: "anthropic/claude-haiku-4.5", +}; + +const provisioned = { + session: { + id: "sess-1", + clone_url: "https://github.com/recoupable/acc-1", + title: "Scheduled generation", + }, + chat: { id: "chat-1" }, + sandboxState: { type: "vercel", sandboxName: "session-sess-1" }, + workingDirectory: "/vercel/sandbox", + skills: [], +}; + +describe("handleStartChatRun", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateChatRunRequest).mockResolvedValue(validated as never); + vi.mocked(provisionRunSession).mockResolvedValue(provisioned as never); + vi.mocked(mintEphemeralAccountKey).mockResolvedValue({ + rawKey: "recoup_sk_raw", + keyId: "key-1", + }); + vi.mocked(start).mockResolvedValue({ runId: "wrun_abc" } as never); + }); + + it("provisions, mints, starts the workflow, and returns 202 { runId }", async () => { + const res = await handleStartChatRun(req()); + expect(res.status).toBe(202); + expect(res.headers.get("Location")).toBe("/api/chat/runs/wrun_abc"); + expect(await res.json()).toEqual({ + runId: "wrun_abc", + chatId: "chat-1", + sessionId: "sess-1", + }); + + expect(provisionRunSession).toHaveBeenCalledWith( + expect.objectContaining({ accountId: "acc-1", title: "Scheduled generation" }), + ); + // the minted key is injected as recoupAccessToken AND threaded as ephemeralKeyId + expect(buildRunAgentInput).toHaveBeenCalledWith( + expect.objectContaining({ + chatId: "chat-1", + sessionId: "sess-1", + recoupAccessToken: "recoup_sk_raw", + ephemeralKeyId: "key-1", + }), + ); + expect(start).toHaveBeenCalledOnce(); + // key is NOT deleted here — the workflow's finally owns that on run end + expect(deleteApiKey).not.toHaveBeenCalled(); + }); + + it("returns the validation error short-circuit", async () => { + vi.mocked(validateChatRunRequest).mockResolvedValue( + NextResponse.json({ status: "error" }, { status: 401 }), + ); + const res = await handleStartChatRun(req()); + expect(res.status).toBe(401); + expect(provisionRunSession).not.toHaveBeenCalled(); + }); + + it("revokes the minted key and 500s when start() fails", async () => { + vi.mocked(start).mockRejectedValue(new Error("workflow start boom")); + const res = await handleStartChatRun(req()); + expect(res.status).toBe(500); + expect(deleteApiKey).toHaveBeenCalledWith("key-1"); + }); + + it("does not mint or delete a key when provisioning fails", async () => { + vi.mocked(provisionRunSession).mockRejectedValue(new Error("repo boom")); + const res = await handleStartChatRun(req()); + expect(res.status).toBe(500); + expect(mintEphemeralAccountKey).not.toHaveBeenCalled(); + expect(deleteApiKey).not.toHaveBeenCalled(); + }); +}); diff --git a/lib/chat/runs/__tests__/normalizeRunStatus.test.ts b/lib/chat/runs/__tests__/normalizeRunStatus.test.ts new file mode 100644 index 000000000..1b6ccd845 --- /dev/null +++ b/lib/chat/runs/__tests__/normalizeRunStatus.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "vitest"; +import { normalizeRunStatus } from "@/lib/chat/runs/normalizeRunStatus"; + +describe("normalizeRunStatus", () => { + it("maps known states to the documented enum", () => { + expect(normalizeRunStatus("queued")).toBe("queued"); + expect(normalizeRunStatus("running")).toBe("running"); + expect(normalizeRunStatus("pending")).toBe("running"); + expect(normalizeRunStatus("completed")).toBe("completed"); + expect(normalizeRunStatus("succeeded")).toBe("completed"); + expect(normalizeRunStatus("failed")).toBe("failed"); + expect(normalizeRunStatus("error")).toBe("failed"); + expect(normalizeRunStatus("cancelled")).toBe("cancelled"); + expect(normalizeRunStatus("canceled")).toBe("cancelled"); + }); + + it("is case-insensitive", () => { + expect(normalizeRunStatus("COMPLETED")).toBe("completed"); + expect(normalizeRunStatus("Running")).toBe("running"); + }); + + it("defaults unknown strings to running (no invented terminal state)", () => { + expect(normalizeRunStatus("something-new")).toBe("running"); + }); +}); diff --git a/lib/chat/runs/__tests__/validateChatRunRequest.test.ts b/lib/chat/runs/__tests__/validateChatRunRequest.test.ts new file mode 100644 index 000000000..aae8f0903 --- /dev/null +++ b/lib/chat/runs/__tests__/validateChatRunRequest.test.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateChatRunRequest } from "@/lib/chat/runs/validateChatRunRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +function req(body: unknown): NextRequest { + return new NextRequest("https://x.test/api/chat/runs", { + method: "POST", + headers: { "content-type": "application/json", "x-api-key": "recoup_sk_test" }, + body: JSON.stringify(body), + }); +} + +const okAuth = { accountId: "acc-1", orgId: null, authToken: "recoup_sk_test" }; + +describe("validateChatRunRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(validateAuthContext).mockResolvedValue(okAuth); + }); + + it("converts a prompt into a single user UIMessage", async () => { + const result = await validateChatRunRequest(req({ prompt: "weekly report please" })); + expect(result).not.toBeInstanceOf(NextResponse); + if (result instanceof NextResponse) return; + expect(result.accountId).toBe("acc-1"); + expect(result.messages).toHaveLength(1); + expect(result.messages[0].role).toBe("user"); + expect(JSON.stringify(result.messages[0].parts)).toContain("weekly report please"); + }); + + it("passes messages through and applies the model override + default", async () => { + const withModel = await validateChatRunRequest( + req({ + messages: [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }], + model: "anthropic/claude-opus-4-8", + }), + ); + if (withModel instanceof NextResponse) throw new Error("unexpected error"); + expect(withModel.modelId).toBe("anthropic/claude-opus-4-8"); + + const noModel = await validateChatRunRequest(req({ prompt: "hi" })); + if (noModel instanceof NextResponse) throw new Error("unexpected error"); + expect(noModel.modelId).toBe("anthropic/claude-haiku-4.5"); + }); + + it("rejects when neither prompt nor messages is provided (400)", async () => { + const result = await validateChatRunRequest(req({ model: "anthropic/claude-haiku-4.5" })); + expect(result).toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) return; + expect(result.status).toBe(400); + }); + + it("rejects a whitespace-only prompt (400)", async () => { + const result = await validateChatRunRequest(req({ prompt: " \n\t " })); + expect(result).toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) return; + expect(result.status).toBe(400); + }); + + it("returns the auth error response when auth fails", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json({ status: "error" }, { status: 401 }), + ); + const result = await validateChatRunRequest(req({ prompt: "hi" })); + expect(result).toBeInstanceOf(NextResponse); + if (!(result instanceof NextResponse)) return; + expect(result.status).toBe(401); + }); + + it("forwards body accountId override to validateAuthContext", async () => { + await validateChatRunRequest(req({ prompt: "hi", accountId: "member-acc" })); + expect(validateAuthContext).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ accountId: "member-acc" }), + ); + }); +}); diff --git a/lib/chat/runs/handleChatRunStatus.ts b/lib/chat/runs/handleChatRunStatus.ts new file mode 100644 index 000000000..a26fcbd8f --- /dev/null +++ b/lib/chat/runs/handleChatRunStatus.ts @@ -0,0 +1,35 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getRun } from "workflow/api"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { normalizeRunStatus } from "@/lib/chat/runs/normalizeRunStatus"; + +/** + * Handles `GET /api/chat/runs/{runId}` — a point-in-time status snapshot for an + * asynchronous run started via `POST /api/chat/runs` (recoupable/chat#1813). + * Wraps the durable workflow's `getRun(runId).status`; returns `{ runId, status }`. + * Not the generated content — read that via the chat (`chatId` from the 202 start + * response): `GET /api/chat/{chatId}/stream` or the persisted messages. + * + * @param request - The incoming request (x-api-key auth). + * @param runId - The durable workflow run id from the path. + * @returns 200 `{ runId, status }`, 401/403 on auth, or 404 if the run is unknown. + */ +export async function handleChatRunStatus(request: NextRequest, runId: string): Promise { + const auth = await validateAuthContext(request); + if (auth instanceof NextResponse) return auth; + + let rawStatus: string; + try { + rawStatus = await getRun(runId).status; + } catch (error) { + console.error(`[handleChatRunStatus] run not found ${runId}:`, error); + return errorResponse("Run not found", 404); + } + + return NextResponse.json( + { runId, status: normalizeRunStatus(rawStatus) }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/chat/runs/handleStartChatRun.ts b/lib/chat/runs/handleStartChatRun.ts new file mode 100644 index 000000000..2db6a305a --- /dev/null +++ b/lib/chat/runs/handleStartChatRun.ts @@ -0,0 +1,96 @@ +import { NextRequest, NextResponse } from "next/server"; +import { start } from "workflow/api"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { validateChatRunRequest } from "@/lib/chat/runs/validateChatRunRequest"; +import { provisionRunSession } from "@/lib/chat/runs/provisionRunSession"; +import { mintEphemeralAccountKey } from "@/lib/keys/mintEphemeralAccountKey"; +import { deleteApiKey } from "@/lib/supabase/account_api_keys/deleteApiKey"; +import { buildRunAgentInput } from "@/lib/chat/buildRunAgentInput"; +import { runAgentWorkflow } from "@/app/lib/workflows/runAgentWorkflow"; + +/** Default title for the session a headless run provisions (no caller-supplied title). */ +const DEFAULT_RUN_SESSION_TITLE = "Scheduled generation"; + +/** + * Handles `POST /api/chat/runs` — the headless, asynchronous counterpart of + * interactive `/api/chat`. Runs on the durable `runAgentWorkflow` + * (recoupable/chat#1813): it provisions a session + active sandbox, mints a + * short-lived account-scoped `recoup_sk_…` key for in-sandbox `recoup-api` + * calls, builds the shared workflow input via `buildRunAgentInput`, and + * `start()`s the run — returning `{ runId, chatId, sessionId }` with **202** + * (plus a `Location` header pointing at the run-status resource) immediately. + * + * Generation, assistant-message persistence, the credit charge, and the + * ephemeral-key revocation all happen server-side inside the workflow after + * this response. The legacy synchronous `ToolLoopAgent` path is gone. + * + * The minted key is injected as the agent's `recoupAccessToken` (so the service + * key never enters model-issued bash) and threaded as `ephemeralKeyId` so the + * workflow deletes it on run end; its ~15m TTL is the backstop. If the run + * fails to start, we revoke the key here since the workflow never ran. + * + * @param request - The incoming request (x-api-key auth). + * @returns 202 `{ runId, chatId, sessionId }`, or a 4xx/5xx error. + */ +export async function handleStartChatRun(request: NextRequest): Promise { + const validated = await validateChatRunRequest(request); + if (validated instanceof NextResponse) return validated; + + const { accountId, messages, artistId, modelId } = validated; + + let ephemeralKeyId: string | undefined; + try { + const provisioned = await provisionRunSession({ + accountId, + title: DEFAULT_RUN_SESSION_TITLE, + artistId, + }); + + const { rawKey, keyId } = await mintEphemeralAccountKey(accountId); + ephemeralKeyId = keyId; + + const run = await start(runAgentWorkflow, [ + buildRunAgentInput({ + messages, + chatId: provisioned.chat.id, + sessionId: provisioned.session.id, + accountId, + modelId, + sessionTitle: provisioned.session.title ?? undefined, + cloneUrl: provisioned.session.clone_url, + sandboxState: provisioned.sandboxState, + workingDirectory: provisioned.workingDirectory, + skills: provisioned.skills, + recoupAccessToken: rawKey, + ephemeralKeyId: keyId, + }), + ]); + + // Return the run handle plus the persisted-output identifiers so the caller + // can read the result later (the workflow runId alone can't be resolved back + // to the chat): GET /api/chat/{chatId}/stream resumes the stream, and the + // assistant messages persist under chatId. The Location header points at the + // run-status resource. Mirrors the async-job shape of POST /api/content/create. + return NextResponse.json( + { runId: run.runId, chatId: provisioned.chat.id, sessionId: provisioned.session.id }, + { + status: 202, + headers: { ...getCorsHeaders(), Location: `/api/chat/runs/${run.runId}` }, + }, + ); + } catch (error) { + // The workflow's `finally` revokes the key on run end — but if we never got + // there (provisioning ok, then mint ok, then start threw), the key would + // linger until its TTL. Revoke it now. If mint itself threw, there's no key. + if (ephemeralKeyId) { + try { + await deleteApiKey(ephemeralKeyId); + } catch (cleanupError) { + console.error("[handleStartChatRun] failed to revoke ephemeral key:", cleanupError); + } + } + console.error("[handleStartChatRun] failed to start generation run:", error); + return errorResponse("Internal server error", 500); + } +} diff --git a/lib/chat/runs/normalizeRunStatus.ts b/lib/chat/runs/normalizeRunStatus.ts new file mode 100644 index 000000000..f6411c8fb --- /dev/null +++ b/lib/chat/runs/normalizeRunStatus.ts @@ -0,0 +1,34 @@ +export type ChatRunStatus = "queued" | "running" | "completed" | "failed" | "cancelled"; + +/** + * Normalize a Vercel Workflow run state to the `ChatRunStatusResponse` enum + * documented for `GET /api/chat/runs/{runId}` (recoupable/chat#1813). `pending` + * is treated as running (matching the codebase's `RUNNING_STATUSES`). An unknown + * string is surfaced as `running` rather than inventing a terminal state. + * + * @param raw - The raw `getRun(runId).status` string. + * @returns The normalized lifecycle state. + */ +export function normalizeRunStatus(raw: string): ChatRunStatus { + switch (raw.toLowerCase()) { + case "queued": + return "queued"; + case "running": + case "pending": + return "running"; + case "completed": + case "complete": + case "succeeded": + case "success": + return "completed"; + case "failed": + case "errored": + case "error": + return "failed"; + case "cancelled": + case "canceled": + return "cancelled"; + default: + return "running"; + } +} diff --git a/lib/chat/runs/provisionRunSession.ts b/lib/chat/runs/provisionRunSession.ts new file mode 100644 index 000000000..f1ed3bd95 --- /dev/null +++ b/lib/chat/runs/provisionRunSession.ts @@ -0,0 +1,98 @@ +import ms from "ms"; +import { createSessionWithInitialChat } from "@/lib/sessions/createSessionWithInitialChat"; +import { connectSandbox } from "@/lib/sandbox/factory"; +import { getSessionSandboxName } from "@/lib/sandbox/getSessionSandboxName"; +import { resolveGitUser } from "@/lib/sandbox/resolveGitUser"; +import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; +import { markSessionSandboxActive } from "@/lib/sandbox/markSessionSandboxActive"; +import { discoverSkills } from "@/lib/skills/discoverSkills"; +import { getSandboxSkillDirectories } from "@/lib/skills/getSandboxSkillDirectories"; +import { DEFAULT_WORKING_DIRECTORY } from "@/lib/sandbox/vercel/sandbox/constants"; +import type { Json, Tables } from "@/types/database.types"; +import type { VercelState } from "@/lib/sandbox/vercel/state"; +import type { SkillMetadata } from "@/lib/skills/skillTypes"; + +const SANDBOX_TIMEOUT_MS = ms("30m"); + +export type ProvisionedRunSession = { + session: Tables<"sessions">; + chat: Tables<"chats">; + sandboxState: VercelState; + workingDirectory: string; + skills: SkillMetadata[]; +}; + +/** + * Headlessly provision a session + chat with an ACTIVE sandbox for a + * `POST /api/chat/runs` run (recoupable/chat#1813) — composing the same shared + * building blocks the interactive path uses: `createSessionWithInitialChat` + * (`POST /api/sessions`) + `connectSandbox` / `markSessionSandboxActive` + * (`POST /api/sandbox`). Server-side since there is no client session. + * + * @throws if repo/session/chat/sandbox provisioning fails — the caller maps it + * to a 5xx and revokes any minted ephemeral key. + */ +export async function provisionRunSession({ + accountId, + title, + artistId, +}: { + accountId: string; + title: string; + artistId?: string; +}): Promise { + const created = await createSessionWithInitialChat({ + accountId, + title, + chatTitle: "Scheduled generation", + artistId, + }); + if (created.ok === false) { + throw new Error( + created.reason === "repo" + ? "Failed to provision workspace repository" + : "Failed to create session", + ); + } + const { session, chat } = created; + + const sandbox = await connectSandbox({ + state: { + type: "vercel", + sandboxName: getSessionSandboxName(session.id), + source: { repo: session.clone_url, prebuilt: false }, + }, + options: { + timeout: SANDBOX_TIMEOUT_MS, + ports: [3000], + githubToken: getServiceGithubToken(), + gitUser: await resolveGitUser(accountId), + persistent: true, + resume: true, + createIfMissing: true, + }, + }); + + const updated = await markSessionSandboxActive(session, sandbox.getState() as Json); + if (!updated) throw new Error("Failed to activate session sandbox"); + + // Best-effort skill + working-directory discovery from the live handle — + // a failure falls back to defaults so the run can still start (tools surface + // the underlying issue when they reconnect). Mirrors handleChatWorkflowStream. + let workingDirectory = DEFAULT_WORKING_DIRECTORY; + let skills: SkillMetadata[] = []; + try { + workingDirectory = sandbox.workingDirectory; + skills = await discoverSkills(sandbox, await getSandboxSkillDirectories(sandbox)); + } catch (error) { + console.error("[provisionRunSession] skill discovery failed; using defaults:", error); + } + + return { + session: updated, + chat, + sandboxState: updated.sandbox_state as VercelState, + workingDirectory, + skills, + }; +} diff --git a/lib/chat/runs/validateChatRunRequest.ts b/lib/chat/runs/validateChatRunRequest.ts new file mode 100644 index 000000000..154f47e6c --- /dev/null +++ b/lib/chat/runs/validateChatRunRequest.ts @@ -0,0 +1,88 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import type { UIMessage } from "ai"; +import { z } from "zod"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { validationErrorResponse } from "@/lib/zod/validationErrorResponse"; +import { generateUUID } from "@/lib/uuid/generateUUID"; + +/** Default model for headless generation when the caller omits `model`. */ +export const DEFAULT_RUN_MODEL_ID = "anthropic/claude-haiku-4.5"; + +/** + * Body schema for `POST /api/chat/runs` (the durable-workflow re-point, + * recoupable/chat#1813). Exactly one of `prompt` / `messages` must be present. + * Mirrors `/api/chat`: no session-title / room / tool-exclusion params — this + * path mints its own session + chat (with a default title) and runs native + * sandbox tools. The legacy `topic` / `roomId` / `excludeTools` fields are gone. + */ +export const chatRunBodySchema = z.object({ + prompt: z.string().optional(), + messages: z.array(z.any()).optional(), + artistId: z.string().uuid("artistId must be a valid UUID").optional(), + accountId: z.string().optional(), + organizationId: z.string().optional(), + model: z.string().optional(), +}); + +export type ChatRunRequest = { + accountId: string; + orgId: string | null; + messages: UIMessage[]; + artistId?: string; + modelId: string; +}; + +/** + * Validates a `POST /api/chat/runs` request end-to-end: parses + validates + * the body, runs auth via `validateAuthContext` (x-api-key, with org-key + * account override), and normalizes `prompt`/`messages` into a `UIMessage[]`. + * + * @param request - The incoming NextRequest. + * @returns A NextResponse error short-circuit (400/401/403) or the validated, + * auth-augmented request ready to provision + start a workflow run. + */ +export async function validateChatRunRequest( + request: NextRequest, +): Promise { + let rawBody: unknown; + try { + rawBody = await request.json(); + } catch { + return errorResponse("Invalid JSON body", 400); + } + + const parsed = chatRunBodySchema.safeParse(rawBody); + if (!parsed.success) { + const firstError = parsed.error.issues[0]; + return validationErrorResponse(firstError.message, firstError.path); + } + + const { prompt, messages, artistId, accountId, organizationId, model } = parsed.data; + + const trimmedPrompt = typeof prompt === "string" ? prompt.trim() : ""; + const hasPrompt = trimmedPrompt.length > 0; + const hasMessages = Array.isArray(messages) && messages.length > 0; + if (hasPrompt === hasMessages) { + return errorResponse("Exactly one of prompt or messages must be provided", 400); + } + + const auth = await validateAuthContext(request, { + accountId, + organizationId: organizationId ?? null, + }); + if (auth instanceof NextResponse) return auth; + + const uiMessages: UIMessage[] = hasPrompt + ? [{ id: generateUUID(), role: "user", parts: [{ type: "text", text: trimmedPrompt }] }] + : (messages as UIMessage[]); + + return { + accountId: auth.accountId, + orgId: auth.orgId, + messages: uiMessages, + artistId, + modelId: model ?? DEFAULT_RUN_MODEL_ID, + }; +} diff --git a/lib/keys/__tests__/mintEphemeralAccountKey.test.ts b/lib/keys/__tests__/mintEphemeralAccountKey.test.ts new file mode 100644 index 000000000..1c7ba3614 --- /dev/null +++ b/lib/keys/__tests__/mintEphemeralAccountKey.test.ts @@ -0,0 +1,44 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { + mintEphemeralAccountKey, + DEFAULT_EPHEMERAL_KEY_TTL_MS, +} from "@/lib/keys/mintEphemeralAccountKey"; +import { insertApiKey } from "@/lib/supabase/account_api_keys/insertApiKey"; + +vi.mock("@/lib/keys/generateApiKey", () => ({ + generateApiKey: vi.fn(() => "recoup_sk_RAW"), +})); +vi.mock("@/lib/keys/hashApiKey", () => ({ + hashApiKey: vi.fn(() => "hashed"), +})); +vi.mock("@/lib/supabase/account_api_keys/insertApiKey", () => ({ + insertApiKey: vi.fn(), +})); +vi.mock("@/lib/const", () => ({ PRIVY_PROJECT_SECRET: "secret" })); + +describe("mintEphemeralAccountKey", () => { + beforeEach(() => vi.clearAllMocks()); + + it("mints an account-scoped recoup_sk_ key with a ~15m expiry and returns rawKey + keyId", async () => { + vi.mocked(insertApiKey).mockResolvedValue({ data: { id: "key-1" }, error: null } as never); + + const before = Date.now(); + const result = await mintEphemeralAccountKey("acc-1"); + const after = Date.now(); + + expect(result).toEqual({ rawKey: "recoup_sk_RAW", keyId: "key-1" }); + + const arg = vi.mocked(insertApiKey).mock.calls[0][0]; + expect(arg.account).toBe("acc-1"); + expect(arg.key_hash).toBe("hashed"); + const expMs = new Date(arg.expires_at as string).getTime(); + expect(expMs).toBeGreaterThanOrEqual(before + DEFAULT_EPHEMERAL_KEY_TTL_MS); + expect(expMs).toBeLessThanOrEqual(after + DEFAULT_EPHEMERAL_KEY_TTL_MS); + }); + + it("throws when the insert fails", async () => { + vi.mocked(insertApiKey).mockResolvedValue({ data: null, error: { message: "boom" } } as never); + await expect(mintEphemeralAccountKey("acc-1")).rejects.toThrow(/mint/i); + }); +}); diff --git a/lib/keys/mintEphemeralAccountKey.ts b/lib/keys/mintEphemeralAccountKey.ts new file mode 100644 index 000000000..8b138f279 --- /dev/null +++ b/lib/keys/mintEphemeralAccountKey.ts @@ -0,0 +1,45 @@ +import { generateApiKey } from "@/lib/keys/generateApiKey"; +import { hashApiKey } from "@/lib/keys/hashApiKey"; +import { insertApiKey } from "@/lib/supabase/account_api_keys/insertApiKey"; +import { PRIVY_PROJECT_SECRET } from "@/lib/const"; + +/** Default lifetime for an ephemeral key: 15 minutes. */ +export const DEFAULT_EPHEMERAL_KEY_TTL_MS = 15 * 60 * 1000; + +export type EphemeralAccountKey = { rawKey: string; keyId: string }; + +/** + * Mint a short-lived, account-scoped `recoup_sk_` api key for a headless run + * (recoupable/chat#1813). Returns the raw key — to inject as `$RECOUP_API_KEY` + * into the sandbox — and the row id, so the caller can delete it on run end. + * The key also auto-expires via `account_api_keys.expires_at` (defense in depth + * if the delete is missed; enforced in `getApiKeyAccountId`). The long-lived + * service key never enters the sandbox. + */ +export async function mintEphemeralAccountKey( + accountId: string, + { + ttlMs = DEFAULT_EPHEMERAL_KEY_TTL_MS, + name = "ephemeral:chat-generate", + }: { + ttlMs?: number; + name?: string; + } = {}, +): Promise { + const rawKey = generateApiKey("recoup_sk"); + const keyHash = hashApiKey(rawKey, PRIVY_PROJECT_SECRET); + const expiresAt = new Date(Date.now() + ttlMs).toISOString(); + + const { data, error } = await insertApiKey({ + name, + account: accountId, + key_hash: keyHash, + expires_at: expiresAt, + }); + + if (error || !data) { + throw new Error(`Failed to mint ephemeral api key: ${error?.message ?? "no row returned"}`); + } + + return { rawKey, keyId: data.id }; +} diff --git a/lib/sandbox/__tests__/markSessionSandboxActive.test.ts b/lib/sandbox/__tests__/markSessionSandboxActive.test.ts new file mode 100644 index 000000000..99621fa25 --- /dev/null +++ b/lib/sandbox/__tests__/markSessionSandboxActive.test.ts @@ -0,0 +1,36 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { markSessionSandboxActive } from "@/lib/sandbox/markSessionSandboxActive"; +import { buildActiveLifecycleUpdate } from "@/lib/sandbox/buildActiveLifecycleUpdate"; +import { updateSession } from "@/lib/supabase/sessions/updateSession"; + +vi.mock("@/lib/sandbox/buildActiveLifecycleUpdate", () => ({ + buildActiveLifecycleUpdate: vi.fn(() => ({ + lifecycle_state: "active", + sandbox_expires_at: "T+30m", + })), +})); +vi.mock("@/lib/supabase/sessions/updateSession", () => ({ updateSession: vi.fn() })); + +describe("markSessionSandboxActive", () => { + beforeEach(() => vi.clearAllMocks()); + + it("updates the session with the state, bumped version, active lifecycle, and cleared snapshot", async () => { + vi.mocked(updateSession).mockResolvedValue({ id: "sess-1" } as never); + const sessionRow = { id: "sess-1", lifecycle_version: 4 } as never; + const state = { type: "vercel", sandboxName: "session-sess-1" } as never; + + const out = await markSessionSandboxActive(sessionRow, state); + + expect(buildActiveLifecycleUpdate).toHaveBeenCalledWith(state); + expect(updateSession).toHaveBeenCalledWith("sess-1", { + sandbox_state: state, + lifecycle_version: 5, + lifecycle_state: "active", + sandbox_expires_at: "T+30m", + snapshot_url: null, + snapshot_created_at: null, + }); + expect(out).toEqual({ id: "sess-1" }); + }); +}); diff --git a/lib/sandbox/createSandboxHandler.ts b/lib/sandbox/createSandboxHandler.ts index a4f5237b5..3f2d435c4 100644 --- a/lib/sandbox/createSandboxHandler.ts +++ b/lib/sandbox/createSandboxHandler.ts @@ -3,7 +3,7 @@ import { NextRequest, NextResponse, after } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { validateCreateSandboxBody } from "@/lib/sandbox/validateCreateSandboxBody"; import { selectSessions } from "@/lib/supabase/sessions/selectSessions"; -import { buildActiveLifecycleUpdate } from "@/lib/sandbox/buildActiveLifecycleUpdate"; +import { markSessionSandboxActive } from "@/lib/sandbox/markSessionSandboxActive"; import { connectSandbox } from "@/lib/sandbox/factory"; import { findOrgSnapshot } from "@/lib/sandbox/findOrgSnapshot"; import { getSessionSandboxName } from "@/lib/sandbox/getSessionSandboxName"; @@ -12,7 +12,6 @@ import { kickBuildOrgSnapshotWorkflow } from "@/lib/sandbox/kickBuildOrgSnapshot import { kickSandboxLifecycleWorkflow } from "@/lib/sandbox/kickSandboxLifecycleWorkflow"; import { resolveGitUser } from "@/lib/sandbox/resolveGitUser"; import { extractOrgRepoName } from "@/lib/recoupable/extractOrgRepoName"; -import { updateSession } from "@/lib/supabase/sessions/updateSession"; import { getServiceGithubToken } from "@/lib/github/getServiceGithubToken"; import type { Json, Tables } from "@/types/database.types"; @@ -124,21 +123,12 @@ export async function createSandboxHandler(request: NextRequest): Promise, + sandboxState: Json, +): Promise | null> { + return updateSession(sessionRow.id, { + sandbox_state: sandboxState, + lifecycle_version: sessionRow.lifecycle_version + 1, + ...buildActiveLifecycleUpdate(sandboxState), + snapshot_url: null, + snapshot_created_at: null, + }); +} diff --git a/lib/sessions/__tests__/createSessionWithInitialChat.test.ts b/lib/sessions/__tests__/createSessionWithInitialChat.test.ts new file mode 100644 index 000000000..f30286ffd --- /dev/null +++ b/lib/sessions/__tests__/createSessionWithInitialChat.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { createSessionWithInitialChat } from "@/lib/sessions/createSessionWithInitialChat"; +import { ensurePersonalRepo } from "@/lib/recoupable/ensurePersonalRepo"; +import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow"; +import { insertSession } from "@/lib/supabase/sessions/insertSession"; +import { deleteSessionById } from "@/lib/supabase/sessions/deleteSessionById"; +import { insertChat } from "@/lib/supabase/chats/insertChat"; + +vi.mock("@/lib/uuid/generateUUID", () => ({ generateUUID: vi.fn(() => "chat-uuid") })); +vi.mock("@/lib/recoupable/ensurePersonalRepo", () => ({ ensurePersonalRepo: vi.fn() })); +vi.mock("@/lib/sessions/buildSessionInsertRow", () => ({ + buildSessionInsertRow: vi.fn(x => ({ row: true, ...x })), +})); +vi.mock("@/lib/supabase/sessions/insertSession", () => ({ insertSession: vi.fn() })); +vi.mock("@/lib/supabase/sessions/deleteSessionById", () => ({ deleteSessionById: vi.fn() })); +vi.mock("@/lib/supabase/chats/insertChat", () => ({ insertChat: vi.fn() })); + +const args = { accountId: "acc-1", title: "T", chatTitle: "New chat", artistId: "art-1" }; + +describe("createSessionWithInitialChat", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(ensurePersonalRepo).mockResolvedValue("https://github.com/recoupable/acc-1"); + vi.mocked(insertSession).mockResolvedValue({ id: "sess-1" } as never); + vi.mocked(insertChat).mockResolvedValue({ id: "chat-1" } as never); + vi.mocked(deleteSessionById).mockResolvedValue(true as never); + }); + + it("returns { ok, session, chat } on success and builds the row with the resolved clone url", async () => { + const r = await createSessionWithInitialChat(args); + expect(r).toEqual({ ok: true, session: { id: "sess-1" }, chat: { id: "chat-1" } }); + expect(buildSessionInsertRow).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: "acc-1", + cloneUrl: "https://github.com/recoupable/acc-1", + }), + ); + }); + + it("uses workspaceAccountId for the repo when provided", async () => { + await createSessionWithInitialChat({ ...args, workspaceAccountId: "org-9" }); + expect(ensurePersonalRepo).toHaveBeenCalledWith({ accountId: "org-9" }); + }); + + it("returns reason 'repo' when the workspace repo can't be provisioned", async () => { + vi.mocked(ensurePersonalRepo).mockResolvedValue(null); + expect(await createSessionWithInitialChat(args)).toEqual({ ok: false, reason: "repo" }); + expect(insertSession).not.toHaveBeenCalled(); + }); + + it("returns reason 'insert' when the session insert fails", async () => { + vi.mocked(insertSession).mockResolvedValue(null as never); + expect(await createSessionWithInitialChat(args)).toEqual({ ok: false, reason: "insert" }); + }); + + it("rolls back the session and returns 'insert' when the chat insert fails", async () => { + vi.mocked(insertChat).mockResolvedValue(null as never); + const r = await createSessionWithInitialChat(args); + expect(r).toEqual({ ok: false, reason: "insert" }); + expect(deleteSessionById).toHaveBeenCalledWith("sess-1"); + }); +}); diff --git a/lib/sessions/createSessionHandler.ts b/lib/sessions/createSessionHandler.ts index e48020537..9667695cd 100644 --- a/lib/sessions/createSessionHandler.ts +++ b/lib/sessions/createSessionHandler.ts @@ -1,14 +1,12 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { generateUUID } from "@/lib/uuid/generateUUID"; import { validateCreateSessionBody } from "@/lib/sessions/validateCreateSessionBody"; import { resolveSessionTitle } from "@/lib/sessions/resolveSessionTitle"; -import { ensurePersonalRepo } from "@/lib/recoupable/ensurePersonalRepo"; -import { buildSessionInsertRow } from "@/lib/sessions/buildSessionInsertRow"; +import { + createSessionWithInitialChat, + type CreateSessionWithChatResult, +} from "@/lib/sessions/createSessionWithInitialChat"; import { failedToCreateSession } from "@/lib/sessions/failedToCreateSession"; -import { insertSession } from "@/lib/supabase/sessions/insertSession"; -import { deleteSessionById } from "@/lib/supabase/sessions/deleteSessionById"; -import { insertChat } from "@/lib/supabase/chats/insertChat"; import { toSessionResponse } from "@/lib/sessions/toSessionResponse"; import { toChatResponse } from "@/lib/sessions/toChatResponse"; @@ -18,11 +16,12 @@ const INITIAL_CHAT_TITLE = "New chat"; * Handles `POST /api/sessions`. * * Authenticates, validates the request, resolves a final session - * title (provided > random city fallback), ensures the workspace repo - * exists at `recoupable/`, then creates - * a session row and an initial chat row. If the chat insert fails - * after the session row is persisted, the session is rolled back so - * callers never observe an orphaned session. + * title (provided > random city fallback), then provisions the workspace + * repo + session + initial chat via the shared + * `createSessionWithInitialChat` (also used by the headless + * `POST /api/chat/runs` path). If the chat insert fails after the session + * is persisted, the session is rolled back so callers never observe an + * orphaned session. * * The clone URL is derived server-side — callers never construct * GitHub URLs. Personal sessions (no `organizationId` in body) use @@ -44,47 +43,26 @@ export async function createSessionHandler(request: NextRequest): Promise; chat: Tables<"chats"> } + | { ok: false; reason: "repo" | "insert" }; + +/** + * Shared core for provisioning a session + its initial chat — used by both the + * interactive `POST /api/sessions` (`createSessionHandler`) and the headless + * `POST /api/chat/runs` path (`provisionRunSession`), so the two stay in + * lockstep (recoupable/chat#1813). + * + * Ensures the workspace repo exists (`recoupable/`), inserts + * the session row, then the initial chat row. If the chat insert fails after the + * session is persisted, the session is rolled back so callers never observe an + * orphaned session. + * + * The clone URL is derived server-side. `workspaceAccountId` (org id for org + * sessions) defaults to `accountId` for personal sessions. + * + * @returns `{ ok: true, session, chat }`, or `{ ok: false, reason }` where + * `"repo"` = workspace-repo provisioning failed and `"insert"` = a session/chat + * insert failed (session already rolled back). Callers map these to their own + * error envelope (the route returns 502/500; the headless path throws). + */ +export async function createSessionWithInitialChat({ + accountId, + workspaceAccountId, + title, + chatTitle, + artistId, +}: { + accountId: string; + workspaceAccountId?: string; + title: string; + chatTitle: string; + artistId?: string; +}): Promise { + const cloneUrl = await ensurePersonalRepo({ accountId: workspaceAccountId ?? accountId }); + if (!cloneUrl) return { ok: false, reason: "repo" }; + + const session = await insertSession( + buildSessionInsertRow({ accountId, title, cloneUrl, artistId }), + ); + if (!session) return { ok: false, reason: "insert" }; + + const chat = await insertChat({ id: generateUUID(), session_id: session.id, title: chatTitle }); + if (!chat) { + const rolledBack = await deleteSessionById(session.id); + if (!rolledBack) { + console.error( + "[createSessionWithInitialChat] chat insert failed and session rollback failed — orphaned session:", + session.id, + ); + } + return { ok: false, reason: "insert" }; + } + + return { ok: true, session, chat }; +} diff --git a/lib/supabase/account_api_keys/insertApiKey.ts b/lib/supabase/account_api_keys/insertApiKey.ts index 3904b2f46..3a058dede 100644 --- a/lib/supabase/account_api_keys/insertApiKey.ts +++ b/lib/supabase/account_api_keys/insertApiKey.ts @@ -8,12 +8,14 @@ import type { Database } from "@/types/database.types"; * @param input.name - The input object containing the name, account, and key_hash * @param input.account - The account ID * @param input.key_hash - The hash of the API key + * @param input.expires_at - Optional ISO expiry for ephemeral keys (NULL = never) * @returns The inserted API key */ export async function insertApiKey({ name, account, key_hash, + expires_at, }: Database["public"]["Tables"]["account_api_keys"]["Insert"]) { const { data, error } = await supabase .from("account_api_keys") @@ -21,6 +23,7 @@ export async function insertApiKey({ name, account, key_hash, + expires_at, }) .select() .single();