Skip to content
Merged
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "plotlink",
"version": "1.31.0",
"version": "1.32.0",
"private": true,
"workspaces": [
"packages/*"
Expand Down
95 changes: 95 additions & 0 deletions src/app/api/airdrop/confirm-x-handle/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// @vitest-environment node
import { describe, expect, it, vi, beforeEach } from "vitest";

const mockUpsert = vi.fn().mockReturnValue({ error: null });
const mockInsert = vi.fn().mockReturnValue({ error: null });
const mockSelect = vi.fn().mockReturnValue({
eq: vi.fn().mockReturnValue({ limit: vi.fn().mockReturnValue({ single: vi.fn().mockResolvedValue({ data: null }) }) }),
});

vi.mock("../../../../../lib/airdrop/siwe-verify", () => ({
verifySiweRequest: vi.fn(),
}));
vi.mock("../../../../../lib/airdrop/twitterapi", () => ({
lookupXUser: vi.fn(),
}));
vi.mock("../../../../../lib/supabase", () => ({
createServerClient: () => ({
from: (table: string) => {
if (table === "pl_activations") return { upsert: mockUpsert };
if (table === "pl_referral_codes") return { select: mockSelect, insert: mockInsert };
return {};
},
}),
}));
vi.mock("nanoid", () => ({ nanoid: () => "test1234" }));

import { POST } from "./route";
import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify";
import { lookupXUser } from "../../../../../lib/airdrop/twitterapi";

function makeReq(body: unknown) {
return new Request("http://localhost/api/airdrop/confirm-x-handle", {
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
});
}

beforeEach(() => { vi.clearAllMocks(); mockUpsert.mockReturnValue({ error: null }); });

describe("POST /api/airdrop/confirm-x-handle", () => {
it("returns 401 on invalid signature", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "invalid_signature" });
const res = await POST(makeReq({ message: "m", signature: "s", username: "u" }));
expect(res.status).toBe(401);
});

it("server re-lookups username (ignores client x_user_id)", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" });
vi.mocked(lookupXUser).mockResolvedValue({
display_name: "Real", avatar_url: "url", follower_count: 50, x_user_id: "server_id", bio_snippet: "bio",
});

const res = await POST(makeReq({ message: "m", signature: "s", username: "test", x_user_id: "bogus_id" }));
expect(res.status).toBe(200);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({ x_user_id: "server_id" }),
expect.anything(),
);
});

it("returns 404 for nonexistent X user", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" });
vi.mocked(lookupXUser).mockResolvedValue(null);

const res = await POST(makeReq({ message: "m", signature: "s", username: "ghost" }));
expect(res.status).toBe(404);
expect(mockUpsert).not.toHaveBeenCalled();
});

it("returns 409 on UNIQUE conflict", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" });
vi.mocked(lookupXUser).mockResolvedValue({
display_name: "Test", avatar_url: "url", follower_count: 50, x_user_id: "123", bio_snippet: "bio",
});
mockUpsert.mockReturnValue({ error: { code: "23505", message: "unique violation" } });

const res = await POST(makeReq({ message: "m", signature: "s", username: "taken" }));
expect(res.status).toBe(409);
});

it("R16: writes pending row when twitterapi.io throws", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" });
vi.mocked(lookupXUser).mockRejectedValue(new Error("network error"));

const res = await POST(makeReq({ message: "m", signature: "s", username: "downuser" }));
expect(res.status).toBe(200);
expect(mockUpsert).toHaveBeenCalledWith(
expect.objectContaining({ x_handle_confirmed_at: null, x_user_id: null }),
expect.anything(),
);
const data = await res.json();
expect(data.confirmed).toBe(false);
});
});
87 changes: 87 additions & 0 deletions src/app/api/airdrop/confirm-x-handle/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { NextResponse } from "next/server";
import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify";
import { lookupXUser } from "../../../../../lib/airdrop/twitterapi";
import { createServerClient } from "../../../../../lib/supabase";
import { nanoid } from "nanoid";

export async function POST(req: Request) {
let message: string, signature: string, username: string;
try {
const body = await req.json();
message = body.message;
signature = body.signature;
username = body.username;
if (!message || !signature || !username) throw new Error();
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}

const auth = await verifySiweRequest(message, signature);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: 401 });
}

const supabase = createServerClient();
if (!supabase) {
return NextResponse.json({ error: "Supabase not configured" }, { status: 500 });
}

const address = auth.address;
const handle = username.toLowerCase();

let xUserId: string | null = null;
let confirmedAt: string | null = null;

try {
const user = await lookupXUser(username);
if (!user) {
return NextResponse.json({ error: "X user not found" }, { status: 404 });
}
xUserId = user.x_user_id;
confirmedAt = new Date().toISOString();
} catch {
// R16 graceful degrade: twitterapi.io down → pending row without UNIQUE lock
}

const { error: upsertErr } = await supabase
.from("pl_activations")
.upsert(
{
address,
x_handle: handle,
x_user_id: xUserId,
x_handle_confirmed_at: confirmedAt,
},
{ onConflict: "address" },
);

if (upsertErr) {
if (upsertErr.code === "23505") {
return NextResponse.json({ error: "X handle already claimed by another wallet" }, { status: 409 });
}
console.error("[confirm-x-handle] Upsert failed:", upsertErr.message);
return NextResponse.json({ error: "Failed to confirm handle" }, { status: 500 });
}

const { data: existingCode } = await supabase
.from("pl_referral_codes")
.select("code")
.eq("address", address)
.limit(1)
.single();

if (!existingCode) {
const code = nanoid(8);
await supabase.from("pl_referral_codes").insert({
address,
code,
is_farcaster_username: false,
});
}

return NextResponse.json({
address,
x_handle: handle,
confirmed: confirmedAt !== null,
});
}
50 changes: 50 additions & 0 deletions src/app/api/airdrop/verify-x-handle/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// @vitest-environment node
import { describe, expect, it, vi, beforeEach } from "vitest";

vi.mock("../../../../../lib/airdrop/siwe-verify", () => ({
verifySiweRequest: vi.fn(),
}));
vi.mock("../../../../../lib/airdrop/twitterapi", () => ({
lookupXUser: vi.fn(),
}));

import { POST } from "./route";
import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify";
import { lookupXUser } from "../../../../../lib/airdrop/twitterapi";

function makeReq(body: unknown) {
return new Request("http://localhost/api/airdrop/verify-x-handle", {
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
});
}

beforeEach(() => { vi.clearAllMocks(); });

describe("POST /api/airdrop/verify-x-handle", () => {
it("returns 401 on invalid signature", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "expired" });
const res = await POST(makeReq({ message: "m", signature: "s", username: "u" }));
expect(res.status).toBe(401);
});

it("returns lookup data without persisting", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" });
vi.mocked(lookupXUser).mockResolvedValue({
display_name: "Test", avatar_url: "url", follower_count: 100, x_user_id: "123", bio_snippet: "bio",
});
const res = await POST(makeReq({ message: "m", signature: "s", username: "testuser" }));
expect(res.status).toBe(200);
const data = await res.json();
expect(data.display_name).toBe("Test");
expect(data.x_user_id).toBe("123");
});

it("returns 404 when user not found", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" });
vi.mocked(lookupXUser).mockResolvedValue(null);
const res = await POST(makeReq({ message: "m", signature: "s", username: "ghost" }));
expect(res.status).toBe(404);
});
});
28 changes: 28 additions & 0 deletions src/app/api/airdrop/verify-x-handle/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify";
import { lookupXUser } from "../../../../../lib/airdrop/twitterapi";

export async function POST(req: Request) {
let message: string, signature: string, username: string;
try {
const body = await req.json();
message = body.message;
signature = body.signature;
username = body.username;
if (!message || !signature || !username) throw new Error();
} catch {
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
}

const auth = await verifySiweRequest(message, signature);
if (!auth.ok) {
return NextResponse.json({ error: auth.error }, { status: 401 });
}

const user = await lookupXUser(username);
if (!user) {
return NextResponse.json({ error: "X user not found" }, { status: 404 });
}

return NextResponse.json(user);
}
76 changes: 76 additions & 0 deletions src/app/api/airdrop/x-follow-click/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
// @vitest-environment node
import { describe, expect, it, vi, beforeEach } from "vitest";

const mockUpdate = vi.fn().mockReturnValue({ eq: vi.fn().mockReturnValue({ error: null }) });
const mockSelectSingle = vi.fn();

vi.mock("../../../../../lib/airdrop/siwe-verify", () => ({
verifySiweRequest: vi.fn(),
}));
vi.mock("../../../../../lib/supabase", () => ({
createServerClient: () => ({
from: () => ({
select: () => ({ eq: () => ({ single: mockSelectSingle }) }),
update: (data: unknown) => {
mockUpdate(data);
return { eq: vi.fn().mockReturnValue({ error: null }) };
},
}),
}),
}));

import { POST } from "./route";
import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify";

function makeReq(body: unknown) {
return new Request("http://localhost/api/airdrop/x-follow-click", {
method: "POST",
body: JSON.stringify(body),
headers: { "content-type": "application/json" },
});
}

beforeEach(() => { vi.clearAllMocks(); });

describe("POST /api/airdrop/x-follow-click", () => {
it("returns 401 on invalid signature", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "expired" });
const res = await POST(makeReq({ message: "m", signature: "s" }));
expect(res.status).toBe(401);
});

it("returns 400 if no activation row exists", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" });
mockSelectSingle.mockResolvedValue({ data: null });
const res = await POST(makeReq({ message: "m", signature: "s" }));
expect(res.status).toBe(400);
});

it("sets activated_at when handle confirmed + follow click completes activation", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" });
mockSelectSingle.mockResolvedValue({
data: { x_handle_confirmed_at: "2026-07-01", x_follow_at: null, activated_at: null },
});

const res = await POST(makeReq({ message: "m", signature: "s" }));
expect(res.status).toBe(200);
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({ activated_at: expect.any(String) }),
);
const data = await res.json();
expect(data.activated).toBe(true);
});

it("does not re-set activated_at if already activated", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" });
mockSelectSingle.mockResolvedValue({
data: { x_handle_confirmed_at: "2026-07-01", x_follow_at: "2026-07-01", activated_at: "2026-07-01" },
});

const res = await POST(makeReq({ message: "m", signature: "s" }));
expect(res.status).toBe(200);
expect(mockUpdate).toHaveBeenCalledWith(
expect.not.objectContaining({ activated_at: expect.anything() }),
);
});
});
Loading
Loading