From 4097f5c5051054ade8498b7febc777013fcc0bdd Mon Sep 17 00:00:00 2001 From: Recoup Agent Date: Tue, 17 Mar 2026 13:56:21 +0000 Subject: [PATCH] feat: add GET /api/admins/privy endpoint Returns Privy login statistics (total count + login table) filtered by daily/weekly/monthly time period. Requires admin authentication. Co-Authored-By: Claude Sonnet 4.6 --- app/api/admins/privy/route.ts | 18 +++ .../__tests__/getPrivyLoginsHandler.test.ts | 103 ++++++++++++++++++ .../validateGetPrivyLoginsQuery.test.ts | 88 +++++++++++++++ lib/admins/privy/fetchPrivyLogins.ts | 101 +++++++++++++++++ lib/admins/privy/getPrivyLoginsHandler.ts | 40 +++++++ .../privy/validateGetPrivyLoginsQuery.ts | 43 ++++++++ 6 files changed, 393 insertions(+) create mode 100644 app/api/admins/privy/route.ts create mode 100644 lib/admins/privy/__tests__/getPrivyLoginsHandler.test.ts create mode 100644 lib/admins/privy/__tests__/validateGetPrivyLoginsQuery.test.ts create mode 100644 lib/admins/privy/fetchPrivyLogins.ts create mode 100644 lib/admins/privy/getPrivyLoginsHandler.ts create mode 100644 lib/admins/privy/validateGetPrivyLoginsQuery.ts diff --git a/app/api/admins/privy/route.ts b/app/api/admins/privy/route.ts new file mode 100644 index 00000000..073bac60 --- /dev/null +++ b/app/api/admins/privy/route.ts @@ -0,0 +1,18 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { getPrivyLoginsHandler } from "@/lib/admins/privy/getPrivyLoginsHandler"; + +/** + * GET /api/admins/privy + * + * Returns Privy login statistics for the requested time period. + * Supports daily (last 24h), weekly (last 7 days), and monthly (last 30 days) periods. + * Requires admin authentication. + */ +export async function GET(request: NextRequest): Promise { + return getPrivyLoginsHandler(request); +} + +export async function OPTIONS(): Promise { + return new NextResponse(null, { status: 204, headers: getCorsHeaders() }); +} diff --git a/lib/admins/privy/__tests__/getPrivyLoginsHandler.test.ts b/lib/admins/privy/__tests__/getPrivyLoginsHandler.test.ts new file mode 100644 index 00000000..ec81143b --- /dev/null +++ b/lib/admins/privy/__tests__/getPrivyLoginsHandler.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { getPrivyLoginsHandler } from "../getPrivyLoginsHandler"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("../validateGetPrivyLoginsQuery", () => ({ + validateGetPrivyLoginsQuery: vi.fn(), +})); + +vi.mock("../fetchPrivyLogins", () => ({ + fetchPrivyLogins: vi.fn(), +})); + +import { validateGetPrivyLoginsQuery } from "../validateGetPrivyLoginsQuery"; +import { fetchPrivyLogins } from "../fetchPrivyLogins"; + +const mockLogins = [ + { + privy_did: "did:privy:abc123", + email: "user@example.com", + created_at: "2026-03-17T10:00:00.000Z", + }, + { + privy_did: "did:privy:def456", + email: null, + created_at: "2026-03-17T09:00:00.000Z", + }, +]; + +function makeRequest(period = "daily") { + return new NextRequest(`https://example.com/api/admins/privy?period=${period}`); +} + +describe("getPrivyLoginsHandler", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("successful cases", () => { + it("returns 200 with logins and total for daily period", async () => { + vi.mocked(validateGetPrivyLoginsQuery).mockResolvedValue({ period: "daily" }); + vi.mocked(fetchPrivyLogins).mockResolvedValue(mockLogins); + + const response = await getPrivyLoginsHandler(makeRequest("daily")); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.status).toBe("success"); + expect(body.total).toBe(2); + expect(body.logins).toEqual(mockLogins); + }); + + it("returns 200 with empty logins when none exist", async () => { + vi.mocked(validateGetPrivyLoginsQuery).mockResolvedValue({ period: "weekly" }); + vi.mocked(fetchPrivyLogins).mockResolvedValue([]); + + const response = await getPrivyLoginsHandler(makeRequest("weekly")); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.status).toBe("success"); + expect(body.total).toBe(0); + expect(body.logins).toEqual([]); + }); + + it("passes the period to fetchPrivyLogins", async () => { + vi.mocked(validateGetPrivyLoginsQuery).mockResolvedValue({ period: "monthly" }); + vi.mocked(fetchPrivyLogins).mockResolvedValue([]); + + await getPrivyLoginsHandler(makeRequest("monthly")); + + expect(fetchPrivyLogins).toHaveBeenCalledWith("monthly"); + }); + }); + + describe("error cases", () => { + it("returns auth error when validateGetPrivyLoginsQuery returns NextResponse", async () => { + const errorResponse = NextResponse.json( + { status: "error", message: "Forbidden" }, + { status: 403 }, + ); + vi.mocked(validateGetPrivyLoginsQuery).mockResolvedValue(errorResponse); + + const response = await getPrivyLoginsHandler(makeRequest()); + + expect(response.status).toBe(403); + }); + + it("returns 500 when fetchPrivyLogins throws", async () => { + vi.mocked(validateGetPrivyLoginsQuery).mockResolvedValue({ period: "daily" }); + vi.mocked(fetchPrivyLogins).mockRejectedValue(new Error("Privy API error")); + + const response = await getPrivyLoginsHandler(makeRequest()); + const body = await response.json(); + + expect(response.status).toBe(500); + expect(body.status).toBe("error"); + }); + }); +}); diff --git a/lib/admins/privy/__tests__/validateGetPrivyLoginsQuery.test.ts b/lib/admins/privy/__tests__/validateGetPrivyLoginsQuery.test.ts new file mode 100644 index 00000000..28b13239 --- /dev/null +++ b/lib/admins/privy/__tests__/validateGetPrivyLoginsQuery.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest, NextResponse } from "next/server"; +import { validateGetPrivyLoginsQuery } from "../validateGetPrivyLoginsQuery"; + +vi.mock("@/lib/networking/getCorsHeaders", () => ({ + getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), +})); + +vi.mock("@/lib/admins/validateAdminAuth", () => ({ + validateAdminAuth: vi.fn(), +})); + +import { validateAdminAuth } from "@/lib/admins/validateAdminAuth"; + +const mockAuth = { accountId: "test-account", orgId: null, authToken: "token" }; + +function makeRequest(period?: string) { + const url = period + ? `https://example.com/api/admins/privy?period=${period}` + : "https://example.com/api/admins/privy"; + return new NextRequest(url); +} + +describe("validateGetPrivyLoginsQuery", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("successful cases", () => { + it("returns daily period by default", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue(mockAuth); + + const result = await validateGetPrivyLoginsQuery(makeRequest()); + + expect(result).toEqual({ period: "daily" }); + }); + + it("returns daily when period=daily", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue(mockAuth); + + const result = await validateGetPrivyLoginsQuery(makeRequest("daily")); + + expect(result).toEqual({ period: "daily" }); + }); + + it("returns weekly when period=weekly", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue(mockAuth); + + const result = await validateGetPrivyLoginsQuery(makeRequest("weekly")); + + expect(result).toEqual({ period: "weekly" }); + }); + + it("returns monthly when period=monthly", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue(mockAuth); + + const result = await validateGetPrivyLoginsQuery(makeRequest("monthly")); + + expect(result).toEqual({ period: "monthly" }); + }); + }); + + describe("error cases", () => { + it("returns auth error when validateAdminAuth returns NextResponse", async () => { + const errorResponse = NextResponse.json( + { status: "error", message: "Unauthorized" }, + { status: 401 }, + ); + vi.mocked(validateAdminAuth).mockResolvedValue(errorResponse); + + const result = await validateGetPrivyLoginsQuery(makeRequest()); + + expect(result).toBeInstanceOf(NextResponse); + const body = await (result as NextResponse).json(); + expect(body.status).toBe("error"); + }); + + it("returns 400 for invalid period value", async () => { + vi.mocked(validateAdminAuth).mockResolvedValue(mockAuth); + + const result = await validateGetPrivyLoginsQuery(makeRequest("yearly")); + + expect(result).toBeInstanceOf(NextResponse); + const body = await (result as NextResponse).json(); + expect(body.status).toBe("error"); + }); + }); +}); diff --git a/lib/admins/privy/fetchPrivyLogins.ts b/lib/admins/privy/fetchPrivyLogins.ts new file mode 100644 index 00000000..fe41223c --- /dev/null +++ b/lib/admins/privy/fetchPrivyLogins.ts @@ -0,0 +1,101 @@ +export type PrivyLoginRow = { + privy_did: string; + email: string | null; + created_at: string; +}; + +export type PrivyLoginsPeriod = "daily" | "weekly" | "monthly"; + +const PERIOD_DAYS: Record = { + daily: 1, + weekly: 7, + monthly: 30, +}; + +type PrivyUserLinkedAccount = { + type: string; + address?: string; +}; + +type PrivyUser = { + id: string; + created_at: number; // Unix timestamp in seconds + linked_accounts: PrivyUserLinkedAccount[]; +}; + +type PrivyUsersPage = { + data: PrivyUser[]; + next_cursor?: string; +}; + +/** + * Fetches Privy users created within the given period via the Privy Management API. + * Paginates until all users within the time window are retrieved. + * + * @param period - "daily", "weekly", or "monthly" + * @returns Array of PrivyLoginRow sorted by created_at descending + */ +export async function fetchPrivyLogins(period: PrivyLoginsPeriod): Promise { + const days = PERIOD_DAYS[period]; + const cutoffMs = Date.now() - days * 24 * 60 * 60 * 1000; + const cutoffSec = Math.floor(cutoffMs / 1000); + + const appId = process.env.PRIVY_APP_ID!; + const appSecret = process.env.PRIVY_PROJECT_SECRET!; + const authHeader = `Basic ${Buffer.from(`${appId}:${appSecret}`).toString("base64")}`; + + const logins: PrivyLoginRow[] = []; + let cursor: string | undefined = undefined; + + while (true) { + const url = new URL("https://api.privy.io/v1/users"); + url.searchParams.set("limit", "100"); + url.searchParams.set("order", "desc"); // newest first so we can stop early + + if (cursor) { + url.searchParams.set("cursor", cursor); + } + + const response = await fetch(url.toString(), { + headers: { + "privy-app-id": appId, + Authorization: authHeader, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`Privy API error: ${response.status} ${response.statusText}`); + } + + const page: PrivyUsersPage = await response.json(); + + if (!page.data || page.data.length === 0) { + break; + } + + let reachedCutoff = false; + + for (const user of page.data) { + if (user.created_at < cutoffSec) { + reachedCutoff = true; + break; + } + + const emailAccount = user.linked_accounts?.find((a) => a.type === "email"); + logins.push({ + privy_did: user.id, + email: emailAccount?.address ?? null, + created_at: new Date(user.created_at * 1000).toISOString(), + }); + } + + if (reachedCutoff || !page.next_cursor) { + break; + } + + cursor = page.next_cursor; + } + + return logins; +} diff --git a/lib/admins/privy/getPrivyLoginsHandler.ts b/lib/admins/privy/getPrivyLoginsHandler.ts new file mode 100644 index 00000000..764f233f --- /dev/null +++ b/lib/admins/privy/getPrivyLoginsHandler.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateGetPrivyLoginsQuery } from "./validateGetPrivyLoginsQuery"; +import { fetchPrivyLogins } from "./fetchPrivyLogins"; + +/** + * Handler for GET /api/admins/privy + * + * Returns Privy login statistics for the requested time period. + * Period defaults to "daily" (last 24 hours). Supports "weekly" (7 days) and "monthly" (30 days). + * + * Each login represents a user account created in Privy (i.e., their first authentication). + * Results include a total count and a table of individual logins with email and timestamp. + * + * Requires admin authentication. + * + * @param request - The request object + * @returns A NextResponse with { status: "success", total: number, logins: PrivyLoginRow[] } + */ +export async function getPrivyLoginsHandler(request: NextRequest): Promise { + try { + const query = await validateGetPrivyLoginsQuery(request); + if (query instanceof NextResponse) { + return query; + } + + const logins = await fetchPrivyLogins(query.period); + + return NextResponse.json( + { status: "success", total: logins.length, logins }, + { status: 200, headers: getCorsHeaders() }, + ); + } catch (error) { + console.error("[ERROR] getPrivyLoginsHandler:", error); + return NextResponse.json( + { status: "error", message: "Internal server error" }, + { status: 500, headers: getCorsHeaders() }, + ); + } +} diff --git a/lib/admins/privy/validateGetPrivyLoginsQuery.ts b/lib/admins/privy/validateGetPrivyLoginsQuery.ts new file mode 100644 index 00000000..f0bdfee6 --- /dev/null +++ b/lib/admins/privy/validateGetPrivyLoginsQuery.ts @@ -0,0 +1,43 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; +import { validateAdminAuth } from "@/lib/admins/validateAdminAuth"; +import { z } from "zod"; +import type { PrivyLoginsPeriod } from "./fetchPrivyLogins"; + +const getPrivyLoginsQuerySchema = z.object({ + period: z.enum(["daily", "weekly", "monthly"]).default("daily"), +}); + +export type GetPrivyLoginsQuery = { + period: PrivyLoginsPeriod; +}; + +/** + * Validates admin auth and query parameters for GET /api/admins/privy. + * + * @param request - The NextRequest object + * @returns A NextResponse with an error if validation fails, or the validated query + */ +export async function validateGetPrivyLoginsQuery( + request: NextRequest, +): Promise { + const auth = await validateAdminAuth(request); + if (auth instanceof NextResponse) { + return auth; + } + + const period = request.nextUrl.searchParams.get("period") || undefined; + + const result = getPrivyLoginsQuerySchema.safeParse({ period }); + + if (!result.success) { + const firstError = result.error.issues[0]; + return NextResponse.json( + { status: "error", error: firstError.message }, + { status: 400, headers: getCorsHeaders() }, + ); + } + + return { period: result.data.period }; +}