diff --git a/app/api/accounts/[id]/subscription/route.ts b/app/api/accounts/[id]/subscription/route.ts new file mode 100644 index 000000000..4f1fd3c7d --- /dev/null +++ b/app/api/accounts/[id]/subscription/route.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getSubscriptionsHandler } from "@/lib/subscriptions/getSubscriptionsHandler"; + +/** + * 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/accounts/{id}/subscription + * + * Returns the account's active Stripe subscription, or a short-circuit + * `{ isEnterprise: true }` for enterprise-domain accounts. + * + * @param request - The request object + * @param options - Route options containing params + * @param options.params - Route params containing the account ID + * @returns A NextResponse with `{ status, subscription }` or `{ status, isEnterprise }` + */ +export async function GET(request: NextRequest, options: { params: Promise<{ id: string }> }) { + return getSubscriptionsHandler(request, options.params); +} + +export const dynamic = "force-dynamic"; +export const fetchCache = "force-no-store"; +export const revalidate = 0; diff --git a/lib/consts.ts b/lib/consts.ts index 388428d85..4441de323 100644 --- a/lib/consts.ts +++ b/lib/consts.ts @@ -6,3 +6,21 @@ export * from "./const"; // Additional constants needed for evals that are specific to Recoup-Chat export const NEW_API_BASE_URL = "https://recoup-api.vercel.app"; + +/** + * Email domains that grant enterprise-tier access. + * + * Used by `isEnterprise()` to short-circuit the Stripe lookup in + * `GET /api/accounts/{id}/subscription`: accounts whose emails match any + * of these domains are treated as paid without needing an active Stripe + * subscription. Ported verbatim from the legacy Express service to + * preserve behaviour. + */ +export const ENTERPRISE_DOMAINS: ReadonlySet = new Set([ + "recoupable.com", + "rostrum.com", + "spaceheatermusic.io", + "fatbeats.com", + "cantorarecords.net", + "rostrumrecords.com", +]); diff --git a/lib/enterprise/isEnterprise.ts b/lib/enterprise/isEnterprise.ts new file mode 100644 index 000000000..34ea0ce92 --- /dev/null +++ b/lib/enterprise/isEnterprise.ts @@ -0,0 +1,15 @@ +import { ENTERPRISE_DOMAINS } from "@/lib/consts"; +import { extractDomain } from "@/lib/email/extractDomain"; + +/** + * Returns true when the email's domain is on the enterprise allow-list. + * + * Enterprise-tagged accounts bypass the Stripe active-subscription check + * in `GET /api/accounts/{id}/subscription`. + */ +export function isEnterprise(email: string): boolean { + if (!email) return false; + const domain = extractDomain(email); + if (!domain) return false; + return ENTERPRISE_DOMAINS.has(domain); +} diff --git a/lib/stripe/client.ts b/lib/stripe/client.ts new file mode 100644 index 000000000..80dcf0c95 --- /dev/null +++ b/lib/stripe/client.ts @@ -0,0 +1,25 @@ +import Stripe from "stripe"; + +/** + * Pinned Stripe API version. Matches the default version shipped with + * `stripe@17.x` (the version used by `mono/chat`), so this service and + * the chat app talk to the same Stripe schema. + */ +const STRIPE_API_VERSION = "2024-10-28.acacia" as const; + +const stripeSecretKey = process.env.STRIPE_SECRET_KEY ?? process.env.STRIPE_SK; + +// Fail-closed in production: a missing secret means every subscription +// lookup would silently 500 at first Stripe call. Outside production we +// let module-load succeed so tests and local builds don't break. +if (!stripeSecretKey && process.env.NODE_ENV === "production") { + throw new Error( + "Stripe secret key is not configured. Set STRIPE_SECRET_KEY (preferred) or STRIPE_SK.", + ); +} + +const stripeClient = new Stripe(stripeSecretKey ?? "sk_test_unset", { + apiVersion: STRIPE_API_VERSION as Stripe.LatestApiVersion, +}); + +export default stripeClient; diff --git a/lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts b/lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts new file mode 100644 index 000000000..2c0f8a801 --- /dev/null +++ b/lib/subscriptions/__tests__/getSubscriptionsHandler.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { getSubscriptionsHandler } from "../getSubscriptionsHandler"; +import { validateGetSubscriptionRequest } from "../validateGetSubscriptionRequest"; +import { getAccountSubscription } from "../getAccountSubscription"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateGetSubscriptionRequest", () => ({ + validateGetSubscriptionRequest: vi.fn(), +})); + +vi.mock("../getAccountSubscription", () => ({ + getAccountSubscription: vi.fn(), +})); + +const accountId = "550e8400-e29b-41d4-a716-446655440000"; +const validated = { account_id: accountId }; +const makeRequest = () => + new NextRequest(`http://localhost/api/accounts/${accountId}/subscription`, { method: "GET" }); + +describe("getSubscriptionsHandler", () => { + beforeEach(() => vi.clearAllMocks()); + + it("returns the validator error when validation fails", async () => { + const err = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(err); + + const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); + + expect(res).toBe(err); + expect(getAccountSubscription).not.toHaveBeenCalled(); + }); + + it("returns 200 isEnterprise for enterprise accounts", async () => { + vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); + vi.mocked(getAccountSubscription).mockResolvedValue({ isEnterprise: true }); + + const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ status: "success", isEnterprise: true }); + }); + + it("returns 200 with the subscription for a paid account", async () => { + const subscription = { id: "sub_123", metadata: { accountId } } as never; + vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); + vi.mocked(getAccountSubscription).mockResolvedValue({ subscription }); + + const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + status: "success", + subscription: { id: "sub_123", metadata: { accountId } }, + }); + expect(getAccountSubscription).toHaveBeenCalledWith(accountId); + }); + + it("returns 404 when no active subscription exists", async () => { + vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); + vi.mocked(getAccountSubscription).mockResolvedValue(null); + + const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); + + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ status: "error", error: "No active subscription found" }); + }); + + it("returns 500 generic error and never leaks the raw exception message", async () => { + vi.mocked(validateGetSubscriptionRequest).mockResolvedValue(validated); + vi.mocked(getAccountSubscription).mockImplementation(() => { + throw new Error("db down: connection refused at 10.0.0.1:5432"); + }); + + const res = await getSubscriptionsHandler(makeRequest(), Promise.resolve({ id: accountId })); + const body = await res.json(); + + expect(res.status).toBe(500); + expect(body).toEqual({ status: "error", error: "Internal server error" }); + expect(body.error).not.toContain("db down"); + }); +}); diff --git a/lib/subscriptions/__tests__/validateGetSubscriptionRequest.test.ts b/lib/subscriptions/__tests__/validateGetSubscriptionRequest.test.ts new file mode 100644 index 000000000..9722e1655 --- /dev/null +++ b/lib/subscriptions/__tests__/validateGetSubscriptionRequest.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; + +import { validateGetSubscriptionRequest } from "../validateGetSubscriptionRequest"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/auth/validateAuthContext", () => ({ + validateAuthContext: vi.fn(), +})); + +vi.mock("@/lib/supabase/accounts/selectAccounts", () => ({ + selectAccounts: vi.fn(), +})); + +vi.mock("@/lib/organizations/canAccessAccount", () => ({ + canAccessAccount: vi.fn(), +})); + +const accountId = "550e8400-e29b-41d4-a716-446655440000"; +const requesterId = "660e8400-e29b-41d4-a716-446655440000"; +const authOk = { accountId: requesterId, authToken: "t", orgId: null }; +const makeRequest = (id = accountId) => + new NextRequest(`http://localhost/api/accounts/${id}/subscription`, { + method: "GET", + headers: { Authorization: "Bearer test-token" }, + }); + +describe("validateGetSubscriptionRequest", () => { + beforeEach(() => vi.clearAllMocks()); + + it.each([ + ["not-a-uuid", "non-UUID id"], + ["", "empty id"], + ])("returns 400 for %s (%s)", async id => { + vi.mocked(validateAuthContext).mockResolvedValue(authOk); + + const result = await validateGetSubscriptionRequest(makeRequest(), id); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(400); + expect(selectAccounts).not.toHaveBeenCalled(); + }); + + it("returns the auth error when authentication fails", async () => { + const authError = NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + vi.mocked(validateAuthContext).mockResolvedValue(authError); + + const result = await validateGetSubscriptionRequest(makeRequest(), accountId); + + expect(result).toBe(authError); + expect(selectAccounts).not.toHaveBeenCalled(); + }); + + it("returns 404 when the account does not exist", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(authOk); + vi.mocked(selectAccounts).mockResolvedValue([]); + + const result = await validateGetSubscriptionRequest(makeRequest(), accountId); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(404); + expect(canAccessAccount).not.toHaveBeenCalled(); + }); + + it("returns 403 when the requester cannot access the account", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(authOk); + vi.mocked(selectAccounts).mockResolvedValue([{ id: accountId }] as never); + vi.mocked(canAccessAccount).mockResolvedValue(false); + + const result = await validateGetSubscriptionRequest(makeRequest(), accountId); + + expect(result).toBeInstanceOf(NextResponse); + expect((result as NextResponse).status).toBe(403); + // Regression: access check must compare the CALLER's account id against + // the target path id — never the path id against itself. + expect(canAccessAccount).toHaveBeenCalledWith({ + currentAccountId: requesterId, + targetAccountId: accountId, + }); + }); + + it("does not pass the target accountId as an override to validateAuthContext", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(authOk); + vi.mocked(selectAccounts).mockResolvedValue([{ id: accountId }] as never); + vi.mocked(canAccessAccount).mockResolvedValue(true); + + await validateGetSubscriptionRequest(makeRequest(), accountId); + + // Regression: passing `{ accountId: id }` here would rewrite + // authResult.accountId to the target, collapsing the access check into + // a self-check that always returns true. + const call = vi.mocked(validateAuthContext).mock.calls[0]; + expect(call[1]).toBeUndefined(); + }); + + it("returns the validated account_id on success", async () => { + vi.mocked(validateAuthContext).mockResolvedValue(authOk); + vi.mocked(selectAccounts).mockResolvedValue([{ id: accountId }] as never); + vi.mocked(canAccessAccount).mockResolvedValue(true); + + const result = await validateGetSubscriptionRequest(makeRequest(), accountId); + + expect(result).toEqual({ account_id: accountId }); + }); +}); diff --git a/lib/subscriptions/getAccountSubscription.ts b/lib/subscriptions/getAccountSubscription.ts new file mode 100644 index 000000000..489ded3f4 --- /dev/null +++ b/lib/subscriptions/getAccountSubscription.ts @@ -0,0 +1,37 @@ +import Stripe from "stripe"; +import stripeClient from "@/lib/stripe/client"; +import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; +import { isEnterprise } from "@/lib/enterprise/isEnterprise"; + +export type AccountSubscription = + | { isEnterprise: true } + | { subscription: Stripe.Subscription } + | null; + +/** + * Resolves an account's subscription state: enterprise (via email domain), + * an active Stripe subscription (`metadata.accountId` match), or null. + * + * Uses `stripe.subscriptions.search` so the metadata + status filter runs on + * Stripe's side — scales independently of total subscription count (a prior + * `list({ limit: 100 })` + client-side filter was lossy past 100). Search is + * eventually consistent (~1 min lag after writes). + */ +export async function getAccountSubscription(accountId: string): Promise { + try { + const emails = await selectAccountEmails({ accountIds: accountId }); + if (emails.some(record => isEnterprise(record.email || ""))) { + return { isEnterprise: true }; + } + + const result = await stripeClient.subscriptions.search({ + query: `status:"active" AND metadata["accountId"]:"${accountId}"`, + limit: 1, + }); + const subscription = result.data[0]; + return subscription ? { subscription } : null; + } catch (error) { + console.error("[ERROR] getAccountSubscription:", error); + return null; + } +} diff --git a/lib/subscriptions/getSubscriptionsHandler.ts b/lib/subscriptions/getSubscriptionsHandler.ts new file mode 100644 index 000000000..1dcde69c5 --- /dev/null +++ b/lib/subscriptions/getSubscriptionsHandler.ts @@ -0,0 +1,34 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { validateGetSubscriptionRequest } from "@/lib/subscriptions/validateGetSubscriptionRequest"; +import { getAccountSubscription } from "@/lib/subscriptions/getAccountSubscription"; + +/** + * Handler for GET /api/accounts/{id}/subscription. When the account is a paid + * Stripe customer, `subscription` is a raw `Stripe.Subscription` — its keys + * stay camelCase by design (third-party typed payload), despite the rest of + * the API being snake_case. + */ +export async function getSubscriptionsHandler( + request: NextRequest, + params: Promise<{ id: string }>, +): Promise { + try { + const { id } = await params; + + const validated = await validateGetSubscriptionRequest(request, id); + if (validated instanceof NextResponse) return validated; + + const result = await getAccountSubscription(validated.account_id); + if (!result) return errorResponse("No active subscription found", 404); + + return NextResponse.json( + { status: "success", ...result }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[ERROR] getSubscriptionsHandler:", error); + return errorResponse("Internal server error", 500); + } +} diff --git a/lib/subscriptions/validateGetSubscriptionRequest.ts b/lib/subscriptions/validateGetSubscriptionRequest.ts new file mode 100644 index 000000000..4d9cda915 --- /dev/null +++ b/lib/subscriptions/validateGetSubscriptionRequest.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { validateAuthContext } from "@/lib/auth/validateAuthContext"; +import { validationErrorResponse } from "@/lib/zod/validationErrorResponse"; +import { errorResponse } from "@/lib/networking/errorResponse"; +import { selectAccounts } from "@/lib/supabase/accounts/selectAccounts"; +import { canAccessAccount } from "@/lib/organizations/canAccessAccount"; + +export const getSubscriptionParamsSchema = z.object({ + account_id: z.string().uuid("account_id must be a valid UUID"), +}); + +export type GetSubscriptionParams = z.infer; + +/** + * Bundles auth, path-id parsing, account existence (404), and account-access + * check (403). Path id is not passed as an auth override — that would collapse + * the access check into a self-check that always passes. + */ +export async function validateGetSubscriptionRequest( + request: NextRequest, + id: string, +): Promise { + const authResult = await validateAuthContext(request); + if (authResult instanceof NextResponse) return authResult; + + const parsed = getSubscriptionParamsSchema.safeParse({ account_id: id }); + if (!parsed.success) { + const firstError = parsed.error.issues[0]; + return validationErrorResponse(firstError.message, firstError.path); + } + + const [account] = await selectAccounts(parsed.data.account_id); + if (!account) return errorResponse("Account not found", 404); + + const hasAccess = await canAccessAccount({ + currentAccountId: authResult.accountId, + targetAccountId: parsed.data.account_id, + }); + if (!hasAccess) return errorResponse("Unauthorized", 403); + + return parsed.data; +} diff --git a/package.json b/package.json index 5d12b8b07..263dc6147 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,7 @@ "react-dom": "^19.2.1", "resend": "^6.6.0", "sharp": "^0.34.5", + "stripe": "^17.4.0", "viem": "^2.21.26", "x402-fetch": "^0.7.3", "x402-next": "^0.7.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72b683bc4..a841ff857 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -125,6 +125,9 @@ importers: sharp: specifier: ^0.34.5 version: 0.34.5 + stripe: + specifier: ^17.4.0 + version: 17.7.0 viem: specifier: ^2.21.26 version: 2.40.3(bufferutil@4.0.9)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.1.13) @@ -5820,6 +5823,10 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + stripe@17.7.0: + resolution: {integrity: sha512-aT2BU9KkizY9SATf14WhhYVv2uOapBWX0OFWF4xvcj1mPaNotlSc2CsxpS4DS46ZueSppmCF5BX1sNYBtwBvfw==} + engines: {node: '>=12.*'} + styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} engines: {node: '>= 12.0.0'} @@ -14186,6 +14193,11 @@ snapshots: dependencies: js-tokens: 9.0.1 + stripe@17.7.0: + dependencies: + '@types/node': 20.19.25 + qs: 6.14.0 + styled-jsx@5.1.6(react@19.2.1): dependencies: client-only: 0.0.1