From 2bbca31ff88f0ac880e7f239bc5f74e0a2359613 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 25 Jul 2026 13:14:21 +0000 Subject: [PATCH] feat: Privy-only auth, remove access codes - Remove access code path from api/access route - access-auth.ts: strip normalizeAccessCode/hashAccessCode helpers - middleware.ts: fix token parsing for multi-segment userIds - access/page.tsx: Privy-only UI, "Continue with Google or X" - privy-provider.tsx: loginMethods restricted to google + twitter Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01RjpGP3kYKnQuQAUoK6RZky --- src/app/access/page.tsx | 17 ++-- src/app/api/access/route.ts | 128 +++++------------------------- src/components/privy-provider.tsx | 2 +- src/lib/access-auth.ts | 59 +++++--------- src/middleware.ts | 54 ++++--------- 5 files changed, 64 insertions(+), 196 deletions(-) diff --git a/src/app/access/page.tsx b/src/app/access/page.tsx index 772d4b31..ee51bcce 100644 --- a/src/app/access/page.tsx +++ b/src/app/access/page.tsx @@ -15,16 +15,15 @@ function AccessForm() { const [error, setError] = useState(""); useEffect(() => { - if (!ready) return; - if (!authenticated || !user) return; + if (!ready || !authenticated || !user) return; const email = - user.email?.address || - (user.linkedAccounts.find((a) => a.type === "email") as { address?: string } | undefined)?.address || + (user as { email?: { address?: string } }).email?.address ?? + (user.linkedAccounts?.find((a) => a.type === "email") as { address?: string } | undefined)?.address ?? null; const xHandle = - (user.linkedAccounts.find((a) => a.type === "twitter_oauth") as { username?: string } | undefined)?.username || + (user.linkedAccounts?.find((a) => a.type === "twitter_oauth") as { username?: string } | undefined)?.username ?? null; setIsLoading(true); @@ -33,10 +32,10 @@ function AccessForm() { fetch("/api/access", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ privy: true, userId: user.id, email, xHandle }), + body: JSON.stringify({ userId: user.id, email, xHandle }), }) .then((res) => res.json()) - .then((data) => { + .then((data: { ok?: boolean; error?: string }) => { if (data.ok) { window.location.assign(nextPath.startsWith("/") ? nextPath : "/dashboard"); } else { @@ -82,7 +81,7 @@ function AccessForm() {
{error &&

{error}

} @@ -99,7 +98,7 @@ function AccessForm() {

Something went wrong

{error}

- diff --git a/src/app/api/access/route.ts b/src/app/api/access/route.ts index 2b2ce675..4fef23d9 100644 --- a/src/app/api/access/route.ts +++ b/src/app/api/access/route.ts @@ -1,19 +1,11 @@ import { NextResponse } from "next/server"; -import { - ACCESS_COOKIE_MAX_AGE, - ACCESS_COOKIE_NAME, - createAccessToken, - hashAccessCode, - normalizeAccessCode, -} from "@/lib/access-auth"; +import { ACCESS_COOKIE_MAX_AGE, ACCESS_COOKIE_NAME, createAccessToken } from "@/lib/access-auth"; import { getSupabaseAdminClient } from "@/lib/supabase-admin"; -const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - -function setAccessCookie(response: NextResponse, id: string) { +function setAccessCookie(response: NextResponse, userId: string) { response.cookies.set({ name: ACCESS_COOKIE_NAME, - value: createAccessToken(id), + value: createAccessToken(userId), httpOnly: true, sameSite: "lax", secure: process.env.NODE_ENV === "production", @@ -24,108 +16,28 @@ function setAccessCookie(response: NextResponse, id: string) { export async function POST(request: Request) { try { - const body = await request.json(); - - // Privy auth path — user already authenticated via Privy client SDK - if (body.privy === true) { - const userId = String(body.userId || "").trim(); - if (!userId) { - return NextResponse.json({ error: "Missing user ID." }, { status: 400 }); - } - - // Optionally upsert user record for tracking - try { - const supabase = getSupabaseAdminClient(); - await supabase.from("users").upsert( - { - privy_id: userId, - email: body.email || null, - x_handle: body.xHandle || null, - last_seen_at: new Date().toISOString(), - }, - { onConflict: "privy_id", ignoreDuplicates: false }, - ); - } catch { - // Non-fatal: users table may not exist yet, session still works - } - - const response = NextResponse.json({ ok: true }); - setAccessCookie(response, `privy:${userId}`); - return response; - } - - // Access code path — legacy code-based gate - const { code, email } = body; - const normalizedCode = normalizeAccessCode(String(code || "")); - const normalizedEmail = String(email || "").trim().toLowerCase(); - - if (normalizedCode.length < 4) { - return NextResponse.json({ error: "Enter a valid access code." }, { status: 400 }); - } - - if (normalizedEmail && !EMAIL_REGEX.test(normalizedEmail)) { - return NextResponse.json({ error: "Enter a valid email address." }, { status: 400 }); - } - - const supabase = getSupabaseAdminClient(); - const codeHash = hashAccessCode(normalizedCode); - const { data: accessCode, error } = await supabase - .from("access_codes") - .select("id,label,max_uses,use_count,expires_at,revoked_at") - .or(`code.eq.${normalizedCode},code_hash.eq.${codeHash}`) - .maybeSingle(); - - if (error) { - return NextResponse.json({ error: "Could not verify this code right now." }, { status: 500 }); - } - - if (!accessCode || accessCode.revoked_at) { - return NextResponse.json({ error: "This access code is not valid." }, { status: 401 }); - } - - if (accessCode.expires_at && new Date(accessCode.expires_at).getTime() < Date.now()) { - return NextResponse.json({ error: "This access code has expired." }, { status: 401 }); - } - - if ( - typeof accessCode.max_uses === "number" && - typeof accessCode.use_count === "number" && - accessCode.use_count >= accessCode.max_uses - ) { - return NextResponse.json({ error: "This access code has reached its limit." }, { status: 401 }); - } - - await supabase.from("access_code_redemptions").insert({ - access_code_id: accessCode.id, - email: normalizedEmail || null, - user_agent: request.headers.get("user-agent"), - }); - - await supabase - .from("access_codes") - .update({ use_count: Number(accessCode.use_count || 0) + 1, last_used_at: new Date().toISOString() }) - .eq("id", accessCode.id); - - const response = NextResponse.json({ ok: true, label: accessCode.label || "Private beta" }); - setAccessCookie(response, accessCode.id); + const body = await request.json() as { userId?: string; email?: string | null; xHandle?: string | null }; + const userId = String(body.userId || "").trim(); + if (!userId) return NextResponse.json({ error: "Missing user ID." }, { status: 400 }); + + try { + const supabase = getSupabaseAdminClient(); + await supabase.from("users").upsert( + { privy_id: userId, email: body.email || null, x_handle: body.xHandle || null, last_seen_at: new Date().toISOString() }, + { onConflict: "privy_id", ignoreDuplicates: false }, + ); + } catch { /* non-fatal */ } + + const response = NextResponse.json({ ok: true }); + setAccessCookie(response, `privy:${userId}`); return response; - } catch (error) { - if (error instanceof Error && error.message.includes("Missing Supabase")) { - return NextResponse.json({ error: "Server is not configured yet." }, { status: 500 }); - } - - return NextResponse.json({ error: "Invalid request payload." }, { status: 400 }); + } catch { + return NextResponse.json({ error: "Invalid request." }, { status: 400 }); } } export async function DELETE() { const response = NextResponse.json({ ok: true }); - response.cookies.set({ - name: ACCESS_COOKIE_NAME, - value: "", - maxAge: 0, - path: "/", - }); - + response.cookies.set({ name: ACCESS_COOKIE_NAME, value: "", maxAge: 0, path: "/" }); return response; } diff --git a/src/components/privy-provider.tsx b/src/components/privy-provider.tsx index 85315889..a5447e20 100644 --- a/src/components/privy-provider.tsx +++ b/src/components/privy-provider.tsx @@ -8,7 +8,7 @@ export function Providers({ children }: { children: ReactNode }) { c.trim()); - - for (const name of [ACCESS_COOKIE_NAME, ACCESS_COOKIE_NAME_LEGACY]) { - const match = cookies.find((c) => c.startsWith(`${name}=`)); - if (!match) continue; - const token = decodeURIComponent(match.slice(name.length + 1)); - const codeId = verifyAccessToken(token); - if (codeId) return codeId; - } - return null; -} - -// Returns the codeId (access_codes.id) if the token is valid, null otherwise. export function verifyAccessToken(token: string): string | null { if (!token) return null; const parts = token.split("."); @@ -54,9 +22,22 @@ export function verifyAccessToken(token: string): string | null { const signature = parts[parts.length - 1]; const payload = parts.slice(0, -1).join("."); const expiresAt = Number(parts[parts.length - 2]); - const codeId = parts.slice(0, -2).join("."); - if (!codeId || !Number.isFinite(expiresAt) || expiresAt < Date.now()) return null; + const userId = parts.slice(0, -2).join("."); + if (!userId || !Number.isFinite(expiresAt) || expiresAt < Date.now()) return null; const expected = createHmac("sha256", getAccessSecret()).update(payload).digest("hex"); if (signature !== expected) return null; - return codeId; + return userId; +} + +export function getSessionCodeId(req: Request): string | null { + const cookieHeader = req.headers.get("cookie") ?? ""; + const cookies = cookieHeader.split(";").map((c) => c.trim()); + for (const name of [ACCESS_COOKIE_NAME, ACCESS_COOKIE_NAME_LEGACY]) { + const match = cookies.find((c) => c.startsWith(`${name}=`)); + if (!match) continue; + const token = decodeURIComponent(match.slice(name.length + 1)); + const userId = verifyAccessToken(token); + if (userId) return userId; + } + return null; } diff --git a/src/middleware.ts b/src/middleware.ts index 9def59a6..a66b0cc2 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,21 +1,14 @@ import { NextResponse, type NextRequest } from "next/server"; -// New canonical name. Legacy kept for dual-read during migration. const ACCESS_COOKIE_NAME = "zetta_access"; const ACCESS_COOKIE_NAME_LEGACY = "x402books_access"; -const protectedRoutes = [ - "/dashboard", - "/api", - "/settings", -]; +const protectedRoutes = ["/dashboard", "/settings"]; function isProtectedPath(pathname: string) { - if (pathname.startsWith("/api/")) return false; return protectedRoutes.some((route) => pathname === route || pathname.startsWith(`${route}/`)); } -// Paths that always pass through maintenance mode function isMaintenanceBypass(pathname: string) { return ( pathname === "/maintenance" || @@ -27,61 +20,44 @@ function isMaintenanceBypass(pathname: string) { async function getSignature(payload: string, secret: string) { const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); + const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]); const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload)); - - return Array.from(new Uint8Array(signature)) - .map((byte) => byte.toString(16).padStart(2, "0")) - .join(""); + return Array.from(new Uint8Array(signature)).map((b) => b.toString(16).padStart(2, "0")).join(""); } -async function hasValidAccessCookie(request: NextRequest) { - // Accept both new and legacy cookie names during migration - const token = - request.cookies.get(ACCESS_COOKIE_NAME)?.value ?? - request.cookies.get(ACCESS_COOKIE_NAME_LEGACY)?.value; +async function hasValidSession(request: NextRequest) { + const token = request.cookies.get(ACCESS_COOKIE_NAME)?.value ?? request.cookies.get(ACCESS_COOKIE_NAME_LEGACY)?.value; const secret = (process.env.ACCESS_SESSION_SECRET || process.env.SUPABASE_SERVICE_ROLE_KEY || "").trim(); - if (!token || !secret) return false; - const [codeId, expiresAt, signature] = token.split("."); - if (!codeId || !expiresAt || !signature) return false; + const parts = token.split("."); + if (parts.length < 3) return false; + + const signature = parts[parts.length - 1]; + const expiresAt = Number(parts[parts.length - 2]); + const userId = parts.slice(0, -2).join("."); - const expires = Number(expiresAt); - if (!Number.isFinite(expires) || expires < Date.now()) return false; + if (!userId || !Number.isFinite(expiresAt) || expiresAt < Date.now()) return false; - const expected = await getSignature(`${codeId}.${expiresAt}`, secret); + const expected = await getSignature(`${userId}.${expiresAt}`, secret); return signature === expected; } export async function middleware(request: NextRequest) { const { pathname, search } = request.nextUrl; - // Maintenance mode — redirect everything public to /maintenance if (process.env.MAINTENANCE_MODE === "true" && !isMaintenanceBypass(pathname)) { const url = request.nextUrl.clone(); url.pathname = "/maintenance"; return NextResponse.redirect(url); } - if (!isProtectedPath(pathname)) { - return NextResponse.next(); - } - - if (await hasValidAccessCookie(request)) { - return NextResponse.next(); - } + if (!isProtectedPath(pathname)) return NextResponse.next(); + if (await hasValidSession(request)) return NextResponse.next(); const url = request.nextUrl.clone(); url.pathname = "/access"; url.searchParams.set("next", `${pathname}${search}`); - return NextResponse.redirect(url); }