diff --git a/package-lock.json b/package-lock.json index f565037..24f017e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink", - "version": "1.31.0", + "version": "1.32.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink", - "version": "1.31.0", + "version": "1.32.0", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index 497d61f..00ef95c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.31.0", + "version": "1.32.0", "private": true, "workspaces": [ "packages/*" diff --git a/src/app/api/airdrop/confirm-x-handle/route.test.ts b/src/app/api/airdrop/confirm-x-handle/route.test.ts new file mode 100644 index 0000000..59c2cfe --- /dev/null +++ b/src/app/api/airdrop/confirm-x-handle/route.test.ts @@ -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); + }); +}); diff --git a/src/app/api/airdrop/confirm-x-handle/route.ts b/src/app/api/airdrop/confirm-x-handle/route.ts new file mode 100644 index 0000000..1384b96 --- /dev/null +++ b/src/app/api/airdrop/confirm-x-handle/route.ts @@ -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, + }); +} diff --git a/src/app/api/airdrop/verify-x-handle/route.test.ts b/src/app/api/airdrop/verify-x-handle/route.test.ts new file mode 100644 index 0000000..8ac895c --- /dev/null +++ b/src/app/api/airdrop/verify-x-handle/route.test.ts @@ -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); + }); +}); diff --git a/src/app/api/airdrop/verify-x-handle/route.ts b/src/app/api/airdrop/verify-x-handle/route.ts new file mode 100644 index 0000000..539442a --- /dev/null +++ b/src/app/api/airdrop/verify-x-handle/route.ts @@ -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); +} diff --git a/src/app/api/airdrop/x-follow-click/route.test.ts b/src/app/api/airdrop/x-follow-click/route.test.ts new file mode 100644 index 0000000..4c12ea6 --- /dev/null +++ b/src/app/api/airdrop/x-follow-click/route.test.ts @@ -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() }), + ); + }); +}); diff --git a/src/app/api/airdrop/x-follow-click/route.ts b/src/app/api/airdrop/x-follow-click/route.ts new file mode 100644 index 0000000..3edf20b --- /dev/null +++ b/src/app/api/airdrop/x-follow-click/route.ts @@ -0,0 +1,60 @@ +import { NextResponse } from "next/server"; +import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; +import { createServerClient } from "../../../../../lib/supabase"; + +export async function POST(req: Request) { + let message: string, signature: string; + try { + const body = await req.json(); + message = body.message; + signature = body.signature; + if (!message || !signature) 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 now = new Date().toISOString(); + + const { data: existing } = await supabase + .from("pl_activations") + .select("x_handle_confirmed_at, x_follow_at, activated_at") + .eq("address", address) + .single(); + + if (!existing) { + return NextResponse.json({ error: "Must confirm X handle first" }, { status: 400 }); + } + + const handleConfirmed = existing.x_handle_confirmed_at !== null; + const alreadyActivated = existing.activated_at !== null; + + const { error } = await supabase + .from("pl_activations") + .update({ + x_follow_at: now, + ...(handleConfirmed && !alreadyActivated ? { activated_at: now } : {}), + }) + .eq("address", address); + + if (error) { + console.error("[x-follow-click] Update failed:", error.message); + return NextResponse.json({ error: "Failed to record follow" }, { status: 500 }); + } + + return NextResponse.json({ + address, + x_follow_at: now, + activated: handleConfirmed && !alreadyActivated ? true : alreadyActivated, + }); +}