From 723a339ed00df5e990afc1b2236e0cfceb26483b Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 08:00:50 +0000 Subject: [PATCH 1/2] [#79] Add rating API with signature verification and token gate - POST /api/ratings: recovers address via recoverMessageAddress(), checks balanceOf() on storyline token, upserts via service role - GET /api/ratings?storylineId=N: returns ratings array + average - Rejects if rater holds 0 tokens (403) Fixes #79 Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/ratings/route.ts | 143 +++++++++++++++++++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 src/app/api/ratings/route.ts diff --git a/src/app/api/ratings/route.ts b/src/app/api/ratings/route.ts new file mode 100644 index 00000000..bf3a2841 --- /dev/null +++ b/src/app/api/ratings/route.ts @@ -0,0 +1,143 @@ +import { NextRequest, NextResponse } from "next/server"; +import { recoverMessageAddress, type Address } from "viem"; +import { publicClient } from "../../../../lib/rpc"; +import { createServerClient, supabase } from "../../../../lib/supabase"; +import { erc20Abi } from "../../../../lib/price"; + +function error(message: string, status = 400) { + return NextResponse.json({ error: message }, { status }); +} + +// --------------------------------------------------------------------------- +// GET /api/ratings?storylineId=N +// --------------------------------------------------------------------------- + +export async function GET(req: NextRequest) { + const storylineId = req.nextUrl.searchParams.get("storylineId"); + if (!storylineId) { + return error("Missing storylineId"); + } + + const db = supabase; + if (!db) { + return error("Supabase not configured", 500); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data, error: dbError } = await (db.from("ratings") as any) + .select("*") + .eq("storyline_id", Number(storylineId)); + + if (dbError) { + return error(`Database error: ${dbError.message}`, 500); + } + + const ratings = data ?? []; + const average = + ratings.length > 0 + ? ratings.reduce( + (sum: number, r: { rating: number }) => sum + r.rating, + 0, + ) / ratings.length + : 0; + + return NextResponse.json({ ratings, average, count: ratings.length }); +} + +// --------------------------------------------------------------------------- +// POST /api/ratings +// --------------------------------------------------------------------------- + +interface RatingBody { + storylineId: number; + rating: number; + comment?: string; + signature: string; + message: string; +} + +export async function POST(req: NextRequest) { + let body: RatingBody; + try { + body = await req.json(); + } catch { + return error("Invalid JSON body"); + } + + const { storylineId, rating, comment, signature, message } = body; + + // Validate inputs + if (!storylineId || typeof storylineId !== "number") { + return error("Missing or invalid storylineId"); + } + if (!rating || typeof rating !== "number" || rating < 1 || rating > 5) { + return error("Rating must be an integer between 1 and 5"); + } + if (!signature || !message) { + return error("Missing signature or message"); + } + + // 1. Recover rater address from signature + let raterAddress: Address; + try { + raterAddress = await recoverMessageAddress({ + message, + signature: signature as `0x${string}`, + }); + } catch { + return error("Failed to verify signature"); + } + + // 2. Look up storyline → get token_address + const serverClient = createServerClient(); + if (!serverClient) { + return error("Supabase not configured", 500); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { data: storyline, error: slError } = await (serverClient.from("storylines") as any) + .select("token_address") + .eq("storyline_id", storylineId) + .single(); + + if (slError || !storyline) { + return error("Storyline not found", 404); + } + + const tokenAddress = storyline.token_address as Address; + + // 3. Token gate: balanceOf(rater, tokenAddress) > 0 + try { + const balance = await publicClient.readContract({ + address: tokenAddress, + abi: erc20Abi, + functionName: "balanceOf", + args: [raterAddress], + }); + + if (balance === BigInt(0)) { + return error("Must hold storyline tokens to rate", 403); + } + } catch { + return error("Failed to check token balance", 502); + } + + // 4. Upsert rating via service role client + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const { error: upsertError } = await (serverClient.from("ratings") as any).upsert( + { + storyline_id: storylineId, + rater_address: raterAddress.toLowerCase(), + rating, + comment: comment ?? null, + updated_at: new Date().toISOString(), + }, + { onConflict: "storyline_id,rater_address" }, + ); + + if (upsertError) { + return error(`Database error: ${upsertError.message}`, 500); + } + + return NextResponse.json({ success: true }); +} From e9b4b0ade4d49d00c7f5af9d73c4e7f3cccea41f Mon Sep 17 00:00:00 2001 From: Cho Young-Hwi Date: Sun, 15 Mar 2026 08:02:17 +0000 Subject: [PATCH 2/2] [#79] Validate signed message content + integer check on rating - Signed message must match "Rate storyline {id} with rating {n}" to prevent replay attacks across storylines/ratings - Added Number.isInteger() check for rating value Co-Authored-By: Claude Opus 4.6 (1M context) --- src/app/api/ratings/route.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/app/api/ratings/route.ts b/src/app/api/ratings/route.ts index bf3a2841..44b320e9 100644 --- a/src/app/api/ratings/route.ts +++ b/src/app/api/ratings/route.ts @@ -70,13 +70,21 @@ export async function POST(req: NextRequest) { if (!storylineId || typeof storylineId !== "number") { return error("Missing or invalid storylineId"); } - if (!rating || typeof rating !== "number" || rating < 1 || rating > 5) { + if (!rating || typeof rating !== "number" || !Number.isInteger(rating) || rating < 1 || rating > 5) { return error("Rating must be an integer between 1 and 5"); } if (!signature || !message) { return error("Missing signature or message"); } + // Validate signed message binds to this specific action + const expectedMessage = `Rate storyline ${storylineId} with rating ${rating}`; + if (message !== expectedMessage) { + return error( + `Signed message must be exactly: "${expectedMessage}"`, + ); + } + // 1. Recover rater address from signature let raterAddress: Address; try {