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
68 changes: 66 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.32.0",
"version": "1.32.1",
"private": true,
"workspaces": [
"packages/*"
Expand Down
109 changes: 109 additions & 0 deletions src/app/api/airdrop/verify-fc/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
// @vitest-environment node
import { describe, expect, it, vi, beforeEach } from "vitest";

const mockUpdate = vi.fn();
let updateResult: { data: unknown; error: unknown } = { data: { address: "0xabc" }, error: null };

vi.mock("../../../../../lib/airdrop/siwe-verify", () => ({
verifySiweRequest: vi.fn(),
}));
vi.mock("../../../../../lib/airdrop/activation-verify", () => ({
verifyFc: vi.fn(),
}));
vi.mock("../../../../../lib/airdrop/config", () => ({
getAirdropConfig: () => ({ PLOTLINK_FC_FID: 12345 }),
}));
vi.mock("../../../../../lib/supabase", () => ({
createServerClient: () => ({
from: () => ({
update: (data: unknown) => {
mockUpdate(data);
return {
eq: () => ({
select: () => ({
single: () => Promise.resolve(updateResult),
}),
}),
};
},
}),
}),
}));

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

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

beforeEach(() => {
vi.clearAllMocks();
updateResult = { data: { address: "0xabc" }, error: null };
});

describe("POST /api/airdrop/verify-fc", () => {
it("returns 401 on invalid SIWE 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);
expect(mockUpdate).not.toHaveBeenCalled();
});

it("returns 404 for user_not_found — no DB write", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" });
vi.mocked(verifyFc).mockResolvedValue({ ok: false, error: "user_not_found" });
const res = await POST(makeReq({ message: "m", signature: "s", username: "ghost" }));
expect(res.status).toBe(404);
expect(mockUpdate).not.toHaveBeenCalled();
});

it("returns 422 for not_following — no DB write", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" });
vi.mocked(verifyFc).mockResolvedValue({ ok: false, error: "not_following" });
const res = await POST(makeReq({ message: "m", signature: "s", username: "nofol" }));
expect(res.status).toBe(422);
expect(mockUpdate).not.toHaveBeenCalled();
});

it("returns 502 for neynar_error — no DB write", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" });
vi.mocked(verifyFc).mockResolvedValue({ ok: false, error: "neynar_error" });
const res = await POST(makeReq({ message: "m", signature: "s", username: "err" }));
expect(res.status).toBe(502);
expect(mockUpdate).not.toHaveBeenCalled();
});

it("persists fid + fc_handle + fc_verified_at on success", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" });
vi.mocked(verifyFc).mockResolvedValue({ ok: true, fid: 999 });
const res = await POST(makeReq({ message: "m", signature: "s", username: "TestUser" }));
expect(res.status).toBe(200);
expect(mockUpdate).toHaveBeenCalledWith(
expect.objectContaining({ fid: 999, fc_handle: "testuser", fc_verified_at: expect.any(String) }),
);
});

it("returns 409 on FID UNIQUE conflict", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" });
vi.mocked(verifyFc).mockResolvedValue({ ok: true, fid: 999 });
updateResult = { data: null, error: { code: "23505", message: "unique violation" } };
const res = await POST(makeReq({ message: "m", signature: "s", username: "dupe" }));
expect(res.status).toBe(409);
});

it("returns 400 when no activation row exists", async () => {
vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xnew" });
vi.mocked(verifyFc).mockResolvedValue({ ok: true, fid: 888 });
updateResult = { data: null, error: { code: "PGRST116", message: "no rows" } };
const res = await POST(makeReq({ message: "m", signature: "s", username: "newuser" }));
expect(res.status).toBe(400);
const data = await res.json();
expect(data.error).toContain("Must confirm X handle first");
});
});
76 changes: 76 additions & 0 deletions src/app/api/airdrop/verify-fc/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { NextResponse } from "next/server";
import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify";
import { verifyFc } from "../../../../../lib/airdrop/activation-verify";
import { getAirdropConfig } from "../../../../../lib/airdrop/config";
import { createServerClient } from "../../../../../lib/supabase";

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 config = getAirdropConfig();
const result = await verifyFc(username, config.PLOTLINK_FC_FID);

if (!result.ok) {
const statusMap = {
user_not_found: 404,
not_following: 422,
neynar_error: 502,
} as const;
return NextResponse.json({ error: result.error }, { status: statusMap[result.error] });
}

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

const address = auth.address;
const now = new Date().toISOString();

const { data: updated, error } = await supabase
.from("pl_activations")
.update({
fid: result.fid,
fc_handle: username.toLowerCase(),
fc_verified_at: now,
})
.eq("address", address)
.select("address")
.single();

if (error) {
if (error.code === "23505") {
return NextResponse.json({ error: "Farcaster account already linked to another wallet" }, { status: 409 });
}
if (error.code === "PGRST116") {
return NextResponse.json({ error: "Must confirm X handle first" }, { status: 400 });
}
console.error("[verify-fc] Update failed:", error.message);
return NextResponse.json({ error: "Failed to save FC verification" }, { status: 500 });
}

if (!updated) {
return NextResponse.json({ error: "Must confirm X handle first" }, { status: 400 });
}

return NextResponse.json({
address,
fid: result.fid,
fc_handle: username.toLowerCase(),
fc_verified_at: now,
});
}
Loading