diff --git a/app/api/agents/signup/route.ts b/app/api/agents/signup/route.ts new file mode 100644 index 00000000..dfd2846c --- /dev/null +++ b/app/api/agents/signup/route.ts @@ -0,0 +1,45 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { + validateAgentSignupBody, + type AgentSignupBody, +} from "@/lib/agents/validateAgentSignupBody"; +import { agentSignupHandler } from "@/lib/agents/agentSignupHandler"; + +/** + * POST /api/agents/signup + * + * Register an agent. For new agent+ emails, returns an API key immediately. + * For all other cases, sends a verification code to the email. + * This endpoint is unauthenticated. + * + * @param req - The incoming request with email in body + * @returns Signup response with account_id, api_key (or null), and message + */ +export async function POST(req: NextRequest) { + const body = await safeParseJson(req); + + const validated = validateAgentSignupBody(body); + if (validated instanceof NextResponse) { + return validated; + } + + return agentSignupHandler(validated as AgentSignupBody); +} + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns NextResponse with CORS headers + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/app/api/agents/verify/route.ts b/app/api/agents/verify/route.ts new file mode 100644 index 00000000..f634bb7c --- /dev/null +++ b/app/api/agents/verify/route.ts @@ -0,0 +1,44 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { safeParseJson } from "@/lib/networking/safeParseJson"; +import { + validateAgentVerifyBody, + type AgentVerifyBody, +} from "@/lib/agents/validateAgentVerifyBody"; +import { agentVerifyHandler } from "@/lib/agents/agentVerifyHandler"; + +/** + * POST /api/agents/verify + * + * Verify an agent's email with the code sent during signup. + * Returns an API key on success. This endpoint is unauthenticated. + * + * @param req - The incoming request with email and code in body + * @returns Verify response with account_id, api_key, and message + */ +export async function POST(req: NextRequest) { + const body = await safeParseJson(req); + + const validated = validateAgentVerifyBody(body); + if (validated instanceof NextResponse) { + return validated; + } + + return agentVerifyHandler(validated as AgentVerifyBody); +} + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns NextResponse with CORS headers + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 204, + headers: getCorsHeaders(), + }); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/agents/__tests__/agentSignupHandler.test.ts b/lib/agents/__tests__/agentSignupHandler.test.ts new file mode 100644 index 00000000..9a290415 --- /dev/null +++ b/lib/agents/__tests__/agentSignupHandler.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { agentSignupHandler } from "@/lib/agents/agentSignupHandler"; + +import { selectAccountByEmail } from "@/lib/supabase/account_emails/selectAccountByEmail"; +import { insertAccount } from "@/lib/supabase/accounts/insertAccount"; +import { getPrivyUserByEmail } from "@/lib/privy/getPrivyUserByEmail"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/supabase/account_emails/selectAccountByEmail", () => ({ + selectAccountByEmail: vi.fn(), +})); + +vi.mock("@/lib/supabase/accounts/insertAccount", () => ({ + insertAccount: vi.fn(), +})); + +vi.mock("@/lib/supabase/account_emails/insertAccountEmail", () => ({ + insertAccountEmail: vi.fn(), +})); + +vi.mock("@/lib/supabase/credits_usage/insertCreditsUsage", () => ({ + insertCreditsUsage: vi.fn(), +})); + +vi.mock("@/lib/organizations/assignAccountToOrg", () => ({ + assignAccountToOrg: vi.fn(), +})); + +vi.mock("@/lib/keys/generateApiKey", () => ({ + generateApiKey: vi.fn(() => "recoup_sk_test123"), +})); + +vi.mock("@/lib/keys/hashApiKey", () => ({ + hashApiKey: vi.fn(() => "hashed_key"), +})); + +vi.mock("@/lib/supabase/account_api_keys/insertApiKey", () => ({ + insertApiKey: vi.fn(() => ({ data: {}, error: null })), +})); + +vi.mock("@/lib/privy/createPrivyUser", () => ({ + createPrivyUser: vi.fn(() => ({ id: "privy_user_123" })), +})); + +vi.mock("@/lib/privy/getPrivyUserByEmail", () => ({ + getPrivyUserByEmail: vi.fn(), +})); + +vi.mock("@/lib/privy/setPrivyCustomMetadata", () => ({ + setPrivyCustomMetadata: vi.fn(), +})); + +vi.mock("@/lib/agents/sendVerificationEmail", () => ({ + sendVerificationEmail: vi.fn(), +})); + +describe("agentSignupHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.PROJECT_SECRET = "test-secret"; + }); + + describe("agent+ prefix email (new account)", () => { + it("returns api_key immediately for new agent+ email", async () => { + vi.mocked(selectAccountByEmail).mockResolvedValue(null); + vi.mocked(insertAccount).mockResolvedValue({ id: "acc_123" } as unknown as Awaited< + ReturnType + >); + + const result = await agentSignupHandler({ email: "agent+bot@example.com" }); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body.account_id).toBe("acc_123"); + expect(body.api_key).toBe("recoup_sk_test123"); + expect(body.message).toBeTruthy(); + }); + }); + + describe("existing account", () => { + it("sends verification code and returns null api_key", async () => { + vi.mocked(selectAccountByEmail).mockResolvedValue({ + account_id: "acc_existing", + email: "user@example.com", + } as unknown as Awaited>); + vi.mocked(getPrivyUserByEmail).mockResolvedValue({ id: "privy_456" }); + + const result = await agentSignupHandler({ email: "user@example.com" }); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body.account_id).toBe("acc_existing"); + expect(body.api_key).toBeNull(); + }); + }); + + describe("normal email (new account)", () => { + it("creates account and sends verification code", async () => { + vi.mocked(selectAccountByEmail).mockResolvedValue(null); + vi.mocked(insertAccount).mockResolvedValue({ id: "acc_new" } as unknown as Awaited< + ReturnType + >); + vi.mocked(getPrivyUserByEmail).mockResolvedValue(null); + + const result = await agentSignupHandler({ email: "user@example.com" }); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body.account_id).toBe("acc_new"); + expect(body.api_key).toBeNull(); + }); + }); +}); diff --git a/lib/agents/__tests__/agentVerifyHandler.test.ts b/lib/agents/__tests__/agentVerifyHandler.test.ts new file mode 100644 index 00000000..d004f366 --- /dev/null +++ b/lib/agents/__tests__/agentVerifyHandler.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { agentVerifyHandler } from "@/lib/agents/agentVerifyHandler"; + +import { selectAccountByEmail } from "@/lib/supabase/account_emails/selectAccountByEmail"; +import { getPrivyUserByEmail } from "@/lib/privy/getPrivyUserByEmail"; +import { setPrivyCustomMetadata } from "@/lib/privy/setPrivyCustomMetadata"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/supabase/account_emails/selectAccountByEmail", () => ({ + selectAccountByEmail: vi.fn(), +})); + +vi.mock("@/lib/keys/generateApiKey", () => ({ + generateApiKey: vi.fn(() => "recoup_sk_verified123"), +})); + +vi.mock("@/lib/keys/hashApiKey", () => ({ + hashApiKey: vi.fn((input: string) => `hashed_${input}`), +})); + +vi.mock("@/lib/supabase/account_api_keys/insertApiKey", () => ({ + insertApiKey: vi.fn(() => ({ data: {}, error: null })), +})); + +vi.mock("@/lib/privy/getPrivyUserByEmail", () => ({ + getPrivyUserByEmail: vi.fn(), +})); + +vi.mock("@/lib/privy/setPrivyCustomMetadata", () => ({ + setPrivyCustomMetadata: vi.fn(), +})); + +describe("agentVerifyHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env.PROJECT_SECRET = "test-secret"; + }); + + it("returns api_key on correct code", async () => { + const codeHash = "hashed_123456"; + vi.mocked(getPrivyUserByEmail).mockResolvedValue({ + id: "privy_123", + custom_metadata: { + verification_code_hash: codeHash, + verification_expires_at: new Date(Date.now() + 60000).toISOString(), + verification_attempts: 0, + }, + }); + vi.mocked(selectAccountByEmail).mockResolvedValue({ + account_id: "acc_123", + email: "user@example.com", + } as unknown as Awaited>); + + const result = await agentVerifyHandler({ email: "user@example.com", code: "123456" }); + const body = await result.json(); + + expect(result.status).toBe(200); + expect(body.account_id).toBe("acc_123"); + expect(body.api_key).toBe("recoup_sk_verified123"); + expect(body.message).toBe("Verified"); + expect(setPrivyCustomMetadata).toHaveBeenCalledWith("privy_123", {}); + }); + + it("returns 400 for wrong code", async () => { + vi.mocked(getPrivyUserByEmail).mockResolvedValue({ + id: "privy_123", + custom_metadata: { + verification_code_hash: "hashed_correct_code", + verification_expires_at: new Date(Date.now() + 60000).toISOString(), + verification_attempts: 0, + }, + }); + + const result = await agentVerifyHandler({ email: "user@example.com", code: "wrong" }); + + expect(result.status).toBe(400); + expect(setPrivyCustomMetadata).toHaveBeenCalledWith( + "privy_123", + expect.objectContaining({ + verification_attempts: 1, + }), + ); + }); + + it("returns 429 after 5 failed attempts", async () => { + vi.mocked(getPrivyUserByEmail).mockResolvedValue({ + id: "privy_123", + custom_metadata: { + verification_code_hash: "hashed_code", + verification_expires_at: new Date(Date.now() + 60000).toISOString(), + verification_attempts: 5, + }, + }); + + const result = await agentVerifyHandler({ email: "user@example.com", code: "123456" }); + + expect(result.status).toBe(429); + }); + + it("returns 400 for expired code", async () => { + vi.mocked(getPrivyUserByEmail).mockResolvedValue({ + id: "privy_123", + custom_metadata: { + verification_code_hash: "hashed_123456", + verification_expires_at: new Date(Date.now() - 1000).toISOString(), + verification_attempts: 0, + }, + }); + + const result = await agentVerifyHandler({ email: "user@example.com", code: "123456" }); + + expect(result.status).toBe(400); + }); + + it("returns 400 when no privy user found", async () => { + vi.mocked(getPrivyUserByEmail).mockResolvedValue(null); + + const result = await agentVerifyHandler({ email: "user@example.com", code: "123456" }); + + expect(result.status).toBe(400); + }); + + it("returns 400 when no verification code stored", async () => { + vi.mocked(getPrivyUserByEmail).mockResolvedValue({ + id: "privy_123", + custom_metadata: {}, + }); + + const result = await agentVerifyHandler({ email: "user@example.com", code: "123456" }); + + expect(result.status).toBe(400); + }); +}); diff --git a/lib/agents/__tests__/isAgentPrefixEmail.test.ts b/lib/agents/__tests__/isAgentPrefixEmail.test.ts new file mode 100644 index 00000000..a995f623 --- /dev/null +++ b/lib/agents/__tests__/isAgentPrefixEmail.test.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from "vitest"; +import { isAgentPrefixEmail } from "@/lib/agents/isAgentPrefixEmail"; + +describe("isAgentPrefixEmail", () => { + it("returns true for agent+ prefix email", () => { + expect(isAgentPrefixEmail("agent+mybot@example.com")).toBe(true); + }); + + it("returns true for uppercase agent+ prefix", () => { + expect(isAgentPrefixEmail("Agent+MyBot@example.com")).toBe(true); + }); + + it("returns false for normal email", () => { + expect(isAgentPrefixEmail("user@example.com")).toBe(false); + }); + + it("returns false for email containing agent but not as prefix", () => { + expect(isAgentPrefixEmail("my-agent@example.com")).toBe(false); + }); +}); diff --git a/lib/agents/__tests__/validateAgentSignupBody.test.ts b/lib/agents/__tests__/validateAgentSignupBody.test.ts new file mode 100644 index 00000000..562a5842 --- /dev/null +++ b/lib/agents/__tests__/validateAgentSignupBody.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from "vitest"; +import { NextResponse } from "next/server"; +import { validateAgentSignupBody } from "@/lib/agents/validateAgentSignupBody"; + +describe("validateAgentSignupBody", () => { + it("returns validated body for valid email", () => { + const result = validateAgentSignupBody({ email: "agent+test@example.com" }); + expect(result).toEqual({ email: "agent+test@example.com" }); + }); + + it("returns validated body for normal email", () => { + const result = validateAgentSignupBody({ email: "user@example.com" }); + expect(result).toEqual({ email: "user@example.com" }); + }); + + it("returns 400 for missing email", () => { + const result = validateAgentSignupBody({}); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 for invalid email", () => { + const result = validateAgentSignupBody({ email: "not-an-email" }); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 for empty body", () => { + const result = validateAgentSignupBody(null); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); +}); diff --git a/lib/agents/__tests__/validateAgentVerifyBody.test.ts b/lib/agents/__tests__/validateAgentVerifyBody.test.ts new file mode 100644 index 00000000..3c08e209 --- /dev/null +++ b/lib/agents/__tests__/validateAgentVerifyBody.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from "vitest"; +import { NextResponse } from "next/server"; +import { validateAgentVerifyBody } from "@/lib/agents/validateAgentVerifyBody"; + +describe("validateAgentVerifyBody", () => { + it("returns validated body for valid input", () => { + const result = validateAgentVerifyBody({ email: "user@example.com", code: "123456" }); + expect(result).toEqual({ email: "user@example.com", code: "123456" }); + }); + + it("returns 400 for missing email", () => { + const result = validateAgentVerifyBody({ code: "123456" }); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 for missing code", () => { + const result = validateAgentVerifyBody({ email: "user@example.com" }); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 for empty code", () => { + const result = validateAgentVerifyBody({ email: "user@example.com", code: "" }); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); + + it("returns 400 for invalid email", () => { + const result = validateAgentVerifyBody({ email: "bad", code: "123456" }); + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + }); +}); diff --git a/lib/agents/agentSignupHandler.ts b/lib/agents/agentSignupHandler.ts new file mode 100644 index 00000000..13311428 --- /dev/null +++ b/lib/agents/agentSignupHandler.ts @@ -0,0 +1,158 @@ +import { NextResponse } from "next/server"; +import { randomInt } from "crypto"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { selectAccountByEmail } from "@/lib/supabase/account_emails/selectAccountByEmail"; +import { insertAccount } from "@/lib/supabase/accounts/insertAccount"; +import { insertAccountEmail } from "@/lib/supabase/account_emails/insertAccountEmail"; +import { insertCreditsUsage } from "@/lib/supabase/credits_usage/insertCreditsUsage"; +import { assignAccountToOrg } from "@/lib/organizations/assignAccountToOrg"; +import { generateApiKey } from "@/lib/keys/generateApiKey"; +import { hashApiKey } from "@/lib/keys/hashApiKey"; +import { insertApiKey } from "@/lib/supabase/account_api_keys/insertApiKey"; +import { isAgentPrefixEmail } from "@/lib/agents/isAgentPrefixEmail"; +import { createPrivyUser } from "@/lib/privy/createPrivyUser"; +import { getPrivyUserByEmail } from "@/lib/privy/getPrivyUserByEmail"; +import { setPrivyCustomMetadata } from "@/lib/privy/setPrivyCustomMetadata"; +import { sendVerificationEmail } from "@/lib/agents/sendVerificationEmail"; +import type { AgentSignupBody } from "@/lib/agents/validateAgentSignupBody"; + +const GENERIC_MESSAGE = + "If this is a new agent+ email, your API key is included. Otherwise, check your email for a verification code."; + +/** + * Handles agent signup flow. + * + * @param body - Validated signup request body + * @returns NextResponse with account_id, api_key (or null), and message + */ +export async function agentSignupHandler(body: AgentSignupBody): Promise { + const { email } = body; + + try { + const existingAccount = await selectAccountByEmail(email); + + if (existingAccount) { + // Case 3c: Account exists — send verification code + return handleExistingAccount(existingAccount.account_id, email); + } + + if (isAgentPrefixEmail(email)) { + // Case 3a: New account + agent+ prefix — instant key + return handleAgentPrefixSignup(email); + } + + // Case 3b: New account + normal email — verification required + return handleNormalSignup(email); + } catch (error) { + console.error("[ERROR] agentSignupHandler:", error); + return NextResponse.json( + { account_id: null, api_key: null, message: GENERIC_MESSAGE }, + { status: 200, headers: getCorsHeaders() }, + ); + } +} + +/** + * Creates a new account with email, credits, and org assignment. + * + * @param email - The email address for the new account + * @returns The new account ID + */ +async function createAccountWithEmail(email: string): Promise { + const account = await insertAccount({}); + await insertAccountEmail(account.id, email); + await insertCreditsUsage(account.id); + await assignAccountToOrg(account.id, email); + return account.id; +} + +/** + * Generates a new API key, hashes it, and stores it in the database. + * + * @param accountId - The account ID to associate the key with + * @returns The raw API key string + */ +async function generateAndStoreApiKey(accountId: string): Promise { + const today = new Date().toISOString().slice(0, 10); + const rawKey = generateApiKey("recoup_sk"); + const keyHash = hashApiKey(rawKey, process.env.PROJECT_SECRET!); + await insertApiKey({ name: `Agent ${today}`, account: accountId, key_hash: keyHash }); + return rawKey; +} + +/** + * Handles instant signup for agent+ prefix emails. + * + * @param email - The agent+ email address + * @returns NextResponse with api_key + */ +async function handleAgentPrefixSignup(email: string): Promise { + const accountId = await createAccountWithEmail(email); + + // Create Privy user and clear metadata + const privyUser = await createPrivyUser(email); + await setPrivyCustomMetadata(privyUser.id, {}); + + const rawKey = await generateAndStoreApiKey(accountId); + + return NextResponse.json( + { account_id: accountId, api_key: rawKey, message: GENERIC_MESSAGE }, + { status: 200, headers: getCorsHeaders() }, + ); +} + +/** + * Generates a verification code, stores its hash in Privy, and sends it via email. + * + * @param email - The email address to send the code to + */ +async function storeVerificationCode(email: string): Promise { + const code = randomInt(100000, 999999).toString(); + const codeHash = hashApiKey(code, process.env.PROJECT_SECRET!); + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + + let privyUser = await getPrivyUserByEmail(email); + if (!privyUser) { + privyUser = await createPrivyUser(email); + } + + await setPrivyCustomMetadata(privyUser.id, { + verification_code_hash: codeHash, + verification_expires_at: expiresAt, + verification_attempts: 0, + }); + + await sendVerificationEmail(email, code); +} + +/** + * Handles signup for an email that already has an account. + * + * @param accountId - The existing account ID + * @param email - The email address + * @returns NextResponse with null api_key + */ +async function handleExistingAccount(accountId: string, email: string): Promise { + await storeVerificationCode(email); + + return NextResponse.json( + { account_id: accountId, api_key: null, message: GENERIC_MESSAGE }, + { status: 200, headers: getCorsHeaders() }, + ); +} + +/** + * Handles signup for a normal email (creates account, sends verification code). + * + * @param email - The email address + * @returns NextResponse with null api_key + */ +async function handleNormalSignup(email: string): Promise { + const accountId = await createAccountWithEmail(email); + await storeVerificationCode(email); + + return NextResponse.json( + { account_id: accountId, api_key: null, message: GENERIC_MESSAGE }, + { status: 200, headers: getCorsHeaders() }, + ); +} diff --git a/lib/agents/agentVerifyHandler.ts b/lib/agents/agentVerifyHandler.ts new file mode 100644 index 00000000..ada1e73d --- /dev/null +++ b/lib/agents/agentVerifyHandler.ts @@ -0,0 +1,111 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { selectAccountByEmail } from "@/lib/supabase/account_emails/selectAccountByEmail"; +import { generateApiKey } from "@/lib/keys/generateApiKey"; +import { hashApiKey } from "@/lib/keys/hashApiKey"; +import { insertApiKey } from "@/lib/supabase/account_api_keys/insertApiKey"; +import { getPrivyUserByEmail } from "@/lib/privy/getPrivyUserByEmail"; +import { setPrivyCustomMetadata } from "@/lib/privy/setPrivyCustomMetadata"; +import type { AgentVerifyBody } from "@/lib/agents/validateAgentVerifyBody"; + +const GENERIC_ERROR = "Invalid or expired verification code."; +const MAX_ATTEMPTS = 5; + +/** + * Handles agent email verification flow. + * + * @param body - Validated verify request body + * @returns NextResponse with account_id, api_key, and message + */ +export async function agentVerifyHandler(body: AgentVerifyBody): Promise { + const { email, code } = body; + + try { + // Look up Privy user and custom_metadata + const privyUser = await getPrivyUserByEmail(email); + if (!privyUser?.custom_metadata) { + return NextResponse.json( + { error: GENERIC_ERROR }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const metadata = privyUser.custom_metadata as { + verification_code_hash?: string; + verification_expires_at?: string; + verification_attempts?: number; + }; + + // No code stored + if (!metadata.verification_code_hash) { + return NextResponse.json( + { error: GENERIC_ERROR }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + // Expired + if (metadata.verification_expires_at) { + const expiresAt = new Date(metadata.verification_expires_at).getTime(); + if (Date.now() > expiresAt) { + return NextResponse.json( + { error: GENERIC_ERROR }, + { status: 400, headers: getCorsHeaders() }, + ); + } + } + + // Too many attempts + const attempts = metadata.verification_attempts ?? 0; + if (attempts >= MAX_ATTEMPTS) { + return NextResponse.json( + { error: "Too many failed verification attempts. Please request a new code." }, + { status: 429, headers: getCorsHeaders() }, + ); + } + + // Compare hashes + const providedHash = hashApiKey(code, process.env.PROJECT_SECRET!); + if (providedHash !== metadata.verification_code_hash) { + // Increment attempts + await setPrivyCustomMetadata(privyUser.id, { + ...metadata, + verification_attempts: attempts + 1, + }); + + return NextResponse.json( + { error: GENERIC_ERROR }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + // Code matches — generate API key + const existingAccount = await selectAccountByEmail(email); + if (!existingAccount) { + return NextResponse.json( + { error: GENERIC_ERROR }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const accountId = existingAccount.account_id; + const today = new Date().toISOString().slice(0, 10); + const rawKey = generateApiKey("recoup_sk"); + const keyHash = hashApiKey(rawKey, process.env.PROJECT_SECRET!); + await insertApiKey({ name: `Agent ${today}`, account: accountId, key_hash: keyHash }); + + // Clear custom_metadata + await setPrivyCustomMetadata(privyUser.id, {}); + + return NextResponse.json( + { account_id: accountId, api_key: rawKey, message: "Verified" }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[ERROR] agentVerifyHandler:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/agents/isAgentPrefixEmail.ts b/lib/agents/isAgentPrefixEmail.ts new file mode 100644 index 00000000..9a6ae70b --- /dev/null +++ b/lib/agents/isAgentPrefixEmail.ts @@ -0,0 +1,9 @@ +/** + * Checks if an email address uses the agent+ prefix. + * + * @param email - The email address to check + * @returns True if the email starts with "agent+" + */ +export function isAgentPrefixEmail(email: string): boolean { + return email.toLowerCase().startsWith("agent+"); +} diff --git a/lib/agents/sendVerificationEmail.ts b/lib/agents/sendVerificationEmail.ts new file mode 100644 index 00000000..fa160f18 --- /dev/null +++ b/lib/agents/sendVerificationEmail.ts @@ -0,0 +1,21 @@ +import { sendEmailWithResend } from "@/lib/emails/sendEmail"; +import { RECOUP_FROM_EMAIL } from "@/lib/const"; + +/** + * Sends a 6-digit verification code to the given email address. + * + * @param email - The recipient email address + * @param code - The 6-digit verification code + */ +export async function sendVerificationEmail(email: string, code: string): Promise { + const result = await sendEmailWithResend({ + from: RECOUP_FROM_EMAIL, + to: email, + subject: "Your Recoup verification code", + html: `

Your verification code is: ${code}

This code expires in 24 hours.

`, + }); + + if (result instanceof Response) { + throw new Error("Failed to send verification email"); + } +} diff --git a/lib/agents/validateAgentSignupBody.ts b/lib/agents/validateAgentSignupBody.ts new file mode 100644 index 00000000..1571006c --- /dev/null +++ b/lib/agents/validateAgentSignupBody.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const agentSignupBodySchema = z.object({ + email: z.string({ message: "email is required" }).email("email must be a valid email address"), +}); + +export type AgentSignupBody = z.infer; + +/** + * Validates request body for POST /api/agents/signup. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body + */ +export function validateAgentSignupBody(body: unknown): NextResponse | AgentSignupBody { + const result = agentSignupBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/agents/validateAgentVerifyBody.ts b/lib/agents/validateAgentVerifyBody.ts new file mode 100644 index 00000000..cc9779d9 --- /dev/null +++ b/lib/agents/validateAgentVerifyBody.ts @@ -0,0 +1,37 @@ +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { z } from "zod"; + +export const agentVerifyBodySchema = z.object({ + email: z.string({ message: "email is required" }).email("email must be a valid email address"), + code: z.string({ message: "code is required" }).min(1, "code cannot be empty"), +}); + +export type AgentVerifyBody = z.infer; + +/** + * Validates request body for POST /api/agents/verify. + * + * @param body - The request body + * @returns A NextResponse with an error if validation fails, or the validated body + */ +export function validateAgentVerifyBody(body: unknown): NextResponse | AgentVerifyBody { + const result = agentVerifyBodySchema.safeParse(body); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { + status: "error", + missing_fields: firstError.path, + error: firstError.message, + }, + { + status: 400, + headers: getCorsHeaders(), + }, + ); + } + + return result.data; +} diff --git a/lib/privy/createPrivyUser.ts b/lib/privy/createPrivyUser.ts new file mode 100644 index 00000000..86c7d0df --- /dev/null +++ b/lib/privy/createPrivyUser.ts @@ -0,0 +1,26 @@ +/** + * Creates a new Privy user with a linked email account. + * + * @param email - The email address to link to the new Privy user + * @returns The created Privy user object + */ +export async function createPrivyUser(email: string): Promise<{ id: string }> { + const response = await fetch("https://api.privy.io/v1/users", { + method: "POST", + headers: { + "Content-Type": "application/json", + "privy-app-id": process.env.PRIVY_APP_ID!, + Authorization: `Basic ${btoa(process.env.PRIVY_APP_ID! + ":" + process.env.PRIVY_PROJECT_SECRET!)}`, + }, + body: JSON.stringify({ + linked_accounts: [{ type: "email", address: email }], + }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`Failed to create Privy user: ${response.status} ${errorBody}`); + } + + return response.json(); +} diff --git a/lib/privy/getPrivyUserByEmail.ts b/lib/privy/getPrivyUserByEmail.ts new file mode 100644 index 00000000..988d8e57 --- /dev/null +++ b/lib/privy/getPrivyUserByEmail.ts @@ -0,0 +1,30 @@ +/** + * Looks up a Privy user by email address. + * + * @param email - The email address to look up + * @returns The Privy user object if found, null if not found + */ +export async function getPrivyUserByEmail( + email: string, +): Promise<{ id: string; custom_metadata?: Record } | null> { + const response = await fetch("https://api.privy.io/v1/users/email/address", { + method: "POST", + headers: { + "Content-Type": "application/json", + "privy-app-id": process.env.PRIVY_APP_ID!, + Authorization: `Basic ${btoa(process.env.PRIVY_APP_ID! + ":" + process.env.PRIVY_PROJECT_SECRET!)}`, + }, + body: JSON.stringify({ email }), + }); + + if (response.status === 404) { + return null; + } + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`Failed to get Privy user by email: ${response.status} ${errorBody}`); + } + + return response.json(); +} diff --git a/lib/privy/setPrivyCustomMetadata.ts b/lib/privy/setPrivyCustomMetadata.ts new file mode 100644 index 00000000..db942d91 --- /dev/null +++ b/lib/privy/setPrivyCustomMetadata.ts @@ -0,0 +1,25 @@ +/** + * Sets custom metadata on a Privy user. + * + * @param userId - The Privy user ID + * @param metadata - The custom metadata to set + */ +export async function setPrivyCustomMetadata( + userId: string, + metadata: Record, +): Promise { + const response = await fetch(`https://api.privy.io/v1/users/${userId}/custom_metadata`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "privy-app-id": process.env.PRIVY_APP_ID!, + Authorization: `Basic ${btoa(process.env.PRIVY_APP_ID! + ":" + process.env.PRIVY_PROJECT_SECRET!)}`, + }, + body: JSON.stringify({ custom_metadata: metadata }), + }); + + if (!response.ok) { + const errorBody = await response.text(); + throw new Error(`Failed to set Privy custom metadata: ${response.status} ${errorBody}`); + } +}