From 8da446bfa9f72e2e2254e9ab80c6a5e32d0eedf6 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 06:31:45 +0000 Subject: [PATCH 1/2] [#1243] Add /verify-fc endpoint with partial-failure semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SIWE-authenticated FC verification: calls verifyFc then persists fid + fc_handle + fc_verified_at on success. Partial-failure paths: user_not_found → 404, not_following → 422, neynar_error → 502 — all with zero DB writes. FID UNIQUE conflict → 409. 6 tests cover all acceptance paths. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 68 +++++++++++++++- package.json | 2 +- src/app/api/airdrop/verify-fc/route.test.ts | 90 +++++++++++++++++++++ src/app/api/airdrop/verify-fc/route.ts | 67 +++++++++++++++ 4 files changed, 224 insertions(+), 3 deletions(-) create mode 100644 src/app/api/airdrop/verify-fc/route.test.ts create mode 100644 src/app/api/airdrop/verify-fc/route.ts diff --git a/package-lock.json b/package-lock.json index 24f017e..37db427 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink", - "version": "1.32.0", + "version": "1.32.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink", - "version": "1.32.0", + "version": "1.32.1", "workspaces": [ "packages/*" ], @@ -9087,6 +9087,70 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", diff --git a/package.json b/package.json index 00ef95c..6f10b0d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.32.0", + "version": "1.32.1", "private": true, "workspaces": [ "packages/*" diff --git a/src/app/api/airdrop/verify-fc/route.test.ts b/src/app/api/airdrop/verify-fc/route.test.ts new file mode 100644 index 0000000..a6816fa --- /dev/null +++ b/src/app/api/airdrop/verify-fc/route.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment node +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockUpdate = vi.fn(); +const mockEq = vi.fn().mockReturnValue({ 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: mockEq }; }, + }), + }), +})); + +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(); + mockEq.mockReturnValue({ 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 }); + mockEq.mockReturnValue({ error: { code: "23505", message: "unique violation" } }); + const res = await POST(makeReq({ message: "m", signature: "s", username: "dupe" })); + expect(res.status).toBe(409); + }); +}); diff --git a/src/app/api/airdrop/verify-fc/route.ts b/src/app/api/airdrop/verify-fc/route.ts new file mode 100644 index 0000000..def8244 --- /dev/null +++ b/src/app/api/airdrop/verify-fc/route.ts @@ -0,0 +1,67 @@ +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 { error } = await supabase + .from("pl_activations") + .update({ + fid: result.fid, + fc_handle: username.toLowerCase(), + fc_verified_at: now, + }) + .eq("address", address); + + if (error) { + if (error.code === "23505") { + return NextResponse.json({ error: "Farcaster account already linked to another wallet" }, { status: 409 }); + } + console.error("[verify-fc] Update failed:", error.message); + return NextResponse.json({ error: "Failed to save FC verification" }, { status: 500 }); + } + + return NextResponse.json({ + address, + fid: result.fid, + fc_handle: username.toLowerCase(), + fc_verified_at: now, + }); +} From d7e16803db6b8b3fc0d59a77e1a8c979d6ef4111 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 06:33:59 +0000 Subject: [PATCH 2/2] [#1243] Verify row exists after update, add no-activation test Use .select().single() after update to confirm a row was modified. Return 400 when no pl_activations row exists (PGRST116 or null). Added 7th test for no-existing-activation-row path. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/airdrop/verify-fc/route.test.ts | 27 ++++++++++++++++++--- src/app/api/airdrop/verify-fc/route.ts | 13 ++++++++-- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/src/app/api/airdrop/verify-fc/route.test.ts b/src/app/api/airdrop/verify-fc/route.test.ts index a6816fa..a10ca1e 100644 --- a/src/app/api/airdrop/verify-fc/route.test.ts +++ b/src/app/api/airdrop/verify-fc/route.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi, beforeEach } from "vitest"; const mockUpdate = vi.fn(); -const mockEq = vi.fn().mockReturnValue({ error: null }); +let updateResult: { data: unknown; error: unknown } = { data: { address: "0xabc" }, error: null }; vi.mock("../../../../../lib/airdrop/siwe-verify", () => ({ verifySiweRequest: vi.fn(), @@ -16,7 +16,16 @@ vi.mock("../../../../../lib/airdrop/config", () => ({ vi.mock("../../../../../lib/supabase", () => ({ createServerClient: () => ({ from: () => ({ - update: (data: unknown) => { mockUpdate(data); return { eq: mockEq }; }, + update: (data: unknown) => { + mockUpdate(data); + return { + eq: () => ({ + select: () => ({ + single: () => Promise.resolve(updateResult), + }), + }), + }; + }, }), }), })); @@ -35,7 +44,7 @@ function makeReq(body: unknown) { beforeEach(() => { vi.clearAllMocks(); - mockEq.mockReturnValue({ error: null }); + updateResult = { data: { address: "0xabc" }, error: null }; }); describe("POST /api/airdrop/verify-fc", () => { @@ -83,8 +92,18 @@ describe("POST /api/airdrop/verify-fc", () => { it("returns 409 on FID UNIQUE conflict", async () => { vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); vi.mocked(verifyFc).mockResolvedValue({ ok: true, fid: 999 }); - mockEq.mockReturnValue({ error: { code: "23505", message: "unique violation" } }); + 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"); + }); }); diff --git a/src/app/api/airdrop/verify-fc/route.ts b/src/app/api/airdrop/verify-fc/route.ts index def8244..9185218 100644 --- a/src/app/api/airdrop/verify-fc/route.ts +++ b/src/app/api/airdrop/verify-fc/route.ts @@ -41,23 +41,32 @@ export async function POST(req: Request) { const address = auth.address; const now = new Date().toISOString(); - const { error } = await supabase + const { data: updated, error } = await supabase .from("pl_activations") .update({ fid: result.fid, fc_handle: username.toLowerCase(), fc_verified_at: now, }) - .eq("address", address); + .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,