Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 10
feat: POST /api/emails + route ephemeral key to RECOUP_API_KEY (#1815)#708
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
4f27ebc133e4d9a1a7270File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; | ||
| import { sendEmailHandler } from "@/lib/emails/sendEmailHandler"; | ||
| /** | ||
| * OPTIONS handler for CORS preflight requests. | ||
| * | ||
| * @returns A NextResponse with CORS headers. | ||
| */ | ||
| export async function OPTIONS() { | ||
| return new NextResponse(null, { | ||
| status: 204, | ||
| headers: getCorsHeaders(), | ||
| }); | ||
| } | ||
| /** | ||
| * POST /api/emails | ||
| * | ||
| * Sends an email to one or more explicit recipients via Resend. Emails are sent | ||
| * from `Agent by Recoup <agent@recoupable.com>`. Account-scoped — requires | ||
| * authentication via x-api-key header or Authorization Bearer token. | ||
| * | ||
| * Body parameters: | ||
| * - to (required): array of recipient email addresses | ||
| * - subject (required): email subject line | ||
| * - text (optional): plain text / Markdown body | ||
| * - html (optional): raw HTML body (takes precedence over text) | ||
| * - cc (optional): array of CC email addresses | ||
| * - headers (optional): custom email headers | ||
| * - chat_id (optional): chat ID for a chat link in the footer | ||
| * - account_id (optional): UUID of the account to send for (org keys only) | ||
| * | ||
| * Recipient restriction: without a payment method on file, to/cc are limited to | ||
| * the account's own email; a card on file lifts the restriction (403 otherwise). | ||
| * | ||
| * @param request - The request object. | ||
| * @returns A NextResponse with the send result. | ||
| */ | ||
| export async function POST(request: NextRequest): Promise<NextResponse> { | ||
| return sendEmailHandler(request); | ||
| } | ||
| export const dynamic = "force-dynamic"; | ||
| export const fetchCache = "force-no-store"; | ||
| export const revalidate = 0; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { NextRequest } from "next/server"; | ||
| import { getAuthenticatedAccountId } from "@/lib/auth/getAuthenticatedAccountId"; | ||
| import { getAccountIdByApiKey } from "@/lib/auth/getAccountIdByApiKey"; | ||
| import { getOrCreateAccountIdByAuthToken } from "@/lib/privy/getOrCreateAccountIdByAuthToken"; | ||
| vi.mock("@/lib/networking/getCorsHeaders", () => ({ getCorsHeaders: () => ({}) })); | ||
| vi.mock("@/lib/auth/getAccountIdByApiKey", () => ({ getAccountIdByApiKey: vi.fn() })); | ||
| vi.mock("@/lib/privy/getOrCreateAccountIdByAuthToken", () => ({ | ||
| getOrCreateAccountIdByAuthToken: vi.fn(), | ||
| })); | ||
| function req(bearer?: string) { | ||
| const headers = new Headers(); | ||
| if (bearer) headers.set("authorization", `Bearer ${bearer}`); | ||
| return new NextRequest("https://x.test/api", { headers }); | ||
| } | ||
| describe("getAuthenticatedAccountId", () => { | ||
| beforeEach(() => vi.clearAllMocks()); | ||
| it("401 when no bearer token", async () => { | ||
| const res = await getAuthenticatedAccountId(req()); | ||
| expect((res as Response).status).toBe(401); | ||
| }); | ||
| it("validates a recoup_sk_ Bearer token as an API key (no Privy call)", async () => { | ||
| vi.mocked(getAccountIdByApiKey).mockResolvedValue("acc-key"); | ||
| const res = await getAuthenticatedAccountId(req("recoup_sk_abc")); | ||
| expect(res).toBe("acc-key"); | ||
| expect(getAccountIdByApiKey).toHaveBeenCalledWith("recoup_sk_abc"); | ||
| expect(getOrCreateAccountIdByAuthToken).not.toHaveBeenCalled(); | ||
| }); | ||
| it("401 when a recoup_sk_ Bearer key is unknown/expired", async () => { | ||
| vi.mocked(getAccountIdByApiKey).mockResolvedValue(null); | ||
| const res = await getAuthenticatedAccountId(req("recoup_sk_bad")); | ||
| expect((res as Response).status).toBe(401); | ||
| expect(getOrCreateAccountIdByAuthToken).not.toHaveBeenCalled(); | ||
| }); | ||
| it("treats a non-recoup_sk_ token as a Privy JWT (no API-key call)", async () => { | ||
| vi.mocked(getOrCreateAccountIdByAuthToken).mockResolvedValue("acc-privy"); | ||
| const res = await getAuthenticatedAccountId(req("eyJhbGci.jwt.value")); | ||
| expect(res).toBe("acc-privy"); | ||
| expect(getOrCreateAccountIdByAuthToken).toHaveBeenCalledWith("eyJhbGci.jwt.value"); | ||
| expect(getAccountIdByApiKey).not.toHaveBeenCalled(); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| 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"; | ||
| /** | ||
| * Resolve the account id for a raw Recoup API key (`recoup_sk_…`), or `null` | ||
| * when the key is unknown, the lookup fails, or the key is past its `expires_at` | ||
| * TTL (ephemeral keys — chat#1813). | ||
| * | ||
| * Shared by both auth entry points so a `recoup_sk_` key authenticates the same | ||
| * way whether it arrives as `x-api-key` (`getApiKeyAccountId`) or as | ||
| * `Authorization: Bearer` (`getAuthenticatedAccountId`). | ||
| * | ||
| * @param apiKey - The raw API key string. | ||
| */ | ||
| export async function getAccountIdByApiKey(apiKey: string): Promise<string | null> { | ||
| const keyHash = hashApiKey(apiKey, PRIVY_PROJECT_SECRET); | ||
| const apiKeys = await selectAccountApiKeys({ keyHash }); | ||
| if (apiKeys === null) { | ||
| console.error("[ERROR] selectAccountApiKeys returned null"); | ||
| return null; | ||
| } | ||
| const matched = apiKeys[0]; | ||
| const accountId = matched?.account ?? null; | ||
| // Reject an unknown key, or an ephemeral key past its TTL. | ||
| if (!accountId || isApiKeyExpired(matched?.expires_at)) { | ||
| return null; | ||
| } | ||
| return accountId; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,13 +1,11 @@ | ||||||||||||||||||||||||
| 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"; | ||||||||||||||||||||||||
| import { getAccountIdByApiKey } from "@/lib/auth/getAccountIdByApiKey"; | ||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||
| * Extracts and validates the API key from the request, | ||||||||||||||||||||||||
| * then returns the associated account ID. | ||||||||||||||||||||||||
| * Extracts the API key from the `x-api-key` header and returns the associated | ||||||||||||||||||||||||
| * account ID, delegating the hash/lookup/TTL check to `getAccountIdByApiKey` | ||||||||||||||||||||||||
| * (shared with the Bearer path). | ||||||||||||||||||||||||
| * | ||||||||||||||||||||||||
| * @param request - The NextRequest object | ||||||||||||||||||||||||
| * @returns Either the account ID string, or a NextResponse error if validation fails | ||||||||||||||||||||||||
| @@ -17,64 +15,19 @@ export async function getApiKeyAccountId(request: NextRequest): Promise<string | | ||||||||||||||||||||||||
| if (!apiKey) { | ||||||||||||||||||||||||
| return NextResponse.json( | ||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| status: "error", | ||||||||||||||||||||||||
| message: "x-api-key header required", | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| status: 401, | ||||||||||||||||||||||||
| headers: getCorsHeaders(), | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
| { status: "error", message: "x-api-key header required" }, | ||||||||||||||||||||||||
| { status: 401, headers: getCorsHeaders() }, | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||
| const keyHash = hashApiKey(apiKey, PRIVY_PROJECT_SECRET); | ||||||||||||||||||||||||
| const apiKeys = await selectAccountApiKeys({ keyHash }); | ||||||||||||||||||||||||
| const accountId = await getAccountIdByApiKey(apiKey); | ||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P2: Missing exception handling around API-key lookup can turn auth failures into unhandled 500s without this function’s standard error payload/headers. Restore local catch/log and return a hardcoded internal-error response. Prompt for AI agents
Suggested change
| ||||||||||||||||||||||||
| if (apiKeys === null) { | ||||||||||||||||||||||||
| console.error("[ERROR] selectAccountApiKeys returned null"); | ||||||||||||||||||||||||
| return NextResponse.json( | ||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| status: "error", | ||||||||||||||||||||||||
| message: "Internal server error", | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| status: 500, | ||||||||||||||||||||||||
| headers: getCorsHeaders(), | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| const matched = apiKeys[0]; | ||||||||||||||||||||||||
| const accountId = matched?.account ?? null; | ||||||||||||||||||||||||
| // Reject an unknown key, or an ephemeral key past its TTL (chat#1813). | ||||||||||||||||||||||||
| if (!accountId || isApiKeyExpired(matched?.expires_at)) { | ||||||||||||||||||||||||
| return NextResponse.json( | ||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| status: "error", | ||||||||||||||||||||||||
| message: "Unauthorized", | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| status: 401, | ||||||||||||||||||||||||
| headers: getCorsHeaders(), | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| return accountId; | ||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||
| console.error("[ERROR] getApiKeyAccountId:", error); | ||||||||||||||||||||||||
| if (!accountId) { | ||||||||||||||||||||||||
| return NextResponse.json( | ||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| status: "error", | ||||||||||||||||||||||||
| message: "Internal server error", | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
| { | ||||||||||||||||||||||||
| status: 500, | ||||||||||||||||||||||||
| headers: getCorsHeaders(), | ||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||
| { status: "error", message: "Unauthorized" }, | ||||||||||||||||||||||||
| { status: 401, headers: getCorsHeaders() }, | ||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| return accountId; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| import { describe, it, expect, vi, beforeEach } from "vitest"; | ||
| import { assertRecipientsAllowed } from "../assertRecipientsAllowed"; | ||
| const mockAccountHasPaymentMethod = vi.fn(); | ||
| const mockSelectAccountEmails = vi.fn(); | ||
| vi.mock("@/lib/stripe/accountHasPaymentMethod", () => ({ | ||
| accountHasPaymentMethod: (...args: unknown[]) => mockAccountHasPaymentMethod(...args), | ||
| })); | ||
| vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ | ||
| default: (...args: unknown[]) => mockSelectAccountEmails(...args), | ||
| })); | ||
| describe("assertRecipientsAllowed", () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| mockSelectAccountEmails.mockResolvedValue([{ email: "owner@account.com" }]); | ||
| }); | ||
| it("allows any recipient when a payment method is on file", async () => { | ||
| mockAccountHasPaymentMethod.mockResolvedValue(true); | ||
| const result = await assertRecipientsAllowed({ | ||
| accountId: "acct-1", | ||
| recipients: ["stranger@example.com", "another@example.com"], | ||
| }); | ||
| expect(result.allowed).toBe(true); | ||
| // No need to look up account emails when a card is on file. | ||
| expect(mockSelectAccountEmails).not.toHaveBeenCalled(); | ||
| }); | ||
| it("allows the account's own email without a payment method (case-insensitive)", async () => { | ||
| mockAccountHasPaymentMethod.mockResolvedValue(false); | ||
| const result = await assertRecipientsAllowed({ | ||
| accountId: "acct-1", | ||
| recipients: ["OWNER@Account.com"], | ||
| }); | ||
| expect(result.allowed).toBe(true); | ||
| }); | ||
| it("blocks foreign recipients without a payment method and lists them", async () => { | ||
| mockAccountHasPaymentMethod.mockResolvedValue(false); | ||
| const result = await assertRecipientsAllowed({ | ||
| accountId: "acct-1", | ||
| recipients: ["owner@account.com", "stranger@example.com"], | ||
| }); | ||
| expect(result.allowed).toBe(false); | ||
| if (result.allowed === false) { | ||
| expect(result.disallowed).toEqual(["stranger@example.com"]); | ||
| } | ||
| }); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
P2: Missing test for the Privy JWT error/throw path — only the happy path (
mockResolvedValue) is covered. The implementation catches errors fromgetOrCreateAccountIdByAuthTokenand returns a 401 Response with the error message; this branch is exercised in production when the token is invalid/expired and should be tested.Prompt for AI agents