Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions app/api/admins/privy/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
return getPrivyLoginsHandler(request);
}

export async function OPTIONS(): Promise<NextResponse> {
return new NextResponse(null, { status: 204, headers: getCorsHeaders() });
}
103 changes: 103 additions & 0 deletions lib/admins/privy/__tests__/getPrivyLoginsHandler.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
88 changes: 88 additions & 0 deletions lib/admins/privy/__tests__/validateGetPrivyLoginsQuery.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
101 changes: 101 additions & 0 deletions lib/admins/privy/fetchPrivyLogins.ts
Original file line number Diff line number Diff line change
@@ -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<PrivyLoginsPeriod, number> = {
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<PrivyLoginRow[]> {
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;
}
40 changes: 40 additions & 0 deletions lib/admins/privy/getPrivyLoginsHandler.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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() },
);
}
}
43 changes: 43 additions & 0 deletions lib/admins/privy/validateGetPrivyLoginsQuery.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse | GetPrivyLoginsQuery> {
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 };
}
Loading