diff --git a/lib/auth/__tests__/getApiKeyAccountId.test.ts b/lib/auth/__tests__/getApiKeyAccountId.test.ts new file mode 100644 index 000000000..e2a9aa7cc --- /dev/null +++ b/lib/auth/__tests__/getApiKeyAccountId.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; +import { getApiKeyAccountId } from "@/lib/auth/getApiKeyAccountId"; +import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAccountApiKeys"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) })); +vi.mock("@/lib/keys/hashApiKey", () => ({ hashApiKey: (k: string) => `hashed_${k}` })); +vi.mock("@/lib/const", () => ({ PRIVY_PROJECT_SECRET: "test_secret" })); +vi.mock("@/lib/supabase/account_api_keys/selectAccountApiKeys", () => ({ + selectAccountApiKeys: vi.fn(), +})); + +function req(apiKey?: string) { + const headers = new Headers(); + if (apiKey) headers.set("x-api-key", apiKey); + return new NextRequest("https://x.test/api", { headers }); +} + +const baseRow = { + id: "k", + account: "acc-1", + key_hash: "hashed_recoup_sk_x", + name: "n", + last_used: null, + created_at: "2026-01-01T00:00:00Z", +}; + +describe("getApiKeyAccountId", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns accountId for a non-expiring key (expires_at null)", async () => { + vi.mocked(selectAccountApiKeys).mockResolvedValue([{ ...baseRow, expires_at: null }]); + expect(await getApiKeyAccountId(req("recoup_sk_x"))).toBe("acc-1"); + }); + + it("returns accountId for a future expiry", async () => { + const future = new Date(Date.now() + 60_000).toISOString(); + vi.mocked(selectAccountApiKeys).mockResolvedValue([{ ...baseRow, expires_at: future }]); + expect(await getApiKeyAccountId(req("recoup_sk_x"))).toBe("acc-1"); + }); + + it("rejects an expired key with 401", async () => { + const past = new Date(Date.now() - 60_000).toISOString(); + vi.mocked(selectAccountApiKeys).mockResolvedValue([{ ...baseRow, expires_at: past }]); + const res = await getApiKeyAccountId(req("recoup_sk_x")); + expect(res).toBeInstanceOf(Response); + expect((res as Response).status).toBe(401); + }); + + it("401 when no x-api-key header", async () => { + const res = await getApiKeyAccountId(req()); + expect((res as Response).status).toBe(401); + }); +}); diff --git a/lib/auth/getApiKeyAccountId.ts b/lib/auth/getApiKeyAccountId.ts index 49af4106a..a5dce9ed6 100644 --- a/lib/auth/getApiKeyAccountId.ts +++ b/lib/auth/getApiKeyAccountId.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { hashApiKey } from "@/lib/keys/hashApiKey"; +import { isApiKeyExpired } from "@/lib/keys/isApiKeyExpired"; import { PRIVY_PROJECT_SECRET } from "@/lib/const"; import { selectAccountApiKeys } from "@/lib/supabase/account_api_keys/selectAccountApiKeys"; @@ -45,9 +46,11 @@ export async function getApiKeyAccountId(request: NextRequest): Promise ({ + parseGitHubRepoIdentifiers: vi.fn(() => ({ owner: "o", repo: "r" })), +})); +vi.mock("@/lib/recoupable/extractOrgId", () => ({ + extractOrgId: vi.fn(() => "org-1"), +})); + +const base = { + messages: [{ id: "m1", role: "user", parts: [] }] as never, + chatId: "chat-1", + sessionId: "sess-1", + accountId: "acc-1", + modelId: "anthropic/claude-haiku-4.5", + sessionTitle: "Weekly report", + cloneUrl: "https://github.com/o/r.git", + sandboxState: { type: "vercel", sandboxName: "sb-1" } as never, + workingDirectory: "/work", + skills: [] as never, +}; + +describe("buildRunAgentInput", () => { + beforeEach(() => vi.clearAllMocks()); + + it("assembles the workflow input, deriving repo ids + org id from cloneUrl", () => { + const input = buildRunAgentInput(base); + expect(input.chatId).toBe("chat-1"); + expect(input.sessionId).toBe("sess-1"); + expect(input.accountId).toBe("acc-1"); + expect(input.modelId).toBe("anthropic/claude-haiku-4.5"); + expect(input.sessionTitle).toBe("Weekly report"); + expect(input.repoOwner).toBe("o"); + expect(input.repoName).toBe("r"); + expect(input.agentContext.recoupOrgId).toBe("org-1"); + expect(input.agentContext.sandbox).toEqual({ + state: { type: "vercel", sandboxName: "sb-1" }, + workingDirectory: "/work", + }); + }); + + it("includes recoupAccessToken when provided", () => { + const input = buildRunAgentInput({ ...base, recoupAccessToken: "tok-123" }); + expect(input.agentContext.recoupAccessToken).toBe("tok-123"); + }); + + it("omits recoupAccessToken entirely when absent", () => { + const input = buildRunAgentInput(base); + expect("recoupAccessToken" in input.agentContext).toBe(false); + }); + + it("leaves recoupOrgId undefined when cloneUrl is null (no org derivation)", () => { + const input = buildRunAgentInput({ ...base, cloneUrl: null }); + expect(input.agentContext.recoupOrgId).toBeUndefined(); + expect(extractOrgId).not.toHaveBeenCalled(); + expect(parseGitHubRepoIdentifiers).toHaveBeenCalledWith(null); + }); +}); diff --git a/lib/chat/buildRunAgentInput.ts b/lib/chat/buildRunAgentInput.ts new file mode 100644 index 000000000..95b0df423 --- /dev/null +++ b/lib/chat/buildRunAgentInput.ts @@ -0,0 +1,66 @@ +import type { UIMessage } from "ai"; +import type { RunAgentWorkflowInput } from "@/app/lib/workflows/runAgentWorkflow"; +import type { DurableAgentContext } from "@/lib/agent/tools/AgentContext"; +import type { VercelState } from "@/lib/sandbox/vercel/state"; +import { parseGitHubRepoIdentifiers } from "@/lib/github/parseGitHubRepoIdentifiers"; +import { extractOrgId } from "@/lib/recoupable/extractOrgId"; + +export type BuildRunAgentInputParams = { + messages: UIMessage[]; + chatId: string; + sessionId: string; + accountId: string; + modelId: string; + sessionTitle?: string; + /** `session.clone_url` — the single source for repo ids + recoup org id. */ + cloneUrl: string | null; + sandboxState: VercelState; + workingDirectory: string; + skills: DurableAgentContext["skills"]; + /** + * 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. + */ + recoupAccessToken?: string; +}; + +/** + * Build the durable `RunAgentWorkflowInput` shared by the interactive and + * headless callers, so both construct workflow input identically + * (recoupable/chat#1813). Repo identifiers and the recoup org id are both + * derived from `cloneUrl` here — one source of truth, no caller duplication. + */ +export function buildRunAgentInput({ + messages, + chatId, + sessionId, + accountId, + modelId, + sessionTitle, + cloneUrl, + sandboxState, + workingDirectory, + skills, + recoupAccessToken, +}: BuildRunAgentInputParams): RunAgentWorkflowInput { + const repoIds = parseGitHubRepoIdentifiers(cloneUrl); + const recoupOrgId = cloneUrl ? (extractOrgId(cloneUrl) ?? undefined) : undefined; + + return { + messages, + chatId, + sessionId, + accountId, + modelId, + sessionTitle, + repoOwner: repoIds?.owner, + repoName: repoIds?.repo, + agentContext: { + sandbox: { state: sandboxState, workingDirectory }, + recoupOrgId, + skills, + ...(recoupAccessToken ? { recoupAccessToken } : {}), + }, + }; +} diff --git a/lib/chat/handleChatWorkflowStream.ts b/lib/chat/handleChatWorkflowStream.ts index 8d61ce96e..fab562e1c 100644 --- a/lib/chat/handleChatWorkflowStream.ts +++ b/lib/chat/handleChatWorkflowStream.ts @@ -14,8 +14,7 @@ import { persistLatestUserMessage } from "@/lib/chat/persistLatestUserMessage"; import { errorResponse } from "@/lib/networking/errorResponse"; import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; import { runAgentWorkflow } from "@/app/lib/workflows/runAgentWorkflow"; -import { extractOrgId } from "@/lib/recoupable/extractOrgId"; -import { parseGitHubRepoIdentifiers } from "@/lib/github/parseGitHubRepoIdentifiers"; +import { buildRunAgentInput } from "@/lib/chat/buildRunAgentInput"; import { DEFAULT_WORKING_DIRECTORY } from "@/lib/sandbox/vercel/sandbox/constants"; import { connectVercel } from "@/lib/sandbox/vercel/connect/connectVercel"; import type { VercelState } from "@/lib/sandbox/vercel/state"; @@ -92,9 +91,6 @@ export async function handleChatWorkflowStream(request: NextRequest): Promise { + const now = Date.parse("2026-06-23T00:00:00Z"); + + it("treats null/undefined expiry as never-expiring", () => { + expect(isApiKeyExpired(null, now)).toBe(false); + expect(isApiKeyExpired(undefined, now)).toBe(false); + }); + + it("is false for a future expiry", () => { + expect(isApiKeyExpired("2026-06-23T01:00:00Z", now)).toBe(false); + }); + + it("is true at or past the expiry", () => { + expect(isApiKeyExpired("2026-06-22T23:59:59Z", now)).toBe(true); + expect(isApiKeyExpired("2026-06-23T00:00:00Z", now)).toBe(true); + }); + + it("treats an unparseable expiry as non-expiring (never lock out)", () => { + expect(isApiKeyExpired("not-a-date", now)).toBe(false); + }); +}); diff --git a/lib/keys/isApiKeyExpired.ts b/lib/keys/isApiKeyExpired.ts new file mode 100644 index 000000000..57e7632ec --- /dev/null +++ b/lib/keys/isApiKeyExpired.ts @@ -0,0 +1,15 @@ +/** + * Whether an api key's `expires_at` has passed. NULL/undefined = never expires. + * An unparseable value is treated as non-expiring so a bad row can't lock a + * caller out. Used by api auth to reject ephemeral keys past their TTL + * (recoupable/chat#1813). + */ +export function isApiKeyExpired( + expiresAt: string | null | undefined, + now: number = Date.now(), +): boolean { + if (!expiresAt) return false; + const exp = Date.parse(expiresAt); + if (Number.isNaN(exp)) return false; + return exp <= now; +} diff --git a/types/database.types.ts b/types/database.types.ts index 1771da89f..872750b72 100644 --- a/types/database.types.ts +++ b/types/database.types.ts @@ -12,6 +12,7 @@ export type Database = { Row: { account: string | null; created_at: string; + expires_at: string | null; id: string; key_hash: string | null; last_used: string | null; @@ -20,6 +21,7 @@ export type Database = { Insert: { account?: string | null; created_at?: string; + expires_at?: string | null; id?: string; key_hash?: string | null; last_used?: string | null; @@ -28,6 +30,7 @@ export type Database = { Update: { account?: string | null; created_at?: string; + expires_at?: string | null; id?: string; key_hash?: string | null; last_used?: string | null;