From 385ed3968e9a5a61150ce87f7063e7c0c81279f3 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 07:04:01 +0000 Subject: [PATCH 1/4] [#1246] Migrate referral POST endpoints to SIWE auth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace verifyWalletOwnership with verifySiweRequest on both register-referral POST and referral-code POST. Closes the activation-signature-replay attack surface (R2/R22) — SIWE validates domain, URI, chainId, statement, and 10-min freshness. GET endpoints remain unauthenticated (referral-code GET preserved for ShareButtons compatibility). Zero remaining verifyWalletOwnership calls in mutation endpoints. Co-Authored-By: Claude Opus 4.7 (1M context) --- package-lock.json | 4 ++-- package.json | 2 +- src/app/api/airdrop/referral-code/route.ts | 11 ++++++----- src/app/api/airdrop/register-referral/route.ts | 11 ++++++----- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index e8350fb7..6e970f33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "plotlink", - "version": "1.33.0", + "version": "1.33.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "plotlink", - "version": "1.33.0", + "version": "1.33.1", "workspaces": [ "packages/*" ], diff --git a/package.json b/package.json index 96d16ffb..7bb61bc4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "plotlink", - "version": "1.33.0", + "version": "1.33.1", "private": true, "workspaces": [ "packages/*" diff --git a/src/app/api/airdrop/referral-code/route.ts b/src/app/api/airdrop/referral-code/route.ts index 9689a4da..da1f580f 100644 --- a/src/app/api/airdrop/referral-code/route.ts +++ b/src/app/api/airdrop/referral-code/route.ts @@ -9,7 +9,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { nanoid } from "nanoid"; import { createServerClient } from "../../../../../lib/supabase"; -import { verifyWalletOwnership } from "../../../../../lib/airdrop/verify-wallet"; +import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; import { checkRateLimit, getClientIp } from "../../../../../lib/rate-limit"; export async function GET(req: NextRequest) { @@ -48,7 +48,7 @@ export async function POST(req: Request) { } let message: string; - let signature: `0x${string}`; + let signature: string; let useFarcasterUsername: boolean; try { const body = await req.json(); @@ -60,10 +60,11 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); } - const address = await verifyWalletOwnership(message, signature); - if (!address) { - return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + const auth = await verifySiweRequest(message, signature); + if (!auth.ok) { + return NextResponse.json({ error: auth.error }, { status: 401 }); } + const address = auth.address; // Check for existing code (immutable once set) const { data: existing } = await supabase diff --git a/src/app/api/airdrop/register-referral/route.ts b/src/app/api/airdrop/register-referral/route.ts index 2b6e486f..5c537808 100644 --- a/src/app/api/airdrop/register-referral/route.ts +++ b/src/app/api/airdrop/register-referral/route.ts @@ -11,7 +11,7 @@ import { NextResponse, type NextRequest } from "next/server"; import { createServerClient } from "../../../../../lib/supabase"; -import { verifyWalletOwnership } from "../../../../../lib/airdrop/verify-wallet"; +import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; import { checkRateLimit, getClientIp } from "../../../../../lib/rate-limit"; export async function GET(req: NextRequest) { @@ -64,7 +64,7 @@ export async function POST(req: Request) { } let message: string; - let signature: `0x${string}`; + let signature: string; let referralCode: string; try { const body = await req.json(); @@ -76,10 +76,11 @@ export async function POST(req: Request) { return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); } - const address = await verifyWalletOwnership(message, signature); - if (!address) { - return NextResponse.json({ error: "Invalid signature" }, { status: 401 }); + const auth = await verifySiweRequest(message, signature); + if (!auth.ok) { + return NextResponse.json({ error: auth.error }, { status: 401 }); } + const address = auth.address; // Check if already referred const { data: existing } = await supabase From 73c8aaa131c25e6ed35a577db32aa58bce322b02 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 07:06:55 +0000 Subject: [PATCH 2/4] [#1246] Add route tests for SIWE auth migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 8 tests: expired/wrong-domain replay → 401 with no DB mutation on both POST endpoints, valid SIWE success paths, GET referral-code remains unauthenticated with { code, is_farcaster_username } shape. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../api/airdrop/referral-code/route.test.ts | 94 +++++++++++++++++++ .../airdrop/register-referral/route.test.ts | 73 ++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 src/app/api/airdrop/referral-code/route.test.ts create mode 100644 src/app/api/airdrop/register-referral/route.test.ts diff --git a/src/app/api/airdrop/referral-code/route.test.ts b/src/app/api/airdrop/referral-code/route.test.ts new file mode 100644 index 00000000..a79cd0ab --- /dev/null +++ b/src/app/api/airdrop/referral-code/route.test.ts @@ -0,0 +1,94 @@ +// @vitest-environment node +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockInsert = vi.fn().mockReturnValue({ error: null }); +const mockSelectCode = vi.fn(); + +vi.mock("../../../../../lib/airdrop/siwe-verify", () => ({ + verifySiweRequest: vi.fn(), +})); +vi.mock("../../../../../lib/rate-limit", () => ({ + checkRateLimit: () => Promise.resolve(true), + getClientIp: () => "127.0.0.1", +})); +vi.mock("../../../../../lib/supabase", () => ({ + createServerClient: () => ({ + from: (table: string) => { + if (table === "pl_referral_codes") { + return { + select: () => ({ eq: () => ({ single: mockSelectCode }) }), + insert: mockInsert, + }; + } + return {}; + }, + }), +})); +vi.mock("nanoid", () => ({ nanoid: () => "testcode" })); + +import { GET, POST } from "./route"; +import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; + +function makePostReq(body: unknown) { + return new Request("http://localhost/api/airdrop/referral-code", { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockInsert.mockReturnValue({ error: null }); +}); + +describe("POST /api/airdrop/referral-code", () => { + it("returns 401 on expired SIWE signature", async () => { + vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "expired" }); + const res = await POST(makePostReq({ message: "m", signature: "s" })); + expect(res.status).toBe(401); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it("returns 401 on wrong chainId", async () => { + vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "chain_id_mismatch" }); + const res = await POST(makePostReq({ message: "m", signature: "s" })); + expect(res.status).toBe(401); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it("succeeds with valid SIWE and generates code", async () => { + vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); + mockSelectCode.mockResolvedValue({ data: null }); + + const res = await POST(makePostReq({ message: "m", signature: "s" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.code).toBe("testcode"); + }); +}); + +describe("GET /api/airdrop/referral-code", () => { + it("remains unauthenticated — returns code without auth headers", async () => { + mockSelectCode.mockResolvedValue({ data: { code: "mycode", is_farcaster_username: false } }); + const req = new Request("http://localhost/api/airdrop/referral-code?address=0xabc") as any; + req.nextUrl = new URL("http://localhost/api/airdrop/referral-code?address=0xabc"); + + const res = await GET(req); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.code).toBe("mycode"); + expect(data).toHaveProperty("is_farcaster_username"); + }); + + it("returns { code: null } for unknown address", async () => { + mockSelectCode.mockResolvedValue({ data: null }); + const req = new Request("http://localhost/api/airdrop/referral-code?address=0xnone") as any; + req.nextUrl = new URL("http://localhost/api/airdrop/referral-code?address=0xnone"); + + const res = await GET(req); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.code).toBeNull(); + }); +}); diff --git a/src/app/api/airdrop/register-referral/route.test.ts b/src/app/api/airdrop/register-referral/route.test.ts new file mode 100644 index 00000000..3351c217 --- /dev/null +++ b/src/app/api/airdrop/register-referral/route.test.ts @@ -0,0 +1,73 @@ +// @vitest-environment node +import { describe, expect, it, vi, beforeEach } from "vitest"; + +const mockInsert = vi.fn().mockReturnValue({ error: null }); +const mockSelectReferred = vi.fn(); +const mockSelectCode = vi.fn(); + +vi.mock("../../../../../lib/airdrop/siwe-verify", () => ({ + verifySiweRequest: vi.fn(), +})); +vi.mock("../../../../../lib/rate-limit", () => ({ + checkRateLimit: () => Promise.resolve(true), + getClientIp: () => "127.0.0.1", +})); +vi.mock("../../../../../lib/supabase", () => ({ + createServerClient: () => ({ + from: (table: string) => { + if (table === "pl_referrals") { + return { + select: () => ({ eq: () => ({ single: mockSelectReferred }) }), + insert: mockInsert, + }; + } + if (table === "pl_referral_codes") { + return { select: () => ({ eq: () => ({ single: mockSelectCode }) }) }; + } + return {}; + }, + }), +})); + +import { POST } from "./route"; +import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; + +function makeReq(body: unknown) { + return new Request("http://localhost/api/airdrop/register-referral", { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockInsert.mockReturnValue({ error: null }); +}); + +describe("POST /api/airdrop/register-referral", () => { + it("returns 401 on expired SIWE signature", async () => { + vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "expired" }); + const res = await POST(makeReq({ message: "m", signature: "s", referralCode: "abc" })); + expect(res.status).toBe(401); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it("returns 401 on wrong domain", async () => { + vi.mocked(verifySiweRequest).mockResolvedValue({ ok: false, error: "domain_mismatch" }); + const res = await POST(makeReq({ message: "m", signature: "s", referralCode: "abc" })); + expect(res.status).toBe(401); + expect(mockInsert).not.toHaveBeenCalled(); + }); + + it("succeeds with valid SIWE signature", async () => { + vi.mocked(verifySiweRequest).mockResolvedValue({ ok: true, address: "0xabc" }); + mockSelectReferred.mockResolvedValue({ data: null }); + mockSelectCode.mockResolvedValue({ data: { address: "0xref" } }); + + const res = await POST(makeReq({ message: "m", signature: "s", referralCode: "code1" })); + expect(res.status).toBe(200); + const data = await res.json(); + expect(data.success).toBe(true); + }); +}); From 276990902ff85e93c50b92b8b4185356cfefeee1 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 07:11:02 +0000 Subject: [PATCH 3/4] [#1246] Fix eslint no-explicit-any in test Replace 'as any' casts with Object.assign for NextRequest mock. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/airdrop/referral-code/route.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/api/airdrop/referral-code/route.test.ts b/src/app/api/airdrop/referral-code/route.test.ts index a79cd0ab..2aef7a33 100644 --- a/src/app/api/airdrop/referral-code/route.test.ts +++ b/src/app/api/airdrop/referral-code/route.test.ts @@ -71,8 +71,8 @@ describe("POST /api/airdrop/referral-code", () => { describe("GET /api/airdrop/referral-code", () => { it("remains unauthenticated — returns code without auth headers", async () => { mockSelectCode.mockResolvedValue({ data: { code: "mycode", is_farcaster_username: false } }); - const req = new Request("http://localhost/api/airdrop/referral-code?address=0xabc") as any; - req.nextUrl = new URL("http://localhost/api/airdrop/referral-code?address=0xabc"); + const url = new URL("http://localhost/api/airdrop/referral-code?address=0xabc"); + const req = Object.assign(new Request(url), { nextUrl: url }); const res = await GET(req); expect(res.status).toBe(200); @@ -83,8 +83,8 @@ describe("GET /api/airdrop/referral-code", () => { it("returns { code: null } for unknown address", async () => { mockSelectCode.mockResolvedValue({ data: null }); - const req = new Request("http://localhost/api/airdrop/referral-code?address=0xnone") as any; - req.nextUrl = new URL("http://localhost/api/airdrop/referral-code?address=0xnone"); + const url = new URL("http://localhost/api/airdrop/referral-code?address=0xnone"); + const req = Object.assign(new Request(url), { nextUrl: url }); const res = await GET(req); expect(res.status).toBe(200); From 666a7c568c58e6ddd5742e5d6d367c8129fd2902 Mon Sep 17 00:00:00 2001 From: dev-agent Date: Tue, 26 May 2026 07:14:40 +0000 Subject: [PATCH 4/4] [#1246] Fix NextRequest type in test mock Use NextRequest constructor instead of Object.assign for type compat. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/airdrop/referral-code/route.test.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/app/api/airdrop/referral-code/route.test.ts b/src/app/api/airdrop/referral-code/route.test.ts index 2aef7a33..66266e38 100644 --- a/src/app/api/airdrop/referral-code/route.test.ts +++ b/src/app/api/airdrop/referral-code/route.test.ts @@ -28,6 +28,7 @@ vi.mock("nanoid", () => ({ nanoid: () => "testcode" })); import { GET, POST } from "./route"; import { verifySiweRequest } from "../../../../../lib/airdrop/siwe-verify"; +import { NextRequest } from "next/server"; function makePostReq(body: unknown) { return new Request("http://localhost/api/airdrop/referral-code", { @@ -71,8 +72,7 @@ describe("POST /api/airdrop/referral-code", () => { describe("GET /api/airdrop/referral-code", () => { it("remains unauthenticated — returns code without auth headers", async () => { mockSelectCode.mockResolvedValue({ data: { code: "mycode", is_farcaster_username: false } }); - const url = new URL("http://localhost/api/airdrop/referral-code?address=0xabc"); - const req = Object.assign(new Request(url), { nextUrl: url }); + const req = new NextRequest("http://localhost/api/airdrop/referral-code?address=0xabc"); const res = await GET(req); expect(res.status).toBe(200); @@ -83,8 +83,7 @@ describe("GET /api/airdrop/referral-code", () => { it("returns { code: null } for unknown address", async () => { mockSelectCode.mockResolvedValue({ data: null }); - const url = new URL("http://localhost/api/airdrop/referral-code?address=0xnone"); - const req = Object.assign(new Request(url), { nextUrl: url }); + const req = new NextRequest("http://localhost/api/airdrop/referral-code?address=0xnone"); const res = await GET(req); expect(res.status).toBe(200);