diff --git a/app/api/subscriptions/status/route.ts b/app/api/subscriptions/status/route.ts new file mode 100644 index 000000000..b9d7619d7 --- /dev/null +++ b/app/api/subscriptions/status/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getSubscriptionStatusHandler } from "@/lib/stripe/getSubscriptionStatusHandler"; + +/** + * OPTIONS handler for CORS preflight requests. + * + * @returns A 200 NextResponse carrying the CORS headers. + */ +export async function OPTIONS() { + return new NextResponse(null, { + status: 200, + headers: getCorsHeaders(), + }); +} + +/** + * GET /api/subscriptions/status?accountId= + * + * Returns whether the account has an active paid subscription (direct or via organization). + * Requires `x-api-key` or `Authorization: Bearer`; the caller must be allowed to access + * the requested account (same rules as account_id override on other routes). + * + * @param request - The incoming HTTP request (query `accountId`, auth headers). + * @returns JSON `{ isPro }`, or 400/401/403 with `{ error }` per API docs. + */ +export async function GET(request: NextRequest) { + return getSubscriptionStatusHandler(request); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/sandbox/__tests__/createSandbox.test.ts b/lib/sandbox/__tests__/createSandbox.test.ts index b80dbc44b..1530afade 100644 --- a/lib/sandbox/__tests__/createSandbox.test.ts +++ b/lib/sandbox/__tests__/createSandbox.test.ts @@ -4,7 +4,7 @@ import { createSandbox } from "../createSandbox"; import { Sandbox } from "@vercel/sandbox"; const mockSandbox = { - name: "sbx_test123", + sandboxId: "sbx_test123", status: "running", timeout: 1800000, createdAt: new Date("2024-01-01T00:00:00Z"), diff --git a/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts b/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts index f3fc2b46a..d7a975072 100644 --- a/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts +++ b/lib/sandbox/__tests__/createSandboxFromSnapshot.test.ts @@ -21,7 +21,7 @@ vi.mock("@/lib/supabase/account_sandboxes/insertAccountSandbox", () => ({ describe("createSandboxFromSnapshot", () => { const mockSandbox = { - name: "sbx_new", + sandboxId: "sbx_new", status: "running", runCommand: vi.fn(), } as unknown as Sandbox; diff --git a/lib/sandbox/__tests__/getActiveSandbox.test.ts b/lib/sandbox/__tests__/getActiveSandbox.test.ts index 0f6ca2e6f..581337076 100644 --- a/lib/sandbox/__tests__/getActiveSandbox.test.ts +++ b/lib/sandbox/__tests__/getActiveSandbox.test.ts @@ -24,7 +24,7 @@ describe("getActiveSandbox", () => { mockSelectAccountSandboxes.mockResolvedValue([{ sandbox_id: "sbx_123", account_id: "acc_1" }]); const mockSandbox = { - name: "sbx_123", + sandboxId: "sbx_123", status: "running", runCommand: vi.fn(), }; @@ -35,7 +35,7 @@ describe("getActiveSandbox", () => { expect(mockSelectAccountSandboxes).toHaveBeenCalledWith({ accountIds: ["acc_1"], }); - expect(Sandbox.get).toHaveBeenCalledWith({ name: "sbx_123" }); + expect(Sandbox.get).toHaveBeenCalledWith({ sandboxId: "sbx_123" }); expect(result).toBe(mockSandbox); }); @@ -54,7 +54,7 @@ describe("getActiveSandbox", () => { ]); const mockSandbox = { - name: "sbx_stopped", + sandboxId: "sbx_stopped", status: "stopped", }; vi.mocked(Sandbox.get).mockResolvedValue(mockSandbox as unknown as Sandbox); diff --git a/lib/sandbox/__tests__/getOrCreateSandbox.test.ts b/lib/sandbox/__tests__/getOrCreateSandbox.test.ts index 063b87927..56e641bc9 100644 --- a/lib/sandbox/__tests__/getOrCreateSandbox.test.ts +++ b/lib/sandbox/__tests__/getOrCreateSandbox.test.ts @@ -21,7 +21,7 @@ describe("getOrCreateSandbox", () => { it("returns existing sandbox with created=false and fromSnapshot=true", async () => { const mockSandbox = { - name: "sbx_existing", + sandboxId: "sbx_existing", status: "running", } as unknown as Sandbox; @@ -40,7 +40,7 @@ describe("getOrCreateSandbox", () => { it("creates new sandbox from snapshot with created=true, fromSnapshot=true", async () => { const mockSandbox = { - name: "sbx_new", + sandboxId: "sbx_new", status: "running", } as unknown as Sandbox; @@ -63,7 +63,7 @@ describe("getOrCreateSandbox", () => { it("creates fresh sandbox with created=true, fromSnapshot=false", async () => { const mockSandbox = { - name: "sbx_fresh", + sandboxId: "sbx_fresh", status: "running", } as unknown as Sandbox; diff --git a/lib/sandbox/__tests__/getSandboxStatus.test.ts b/lib/sandbox/__tests__/getSandboxStatus.test.ts index 949032362..3f84615a6 100644 --- a/lib/sandbox/__tests__/getSandboxStatus.test.ts +++ b/lib/sandbox/__tests__/getSandboxStatus.test.ts @@ -16,7 +16,7 @@ describe("getSandboxStatus", () => { it("returns sandbox status when sandbox exists", async () => { const mockSandbox = { - name: "sbx_123", + sandboxId: "sbx_123", status: "running", timeout: 600000, createdAt: new Date("2024-01-01T00:00:00.000Z"), @@ -25,7 +25,7 @@ describe("getSandboxStatus", () => { const result = await getSandboxStatus("sbx_123"); - expect(Sandbox.get).toHaveBeenCalledWith({ name: "sbx_123" }); + expect(Sandbox.get).toHaveBeenCalledWith({ sandboxId: "sbx_123" }); expect(result).toEqual({ sandboxId: "sbx_123", sandboxStatus: "running", @@ -52,7 +52,7 @@ describe("getSandboxStatus", () => { it("handles stopped sandbox status", async () => { const mockSandbox = { - name: "sbx_stopped", + sandboxId: "sbx_stopped", status: "stopped", timeout: 0, createdAt: new Date("2024-01-01T00:00:00.000Z"), @@ -71,7 +71,7 @@ describe("getSandboxStatus", () => { it("handles pending sandbox status", async () => { const mockSandbox = { - name: "sbx_pending", + sandboxId: "sbx_pending", status: "pending", timeout: 600000, createdAt: new Date("2024-01-01T00:00:00.000Z"), diff --git a/lib/sandbox/__tests__/processCreateSandbox.test.ts b/lib/sandbox/__tests__/processCreateSandbox.test.ts index 160cf287c..5f8b33e2f 100644 --- a/lib/sandbox/__tests__/processCreateSandbox.test.ts +++ b/lib/sandbox/__tests__/processCreateSandbox.test.ts @@ -14,7 +14,7 @@ vi.mock("@/lib/trigger/triggerPromptSandbox", () => ({ })); const mockSandbox = { - name: "sbx_123", + sandboxId: "sbx_123", status: "running", timeout: 600000, createdAt: new Date("2024-01-01T00:00:00.000Z"), diff --git a/lib/sandbox/createSandbox.ts b/lib/sandbox/createSandbox.ts index d6c732128..a09ad37c7 100644 --- a/lib/sandbox/createSandbox.ts +++ b/lib/sandbox/createSandbox.ts @@ -1,10 +1,12 @@ import ms from "ms"; import { Sandbox } from "@vercel/sandbox"; +type VercelSandbox = InstanceType; + export interface SandboxCreatedResponse { - sandboxId: Sandbox["name"]; - sandboxStatus: Sandbox["status"]; - timeout: Sandbox["timeout"]; + sandboxId: VercelSandbox["sandboxId"]; + sandboxStatus: VercelSandbox["status"]; + timeout: VercelSandbox["timeout"]; createdAt: string; } @@ -54,7 +56,7 @@ export async function createSandbox( return { sandbox, response: { - sandboxId: sandbox.name, + sandboxId: sandbox.sandboxId, sandboxStatus: sandbox.status, timeout: sandbox.timeout, createdAt: sandbox.createdAt.toISOString(), diff --git a/lib/sandbox/createSandboxFromSnapshot.ts b/lib/sandbox/createSandboxFromSnapshot.ts index 2dcfb7ef4..1b6da57a6 100644 --- a/lib/sandbox/createSandboxFromSnapshot.ts +++ b/lib/sandbox/createSandboxFromSnapshot.ts @@ -23,7 +23,7 @@ export async function createSandboxFromSnapshot( await insertAccountSandbox({ account_id: accountId, - sandbox_id: sandbox.name, + sandbox_id: sandbox.sandboxId, }); return { sandbox, fromSnapshot }; diff --git a/lib/sandbox/getActiveSandbox.ts b/lib/sandbox/getActiveSandbox.ts index f47dec4f6..faa220ad9 100644 --- a/lib/sandbox/getActiveSandbox.ts +++ b/lib/sandbox/getActiveSandbox.ts @@ -19,7 +19,7 @@ export async function getActiveSandbox(accountId: string): Promise { try { - const sandbox = await Sandbox.get({ name: sandboxId }); + const sandbox = await Sandbox.get({ sandboxId }); return { - sandboxId: sandbox.name, + sandboxId: sandbox.sandboxId, sandboxStatus: sandbox.status, timeout: sandbox.timeout, createdAt: sandbox.createdAt.toISOString(), diff --git a/lib/sandbox/processCreateSandbox.ts b/lib/sandbox/processCreateSandbox.ts index 2863c7d7a..0ce50ed68 100644 --- a/lib/sandbox/processCreateSandbox.ts +++ b/lib/sandbox/processCreateSandbox.ts @@ -23,7 +23,7 @@ export async function processCreateSandbox( const { sandbox } = await createSandboxFromSnapshot(accountId); const result: SandboxCreatedResponse = { - sandboxId: sandbox.name, + sandboxId: sandbox.sandboxId, sandboxStatus: sandbox.status, timeout: sandbox.timeout, createdAt: sandbox.createdAt.toISOString(), @@ -35,7 +35,7 @@ export async function processCreateSandbox( try { const handle = await triggerPromptSandbox({ prompt, - sandboxId: sandbox.name, + sandboxId: sandbox.sandboxId, accountId, }); runId = handle.id; diff --git a/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts b/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts new file mode 100644 index 000000000..57585370d --- /dev/null +++ b/lib/stripe/__tests__/getSubscriptionStatusHandler.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getSubscriptionStatusHandler } from "@/lib/stripe/getSubscriptionStatusHandler"; +import { validateGetSubscriptionStatusRequest } from "@/lib/stripe/validateGetSubscriptionStatusRequest"; +import { getSubscriptionIsPro } from "@/lib/stripe/getSubscriptionIsPro"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/stripe/validateGetSubscriptionStatusRequest", () => ({ + validateGetSubscriptionStatusRequest: vi.fn(), +})); + +vi.mock("@/lib/stripe/getSubscriptionIsPro", () => ({ + getSubscriptionIsPro: vi.fn(), +})); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; + +describe("getSubscriptionStatusHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + afterEach(() => vi.mocked(console.error).mockRestore()); + + it("returns validation response unchanged", async () => { + const err = NextResponse.json({ error: "accountId is required" }, { status: 400 }); + vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue(err); + const req = new NextRequest(`http://localhost/api/subscriptions/status`); + expect(await getSubscriptionStatusHandler(req)).toBe(err); + expect(getSubscriptionIsPro).not.toHaveBeenCalled(); + }); + + it("returns 200 { isPro: true }", async () => { + vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue({ accountId: ACCOUNT }); + vi.mocked(getSubscriptionIsPro).mockResolvedValue(true); + + const res = await getSubscriptionStatusHandler( + new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`), + ); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ isPro: true }); + expect(getSubscriptionIsPro).toHaveBeenCalledWith(ACCOUNT); + }); + + it("returns 200 { isPro: false }", async () => { + vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue({ accountId: ACCOUNT }); + vi.mocked(getSubscriptionIsPro).mockResolvedValue(false); + + const res = await getSubscriptionStatusHandler( + new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`), + ); + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ isPro: false }); + }); + + it("returns 500 when getSubscriptionIsPro throws", async () => { + vi.mocked(validateGetSubscriptionStatusRequest).mockResolvedValue({ accountId: ACCOUNT }); + vi.mocked(getSubscriptionIsPro).mockRejectedValue(new Error("stripe down")); + + const res = await getSubscriptionStatusHandler( + new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`), + ); + expect(res.status).toBe(500); + await expect(res.json()).resolves.toEqual({ error: "Internal server error" }); + }); +}); diff --git a/lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts b/lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts new file mode 100644 index 000000000..9cad86188 --- /dev/null +++ b/lib/stripe/__tests__/validateGetSubscriptionStatusRequest.test.ts @@ -0,0 +1,107 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetSubscriptionStatusRequest } from "@/lib/stripe/validateGetSubscriptionStatusRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateAccountIdOverride } from "@/lib/auth/validateAccountIdOverride"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/auth/validateAccountIdOverride", () => ({ + validateAccountIdOverride: vi.fn(), +})); + +const ACCOUNT = "123e4567-e89b-12d3-a456-426614174000"; + +describe("validateGetSubscriptionStatusRequest", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns 400 { error } when accountId is missing", async () => { + const req = new NextRequest(`http://localhost/api/subscriptions/status`, { + headers: { "x-api-key": "k" }, + }); + const res = await validateGetSubscriptionStatusRequest(req); + expect(res).toBeInstanceOf(NextResponse); + expect((res as NextResponse).status).toBe(400); + await expect((res as NextResponse).json()).resolves.toEqual({ error: "accountId is required" }); + expect(validateAuthContext).not.toHaveBeenCalled(); + }); + + it("returns 400 { error } when accountId is not a valid UUID", async () => { + const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=not-a-uuid`, { + headers: { "x-api-key": "k" }, + }); + const res = await validateGetSubscriptionStatusRequest(req); + expect((res as NextResponse).status).toBe(400); + const j = await (res as NextResponse).json(); + expect(j).toEqual({ error: expect.stringMatching(/uuid|UUID|valid/i) }); + expect(validateAuthContext).not.toHaveBeenCalled(); + }); + + it("maps auth failure to { error }", async () => { + vi.mocked(validateAuthContext).mockResolvedValue( + NextResponse.json( + { status: "error", error: "Exactly one of x-api-key or Authorization must be provided" }, + { status: 401 }, + ), + ); + const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`, { + headers: {}, + }); + const res = await validateGetSubscriptionStatusRequest(req); + expect((res as NextResponse).status).toBe(401); + await expect((res as NextResponse).json()).resolves.toEqual({ + error: "Exactly one of x-api-key or Authorization must be provided", + }); + expect(validateAccountIdOverride).not.toHaveBeenCalled(); + }); + + it("maps account override denial to { error }", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT, + orgId: null, + authToken: "t", + }); + vi.mocked(validateAccountIdOverride).mockResolvedValue( + NextResponse.json( + { status: "error", error: "Access denied to specified account_id" }, + { status: 403 }, + ), + ); + const other = "123e4567-e89b-12d3-a456-426614174001"; + const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${other}`, { + headers: { "x-api-key": "k" }, + }); + const res = await validateGetSubscriptionStatusRequest(req); + expect((res as NextResponse).status).toBe(403); + await expect((res as NextResponse).json()).resolves.toEqual({ + error: "Access denied to specified account_id", + }); + }); + + it("returns accountId when auth and override succeed", async () => { + vi.mocked(validateAuthContext).mockResolvedValue({ + accountId: ACCOUNT, + orgId: null, + authToken: "t", + }); + vi.mocked(validateAccountIdOverride).mockResolvedValue({ accountId: ACCOUNT }); + + const req = new NextRequest(`http://localhost/api/subscriptions/status?accountId=${ACCOUNT}`, { + headers: { "x-api-key": "k" }, + }); + const out = await validateGetSubscriptionStatusRequest(req); + expect(out).toEqual({ accountId: ACCOUNT }); + expect(validateAccountIdOverride).toHaveBeenCalledWith({ + currentAccountId: ACCOUNT, + targetAccountId: ACCOUNT, + }); + }); +}); diff --git a/lib/stripe/getActiveSubscriptionDetails.ts b/lib/stripe/getActiveSubscriptionDetails.ts new file mode 100644 index 000000000..2fac70178 --- /dev/null +++ b/lib/stripe/getActiveSubscriptionDetails.ts @@ -0,0 +1,14 @@ +import type Stripe from "stripe"; +import { getActiveSubscriptions } from "@/lib/stripe/getActiveSubscriptions"; + +export async function getActiveSubscriptionDetails( + accountId: string, +): Promise { + try { + const activeSubscriptions = await getActiveSubscriptions(accountId); + return activeSubscriptions[0] ?? null; + } catch (error) { + console.error("[getActiveSubscriptionDetails]", error); + return null; + } +} diff --git a/lib/stripe/getActiveSubscriptions.ts b/lib/stripe/getActiveSubscriptions.ts new file mode 100644 index 000000000..470027dce --- /dev/null +++ b/lib/stripe/getActiveSubscriptions.ts @@ -0,0 +1,23 @@ +import type Stripe from "stripe"; +import stripeClient from "@/lib/stripe/client"; + +export async function getActiveSubscriptions(accountId: string): Promise { + try { + const subscriptions = await stripeClient.subscriptions.list({ + limit: 100, + current_period_end: { + gt: Math.floor(Date.now() / 1000), + }, + }); + + const activeSubscriptions = + subscriptions?.data?.filter( + (subscription: Stripe.Subscription) => subscription.metadata?.accountId === accountId, + ) ?? []; + + return activeSubscriptions; + } catch (error) { + console.error("[getActiveSubscriptions]", error); + return []; + } +} diff --git a/lib/stripe/getOrgSubscription.ts b/lib/stripe/getOrgSubscription.ts new file mode 100644 index 000000000..8ef4e0406 --- /dev/null +++ b/lib/stripe/getOrgSubscription.ts @@ -0,0 +1,21 @@ +import type Stripe from "stripe"; +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; +import { getAccountOrganizations } from "@/lib/supabase/account_organization_ids/getAccountOrganizations"; + +/** + * Returns an active Stripe subscription for one of the account's organizations, if any. + */ +export async function getOrgSubscription(accountId: string): Promise { + if (!accountId) return null; + + const accountOrgs = await getAccountOrganizations({ accountId }); + if (accountOrgs.length === 0) return null; + + const orgIds = accountOrgs + .map(org => org.organization_id) + .filter((id): id is string => id !== null); + + const subscriptions = await Promise.all(orgIds.map(orgId => getActiveSubscriptionDetails(orgId))); + + return subscriptions.find(sub => sub !== null) ?? null; +} diff --git a/lib/stripe/getSubscriptionIsPro.ts b/lib/stripe/getSubscriptionIsPro.ts new file mode 100644 index 000000000..f681c6b60 --- /dev/null +++ b/lib/stripe/getSubscriptionIsPro.ts @@ -0,0 +1,12 @@ +import { getActiveSubscriptionDetails } from "@/lib/stripe/getActiveSubscriptionDetails"; +import { getOrgSubscription } from "@/lib/stripe/getOrgSubscription"; +import { isActiveSubscription } from "@/lib/stripe/isActiveSubscription"; + +export async function getSubscriptionIsPro(accountId: string): Promise { + const [accountSubscription, orgSubscription] = await Promise.all([ + getActiveSubscriptionDetails(accountId), + getOrgSubscription(accountId), + ]); + + return isActiveSubscription(accountSubscription) || isActiveSubscription(orgSubscription); +} diff --git a/lib/stripe/getSubscriptionStatusHandler.ts b/lib/stripe/getSubscriptionStatusHandler.ts new file mode 100644 index 000000000..8f73963a7 --- /dev/null +++ b/lib/stripe/getSubscriptionStatusHandler.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getSubscriptionIsPro } from "@/lib/stripe/getSubscriptionIsPro"; +import { validateGetSubscriptionStatusRequest } from "@/lib/stripe/validateGetSubscriptionStatusRequest"; + +export async function getSubscriptionStatusHandler(request: NextRequest): Promise { + try { + const validated = await validateGetSubscriptionStatusRequest(request); + if (validated instanceof NextResponse) { + return validated; + } + + const isPro = await getSubscriptionIsPro(validated.accountId); + return NextResponse.json({ isPro }, { status: 200, headers: getCorsHeaders() }); + } catch (error) { + console.error("[getSubscriptionStatusHandler]", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/stripe/isActiveSubscription.ts b/lib/stripe/isActiveSubscription.ts new file mode 100644 index 000000000..00ea9e296 --- /dev/null +++ b/lib/stripe/isActiveSubscription.ts @@ -0,0 +1,8 @@ +import type Stripe from "stripe"; + +export function isActiveSubscription(subscription?: Stripe.Subscription | null): boolean { + if (!subscription) return false; + const isTrial = subscription.status === "trialing"; + const isCanceledTrial = isTrial && subscription.canceled_at; + return !isCanceledTrial; +} diff --git a/lib/stripe/validateGetSubscriptionStatusRequest.ts b/lib/stripe/validateGetSubscriptionStatusRequest.ts new file mode 100644 index 000000000..e12aa8eca --- /dev/null +++ b/lib/stripe/validateGetSubscriptionStatusRequest.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validateAccountIdOverride } from "@/lib/auth/validateAccountIdOverride"; +import { mapToSubscriptionSessionError } from "@/lib/stripe/mapToSubscriptionSessionError"; + +export type ValidatedGetSubscriptionStatusRequest = { + accountId: string; +}; + +/** + * Validates GET /api/subscriptions/status: required query `accountId` (UUID), + * auth, and access to the target account (same rules as body account_id override). + */ +export async function validateGetSubscriptionStatusRequest( + request: NextRequest, +): Promise { + const raw = request.nextUrl.searchParams.get("accountId"); + if (raw === null || raw.trim() === "") { + return NextResponse.json( + { error: "accountId is required" }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + const parsedUuid = z.string().uuid("accountId must be a valid UUID").safeParse(raw); + if (!parsedUuid.success) { + const first = parsedUuid.error.issues[0]; + return NextResponse.json({ error: first.message }, { status: 400, headers: getCorsHeaders() }); + } + + const accountId = parsedUuid.data; + + const authContext = await validateAuthContext(request); + if (authContext instanceof NextResponse) { + return await mapToSubscriptionSessionError(authContext); + } + + const override = await validateAccountIdOverride({ + currentAccountId: authContext.accountId, + targetAccountId: accountId, + }); + if (override instanceof NextResponse) { + return await mapToSubscriptionSessionError(override); + } + + return { accountId: override.accountId }; +}