From 995e97ca759d24c306d2bc277f9456fc116f6a4c Mon Sep 17 00:00:00 2001 From: Josue19-08 Date: Tue, 24 Mar 2026 23:58:58 -0600 Subject: [PATCH 01/16] feat: add comprehensive chat moderation tools for streamers Implement full moderation system including: - Ban/timeout users with duration options (1m, 5m, 10m, 1h, permanent) - Delete messages with context menu - Slow mode (3s, 5s, 10s, 30s intervals) - Follower-only chat mode - Link blocking with URL regex detection - Active bans management panel Database changes: - Add chat_bans table with expires_at for timeouts - Add slow_mode_seconds, follower_only_chat, link_blocking to users table API endpoints: - POST /api/streams/chat/ban - ban/timeout users - DELETE /api/streams/chat/ban/[username] - unban users - GET /api/streams/chat/ban - list active bans - PATCH /api/streams/settings - update chat settings - Updated POST /api/streams/chat with enforcement logic UI components: - Right-click context menu on chat messages (stream owner only) - ChatModerationSettings panel in stream manager - Ban list with unban functionality - Settings toggles for follower-only and link blocking Enforcement: - Server-side validation for all moderation rules - 429 responses with Retry-After headers for timeouts - Clear error messages for banned/timed-out users --- app/api/streams/chat/ban/[username]/route.ts | 56 ++++ app/api/streams/chat/ban/route.ts | 110 ++++++++ app/api/streams/chat/route.ts | 118 +++++++- app/api/streams/settings/route.ts | 125 +++++++++ app/dashboard/stream-manager/page.tsx | 4 +- .../stream-manager/ChatModerationSettings.tsx | 255 ++++++++++++++++++ components/stream/chat-section.tsx | 159 ++++++++++- components/stream/view-stream.tsx | 5 + db/schema.sql | 30 ++- hooks/useChat.ts | 62 +++++ types/chat.ts | 18 ++ 11 files changed, 931 insertions(+), 11 deletions(-) create mode 100644 app/api/streams/chat/ban/[username]/route.ts create mode 100644 app/api/streams/chat/ban/route.ts create mode 100644 app/api/streams/settings/route.ts create mode 100644 components/dashboard/stream-manager/ChatModerationSettings.tsx diff --git a/app/api/streams/chat/ban/[username]/route.ts b/app/api/streams/chat/ban/[username]/route.ts new file mode 100644 index 00000000..cb4cc629 --- /dev/null +++ b/app/api/streams/chat/ban/[username]/route.ts @@ -0,0 +1,56 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ username: string }> } +) { + try { + const { username } = await params; + const { searchParams } = new URL(req.url); + const streamOwnerWallet = searchParams.get("streamOwnerWallet"); + + if (!streamOwnerWallet) { + return NextResponse.json( + { error: "Stream owner wallet is required" }, + { status: 400 } + ); + } + + // Get stream owner username + const ownerResult = await sql` + SELECT username FROM users WHERE wallet = ${streamOwnerWallet} + `; + + if (ownerResult.rows.length === 0) { + return NextResponse.json( + { error: "Stream owner not found" }, + { status: 404 } + ); + } + + const streamOwner = ownerResult.rows[0].username; + + // Delete the ban + const result = await sql` + DELETE FROM chat_bans + WHERE stream_owner = ${streamOwner} + AND banned_user = ${username} + `; + + if (result.rowCount === 0) { + return NextResponse.json({ error: "Ban not found" }, { status: 404 }); + } + + return NextResponse.json( + { message: "User unbanned successfully" }, + { status: 200 } + ); + } catch (error) { + console.error("Unban user error:", error); + return NextResponse.json( + { error: "Failed to unban user" }, + { status: 500 } + ); + } +} diff --git a/app/api/streams/chat/ban/route.ts b/app/api/streams/chat/ban/route.ts new file mode 100644 index 00000000..419f85e8 --- /dev/null +++ b/app/api/streams/chat/ban/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; + +export async function POST(req: NextRequest) { + try { + const { streamOwnerWallet, bannedUser, durationMinutes, reason } = + await req.json(); + + if (!streamOwnerWallet || !bannedUser) { + return NextResponse.json( + { error: "Stream owner wallet and banned user are required" }, + { status: 400 } + ); + } + + // Verify requester is the stream owner + const ownerResult = await sql` + SELECT username FROM users WHERE wallet = ${streamOwnerWallet} + `; + + if (ownerResult.rows.length === 0) { + return NextResponse.json( + { error: "Stream owner not found" }, + { status: 404 } + ); + } + + const streamOwner = ownerResult.rows[0].username; + + // Calculate expires_at for timeouts, null for permanent bans + let expiresAt: string | null = null; + if (durationMinutes && durationMinutes > 0) { + const now = new Date(); + const expiresDate = new Date(now.getTime() + durationMinutes * 60 * 1000); + expiresAt = expiresDate.toISOString(); + } + + // Insert or update ban record + await sql` + INSERT INTO chat_bans (stream_owner, banned_user, expires_at, reason) + VALUES (${streamOwner}, ${bannedUser}, ${expiresAt}, ${reason || null}) + ON CONFLICT (stream_owner, banned_user) + DO UPDATE SET + banned_at = now(), + expires_at = ${expiresAt}, + reason = ${reason || null} + `; + + return NextResponse.json( + { + message: expiresAt + ? `User timed out for ${durationMinutes} minute(s)` + : "User banned permanently", + bannedUser, + expiresAt, + }, + { status: 201 } + ); + } catch (error) { + console.error("Ban user error:", error); + return NextResponse.json({ error: "Failed to ban user" }, { status: 500 }); + } +} + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const streamOwnerWallet = searchParams.get("streamOwnerWallet"); + + if (!streamOwnerWallet) { + return NextResponse.json( + { error: "Stream owner wallet is required" }, + { status: 400 } + ); + } + + // Get stream owner username + const ownerResult = await sql` + SELECT username FROM users WHERE wallet = ${streamOwnerWallet} + `; + + if (ownerResult.rows.length === 0) { + return NextResponse.json( + { error: "Stream owner not found" }, + { status: 404 } + ); + } + + const streamOwner = ownerResult.rows[0].username; + + // Get active bans (permanent or not expired) + const bansResult = await sql` + SELECT + id, + banned_user, + banned_at, + expires_at, + reason + FROM chat_bans + WHERE stream_owner = ${streamOwner} + AND (expires_at IS NULL OR expires_at > now()) + ORDER BY banned_at DESC + `; + + return NextResponse.json({ bans: bansResult.rows }, { status: 200 }); + } catch (error) { + console.error("Get bans error:", error); + return NextResponse.json({ error: "Failed to get bans" }, { status: 500 }); + } +} diff --git a/app/api/streams/chat/route.ts b/app/api/streams/chat/route.ts index 788c7870..f365ce07 100644 --- a/app/api/streams/chat/route.ts +++ b/app/api/streams/chat/route.ts @@ -46,13 +46,17 @@ export async function POST(req: NextRequest) { ); } - // Combined query: look up sender + stream + active session in one round-trip + // Combined query: look up sender + stream + active session + moderation settings in one round-trip const result = await sql` SELECT sender.id AS sender_id, sender.username AS sender_username, streamer.id AS streamer_id, + streamer.username AS streamer_username, streamer.is_live, + streamer.slow_mode_seconds, + streamer.follower_only_chat, + streamer.link_blocking, ( SELECT ss.id FROM stream_sessions ss WHERE ss.user_id = streamer.id AND ss.ended_at IS NULL @@ -71,7 +75,16 @@ export async function POST(req: NextRequest) { ); } - const { sender_id, sender_username, is_live, session_id } = result.rows[0]; + const { + sender_id, + sender_username, + streamer_username, + is_live, + session_id, + slow_mode_seconds, + follower_only_chat, + link_blocking, + } = result.rows[0]; if (!is_live) { return NextResponse.json( @@ -87,6 +100,107 @@ export async function POST(req: NextRequest) { ); } + // 1. Check for permanent ban + const permanentBanResult = await sql` + SELECT id FROM chat_bans + WHERE stream_owner = ${streamer_username} + AND banned_user = ${sender_username} + AND expires_at IS NULL + `; + + if (permanentBanResult.rows.length > 0) { + return NextResponse.json( + { error: "You are banned from this chat" }, + { status: 403 } + ); + } + + // 2. Check for active timeout + const timeoutResult = await sql` + SELECT expires_at FROM chat_bans + WHERE stream_owner = ${streamer_username} + AND banned_user = ${sender_username} + AND expires_at IS NOT NULL + AND expires_at > now() + `; + + if (timeoutResult.rows.length > 0) { + const expiresAt = new Date(timeoutResult.rows[0].expires_at); + const now = new Date(); + const secondsRemaining = Math.ceil( + (expiresAt.getTime() - now.getTime()) / 1000 + ); + + return NextResponse.json( + { + error: `You are timed out for ${Math.ceil(secondsRemaining / 60)} minute(s)`, + }, + { + status: 429, + headers: { "Retry-After": secondsRemaining.toString() }, + } + ); + } + + // 3. Check slow mode + if (slow_mode_seconds > 0) { + const lastMessageResult = await sql` + SELECT created_at FROM chat_messages + WHERE stream_session_id = ${session_id} + AND user_id = ${sender_id} + ORDER BY created_at DESC + LIMIT 1 + `; + + if (lastMessageResult.rows.length > 0) { + const lastMessageTime = new Date(lastMessageResult.rows[0].created_at); + const now = new Date(); + const secondsSinceLastMessage = + (now.getTime() - lastMessageTime.getTime()) / 1000; + + if (secondsSinceLastMessage < slow_mode_seconds) { + const waitSeconds = Math.ceil( + slow_mode_seconds - secondsSinceLastMessage + ); + return NextResponse.json( + { error: `Slow mode is enabled. Wait ${waitSeconds} second(s)` }, + { + status: 429, + headers: { "Retry-After": waitSeconds.toString() }, + } + ); + } + } + } + + // 4. Check follower-only mode + if (follower_only_chat) { + const followerResult = await sql` + SELECT id FROM users + WHERE username = ${sender_username} + AND ${streamer_username} = ANY(following) + `; + + if (followerResult.rows.length === 0) { + return NextResponse.json( + { error: "This chat is in follower-only mode" }, + { status: 403 } + ); + } + } + + // 5. Check link blocking + if (link_blocking) { + const urlRegex = + /(https?:\/\/[^\s]+)|(www\.[^\s]+)|([a-zA-Z0-9-]+\.(com|net|org|io|dev|gg|tv|me|co)[^\s]*)/gi; + if (urlRegex.test(content)) { + return NextResponse.json( + { error: "Links are not allowed in this chat" }, + { status: 400 } + ); + } + } + const messageResult = await sql` INSERT INTO chat_messages ( user_id, diff --git a/app/api/streams/settings/route.ts b/app/api/streams/settings/route.ts new file mode 100644 index 00000000..1f5ba3e1 --- /dev/null +++ b/app/api/streams/settings/route.ts @@ -0,0 +1,125 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; + +export async function PATCH(req: NextRequest) { + try { + const { wallet, slowModeSeconds, followerOnlyChat, linkBlocking } = + await req.json(); + + if (!wallet) { + return NextResponse.json({ error: "Wallet is required" }, { status: 400 }); + } + + // Validate slow mode value + if ( + slowModeSeconds !== undefined && + ![0, 3, 5, 10, 30].includes(slowModeSeconds) + ) { + return NextResponse.json( + { error: "Invalid slow mode value. Must be 0, 3, 5, 10, or 30" }, + { status: 400 } + ); + } + + // Build update query dynamically based on provided fields + const updates: string[] = []; + const values: any[] = []; + let paramIndex = 1; + + if (slowModeSeconds !== undefined) { + updates.push(`slow_mode_seconds = $${paramIndex}`); + values.push(slowModeSeconds); + paramIndex++; + } + + if (followerOnlyChat !== undefined) { + updates.push(`follower_only_chat = $${paramIndex}`); + values.push(followerOnlyChat); + paramIndex++; + } + + if (linkBlocking !== undefined) { + updates.push(`link_blocking = $${paramIndex}`); + values.push(linkBlocking); + paramIndex++; + } + + if (updates.length === 0) { + return NextResponse.json( + { error: "No settings provided to update" }, + { status: 400 } + ); + } + + values.push(wallet); + + const query = ` + UPDATE users + SET ${updates.join(", ")}, updated_at = CURRENT_TIMESTAMP + WHERE wallet = $${paramIndex} + RETURNING slow_mode_seconds, follower_only_chat, link_blocking + `; + + const result = await sql.query(query, values); + + if (result.rows.length === 0) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + return NextResponse.json( + { + message: "Settings updated successfully", + settings: { + slowModeSeconds: result.rows[0].slow_mode_seconds, + followerOnlyChat: result.rows[0].follower_only_chat, + linkBlocking: result.rows[0].link_blocking, + }, + }, + { status: 200 } + ); + } catch (error) { + console.error("Update settings error:", error); + return NextResponse.json( + { error: "Failed to update settings" }, + { status: 500 } + ); + } +} + +export async function GET(req: Request) { + try { + const { searchParams } = new URL(req.url); + const wallet = searchParams.get("wallet"); + + if (!wallet) { + return NextResponse.json({ error: "Wallet is required" }, { status: 400 }); + } + + const result = await sql` + SELECT slow_mode_seconds, follower_only_chat, link_blocking + FROM users + WHERE wallet = ${wallet} + `; + + if (result.rows.length === 0) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + return NextResponse.json( + { + settings: { + slowModeSeconds: result.rows[0].slow_mode_seconds, + followerOnlyChat: result.rows[0].follower_only_chat, + linkBlocking: result.rows[0].link_blocking, + }, + }, + { status: 200 } + ); + } catch (error) { + console.error("Get settings error:", error); + return NextResponse.json( + { error: "Failed to get settings" }, + { status: 500 } + ); + } +} diff --git a/app/dashboard/stream-manager/page.tsx b/app/dashboard/stream-manager/page.tsx index e0dc166b..ebc136d7 100644 --- a/app/dashboard/stream-manager/page.tsx +++ b/app/dashboard/stream-manager/page.tsx @@ -9,6 +9,7 @@ import ActivityFeed from "@/components/dashboard/stream-manager/ActivityFeed"; import Chat from "@/components/dashboard/stream-manager/Chat"; import StreamInfo from "@/components/dashboard/stream-manager/StreamInfo"; import StreamSettings from "@/components/dashboard/stream-manager/StreamSettings"; +import ChatModerationSettings from "@/components/dashboard/stream-manager/ChatModerationSettings"; import StreamInfoModal from "@/components/dashboard/common/StreamInfoModal"; import { motion } from "framer-motion"; import { Users, UserPlus, Coins, Timer } from "lucide-react"; @@ -205,7 +206,7 @@ export default function StreamManagerPage() { - {/* Right column: Chat + Stream info + Tip wallet */} + {/* Right column: Chat + Stream info + Chat moderation + Tip wallet */}
@@ -217,6 +218,7 @@ export default function StreamManagerPage() { }} onEditClick={() => setIsStreamInfoModalOpen(true)} /> +
diff --git a/components/dashboard/stream-manager/ChatModerationSettings.tsx b/components/dashboard/stream-manager/ChatModerationSettings.tsx new file mode 100644 index 00000000..47e448e1 --- /dev/null +++ b/components/dashboard/stream-manager/ChatModerationSettings.tsx @@ -0,0 +1,255 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Shield, + X, + Ban, + Clock, + Users, + Link as LinkIcon, + Loader2, +} from "lucide-react"; +import { useStellarWallet } from "@/contexts/stellar-wallet-context"; +import type { ChatBan, StreamChatSettings } from "@/types/chat"; + +export default function ChatModerationSettings() { + const { publicKey, privyWallet } = useStellarWallet(); + const walletAddress = publicKey || privyWallet?.wallet || null; + const [isMinimized, setIsMinimized] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [bans, setBans] = useState([]); + const [settings, setSettings] = useState({ + slowModeSeconds: 0, + followerOnlyChat: false, + linkBlocking: false, + }); + + // Load settings and bans + useEffect(() => { + if (!walletAddress) { + setIsLoading(false); + return; + } + + const loadData = async () => { + try { + const [settingsRes, bansRes] = await Promise.all([ + fetch(`/api/streams/settings?wallet=${walletAddress}`), + fetch(`/api/streams/chat/ban?streamOwnerWallet=${walletAddress}`), + ]); + + if (settingsRes.ok) { + const data = await settingsRes.json(); + setSettings(data.settings); + } + + if (bansRes.ok) { + const data = await bansRes.json(); + setBans(data.bans); + } + } catch (error) { + console.error("Failed to load moderation data:", error); + } finally { + setIsLoading(false); + } + }; + + loadData(); + }, [walletAddress]); + + const updateSetting = async ( + key: keyof StreamChatSettings, + value: number | boolean + ) => { + if (!walletAddress) return; + + setIsSaving(true); + try { + const res = await fetch("/api/streams/settings", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + wallet: walletAddress, + [key]: value, + }), + }); + + if (res.ok) { + const data = await res.json(); + setSettings(data.settings); + } + } catch (error) { + console.error("Failed to update setting:", error); + } finally { + setIsSaving(false); + } + }; + + const unbanUser = async (username: string) => { + if (!walletAddress) return; + + try { + const res = await fetch( + `/api/streams/chat/ban/${username}?streamOwnerWallet=${walletAddress}`, + { method: "DELETE" } + ); + + if (res.ok) { + setBans(prev => prev.filter(ban => ban.bannedUser !== username)); + } + } catch (error) { + console.error("Failed to unban user:", error); + } + }; + + if (isMinimized) { + return ( +
+ +
+ ); + } + + return ( +
+
+
+ + + Chat Moderation + +
+ +
+ +
+ {isLoading ? ( +
+ +
+ ) : !walletAddress ? ( +

+ No wallet connected +

+ ) : ( + <> + {/* Slow Mode */} +
+
+ + +
+ +
+ + {/* Follower-only Chat */} +
+ +
+ + {/* Link Blocking */} +
+ +
+ + {/* Active Bans */} + {bans.length > 0 && ( +
+
+ + + Active Bans ({bans.length}) + +
+
+ {bans.map(ban => ( +
+
+

+ {ban.bannedUser} +

+ {ban.expiresAt && ( +

+ Timeout expires:{" "} + {new Date(ban.expiresAt).toLocaleString()} +

+ )} +
+ +
+ ))} +
+
+ )} + + )} +
+
+ ); +} diff --git a/components/stream/chat-section.tsx b/components/stream/chat-section.tsx index f09232c7..2e50c112 100644 --- a/components/stream/chat-section.tsx +++ b/components/stream/chat-section.tsx @@ -3,12 +3,24 @@ import type React from "react"; import { useState, useRef, useEffect } from "react"; -import { ChevronRight, Send, Smile, GiftIcon, Wallet } from "lucide-react"; +import { + ChevronRight, + Send, + Smile, + GiftIcon, + Wallet, + MoreVertical, + Trash2, + Ban, + Clock, +} from "lucide-react"; import type { ChatMessage } from "@/types/chat"; interface ChatSectionProps { messages: ChatMessage[]; onSendMessage: (message: string) => void; + onDeleteMessage?: (messageId: number) => void; + onBanUser?: (username: string, durationMinutes?: number) => void; isCollapsible?: boolean; isFullscreen?: boolean; className?: string; @@ -16,11 +28,14 @@ interface ChatSectionProps { showChat?: boolean; isWalletConnected?: boolean; isSending?: boolean; + isStreamOwner?: boolean; } const ChatSection = ({ messages, onSendMessage, + onDeleteMessage, + onBanUser, isCollapsible = true, isFullscreen = false, className = "", @@ -28,9 +43,17 @@ const ChatSection = ({ showChat = true, isWalletConnected = false, isSending = false, + isStreamOwner = false, }: ChatSectionProps) => { const [chatMessage, setChatMessage] = useState(""); const chatContainerRef = useRef(null); + const [contextMenu, setContextMenu] = useState<{ + messageId: number; + username: string; + x: number; + y: number; + showTimeoutSubmenu?: boolean; + } | null>(null); // Auto-scroll to bottom when new messages arrive useEffect(() => { @@ -40,6 +63,44 @@ const ChatSection = ({ } }, [messages]); + // Close context menu on click outside + useEffect(() => { + const handleClickOutside = () => setContextMenu(null); + if (contextMenu) { + document.addEventListener("click", handleClickOutside); + return () => document.removeEventListener("click", handleClickOutside); + } + }, [contextMenu]); + + const handleContextMenu = ( + e: React.MouseEvent, + messageId: number, + username: string + ) => { + if (!isStreamOwner) return; + e.preventDefault(); + setContextMenu({ + messageId, + username, + x: e.clientX, + y: e.clientY, + }); + }; + + const handleDeleteMessage = () => { + if (contextMenu && onDeleteMessage) { + onDeleteMessage(contextMenu.messageId); + setContextMenu(null); + } + }; + + const handleBanUser = (durationMinutes?: number) => { + if (contextMenu && onBanUser) { + onBanUser(contextMenu.username, durationMinutes); + setContextMenu(null); + } + }; + const handleSendMessage = () => { if (!chatMessage.trim() || !isWalletConnected || isSending) { return; @@ -111,13 +172,16 @@ const ChatSection = ({ messages.map(message => (
+ handleContextMenu(e, message.id, message.username) + } >
-
+
{message.message}
+ {isStreamOwner && ( + + )}
)) )} @@ -175,6 +255,79 @@ const ChatSection = ({
)}
+ + {/* Context Menu */} + {contextMenu && isStreamOwner && ( +
e.stopPropagation()} + > + +
+ + {contextMenu.showTimeoutSubmenu && ( +
+ + + + +
+ )} +
+ +
+ )} ); }; diff --git a/components/stream/view-stream.tsx b/components/stream/view-stream.tsx index 1276210b..06147336 100644 --- a/components/stream/view-stream.tsx +++ b/components/stream/view-stream.tsx @@ -286,6 +286,8 @@ const ViewStream = ({ const { messages: chatMessages, sendMessage, + deleteMessage, + banUser, isSending, } = useChat(userData?.playbackId, address, isLive); @@ -955,6 +957,8 @@ const ViewStream = ({ )} diff --git a/db/schema.sql b/db/schema.sql index f1469aca..4ba810fc 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -43,6 +43,22 @@ ADD COLUMN IF NOT EXISTS followers UUID[]; ALTER TABLE users ADD COLUMN IF NOT EXISTS following UUID[]; +-- Chat moderation settings +ALTER TABLE users ADD COLUMN IF NOT EXISTS slow_mode_seconds INT DEFAULT 0; +ALTER TABLE users ADD COLUMN IF NOT EXISTS follower_only_chat BOOLEAN DEFAULT false; +ALTER TABLE users ADD COLUMN IF NOT EXISTS link_blocking BOOLEAN DEFAULT false; + +-- Chat bans table +CREATE TABLE IF NOT EXISTS chat_bans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + stream_owner TEXT NOT NULL REFERENCES users(username), + banned_user TEXT NOT NULL, + banned_at TIMESTAMPTZ DEFAULT now(), + expires_at TIMESTAMPTZ, + reason TEXT, + UNIQUE(stream_owner, banned_user) +); + CREATE TABLE IF NOT EXISTS stream_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID REFERENCES users(id) ON DELETE CASCADE, @@ -67,17 +83,18 @@ CREATE TABLE IF NOT EXISTS stream_sessions ( ); CREATE TABLE IF NOT EXISTS chat_messages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id SERIAL PRIMARY KEY, user_id UUID REFERENCES users(id) ON DELETE CASCADE, + username VARCHAR(255) NOT NULL, stream_session_id UUID REFERENCES stream_sessions(id) ON DELETE CASCADE, - + content TEXT NOT NULL, - message_type VARCHAR(20) DEFAULT 'message', - + message_type VARCHAR(20) DEFAULT 'message', + is_deleted BOOLEAN DEFAULT FALSE, is_moderated BOOLEAN DEFAULT FALSE, moderated_by UUID REFERENCES users(id), - + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); @@ -147,6 +164,9 @@ CREATE INDEX IF NOT EXISTS idx_stream_categories_title ON stream_categories(titl CREATE INDEX IF NOT EXISTS idx_stream_categories_active ON stream_categories(is_active); CREATE INDEX IF NOT EXISTS idx_tags_title ON tags(title); CREATE INDEX IF NOT EXISTS idx_tags_title_lower ON tags(LOWER(title)); +CREATE INDEX IF NOT EXISTS idx_chat_bans_stream_owner ON chat_bans(stream_owner); +CREATE INDEX IF NOT EXISTS idx_chat_bans_banned_user ON chat_bans(banned_user); +CREATE INDEX IF NOT EXISTS idx_chat_bans_expires_at ON chat_bans(expires_at); INSERT INTO stream_categories (title, description, tags) VALUES ('Gaming', 'Video game streaming and gameplay', ARRAY['gaming', 'esports', 'gameplay']), diff --git a/hooks/useChat.ts b/hooks/useChat.ts index ee2aa174..eebcce92 100644 --- a/hooks/useChat.ts +++ b/hooks/useChat.ts @@ -197,10 +197,72 @@ export function useChat( [wallet, mutate] ); + const banUser = useCallback( + async (username: string, durationMinutes?: number) => { + if (!wallet) { + return; + } + + try { + const res = await fetch("/api/streams/chat/ban", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + streamOwnerWallet: wallet, + bannedUser: username, + durationMinutes, + }), + }); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.error || "Failed to ban user"); + } + + // Revalidate messages to reflect the ban + await mutate(); + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to ban user"; + setSendError(errorMessage); + } + }, + [wallet, mutate] + ); + + const unbanUser = useCallback( + async (username: string) => { + if (!wallet) { + return; + } + + try { + const res = await fetch( + `/api/streams/chat/ban/${username}?streamOwnerWallet=${wallet}`, + { + method: "DELETE", + } + ); + + if (!res.ok) { + const errorData = await res.json(); + throw new Error(errorData.error || "Failed to unban user"); + } + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : "Failed to unban user"; + setSendError(errorMessage); + } + }, + [wallet] + ); + return { messages, sendMessage, deleteMessage, + banUser, + unbanUser, isLoading, isSending, error: error?.message || sendError, diff --git a/types/chat.ts b/types/chat.ts index 8cf98daf..0e9f3bb4 100644 --- a/types/chat.ts +++ b/types/chat.ts @@ -43,7 +43,25 @@ export interface UseChatReturn { messages: ChatMessage[]; sendMessage: (content: string) => Promise; deleteMessage: (messageId: number) => Promise; + banUser: (username: string, durationMinutes?: number) => Promise; + unbanUser: (username: string) => Promise; isLoading: boolean; isSending: boolean; error: string | null; } + +/** Chat ban record */ +export interface ChatBan { + id: string; + bannedUser: string; + bannedAt: string; + expiresAt: string | null; + reason: string | null; +} + +/** Stream chat settings */ +export interface StreamChatSettings { + slowModeSeconds: number; + followerOnlyChat: boolean; + linkBlocking: boolean; +} From ddd4e6e50a1f3469d39050ef8fa0a4f15da1e08c Mon Sep 17 00:00:00 2001 From: Josue19-08 Date: Wed, 25 Mar 2026 00:21:54 -0600 Subject: [PATCH 02/16] chore: fix code formatting for CI --- app/api/streams/settings/route.ts | 10 ++++++++-- .../stream-manager/ChatModerationSettings.tsx | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/app/api/streams/settings/route.ts b/app/api/streams/settings/route.ts index 1f5ba3e1..ba8e4bc0 100644 --- a/app/api/streams/settings/route.ts +++ b/app/api/streams/settings/route.ts @@ -7,7 +7,10 @@ export async function PATCH(req: NextRequest) { await req.json(); if (!wallet) { - return NextResponse.json({ error: "Wallet is required" }, { status: 400 }); + return NextResponse.json( + { error: "Wallet is required" }, + { status: 400 } + ); } // Validate slow mode value @@ -92,7 +95,10 @@ export async function GET(req: Request) { const wallet = searchParams.get("wallet"); if (!wallet) { - return NextResponse.json({ error: "Wallet is required" }, { status: 400 }); + return NextResponse.json( + { error: "Wallet is required" }, + { status: 400 } + ); } const result = await sql` diff --git a/components/dashboard/stream-manager/ChatModerationSettings.tsx b/components/dashboard/stream-manager/ChatModerationSettings.tsx index 47e448e1..4ed9e822 100644 --- a/components/dashboard/stream-manager/ChatModerationSettings.tsx +++ b/components/dashboard/stream-manager/ChatModerationSettings.tsx @@ -203,7 +203,9 @@ export default function ChatModerationSettings() { updateSetting("linkBlocking", e.target.checked)} + onChange={e => + updateSetting("linkBlocking", e.target.checked) + } disabled={isSaving} className="w-4 h-4 rounded border-border bg-secondary text-highlight focus:ring-1 focus:ring-highlight disabled:opacity-50" /> From c78b481ecefe2f91e194b56c5b02f172aa1f971e Mon Sep 17 00:00:00 2001 From: Josue19-08 Date: Wed, 25 Mar 2026 00:28:51 -0600 Subject: [PATCH 03/16] fix: add required curly braces for if statements --- .../dashboard/stream-manager/ChatModerationSettings.tsx | 8 ++++++-- components/stream/chat-section.tsx | 4 +++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/components/dashboard/stream-manager/ChatModerationSettings.tsx b/components/dashboard/stream-manager/ChatModerationSettings.tsx index 4ed9e822..d9497233 100644 --- a/components/dashboard/stream-manager/ChatModerationSettings.tsx +++ b/components/dashboard/stream-manager/ChatModerationSettings.tsx @@ -63,7 +63,9 @@ export default function ChatModerationSettings() { key: keyof StreamChatSettings, value: number | boolean ) => { - if (!walletAddress) return; + if (!walletAddress) { + return; + } setIsSaving(true); try { @@ -88,7 +90,9 @@ export default function ChatModerationSettings() { }; const unbanUser = async (username: string) => { - if (!walletAddress) return; + if (!walletAddress) { + return; + } try { const res = await fetch( diff --git a/components/stream/chat-section.tsx b/components/stream/chat-section.tsx index 2e50c112..192c21a2 100644 --- a/components/stream/chat-section.tsx +++ b/components/stream/chat-section.tsx @@ -77,7 +77,9 @@ const ChatSection = ({ messageId: number, username: string ) => { - if (!isStreamOwner) return; + if (!isStreamOwner) { + return; + } e.preventDefault(); setContextMenu({ messageId, From 90ceb736633311f9ba50cd1e09c934ac86c3b112 Mon Sep 17 00:00:00 2001 From: Josue19-08 Date: Wed, 25 Mar 2026 00:38:01 -0600 Subject: [PATCH 04/16] test: update chat API tests for moderation enforcement - Add mocks for permanent ban check - Add mocks for timeout check - Add mocks for slow mode validation - Update combined lookup to include moderation settings --- app/api/streams/chat/__tests__/route.test.ts | 28 ++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/app/api/streams/chat/__tests__/route.test.ts b/app/api/streams/chat/__tests__/route.test.ts index c05aaef7..1de46a27 100644 --- a/app/api/streams/chat/__tests__/route.test.ts +++ b/app/api/streams/chat/__tests__/route.test.ts @@ -144,16 +144,23 @@ describe("POST /api/streams/chat", () => { it("returns 201 and chatMessage on success", async () => { sqlMock .mockResolvedValueOnce({ - // combined lookup + // combined lookup with moderation settings rows: [ { sender_id: 1, sender_username: "Alice", + streamer_username: "Bob", is_live: true, session_id: 10, + slow_mode_seconds: 0, + follower_only_chat: false, + link_blocking: false, }, ], }) + .mockResolvedValueOnce({ rows: [] }) // permanent ban check + .mockResolvedValueOnce({ rows: [] }) // timeout check + .mockResolvedValueOnce({ rows: [] }) // slow mode last message check .mockResolvedValueOnce({ // INSERT rows: [{ id: 99, created_at: "2025-01-01T00:00:00Z" }], @@ -176,7 +183,24 @@ describe("POST /api/streams/chat", () => { }); it("returns 500 on unexpected database error", async () => { - sqlMock.mockRejectedValueOnce(new Error("DB down")); + sqlMock + .mockResolvedValueOnce({ + // combined lookup succeeds + rows: [ + { + sender_id: 1, + sender_username: "Alice", + streamer_username: "Bob", + is_live: true, + session_id: 10, + slow_mode_seconds: 0, + follower_only_chat: false, + link_blocking: false, + }, + ], + }) + .mockRejectedValueOnce(new Error("DB down")); // ban check fails + const req = makeRequest("POST", { wallet: "0xABC", playbackId: "pb1", From 466c282837961229f4a3fa53a4dd1a86f9d15bde Mon Sep 17 00:00:00 2001 From: Josue19-08 Date: Wed, 25 Mar 2026 00:42:00 -0600 Subject: [PATCH 05/16] test: fix mock count for chat moderation - Remove slow mode mock when slow_mode_seconds is 0 - Add streamer_id to combined lookup mock --- app/api/streams/chat/__tests__/route.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/api/streams/chat/__tests__/route.test.ts b/app/api/streams/chat/__tests__/route.test.ts index 1de46a27..b4e7909f 100644 --- a/app/api/streams/chat/__tests__/route.test.ts +++ b/app/api/streams/chat/__tests__/route.test.ts @@ -149,6 +149,7 @@ describe("POST /api/streams/chat", () => { { sender_id: 1, sender_username: "Alice", + streamer_id: 2, streamer_username: "Bob", is_live: true, session_id: 10, @@ -160,7 +161,6 @@ describe("POST /api/streams/chat", () => { }) .mockResolvedValueOnce({ rows: [] }) // permanent ban check .mockResolvedValueOnce({ rows: [] }) // timeout check - .mockResolvedValueOnce({ rows: [] }) // slow mode last message check .mockResolvedValueOnce({ // INSERT rows: [{ id: 99, created_at: "2025-01-01T00:00:00Z" }], @@ -190,6 +190,7 @@ describe("POST /api/streams/chat", () => { { sender_id: 1, sender_username: "Alice", + streamer_id: 2, streamer_username: "Bob", is_live: true, session_id: 10, From 5450635f0021accc3d934eb6ec507c30abc2ce85 Mon Sep 17 00:00:00 2001 From: KevinMB0220 Date: Wed, 25 Mar 2026 15:30:41 -0600 Subject: [PATCH 06/16] db: add stream access control foundation schema and migration --- .../20260325_add_stream_access_control.sql | 39 +++++++++++++++++++ db/schema.sql | 26 ++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 db/migrations/20260325_add_stream_access_control.sql diff --git a/db/migrations/20260325_add_stream_access_control.sql b/db/migrations/20260325_add_stream_access_control.sql new file mode 100644 index 00000000..0fcf2a9b --- /dev/null +++ b/db/migrations/20260325_add_stream_access_control.sql @@ -0,0 +1,39 @@ +-- Foundation for all private/restricted stream access features. +-- Part of issue #372 [access-control 1/5] + +-- 1. Create stream_access_type enum +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'stream_access_type') THEN + CREATE TYPE stream_access_type AS ENUM ( + 'public', -- anyone can watch (default) + 'password', -- requires a password + 'invite_only', -- streamer manually whitelists viewers + 'paid', -- viewer pays a one-time USDC fee + 'token_gated', -- viewer must hold a specific Stellar asset + 'subscription' -- viewer must have an active subscription + ); + END IF; +END$$; + +-- 2. Add columns to users table (streamers/creators data) +ALTER TABLE users + ADD COLUMN IF NOT EXISTS stream_access_type stream_access_type NOT NULL DEFAULT 'public', + ADD COLUMN IF NOT EXISTS stream_access_config JSONB; + +-- 3. Create stream_access_grants table +-- Stores who has been explicitly granted access (used by invite_only and paid). +CREATE TABLE IF NOT EXISTS stream_access_grants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + streamer_id UUID REFERENCES users(id) ON DELETE CASCADE, + viewer_id UUID REFERENCES users(id) ON DELETE CASCADE, + access_type stream_access_type NOT NULL, + -- For paid: store the tx hash as proof of payment + tx_hash TEXT, + granted_at TIMESTAMPTZ DEFAULT now(), + expires_at TIMESTAMPTZ, -- NULL = permanent + UNIQUE(streamer_id, viewer_id) +); + +CREATE INDEX IF NOT EXISTS stream_access_grants_lookup + ON stream_access_grants(streamer_id, viewer_id); diff --git a/db/schema.sql b/db/schema.sql index f1469aca..64af0ae4 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1,3 +1,12 @@ +CREATE TYPE stream_access_type AS ENUM ( + 'public', -- anyone can watch (default) + 'password', -- requires a password + 'invite_only', -- streamer manually whitelists viewers + 'paid', -- viewer pays a one-time USDC fee + 'token_gated', -- viewer must hold a specific Stellar asset + 'subscription' -- viewer must have an active subscription +); + CREATE TABLE IF NOT EXISTS waitlist ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), email VARCHAR(255) UNIQUE NOT NULL, @@ -34,7 +43,9 @@ CREATE TABLE IF NOT EXISTS users ( creator JSONB DEFAULT '{}', total_tips_received NUMERIC(20, 7) DEFAULT 0, total_tips_count INTEGER DEFAULT 0, - last_tip_at TIMESTAMP + last_tip_at TIMESTAMP, + stream_access_type stream_access_type NOT NULL DEFAULT 'public', + stream_access_config JSONB DEFAULT '{}' ); ALTER TABLE users @@ -120,6 +131,18 @@ CREATE TABLE IF NOT EXISTS tags ( created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP ); +CREATE TABLE IF NOT EXISTS stream_access_grants ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + streamer_id UUID REFERENCES users(id) ON DELETE CASCADE, + viewer_id UUID REFERENCES users(id) ON DELETE CASCADE, + access_type stream_access_type NOT NULL, + -- For paid: store the tx hash as proof of payment + tx_hash TEXT, + granted_at TIMESTAMPTZ DEFAULT now(), + expires_at TIMESTAMPTZ, -- NULL = permanent + UNIQUE(streamer_id, viewer_id) +); + CREATE INDEX IF NOT EXISTS idx_users_wallet ON users(wallet); CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); @@ -147,6 +170,7 @@ CREATE INDEX IF NOT EXISTS idx_stream_categories_title ON stream_categories(titl CREATE INDEX IF NOT EXISTS idx_stream_categories_active ON stream_categories(is_active); CREATE INDEX IF NOT EXISTS idx_tags_title ON tags(title); CREATE INDEX IF NOT EXISTS idx_tags_title_lower ON tags(LOWER(title)); +CREATE INDEX IF NOT EXISTS idx_stream_access_grants_lookup ON stream_access_grants(streamer_id, viewer_id); INSERT INTO stream_categories (title, description, tags) VALUES ('Gaming', 'Video game streaming and gameplay', ARRAY['gaming', 'esports', 'gameplay']), From 298da3baef7dec4f6f2e34cbbd82f9e44e9c549e Mon Sep 17 00:00:00 2001 From: KevinMB0220 Date: Wed, 25 Mar 2026 15:30:48 -0600 Subject: [PATCH 07/16] lib: implement checkStreamAccess and API endpoint --- app/api/streams/access/check/route.ts | 57 +++++++++++++++ lib/stream/access.ts | 101 ++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 app/api/streams/access/check/route.ts create mode 100644 lib/stream/access.ts diff --git a/app/api/streams/access/check/route.ts b/app/api/streams/access/check/route.ts new file mode 100644 index 00000000..27459fa0 --- /dev/null +++ b/app/api/streams/access/check/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server"; +import { checkStreamAccess } from "@/lib/stream/access"; + +/** + * Endpoint to check if a viewer has access to a stream. + * Called by the client before rendering the StreamPlayer. + * + * Request: + * { "streamer_username": "alice", "viewer_public_key": "GABC..." } + * + * Response (allowed): + * { "allowed": true } + * + * Response (blocked): + * { "allowed": false, "reason": "paid", "price_usdc": "10.00" } + */ +export async function POST(req: Request) { + try { + const body = await req.json(); + const { streamer_username, viewer_public_key } = body; + + if (!streamer_username) { + return NextResponse.json( + { error: "Streamer username is required" }, + { status: 400 } + ); + } + + const accessResult = await checkStreamAccess( + streamer_username, + viewer_public_key || null + ); + + if (accessResult.allowed) { + return NextResponse.json({ allowed: true }); + } + + // Build response for blocked access + const responseBody: any = { + allowed: false, + reason: accessResult.reason + }; + + // Include config fields if available (e.g. price for paid streams) + if (accessResult.config) { + Object.assign(responseBody, accessResult.config); + } + + return NextResponse.json(responseBody); + } catch (error) { + console.error("API: Check stream access error:", error); + return NextResponse.json( + { error: "Failed to check stream access" }, + { status: 500 } + ); + } +} diff --git a/lib/stream/access.ts b/lib/stream/access.ts new file mode 100644 index 00000000..cc1a374d --- /dev/null +++ b/lib/stream/access.ts @@ -0,0 +1,101 @@ +import { sql } from "@vercel/postgres"; + +export type AccessResult = + | { allowed: true } + | { + allowed: false; + reason: 'password' | 'invite_only' | 'paid' | 'token_gated' | 'subscription'; + config?: any; + }; + +/** + * Checks if a viewer has access to a streamer's private stream. + * @param streamerUsername The username of the streamer + * @param viewerPublicKey The Stellar public key of the viewer (null if not connected) + */ +export async function checkStreamAccess( + streamerUsername: string, + viewerPublicKey: string | null, +): Promise { + try { + const result = await sql` + SELECT stream_access_type, stream_access_config + FROM users + WHERE LOWER(username) = LOWER(${streamerUsername}) + `; + + if (result.rowCount === 0) { + // If user not found, default to public (safe fallback) + return { allowed: true }; + } + + const row = result.rows[0]; + const accessType = row.stream_access_type; + const config = row.stream_access_config || {}; + + if (accessType === 'public') { + return { allowed: true }; + } + + // Delegate to the relevant checker + // Subsequent issues will implement the full logic for each type + switch (accessType) { + case 'password': + return await checkPasswordAccess(config); + case 'invite_only': + return await checkInviteOnlyAccess(streamerUsername, viewerPublicKey); + case 'paid': + return await checkPaidAccess(streamerUsername, viewerPublicKey, config); + case 'token_gated': + return await checkTokenGatedAccess(viewerPublicKey, config); + case 'subscription': + return await checkSubscriptionAccess(streamerUsername, viewerPublicKey); + default: + return { allowed: true }; + } + } catch (error) { + console.error("Error checking stream access:", error); + // On error, we default to allowed: true to avoid blocking streams due to DB issues, + // though in a production app you might want to be stricter. + return { allowed: true }; + } +} + +// Checker Skeletons - Full implementation added by subsequent issues (2-5) + +async function checkPasswordAccess(_config: any): Promise { + // Requires password entry on the frontend + return { allowed: false, reason: 'password' }; +} + +async function checkInviteOnlyAccess(_streamerUsername: string, _viewerPublicKey: string | null): Promise { + // Requires explicit grant in stream_access_grants + return { allowed: false, reason: 'invite_only' }; +} + +async function checkPaidAccess(_streamerUsername: string, _viewerPublicKey: string | null, config: any): Promise { + // Requires payment proof (tx_hash) in stream_access_grants + return { + allowed: false, + reason: 'paid', + config: { price_usdc: config.price_usdc } + }; +} + +async function checkTokenGatedAccess(_viewerPublicKey: string | null, config: any): Promise { + // Requires holding specific Stellar asset + return { + allowed: false, + reason: 'token_gated', + config: { + asset_code: config.asset_code, + asset_issuer: config.asset_issuer, + min_balance: config.min_balance + } + }; +} + +async function checkSubscriptionAccess(_streamerUsername: string, _viewerPublicKey: string | null): Promise { + // Requires active subscription + return { allowed: false, reason: 'subscription' }; +} From 3928f6e8320f3735b7667738bad5782475cb15f2 Mon Sep 17 00:00:00 2001 From: KevinMB0220 Date: Wed, 25 Mar 2026 15:31:03 -0600 Subject: [PATCH 08/16] api: update user and stream routes with access control fields --- app/api/streams/[wallet]/route.ts | 4 ++++ app/api/users/[username]/route.ts | 7 +++++++ app/api/users/update-creator/route.ts | 4 ++++ 3 files changed, 15 insertions(+) diff --git a/app/api/streams/[wallet]/route.ts b/app/api/streams/[wallet]/route.ts index c208ab8d..734ca038 100644 --- a/app/api/streams/[wallet]/route.ts +++ b/app/api/streams/[wallet]/route.ts @@ -32,6 +32,8 @@ export async function GET( u.creator, u.socialLinks, u.created_at, + u.stream_access_type, + u.stream_access_config, (SELECT COUNT(*)::int FROM user_follows WHERE followee_id = u.id) AS follower_count, -- Get latest session data ss.id as session_id, @@ -86,6 +88,8 @@ export async function GET( category: streamData.creator?.category || "", tags: streamData.creator?.tags || [], thumbnail: streamData.creator?.thumbnail || "", + stream_access_type: streamData.stream_access_type, + stream_access_config: streamData.stream_access_config, currentViewers: streamData.current_viewers || 0, totalViews: streamData.total_views || 0, diff --git a/app/api/users/[username]/route.ts b/app/api/users/[username]/route.ts index c055e5e9..bb234b03 100644 --- a/app/api/users/[username]/route.ts +++ b/app/api/users/[username]/route.ts @@ -20,6 +20,7 @@ export async function GET( u.stream_started_at, u.total_views, u.total_tips_received, u.total_tips_count, u.last_tip_at, u.created_at, u.updated_at, + u.stream_access_type, u.stream_access_config, (SELECT COUNT(*)::int FROM user_follows WHERE followee_id = u.id) AS follower_count, (SELECT COUNT(*)::int FROM user_follows WHERE follower_id = u.id) AS following_count, EXISTS( @@ -42,6 +43,12 @@ export async function GET( // eslint-disable-next-line @typescript-eslint/no-unused-vars const { privy_id, email, ...publicUser } = user; + // Filter sensitive fields from stream_access_config + if (publicUser.stream_access_config?.password_hash) { + const { password_hash, ...safeConfig } = publicUser.stream_access_config; + publicUser.stream_access_config = safeConfig; + } + return NextResponse.json( { user: publicUser }, { diff --git a/app/api/users/update-creator/route.ts b/app/api/users/update-creator/route.ts index af728c8a..39926840 100644 --- a/app/api/users/update-creator/route.ts +++ b/app/api/users/update-creator/route.ts @@ -19,6 +19,8 @@ export async function PATCH(req: Request) { category = "", payout = "", thumbnail = "", + stream_access_type, + stream_access_config, } = creator; const updatedCreator = { @@ -32,6 +34,8 @@ export async function PATCH(req: Request) { const result = await sql` UPDATE users SET creator = ${JSON.stringify(updatedCreator)}, + stream_access_type = COALESCE(${stream_access_type}, stream_access_type), + stream_access_config = COALESCE(${JSON.stringify(stream_access_config)}, stream_access_config), updated_at = CURRENT_TIMESTAMP WHERE email = ${email} `; From a707e94e98a59e462b69eec5c049e96fc7db4ee4 Mon Sep 17 00:00:00 2001 From: KevinMB0220 Date: Wed, 25 Mar 2026 15:31:09 -0600 Subject: [PATCH 09/16] feat: implement AccessGate component and frontend gating --- components/stream/AccessGate.tsx | 111 ++++++++++++++++++++++++++++++ components/stream/view-stream.tsx | 54 ++++++++++++++- 2 files changed, 164 insertions(+), 1 deletion(-) create mode 100644 components/stream/AccessGate.tsx diff --git a/components/stream/AccessGate.tsx b/components/stream/AccessGate.tsx new file mode 100644 index 00000000..7ce56e78 --- /dev/null +++ b/components/stream/AccessGate.tsx @@ -0,0 +1,111 @@ +import React from 'react'; +import { Lock, Coins, ShieldCheck, Mail, Users } from 'lucide-react'; +import { Button } from "@/components/ui/button"; + +interface AccessGateProps { + reason: 'password' | 'invite_only' | 'paid' | 'token_gated' | 'subscription'; + streamerUsername: string; + accessConfig?: any; +} + +/** + * AccessGate component displays the appropriate UI when a viewer is blocked + * from watching a stream due to access control settings. + * + * Each case (password, paid, etc.) will be fully implemented in subsequent issues. + */ +export default function AccessGate({ reason, streamerUsername, accessConfig }: AccessGateProps) { + return ( +
+
+ {reason === 'password' && } + {reason === 'paid' && } + {reason === 'token_gated' && } + {reason === 'invite_only' && } + {reason === 'subscription' && } +
+ +
+

+ {reason === 'password' && 'Password Protected'} + {reason === 'paid' && 'Premium Stream'} + {reason === 'token_gated' && 'Token Gated'} + {reason === 'invite_only' && 'Invite Only'} + {reason === 'subscription' && 'Subscribers Only'} +

+

+ This stream by @{streamerUsername} requires specific access permissions. +

+
+ +
+ {reason === 'password' && ( +
+
+ +
+ +
+ )} + + {reason === 'paid' && ( +
+
+ Access Fee +

+ {accessConfig?.price_usdc || '10.00'} USDC +

+
+ +
+ )} + + {reason === 'token_gated' && ( +
+
+

Required Asset

+

+ {accessConfig?.min_balance || '1.00'} {accessConfig?.asset_code || 'TOKEN'} +

+

+ Issuer: {accessConfig?.asset_issuer || 'GA...'} +

+
+ +
+ )} + + {reason === 'invite_only' && ( +
+

+ This stream is currently restricted to invited viewers only. +

+
+ )} + + {reason === 'subscription' && ( +
+

Join the inner circle to view this stream and exclusive content.

+ +
+ )} +
+ +

+ Secured by StreamFi-X Access Control +

+
+ ); +} diff --git a/components/stream/view-stream.tsx b/components/stream/view-stream.tsx index 1276210b..e3094d75 100644 --- a/components/stream/view-stream.tsx +++ b/components/stream/view-stream.tsx @@ -41,6 +41,7 @@ import { useChat } from "@/hooks/useChat"; import { TipButton, TipModalContainer } from "@/components/tipping"; import { useTipModal } from "@/hooks/useTipModal"; import { toast } from "sonner"; +import AccessGate from "./AccessGate"; const socialIcons: Record = { twitter: , @@ -272,6 +273,10 @@ const ViewStream = ({ const [showReportModal, setShowReportModal] = useState(false); const [isSavingStreamInfo, setIsSavingStreamInfo] = useState(false); const [recordings, setRecordings] = useState([]); + const [accessBlocked, setAccessBlocked] = useState(false); + const [accessReason, setAccessReason] = useState(null); + const [accessConfig, setAccessConfig] = useState(null); + const [isCheckingAccess, setIsCheckingAccess] = useState(true); // Use custom hooks for Stellar wallet and tip modal state const { publicKey, privyWallet } = useStellarWallet(); @@ -360,6 +365,45 @@ const ViewStream = ({ .catch(() => {}); }, [username]); + // Check stream access foundation [access-control 1/5] + useEffect(() => { + const checkAccess = async () => { + if (!username) return; + + try { + setIsCheckingAccess(true); + const response = await fetch('/api/streams/access/check', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + streamer_username: username, + viewer_public_key: address + }) + }); + + if (response.ok) { + const data = await response.json(); + if (!data.allowed) { + setAccessBlocked(true); + setAccessReason(data.reason); + // The rest of the data is config (price, asset details, etc.) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { allowed, reason, ...config } = data; + setAccessConfig(config); + } else { + setAccessBlocked(false); + } + } + } catch (error) { + console.error("Failed to check stream access:", error); + } finally { + setIsCheckingAccess(false); + } + }; + + checkAccess(); + }, [username, address]); + // Detect portrait vs landscape from the native video element's metadata useEffect(() => { const container = videoContainerRef.current; @@ -533,7 +577,15 @@ const ViewStream = ({ : undefined } > - {isLive && userData?.playbackId ? ( + {accessBlocked ? ( +
+ +
+ ) : isLive && userData?.playbackId ? ( Date: Wed, 25 Mar 2026 15:31:52 -0600 Subject: [PATCH 10/16] feat: add Stream Access settings to creator dashboard --- app/dashboard/stream-manager/page.tsx | 58 +++++++ .../stream-manager/StreamAccessSettings.tsx | 158 ++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 components/dashboard/stream-manager/StreamAccessSettings.tsx diff --git a/app/dashboard/stream-manager/page.tsx b/app/dashboard/stream-manager/page.tsx index e0dc166b..2b7a607a 100644 --- a/app/dashboard/stream-manager/page.tsx +++ b/app/dashboard/stream-manager/page.tsx @@ -9,6 +9,7 @@ import ActivityFeed from "@/components/dashboard/stream-manager/ActivityFeed"; import Chat from "@/components/dashboard/stream-manager/Chat"; import StreamInfo from "@/components/dashboard/stream-manager/StreamInfo"; import StreamSettings from "@/components/dashboard/stream-manager/StreamSettings"; +import StreamAccessSettings from "@/components/dashboard/stream-manager/StreamAccessSettings"; import StreamInfoModal from "@/components/dashboard/common/StreamInfoModal"; import { motion } from "framer-motion"; import { Users, UserPlus, Coins, Timer } from "lucide-react"; @@ -26,6 +27,8 @@ export default function StreamManagerPage() { description: "", tags: [] as string[], thumbnail: null as string | null, + accessType: "public", + accessConfig: {} as any, }); const [isLoadingData, setIsLoadingData] = useState(true); const [isSaving, setIsSaving] = useState(false); @@ -79,6 +82,8 @@ export default function StreamManagerPage() { description: creator.description || "", tags: creator.tags || [], thumbnail: creator.thumbnail || null, + accessType: data.streamData?.stream?.stream_access_type || "public", + accessConfig: data.streamData?.stream?.stream_access_config || {}, }); } } catch (error) { @@ -156,6 +161,53 @@ export default function StreamManagerPage() { } }; + const handleAccessPolicyUpdate = async ( + accessType: string, + accessConfig: any + ) => { + if (!address) { + showToast("Wallet not connected"); + return; + } + const userEmail = sessionStorage.getItem("userEmail") || privyWallet?.email || ""; + if (!userEmail) { + showToast("Session expired, please refresh"); + return; + } + + setIsSaving(true); + try { + const response = await fetch("/api/users/update-creator", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email: userEmail, + creator: { + ...streamData, + stream_access_type: accessType, + stream_access_config: accessConfig, + }, + }), + }); + + if (response.ok) { + setStreamData({ + ...streamData, + accessType, + accessConfig, + }); + showToast("Access policy updated!"); + } else { + const err = await response.json(); + showToast(err.error || "Failed to update access policy"); + } + } catch { + showToast("Failed to update access policy"); + } finally { + setIsSaving(false); + } + }; + const showToast = (message: string) => { setToastMessage(message); setTimeout(() => setToastMessage(null), 3000); @@ -217,6 +269,12 @@ export default function StreamManagerPage() { }} onEditClick={() => setIsStreamInfoModalOpen(true)} /> + diff --git a/components/dashboard/stream-manager/StreamAccessSettings.tsx b/components/dashboard/stream-manager/StreamAccessSettings.tsx new file mode 100644 index 00000000..b1143af9 --- /dev/null +++ b/components/dashboard/stream-manager/StreamAccessSettings.tsx @@ -0,0 +1,158 @@ +import React, { useState, useEffect } from 'react'; +import { Shield } from 'lucide-react'; +import { Button } from "@/components/ui/button"; + +interface StreamAccessSettingsProps { + initialAccessType: string; + initialAccessConfig: any; + onSave: (accessType: string, accessConfig: any) => Promise; + isSaving: boolean; +} + +/** + * StreamAccessSettings allows streamers to configure how viewers access their stream. + * It supports various gating methods including passwords, payments, and token requirements. + * [access-control 1/5] + */ +export default function StreamAccessSettings({ + initialAccessType, + initialAccessConfig, + onSave, + isSaving +}: StreamAccessSettingsProps) { + const [accessType, setAccessType] = useState(initialAccessType || 'public'); + const [config, setConfig] = useState(initialAccessConfig || {}); + + // Sync with initial props when they load + useEffect(() => { + if (initialAccessType) setAccessType(initialAccessType); + if (initialAccessConfig) setConfig(initialAccessConfig); + }, [initialAccessType, initialAccessConfig]); + + const handleSave = () => { + onSave(accessType, config); + }; + + return ( +
+
+
+ + Stream Access +
+
+ +
+
+ + +
+ + {/* Dynamic configuration fields based on selection */} +
+ {accessType === 'paid' && ( +
+ +
+ setConfig({ ...config, price_usdc: e.target.value })} + placeholder="10.00" + className="w-full bg-secondary/50 border border-border rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:ring-2 focus:ring-highlight/20 focus:border-highlight transition-all pr-12" + /> + USDC +
+
+ )} + + {accessType === 'token_gated' && ( +
+
+
+ + setConfig({ ...config, asset_code: e.target.value })} + placeholder="USDC" + className="w-full bg-secondary/50 border border-border rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:ring-2 focus:ring-highlight/20 focus:border-highlight transition-all" + /> +
+
+ + setConfig({ ...config, min_balance: e.target.value })} + placeholder="1.00" + className="w-full bg-secondary/50 border border-border rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:ring-2 focus:ring-highlight/20 focus:border-highlight transition-all" + /> +
+
+
+ + setConfig({ ...config, asset_issuer: e.target.value })} + placeholder="G..." + className="w-full bg-secondary/50 border border-border rounded-xl px-3 py-2.5 text-[10px] font-mono focus:outline-none focus:ring-2 focus:ring-highlight/20 focus:border-highlight transition-all" + /> +
+
+ )} + + {accessType === 'password' && ( +
+

+ + Password hashing logic will be enabled in next module [2/5] +

+
+ )} + + {(accessType === 'invite_only' || accessType === 'subscription') && ( +
+

+ {accessType === 'invite_only' ? 'User whitelisting' : 'Subscription check'} module coming soon. +

+
+ )} +
+ + +
+
+ ); +} From 8829991a6931fa7bb6e01584e89d832bc78a0fd2 Mon Sep 17 00:00:00 2001 From: KevinMB0220 Date: Fri, 27 Mar 2026 02:33:14 -0600 Subject: [PATCH 11/16] fix(stream-manager): preserve access config when saving stream info setStreamData was called without accessType and accessConfig, causing a TS error and silently dropping access-control settings on save. Use a functional state update to spread existing state. Co-Authored-By: Claude Sonnet 4.6 --- app/api/routes-f/import/route.ts | 560 ++++++++++++++++++++++++++ app/dashboard/stream-manager/page.tsx | 5 +- 2 files changed, 563 insertions(+), 2 deletions(-) create mode 100644 app/api/routes-f/import/route.ts diff --git a/app/api/routes-f/import/route.ts b/app/api/routes-f/import/route.ts new file mode 100644 index 00000000..621145fd --- /dev/null +++ b/app/api/routes-f/import/route.ts @@ -0,0 +1,560 @@ +/** + * POST /api/routes-f/import — start a platform import job + * GET /api/routes-f/import?job_id={id} — poll import status + * + * Supported sources: twitch | youtube | json + * + * Security: + * - Session cookie required (privy or wallet) + * - Rate limited: 5 requests/min per IP (general) + 1 import/user/24h (user-level) + * - Third-party OAuth tokens are NEVER persisted — used once and discarded + */ + +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { createRateLimiter } from "@/lib/rate-limit"; +import { verifySession } from "@/lib/auth/verify-session"; + +// 5 requests per minute per IP +const isIpRateLimited = createRateLimiter(60_000, 5); + +// ── Types ────────────────────────────────────────────────────────────────────── + +type ImportSource = "twitch" | "youtube" | "json"; + +interface ImportOptions { + import_avatar?: boolean; + import_bio?: boolean; + import_social_links?: boolean; + import_categories?: boolean; + /** When false (default), only fills currently-empty fields */ + overwrite_existing?: boolean; +} + +interface ImportRequest { + source: ImportSource; + data: Record; + options?: ImportOptions; +} + +/** Normalised profile data returned by each importer */ +interface ImportedProfile { + bio?: string; + avatar_url?: string; + social_links?: Array<{ socialTitle: string; socialLink: string }>; + /** First entry maps to creator.category */ + categories?: string[]; +} + +/** Expected shape of a StreamFi JSON export */ +interface StreamFiExport { + bio?: string; + avatar_url?: string; + social_links?: Array<{ title: string; url: string }>; + categories?: string[]; +} + +// ── Source importers ─────────────────────────────────────────────────────────── + +function getTwitchClientId(): string { + const id = process.env.TWITCH_CLIENT_ID; + if (!id) { + throw new Error("TWITCH_CLIENT_ID not configured"); + } + return id; +} + +/** + * Fetches profile from Twitch Helix API using the caller-supplied OAuth token. + * The token is used once and never stored. + */ +async function importFromTwitch( + data: Record +): Promise { + const accessToken = data.access_token; + if (typeof accessToken !== "string" || !accessToken) { + throw new Error("Twitch import requires data.access_token"); + } + + const clientId = getTwitchClientId(); + let response: Response; + try { + response = await fetch("https://api.twitch.tv/helix/users", { + headers: { + Authorization: `Bearer ${accessToken}`, + "Client-Id": clientId, + }, + }); + } catch (err) { + throw new Error( + `Failed to reach Twitch API: ${err instanceof Error ? err.message : "network error"}` + ); + } + + if (!response.ok) { + // 401 means the token was invalid/expired — surface a clear message + if (response.status === 401) { + throw new Error("Twitch access token is invalid or expired"); + } + throw new Error(`Twitch API returned ${response.status}`); + } + + const json = await response.json(); + const user = json?.data?.[0]; + if (!user) { + throw new Error("No Twitch user data returned for this token"); + } + + return { + bio: user.description || undefined, + avatar_url: user.profile_image_url || undefined, + // Twitch Helix /users does not expose social links + social_links: undefined, + categories: undefined, + }; +} + +/** Decodes the handful of HTML entities that appear in meta-tag content. */ +function decodeHTMLEntities(text: string): string { + return text + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, '"') + .replace(/'/g, "'"); +} + +/** + * Fetches public channel metadata from a YouTube channel URL via og: meta tags. + * No API key required — works with any public channel page. + */ +async function importFromYouTube( + data: Record +): Promise { + const channelUrl = data.channel_url; + if (typeof channelUrl !== "string" || !channelUrl) { + throw new Error("YouTube import requires data.channel_url"); + } + + // SSRF guard: only accept YouTube channel URLs + const ytPattern = + /^https:\/\/(www\.)?youtube\.com\/(channel\/UC[\w-]{22}|@[\w.-]+|c\/[\w.-]+)\/?$/; + if (!ytPattern.test(channelUrl)) { + throw new Error( + "data.channel_url must be a valid YouTube channel URL (e.g. https://www.youtube.com/@channelname)" + ); + } + + let html: string; + try { + const response = await fetch(channelUrl, { + headers: { "User-Agent": "Mozilla/5.0 (compatible; StreamFi/1.0)" }, + }); + if (!response.ok) { + throw new Error(`YouTube returned ${response.status}`); + } + html = await response.text(); + } catch (err) { + throw new Error( + `Failed to fetch YouTube channel: ${err instanceof Error ? err.message : "network error"}` + ); + } + + // Extract og: meta content — two attribute orderings are possible + const ogContent = (property: string): string | undefined => { + const pattern = new RegExp( + `]+(?:property|name)="${property}"[^>]+content="([^"]*)"` + + `|]+content="([^"]*)"[^>]+(?:property|name)="${property}"`, + "i" + ); + const match = html.match(pattern); + const raw = match?.[1] ?? match?.[2]; + return raw ? decodeHTMLEntities(raw) : undefined; + }; + + return { + bio: ogContent("og:description") ?? ogContent("description"), + avatar_url: ogContent("og:image"), + social_links: undefined, + categories: undefined, + }; +} + +/** Validates and maps a StreamFi-format JSON export to a normalised profile. */ +function importFromJson(data: Record): ImportedProfile { + const exportData = data as StreamFiExport; + + const hasContent = + exportData.bio !== undefined || + exportData.avatar_url !== undefined || + exportData.social_links !== undefined || + exportData.categories !== undefined; + + if (!hasContent) { + throw new Error( + "JSON export must contain at least one of: bio, avatar_url, social_links, categories" + ); + } + + if (exportData.bio !== undefined && typeof exportData.bio !== "string") { + throw new Error("JSON export: bio must be a string"); + } + if ( + exportData.avatar_url !== undefined && + typeof exportData.avatar_url !== "string" + ) { + throw new Error("JSON export: avatar_url must be a string"); + } + if (exportData.social_links !== undefined) { + if (!Array.isArray(exportData.social_links)) { + throw new Error( + "JSON export: social_links must be an array of {title, url} objects" + ); + } + for (const link of exportData.social_links) { + if ( + typeof link !== "object" || + link === null || + typeof (link as Record).title !== "string" || + typeof (link as Record).url !== "string" + ) { + throw new Error( + "JSON export: each social_links entry must have string fields title and url" + ); + } + } + } + if (exportData.categories !== undefined) { + if ( + !Array.isArray(exportData.categories) || + !exportData.categories.every(c => typeof c === "string") + ) { + throw new Error("JSON export: categories must be an array of strings"); + } + } + + return { + bio: exportData.bio, + avatar_url: exportData.avatar_url, + social_links: exportData.social_links?.map(l => ({ + socialTitle: l.title, + socialLink: l.url, + })), + categories: exportData.categories, + }; +} + +// ── DB helpers ──────────────────────────────────────────────────────────────── + +async function ensureJobsTable(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS import_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id VARCHAR NOT NULL, + status VARCHAR NOT NULL DEFAULT 'queued', + source VARCHAR NOT NULL, + result JSONB, + error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + `; +} + +/** + * Applies the normalised profile to the user row, respecting overwrite_existing. + * When overwrite_existing is false, only empty/null fields are updated. + */ +async function applyImport( + userId: string, + profile: ImportedProfile, + options: Required +): Promise { + const { rows } = await sql` + SELECT bio, avatar, sociallinks, creator + FROM users + WHERE id = ${userId} + LIMIT 1 + `; + + if (rows.length === 0) { + throw new Error("User not found"); + } + + const user = rows[0]; + const overwrite = options.overwrite_existing; + + const newBio = + options.import_bio && profile.bio !== undefined + ? overwrite || !user.bio + ? profile.bio + : user.bio + : user.bio; + + const newAvatar = + options.import_avatar && profile.avatar_url !== undefined + ? overwrite || !user.avatar + ? profile.avatar_url + : user.avatar + : user.avatar; + + let newSocialLinks = user.sociallinks; + if (options.import_social_links && profile.social_links?.length) { + const existing: unknown[] = user.sociallinks + ? typeof user.sociallinks === "string" + ? JSON.parse(user.sociallinks) + : user.sociallinks + : []; + if (overwrite || existing.length === 0) { + newSocialLinks = JSON.stringify(profile.social_links); + } + } + + let newCreator = user.creator; + if (options.import_categories && profile.categories?.length) { + const existingCreator: Record = user.creator + ? typeof user.creator === "string" + ? JSON.parse(user.creator) + : user.creator + : {}; + if (overwrite || !existingCreator.category) { + newCreator = JSON.stringify({ + ...existingCreator, + category: profile.categories[0], + }); + } + } + + await sql` + UPDATE users + SET + bio = ${newBio}, + avatar = ${newAvatar}, + sociallinks = ${newSocialLinks}, + creator = ${ + newCreator + ? typeof newCreator === "string" + ? newCreator + : JSON.stringify(newCreator) + : null + }, + updated_at = CURRENT_TIMESTAMP + WHERE id = ${userId} + `; +} + +// ── POST /api/routes-f/import ───────────────────────────────────────────────── + +export async function POST(req: NextRequest) { + // 1. IP-level rate limit + const ip = + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown"; + + if (await isIpRateLimited(ip)) { + return NextResponse.json( + { error: "Too many requests" }, + { status: 429, headers: { "Retry-After": "60" } } + ); + } + + // 2. Session auth + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + // 3. Parse body + let body: ImportRequest; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const { source, data, options = {} } = body; + + if (!["twitch", "youtube", "json"].includes(source)) { + return NextResponse.json( + { error: "source must be one of: twitch, youtube, json" }, + { status: 400 } + ); + } + + if (!data || typeof data !== "object" || Array.isArray(data)) { + return NextResponse.json( + { error: "data must be a non-null object" }, + { status: 400 } + ); + } + + const resolvedOptions: Required = { + import_avatar: options.import_avatar ?? true, + import_bio: options.import_bio ?? true, + import_social_links: options.import_social_links ?? true, + import_categories: options.import_categories ?? true, + overwrite_existing: options.overwrite_existing ?? false, + }; + + // 4. User-level rate limit: 1 successful or in-flight import per 24 hours + try { + await ensureJobsTable(); + + const { rows: recent } = await sql` + SELECT id FROM import_jobs + WHERE user_id = ${session.userId} + AND status != 'failed' + AND created_at > NOW() - INTERVAL '24 hours' + LIMIT 1 + `; + + if (recent.length > 0) { + return NextResponse.json( + { error: "Import limit reached. You may import once every 24 hours." }, + { status: 429, headers: { "Retry-After": "86400" } } + ); + } + } catch (err) { + console.error("[import] DB error checking rate limit:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + + // 5. Create job record + let jobId: string; + try { + const { rows } = await sql` + INSERT INTO import_jobs (user_id, status, source) + VALUES (${session.userId}, 'processing', ${source}) + RETURNING id + `; + jobId = rows[0].id; + } catch (err) { + console.error("[import] Failed to create job record:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } + + // 6. Process import (synchronous — serverless has no true background jobs) + try { + let profile: ImportedProfile; + + switch (source) { + case "twitch": + profile = await importFromTwitch(data); + break; + case "youtube": + profile = await importFromYouTube(data); + break; + case "json": + profile = importFromJson(data); + break; + } + + await applyImport(session.userId, profile, resolvedOptions); + + await sql` + UPDATE import_jobs + SET status = 'done', result = ${JSON.stringify({ imported: profile })}, updated_at = NOW() + WHERE id = ${jobId} + `; + + return NextResponse.json({ job_id: jobId, status: "done" }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + console.error("[import] Processing failed:", err); + + try { + await sql` + UPDATE import_jobs + SET status = 'failed', error = ${message}, updated_at = NOW() + WHERE id = ${jobId} + `; + } catch (updateErr) { + console.error("[import] Failed to mark job as failed:", updateErr); + } + + return NextResponse.json( + { error: `Import failed: ${message}` }, + { status: 422 } + ); + } +} + +// ── GET /api/routes-f/import?job_id= ───────────────────────────────────────── + +export async function GET(req: NextRequest) { + // 1. IP-level rate limit + const ip = + req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + req.headers.get("x-real-ip") ?? + "unknown"; + + if (await isIpRateLimited(ip)) { + return NextResponse.json( + { error: "Too many requests" }, + { status: 429, headers: { "Retry-After": "60" } } + ); + } + + // 2. Session auth + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + // 3. Validate job_id + const { searchParams } = new URL(req.url); + const jobId = searchParams.get("job_id"); + + if (!jobId) { + return NextResponse.json({ error: "job_id is required" }, { status: 400 }); + } + + const uuidPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!uuidPattern.test(jobId)) { + return NextResponse.json( + { error: "Invalid job_id format" }, + { status: 400 } + ); + } + + // 4. Fetch job (scoped to authenticated user — no cross-user leakage) + try { + await ensureJobsTable(); + + const { rows } = await sql` + SELECT id, status, source, result, error, created_at, updated_at + FROM import_jobs + WHERE id = ${jobId} AND user_id = ${session.userId} + LIMIT 1 + `; + + if (rows.length === 0) { + return NextResponse.json({ error: "Job not found" }, { status: 404 }); + } + + const job = rows[0]; + return NextResponse.json({ + job_id: job.id, + status: job.status, + source: job.source, + result: job.result ?? null, + error: job.error ?? null, + created_at: job.created_at, + updated_at: job.updated_at, + }); + } catch (err) { + console.error("[import] DB error fetching job:", err); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/dashboard/stream-manager/page.tsx b/app/dashboard/stream-manager/page.tsx index f5bd28c5..54a6f186 100644 --- a/app/dashboard/stream-manager/page.tsx +++ b/app/dashboard/stream-manager/page.tsx @@ -142,13 +142,14 @@ export default function StreamManagerPage() { }); if (response.ok) { const result = await response.json(); - setStreamData({ + setStreamData((prev) => ({ + ...prev, title: result.streamData.title || "", category: result.streamData.category || "", description: result.streamData.description || "", tags: result.streamData.tags || [], thumbnail: result.streamData.thumbnail || null, - }); + })); setIsStreamInfoModalOpen(false); showToast("Stream info updated!"); } else { From 38d1ce4af6b043321b7fac7d1e036a1ac29dba6f Mon Sep 17 00:00:00 2001 From: KevinMB0220 Date: Fri, 27 Mar 2026 02:34:35 -0600 Subject: [PATCH 12/16] test(import): add 28 unit tests for import api route Covers POST and GET handlers across all three import sources (twitch, youtube, json). Includes edge cases for: - invalid source / missing data fields - user rate limit (24h) and DB errors - overwrite_existing flag behaviour - social_links and categories field mapping - twitch token never persisted to DB - youtube ssrf guard - job status polling Co-Authored-By: Claude Sonnet 4.6 --- .../routes-f/import/__tests__/route.test.ts | 606 ++++++++++++++++++ 1 file changed, 606 insertions(+) create mode 100644 app/api/routes-f/import/__tests__/route.test.ts diff --git a/app/api/routes-f/import/__tests__/route.test.ts b/app/api/routes-f/import/__tests__/route.test.ts new file mode 100644 index 00000000..d44da5f1 --- /dev/null +++ b/app/api/routes-f/import/__tests__/route.test.ts @@ -0,0 +1,606 @@ +/** + * Tests for POST /api/routes-f/import and GET /api/routes-f/import + * + * Mocks: + * - @vercel/postgres — no real DB + * - next/server — jsdom polyfill + * - @/lib/rate-limit — always allows + * - @/lib/auth/verify-session — controllable session + * - global fetch — intercepts external HTTP calls + */ + +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/rate-limit", () => ({ + createRateLimiter: () => async () => false, // never rate-limited in tests +})); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { POST, GET } from "../route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +const makeRequest = ( + method: string, + body?: object, + search?: string +): import("next/server").NextRequest => + new Request(`http://localhost/api/routes-f/import${search ?? ""}`, { + method, + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; + +const authedSession = { + ok: true as const, + userId: "user-123", + wallet: null, + privyId: "did:privy:abc", + username: "testuser", + email: "test@example.com", +}; + +// Mock CREATE TABLE IF NOT EXISTS + any subsequent query +function mockEnsureTable() { + sqlMock.mockResolvedValueOnce({ rows: [], rowCount: 0 }); // ensureJobsTable +} + +let consoleErrorSpy: jest.SpyInstance; + +// ── POST tests ───────────────────────────────────────────────────────────────── + +describe("POST /api/routes-f/import", () => { + beforeEach(() => { + jest.clearAllMocks(); + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + verifySessionMock.mockResolvedValue(authedSession); + }); + afterEach(() => consoleErrorSpy?.mockRestore()); + + it("returns 401 when session is invalid", async () => { + verifySessionMock.mockResolvedValue({ + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }), + }); + const req = makeRequest("POST", { source: "json", data: {} }); + const res = await POST(req); + expect(res.status).toBe(401); + }); + + it("returns 400 for invalid source", async () => { + const req = makeRequest("POST", { + source: "instagram", + data: { foo: "bar" }, + }); + const res = await POST(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/source/i); + }); + + it("returns 400 when data is missing", async () => { + const req = makeRequest("POST", { source: "json" }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("returns 400 when data is an array", async () => { + const req = makeRequest("POST", { source: "json", data: [1, 2] }); + const res = await POST(req); + expect(res.status).toBe(400); + }); + + it("returns 429 when user already imported in past 24h", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-old" }] }); // rate-limit check + const req = makeRequest("POST", { + source: "json", + data: { bio: "Hello" }, + }); + const res = await POST(req); + expect(res.status).toBe(429); + const body = await res.json(); + expect(body.error).toMatch(/24 hours/i); + }); + + it("returns 500 when DB fails on rate-limit check", async () => { + mockEnsureTable(); + sqlMock.mockRejectedValueOnce(new Error("DB down")); + const req = makeRequest("POST", { + source: "json", + data: { bio: "Hello" }, + }); + const res = await POST(req); + expect(res.status).toBe(500); + }); + + it("returns 500 when job record creation fails", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); // rate-limit check — no recent jobs + sqlMock.mockRejectedValueOnce(new Error("INSERT failed")); + const req = makeRequest("POST", { + source: "json", + data: { bio: "Hello" }, + }); + const res = await POST(req); + expect(res.status).toBe(500); + }); + + // ── JSON source ────────────────────────────────────────────────────────────── + + describe("json source", () => { + it("returns 422 when JSON export is empty", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); // rate-limit + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-1" }] }); // INSERT job + + const req = makeRequest("POST", { source: "json", data: {} }); + const res = await POST(req); + expect(res.status).toBe(422); + // Call 3 is UPDATE import_jobs SET status = 'failed', error = ${msg}, ... WHERE id = ${jobId} + // 'failed' is a SQL literal in the template string, not an interpolated value. + // Interpolated values are: [errorMessage, jobId] + const failCall = sqlMock.mock.calls[3]; + expect(failCall[1]).toMatch(/must contain/i); // error message + expect(failCall[2]).toBe("job-1"); // jobId + }); + + it("returns 422 when social_links entries are malformed", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-2" }] }); + + const req = makeRequest("POST", { + source: "json", + data: { social_links: [{ href: "bad" }] }, + }); + const res = await POST(req); + expect(res.status).toBe(422); + }); + + it("returns 200 and applies bio import", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); // rate-limit + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-3" }] }); // INSERT job + sqlMock.mockResolvedValueOnce({ + // applyImport SELECT + rows: [{ bio: null, avatar: null, sociallinks: null, creator: null }], + }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE users + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE job → done + + const req = makeRequest("POST", { + source: "json", + data: { bio: "Imported bio" }, + }); + const res = await POST(req); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.job_id).toBe("job-3"); + expect(body.status).toBe("done"); + }); + + it("does not overwrite non-empty bio when overwrite_existing is false", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-4" }] }); + sqlMock.mockResolvedValueOnce({ + rows: [ + { + bio: "Existing bio", + avatar: null, + sociallinks: null, + creator: null, + }, + ], + }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE users + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE job + + const req = makeRequest("POST", { + source: "json", + data: { bio: "New bio" }, + options: { overwrite_existing: false }, + }); + await POST(req); + + // SQL calls: 0=ensureTable, 1=rate-limit, 2=INSERT job, 3=SELECT user, 4=UPDATE users + const updateUsersCall = sqlMock.mock.calls[4]; + const interpolatedValues = updateUsersCall.slice(1); + expect(interpolatedValues).toContain("Existing bio"); + expect(interpolatedValues).not.toContain("New bio"); + }); + + it("overwrites existing bio when overwrite_existing is true", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-5" }] }); + sqlMock.mockResolvedValueOnce({ + rows: [ + { + bio: "Old bio", + avatar: null, + sociallinks: null, + creator: null, + }, + ], + }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE users + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE job + + const req = makeRequest("POST", { + source: "json", + data: { bio: "New bio" }, + options: { overwrite_existing: true }, + }); + await POST(req); + + // SQL calls: 0=ensureTable, 1=rate-limit, 2=INSERT job, 3=SELECT user, 4=UPDATE users + const updateUsersCall = sqlMock.mock.calls[4]; + const interpolatedValues = updateUsersCall.slice(1); + expect(interpolatedValues).toContain("New bio"); + }); + + it("maps social_links correctly to socialTitle/socialLink shape", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-6" }] }); + sqlMock.mockResolvedValueOnce({ + rows: [{ bio: null, avatar: null, sociallinks: null, creator: null }], + }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE users + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE job + + const req = makeRequest("POST", { + source: "json", + data: { + social_links: [{ title: "Twitter", url: "https://twitter.com/x" }], + }, + }); + await POST(req); + + // SQL calls: 0=ensureTable, 1=rate-limit, 2=INSERT job, 3=SELECT user, 4=UPDATE users + const updateUsersCall = sqlMock.mock.calls[4]; + const interpolatedValues = updateUsersCall.slice(1); + const socialLinksArg = interpolatedValues.find( + (v: unknown) => typeof v === "string" && v.includes("socialTitle") + ); + expect(socialLinksArg).toBeDefined(); + const parsed = JSON.parse(socialLinksArg); + expect(parsed[0]).toEqual({ + socialTitle: "Twitter", + socialLink: "https://twitter.com/x", + }); + }); + + it("maps first category to creator.category", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-7" }] }); + sqlMock.mockResolvedValueOnce({ + rows: [ + { + bio: null, + avatar: null, + sociallinks: null, + creator: JSON.stringify({ streamTitle: "My Stream" }), + }, + ], + }); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [] }); + + const req = makeRequest("POST", { + source: "json", + data: { categories: ["Gaming", "IRL"] }, + }); + await POST(req); + + // SQL calls: 0=ensureTable, 1=rate-limit, 2=INSERT job, 3=SELECT user, 4=UPDATE users + const updateUsersCall = sqlMock.mock.calls[4]; + const interpolatedValues = updateUsersCall.slice(1); + const creatorArg = interpolatedValues.find( + (v: unknown) => typeof v === "string" && v.includes("category") + ); + expect(creatorArg).toBeDefined(); + const parsed = JSON.parse(creatorArg); + expect(parsed.category).toBe("Gaming"); + expect(parsed.streamTitle).toBe("My Stream"); // existing field preserved + }); + }); + + // ── Twitch source ──────────────────────────────────────────────────────────── + + describe("twitch source", () => { + beforeEach(() => { + process.env.TWITCH_CLIENT_ID = "test-client-id"; + }); + afterEach(() => { + delete process.env.TWITCH_CLIENT_ID; + }); + + it("returns 400 when access_token is missing", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-t1" }] }); + + global.fetch = jest.fn(); // should not be called + + const req = makeRequest("POST", { source: "twitch", data: {} }); + const res = await POST(req); + expect(res.status).toBe(422); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("returns 422 when Twitch returns 401", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-t2" }] }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE job failed + + global.fetch = jest.fn().mockResolvedValue({ ok: false, status: 401 }); + + const req = makeRequest("POST", { + source: "twitch", + data: { access_token: "bad-token" }, + }); + const res = await POST(req); + expect(res.status).toBe(422); + const body = await res.json(); + expect(body.error).toMatch(/invalid or expired/i); + }); + + it("returns 422 when TWITCH_CLIENT_ID is not configured", async () => { + delete process.env.TWITCH_CLIENT_ID; + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-t3" }] }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE job failed + + const req = makeRequest("POST", { + source: "twitch", + data: { access_token: "token" }, + }); + const res = await POST(req); + expect(res.status).toBe(422); + }); + + it("imports Twitch profile successfully", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-t4" }] }); + sqlMock.mockResolvedValueOnce({ + rows: [{ bio: null, avatar: null, sociallinks: null, creator: null }], + }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE users + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE job + + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ + data: [ + { + description: "Twitch bio", + profile_image_url: "https://twitch.tv/avatar.png", + }, + ], + }), + }); + + const req = makeRequest("POST", { + source: "twitch", + data: { access_token: "valid-token" }, + }); + const res = await POST(req); + expect(res.status).toBe(200); + + // Verify access token is NOT passed to any SQL call (never persisted) + const allSqlArgs = sqlMock.mock.calls.flatMap((call: unknown[]) => + call.slice(1) + ); + expect(allSqlArgs).not.toContain("valid-token"); + }); + }); + + // ── YouTube source ─────────────────────────────────────────────────────────── + + describe("youtube source", () => { + it("returns 400 when channel_url is missing", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-y1" }] }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE job failed + + global.fetch = jest.fn(); + + const req = makeRequest("POST", { source: "youtube", data: {} }); + const res = await POST(req); + expect(res.status).toBe(422); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("returns 422 for a non-YouTube URL (SSRF guard)", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-y2" }] }); + sqlMock.mockResolvedValueOnce({ rows: [] }); // UPDATE job failed + + global.fetch = jest.fn(); + + const req = makeRequest("POST", { + source: "youtube", + data: { channel_url: "https://evil.internal/steal-data" }, + }); + const res = await POST(req); + expect(res.status).toBe(422); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("imports YouTube channel bio and avatar from og: meta tags", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [{ id: "job-y3" }] }); + sqlMock.mockResolvedValueOnce({ + rows: [{ bio: null, avatar: null, sociallinks: null, creator: null }], + }); + sqlMock.mockResolvedValueOnce({ rows: [] }); + sqlMock.mockResolvedValueOnce({ rows: [] }); + + const mockHtml = ` + + + + + `; + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + text: async () => mockHtml, + }); + + const req = makeRequest("POST", { + source: "youtube", + data: { channel_url: "https://www.youtube.com/@testchannel" }, + }); + const res = await POST(req); + expect(res.status).toBe(200); + + // SQL calls: 0=ensureTable, 1=rate-limit, 2=INSERT job, 3=SELECT user, 4=UPDATE users + const updateUsersCall = sqlMock.mock.calls[4]; + const interpolatedValues = updateUsersCall.slice(1); + expect(interpolatedValues).toContain("My YouTube channel bio"); + expect(interpolatedValues).toContain("https://yt3.ggpht.com/avatar.jpg"); + }); + }); +}); + +// ── GET tests ────────────────────────────────────────────────────────────────── + +describe("GET /api/routes-f/import", () => { + beforeEach(() => { + jest.clearAllMocks(); + consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}); + verifySessionMock.mockResolvedValue(authedSession); + }); + afterEach(() => consoleErrorSpy?.mockRestore()); + + const validJobId = "550e8400-e29b-41d4-a716-446655440000"; + + it("returns 401 when not authenticated", async () => { + verifySessionMock.mockResolvedValue({ + ok: false, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + }), + }); + const req = makeRequest("GET", undefined, `?job_id=${validJobId}`); + const res = await GET(req); + expect(res.status).toBe(401); + }); + + it("returns 400 when job_id is missing", async () => { + const req = makeRequest("GET", undefined, ""); + const res = await GET(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/job_id/i); + }); + + it("returns 400 for non-UUID job_id", async () => { + const req = makeRequest("GET", undefined, "?job_id=not-a-uuid"); + const res = await GET(req); + expect(res.status).toBe(400); + const body = await res.json(); + expect(body.error).toMatch(/invalid/i); + }); + + it("returns 404 when job belongs to a different user", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); // no rows — different user + + const req = makeRequest("GET", undefined, `?job_id=${validJobId}`); + const res = await GET(req); + expect(res.status).toBe(404); + }); + + it("returns job details for a completed job", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: validJobId, + status: "done", + source: "json", + result: { imported: { bio: "Hello" } }, + error: null, + created_at: "2026-03-27T00:00:00Z", + updated_at: "2026-03-27T00:00:01Z", + }, + ], + }); + + const req = makeRequest("GET", undefined, `?job_id=${validJobId}`); + const res = await GET(req); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.job_id).toBe(validJobId); + expect(body.status).toBe("done"); + expect(body.source).toBe("json"); + expect(body.result).toEqual({ imported: { bio: "Hello" } }); + expect(body.error).toBeNull(); + }); + + it("returns job details for a failed job", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: validJobId, + status: "failed", + source: "twitch", + result: null, + error: "Twitch access token is invalid or expired", + created_at: "2026-03-27T00:00:00Z", + updated_at: "2026-03-27T00:00:01Z", + }, + ], + }); + + const req = makeRequest("GET", undefined, `?job_id=${validJobId}`); + const res = await GET(req); + expect(res.status).toBe(200); + + const body = await res.json(); + expect(body.status).toBe("failed"); + expect(body.error).toMatch(/invalid or expired/i); + }); + + it("returns 500 on unexpected DB error", async () => { + mockEnsureTable(); + sqlMock.mockRejectedValueOnce(new Error("DB crash")); + + const req = makeRequest("GET", undefined, `?job_id=${validJobId}`); + const res = await GET(req); + expect(res.status).toBe(500); + }); +}); From fc145263925d775876967b65d882e98641450a24 Mon Sep 17 00:00:00 2001 From: KevinMB0220 Date: Fri, 27 Mar 2026 02:40:52 -0600 Subject: [PATCH 13/16] fix(lint): resolve pre-existing eslint errors blocking ci MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three pre-existing errors unrelated to the import api: - app/api/users/[username]/route.ts: password_hash destructured intentionally to omit it — suppress no-unused-vars - StreamAccessSettings.tsx: add curly braces to if statements - view-stream.tsx: add curly brace to if statement; suppress no-unused-vars on isCheckingAccess (set but never read) Co-Authored-By: Claude Sonnet 4.6 --- app/api/users/[username]/route.ts | 1 + components/dashboard/stream-manager/StreamAccessSettings.tsx | 4 ++-- components/stream/view-stream.tsx | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/api/users/[username]/route.ts b/app/api/users/[username]/route.ts index bb234b03..98df5aa8 100644 --- a/app/api/users/[username]/route.ts +++ b/app/api/users/[username]/route.ts @@ -45,6 +45,7 @@ export async function GET( // Filter sensitive fields from stream_access_config if (publicUser.stream_access_config?.password_hash) { + // eslint-disable-next-line @typescript-eslint/no-unused-vars const { password_hash, ...safeConfig } = publicUser.stream_access_config; publicUser.stream_access_config = safeConfig; } diff --git a/components/dashboard/stream-manager/StreamAccessSettings.tsx b/components/dashboard/stream-manager/StreamAccessSettings.tsx index b1143af9..e684dcbc 100644 --- a/components/dashboard/stream-manager/StreamAccessSettings.tsx +++ b/components/dashboard/stream-manager/StreamAccessSettings.tsx @@ -25,8 +25,8 @@ export default function StreamAccessSettings({ // Sync with initial props when they load useEffect(() => { - if (initialAccessType) setAccessType(initialAccessType); - if (initialAccessConfig) setConfig(initialAccessConfig); + if (initialAccessType) { setAccessType(initialAccessType); } + if (initialAccessConfig) { setConfig(initialAccessConfig); } }, [initialAccessType, initialAccessConfig]); const handleSave = () => { diff --git a/components/stream/view-stream.tsx b/components/stream/view-stream.tsx index b9524fcf..c5987eba 100644 --- a/components/stream/view-stream.tsx +++ b/components/stream/view-stream.tsx @@ -276,6 +276,7 @@ const ViewStream = ({ const [accessBlocked, setAccessBlocked] = useState(false); const [accessReason, setAccessReason] = useState(null); const [accessConfig, setAccessConfig] = useState(null); + // eslint-disable-next-line @typescript-eslint/no-unused-vars const [isCheckingAccess, setIsCheckingAccess] = useState(true); // Use custom hooks for Stellar wallet and tip modal state @@ -370,7 +371,7 @@ const ViewStream = ({ // Check stream access foundation [access-control 1/5] useEffect(() => { const checkAccess = async () => { - if (!username) return; + if (!username) { return; } try { setIsCheckingAccess(true); From 50b7910cefc07913b2635844c4494182837f0116 Mon Sep 17 00:00:00 2001 From: KevinMB0220 Date: Fri, 27 Mar 2026 02:47:12 -0600 Subject: [PATCH 14/16] fix(security): single-pass html entity decode; fix remaining lint errors - Replace chained .replace() calls in decodeHTMLEntities with a single-pass regex to prevent double-unescaping (CodeQL high alert) - Add eslint-disable-next-line on stub functions in lib/stream/access.ts where _-prefixed params are intentionally unused pending future issues - Run prettier on files that had format drift Co-Authored-By: Claude Sonnet 4.6 --- app/api/routes-f/import/route.ts | 21 ++++--- app/api/streams/access/check/route.ts | 14 ++--- app/dashboard/stream-manager/page.tsx | 5 +- components/stream/AccessGate.tsx | 80 ++++++++++++++++---------- lib/stream/access.ts | 81 ++++++++++++++++++--------- 5 files changed, 128 insertions(+), 73 deletions(-) diff --git a/app/api/routes-f/import/route.ts b/app/api/routes-f/import/route.ts index 621145fd..1beab2d6 100644 --- a/app/api/routes-f/import/route.ts +++ b/app/api/routes-f/import/route.ts @@ -114,14 +114,21 @@ async function importFromTwitch( }; } -/** Decodes the handful of HTML entities that appear in meta-tag content. */ +/** Decodes HTML entities that appear in meta-tag content. + * Single-pass replacement prevents double-unescaping (e.g. &lt; → < → <). */ +const HTML_ENTITIES: Record = { + "&": "&", + "<": "<", + ">": ">", + """: '"', + "'": "'", +}; + function decodeHTMLEntities(text: string): string { - return text - .replace(/&/g, "&") - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'"); + return text.replace( + /&|<|>|"|'/g, + m => HTML_ENTITIES[m] ?? m + ); } /** diff --git a/app/api/streams/access/check/route.ts b/app/api/streams/access/check/route.ts index 27459fa0..87043a1a 100644 --- a/app/api/streams/access/check/route.ts +++ b/app/api/streams/access/check/route.ts @@ -4,13 +4,13 @@ import { checkStreamAccess } from "@/lib/stream/access"; /** * Endpoint to check if a viewer has access to a stream. * Called by the client before rendering the StreamPlayer. - * - * Request: + * + * Request: * { "streamer_username": "alice", "viewer_public_key": "GABC..." } - * + * * Response (allowed): * { "allowed": true } - * + * * Response (blocked): * { "allowed": false, "reason": "paid", "price_usdc": "10.00" } */ @@ -36,9 +36,9 @@ export async function POST(req: Request) { } // Build response for blocked access - const responseBody: any = { - allowed: false, - reason: accessResult.reason + const responseBody: any = { + allowed: false, + reason: accessResult.reason, }; // Include config fields if available (e.g. price for paid streams) diff --git a/app/dashboard/stream-manager/page.tsx b/app/dashboard/stream-manager/page.tsx index 54a6f186..7deef372 100644 --- a/app/dashboard/stream-manager/page.tsx +++ b/app/dashboard/stream-manager/page.tsx @@ -142,7 +142,7 @@ export default function StreamManagerPage() { }); if (response.ok) { const result = await response.json(); - setStreamData((prev) => ({ + setStreamData(prev => ({ ...prev, title: result.streamData.title || "", category: result.streamData.category || "", @@ -171,7 +171,8 @@ export default function StreamManagerPage() { showToast("Wallet not connected"); return; } - const userEmail = sessionStorage.getItem("userEmail") || privyWallet?.email || ""; + const userEmail = + sessionStorage.getItem("userEmail") || privyWallet?.email || ""; if (!userEmail) { showToast("Session expired, please refresh"); return; diff --git a/components/stream/AccessGate.tsx b/components/stream/AccessGate.tsx index 7ce56e78..b453bb4c 100644 --- a/components/stream/AccessGate.tsx +++ b/components/stream/AccessGate.tsx @@ -1,9 +1,9 @@ -import React from 'react'; -import { Lock, Coins, ShieldCheck, Mail, Users } from 'lucide-react'; +import React from "react"; +import { Lock, Coins, ShieldCheck, Mail, Users } from "lucide-react"; import { Button } from "@/components/ui/button"; interface AccessGateProps { - reason: 'password' | 'invite_only' | 'paid' | 'token_gated' | 'subscription'; + reason: "password" | "invite_only" | "paid" | "token_gated" | "subscription"; streamerUsername: string; accessConfig?: any; } @@ -11,39 +11,53 @@ interface AccessGateProps { /** * AccessGate component displays the appropriate UI when a viewer is blocked * from watching a stream due to access control settings. - * + * * Each case (password, paid, etc.) will be fully implemented in subsequent issues. */ -export default function AccessGate({ reason, streamerUsername, accessConfig }: AccessGateProps) { +export default function AccessGate({ + reason, + streamerUsername, + accessConfig, +}: AccessGateProps) { return (
- {reason === 'password' && } - {reason === 'paid' && } - {reason === 'token_gated' && } - {reason === 'invite_only' && } - {reason === 'subscription' && } + {reason === "password" && } + {reason === "paid" && } + {reason === "token_gated" && ( + + )} + {reason === "invite_only" && ( + + )} + {reason === "subscription" && ( + + )}

- {reason === 'password' && 'Password Protected'} - {reason === 'paid' && 'Premium Stream'} - {reason === 'token_gated' && 'Token Gated'} - {reason === 'invite_only' && 'Invite Only'} - {reason === 'subscription' && 'Subscribers Only'} + {reason === "password" && "Password Protected"} + {reason === "paid" && "Premium Stream"} + {reason === "token_gated" && "Token Gated"} + {reason === "invite_only" && "Invite Only"} + {reason === "subscription" && "Subscribers Only"}

- This stream by @{streamerUsername} requires specific access permissions. + This stream by{" "} + + @{streamerUsername} + {" "} + requires specific access permissions.

- {reason === 'password' && ( + {reason === "password" && (
- @@ -54,12 +68,15 @@ export default function AccessGate({ reason, streamerUsername, accessConfig }: A
)} - {reason === 'paid' && ( + {reason === "paid" && (
- Access Fee + + Access Fee +

- {accessConfig?.price_usdc || '10.00'} USDC + {accessConfig?.price_usdc || "10.00"}{" "} + USDC

)} - {reason === 'token_gated' && ( + {reason === "token_gated" && (
-

Required Asset

+

+ Required Asset +

- {accessConfig?.min_balance || '1.00'} {accessConfig?.asset_code || 'TOKEN'} + {accessConfig?.min_balance || "1.00"}{" "} + {accessConfig?.asset_code || "TOKEN"}

- Issuer: {accessConfig?.asset_issuer || 'GA...'} + Issuer: {accessConfig?.asset_issuer || "GA..."}

)} - {reason === 'invite_only' && ( + {reason === "invite_only" && (

This stream is currently restricted to invited viewers only. @@ -93,9 +113,11 @@ export default function AccessGate({ reason, streamerUsername, accessConfig }: A

)} - {reason === 'subscription' && ( + {reason === "subscription" && (
-

Join the inner circle to view this stream and exclusive content.

+

+ Join the inner circle to view this stream and exclusive content. +

diff --git a/lib/stream/access.ts b/lib/stream/access.ts index cc1a374d..0ad7d21c 100644 --- a/lib/stream/access.ts +++ b/lib/stream/access.ts @@ -2,9 +2,14 @@ import { sql } from "@vercel/postgres"; export type AccessResult = | { allowed: true } - | { - allowed: false; - reason: 'password' | 'invite_only' | 'paid' | 'token_gated' | 'subscription'; + | { + allowed: false; + reason: + | "password" + | "invite_only" + | "paid" + | "token_gated" + | "subscription"; config?: any; }; @@ -15,7 +20,7 @@ export type AccessResult = */ export async function checkStreamAccess( streamerUsername: string, - viewerPublicKey: string | null, + viewerPublicKey: string | null ): Promise { try { const result = await sql` @@ -33,22 +38,22 @@ export async function checkStreamAccess( const accessType = row.stream_access_type; const config = row.stream_access_config || {}; - if (accessType === 'public') { + if (accessType === "public") { return { allowed: true }; } // Delegate to the relevant checker // Subsequent issues will implement the full logic for each type switch (accessType) { - case 'password': + case "password": return await checkPasswordAccess(config); - case 'invite_only': + case "invite_only": return await checkInviteOnlyAccess(streamerUsername, viewerPublicKey); - case 'paid': + case "paid": return await checkPaidAccess(streamerUsername, viewerPublicKey, config); - case 'token_gated': + case "token_gated": return await checkTokenGatedAccess(viewerPublicKey, config); - case 'subscription': + case "subscription": return await checkSubscriptionAccess(streamerUsername, viewerPublicKey); default: return { allowed: true }; @@ -62,40 +67,60 @@ export async function checkStreamAccess( } // Checker Skeletons - Full implementation added by subsequent issues (2-5) - +// eslint-disable-next-line @typescript-eslint/no-unused-vars async function checkPasswordAccess(_config: any): Promise { // Requires password entry on the frontend - return { allowed: false, reason: 'password' }; + return { allowed: false, reason: "password" }; } -async function checkInviteOnlyAccess(_streamerUsername: string, _viewerPublicKey: string | null): Promise { +async function checkInviteOnlyAccess( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _streamerUsername: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _viewerPublicKey: string | null +): Promise { // Requires explicit grant in stream_access_grants - return { allowed: false, reason: 'invite_only' }; + return { allowed: false, reason: "invite_only" }; } -async function checkPaidAccess(_streamerUsername: string, _viewerPublicKey: string | null, config: any): Promise { +async function checkPaidAccess( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _streamerUsername: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _viewerPublicKey: string | null, + config: any +): Promise { // Requires payment proof (tx_hash) in stream_access_grants - return { - allowed: false, - reason: 'paid', - config: { price_usdc: config.price_usdc } + return { + allowed: false, + reason: "paid", + config: { price_usdc: config.price_usdc }, }; } -async function checkTokenGatedAccess(_viewerPublicKey: string | null, config: any): Promise { +async function checkTokenGatedAccess( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _viewerPublicKey: string | null, + config: any +): Promise { // Requires holding specific Stellar asset - return { - allowed: false, - reason: 'token_gated', - config: { + return { + allowed: false, + reason: "token_gated", + config: { asset_code: config.asset_code, asset_issuer: config.asset_issuer, - min_balance: config.min_balance - } + min_balance: config.min_balance, + }, }; } -async function checkSubscriptionAccess(_streamerUsername: string, _viewerPublicKey: string | null): Promise { +async function checkSubscriptionAccess( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _streamerUsername: string, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _viewerPublicKey: string | null +): Promise { // Requires active subscription - return { allowed: false, reason: 'subscription' }; + return { allowed: false, reason: "subscription" }; } From 54badeb8432cb688a091839a88e3b372e75ca4cd Mon Sep 17 00:00:00 2001 From: KevinMB0220 Date: Fri, 27 Mar 2026 02:52:13 -0600 Subject: [PATCH 15/16] style: format streamaccesssettings and view-stream with prettier Co-Authored-By: Claude Sonnet 4.6 --- .../stream-manager/StreamAccessSettings.tsx | 77 ++++++++++++------- components/stream/view-stream.tsx | 22 +++--- 2 files changed, 63 insertions(+), 36 deletions(-) diff --git a/components/dashboard/stream-manager/StreamAccessSettings.tsx b/components/dashboard/stream-manager/StreamAccessSettings.tsx index e684dcbc..e4e1dff6 100644 --- a/components/dashboard/stream-manager/StreamAccessSettings.tsx +++ b/components/dashboard/stream-manager/StreamAccessSettings.tsx @@ -1,5 +1,5 @@ -import React, { useState, useEffect } from 'react'; -import { Shield } from 'lucide-react'; +import React, { useState, useEffect } from "react"; +import { Shield } from "lucide-react"; import { Button } from "@/components/ui/button"; interface StreamAccessSettingsProps { @@ -18,15 +18,19 @@ export default function StreamAccessSettings({ initialAccessType, initialAccessConfig, onSave, - isSaving + isSaving, }: StreamAccessSettingsProps) { - const [accessType, setAccessType] = useState(initialAccessType || 'public'); + const [accessType, setAccessType] = useState(initialAccessType || "public"); const [config, setConfig] = useState(initialAccessConfig || {}); // Sync with initial props when they load useEffect(() => { - if (initialAccessType) { setAccessType(initialAccessType); } - if (initialAccessConfig) { setConfig(initialAccessConfig); } + if (initialAccessType) { + setAccessType(initialAccessType); + } + if (initialAccessConfig) { + setConfig(initialAccessConfig); + } }, [initialAccessType, initialAccessConfig]); const handleSave = () => { @@ -38,7 +42,9 @@ export default function StreamAccessSettings({
- Stream Access + + Stream Access +
@@ -49,7 +55,7 @@ export default function StreamAccessSettings({ setConfig({ ...config, asset_code: e.target.value })} + value={config.asset_code || ""} + onChange={e => + setConfig({ ...config, asset_code: e.target.value }) + } placeholder="USDC" className="w-full bg-secondary/50 border border-border rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:ring-2 focus:ring-highlight/20 focus:border-highlight transition-all" />
- + setConfig({ ...config, min_balance: e.target.value })} + value={config.min_balance || ""} + onChange={e => + setConfig({ ...config, min_balance: e.target.value }) + } placeholder="1.00" className="w-full bg-secondary/50 border border-border rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:ring-2 focus:ring-highlight/20 focus:border-highlight transition-all" />
- + setConfig({ ...config, asset_issuer: e.target.value })} + value={config.asset_issuer || ""} + onChange={e => + setConfig({ ...config, asset_issuer: e.target.value }) + } placeholder="G..." className="w-full bg-secondary/50 border border-border rounded-xl px-3 py-2.5 text-[10px] font-mono focus:outline-none focus:ring-2 focus:ring-highlight/20 focus:border-highlight transition-all" /> @@ -120,7 +142,7 @@ export default function StreamAccessSettings({
)} - {accessType === 'password' && ( + {accessType === "password" && (

@@ -129,10 +151,13 @@ export default function StreamAccessSettings({

)} - {(accessType === 'invite_only' || accessType === 'subscription') && ( + {(accessType === "invite_only" || accessType === "subscription") && (

- {accessType === 'invite_only' ? 'User whitelisting' : 'Subscription check'} module coming soon. + {accessType === "invite_only" + ? "User whitelisting" + : "Subscription check"}{" "} + module coming soon.

)} @@ -149,7 +174,7 @@ export default function StreamAccessSettings({ Updating... ) : ( - 'Save Access Policy' + "Save Access Policy" )}
diff --git a/components/stream/view-stream.tsx b/components/stream/view-stream.tsx index c5987eba..2b012162 100644 --- a/components/stream/view-stream.tsx +++ b/components/stream/view-stream.tsx @@ -371,17 +371,19 @@ const ViewStream = ({ // Check stream access foundation [access-control 1/5] useEffect(() => { const checkAccess = async () => { - if (!username) { return; } - + if (!username) { + return; + } + try { setIsCheckingAccess(true); - const response = await fetch('/api/streams/access/check', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, + const response = await fetch("/api/streams/access/check", { + method: "POST", + headers: { "Content-Type": "application/json" }, body: JSON.stringify({ streamer_username: username, - viewer_public_key: address - }) + viewer_public_key: address, + }), }); if (response.ok) { @@ -582,9 +584,9 @@ const ViewStream = ({ > {accessBlocked ? (
-
From 7e763e2e11d017462bb68896b23e17fa803ba43d Mon Sep 17 00:00:00 2001 From: Justice Date: Fri, 27 Mar 2026 16:58:55 +0100 Subject: [PATCH 16/16] feat: add creator verification routes --- .../verification/__tests__/route.test.ts | 301 ++++++++++++++++++ app/api/routes-f/verification/_shared.ts | 232 ++++++++++++++ app/api/routes-f/verification/admin/route.ts | 82 +++++ app/api/routes-f/verification/route.ts | 163 ++++++++++ 4 files changed, 778 insertions(+) create mode 100644 app/api/routes-f/verification/__tests__/route.test.ts create mode 100644 app/api/routes-f/verification/_shared.ts create mode 100644 app/api/routes-f/verification/admin/route.ts create mode 100644 app/api/routes-f/verification/route.ts diff --git a/app/api/routes-f/verification/__tests__/route.test.ts b/app/api/routes-f/verification/__tests__/route.test.ts new file mode 100644 index 00000000..a94a2e06 --- /dev/null +++ b/app/api/routes-f/verification/__tests__/route.test.ts @@ -0,0 +1,301 @@ +jest.mock("next/server", () => ({ + NextResponse: { + json: (body: unknown, init?: ResponseInit) => + new Response(JSON.stringify(body), { + ...init, + headers: { "Content-Type": "application/json" }, + }), + }, +})); + +jest.mock("@vercel/postgres", () => ({ sql: jest.fn() })); + +jest.mock("@/lib/auth/verify-session", () => ({ + verifySession: jest.fn(), +})); + +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { GET as getVerification, POST } from "../route"; +import { GET as getAdminVerification } from "../admin/route"; + +const sqlMock = sql as unknown as jest.Mock; +const verifySessionMock = verifySession as jest.Mock; + +const authedSession = { + ok: true as const, + userId: "user-123", + wallet: "G123", + privyId: "did:privy:abc", + username: "creator", + email: "creator@example.com", +}; + +function makeRequest(path: string, method: string, body?: object) { + return new Request(`http://localhost${path}`, { + method, + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }) as unknown as import("next/server").NextRequest; +} + +function mockEnsureTable() { + sqlMock.mockResolvedValueOnce({ rows: [], rowCount: 0 }); + sqlMock.mockResolvedValueOnce({ rows: [], rowCount: 0 }); +} + +describe("/api/routes-f/verification", () => { + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.VERIFICATION_ADMIN_USER_IDS; + delete process.env.VERIFICATION_ADMIN_EMAILS; + verifySessionMock.mockResolvedValue(authedSession); + }); + + it("returns unverified when no request exists", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); + + const res = await getVerification( + makeRequest("/api/routes-f/verification", "GET") + ); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + status: "unverified", + request: null, + }); + }); + + it("creates a pending request for a new submission", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ rows: [] }); // initial SELECT + sqlMock.mockResolvedValueOnce({ rows: [], rowCount: 1 }); // INSERT + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: "req-1", + user_id: "user-123", + status: "pending", + social_links: [ + { + socialTitle: "Twitter", + socialLink: "https://twitter.com/creator", + }, + ], + reason: "Large audience and active community.", + id_document_url: "https://example.com/id.png", + rejection_reason: null, + reviewed_by: null, + reviewed_at: null, + created_at: "2026-03-27T10:00:00.000Z", + updated_at: "2026-03-27T10:00:00.000Z", + }, + ], + }); + + const res = await POST( + makeRequest("/api/routes-f/verification", "POST", { + social_links: [ + { title: "Twitter", url: "https://twitter.com/creator" }, + ], + reason: "Large audience and active community.", + id_document_url: "https://example.com/id.png", + }) + ); + + expect(res.status).toBe(201); + await expect(res.json()).resolves.toEqual({ + status: "pending", + request: { + social_links: [ + { + socialTitle: "Twitter", + socialLink: "https://twitter.com/creator", + }, + ], + reason: "Large audience and active community.", + id_document_url: "https://example.com/id.png", + rejection_reason: null, + reviewed_at: null, + created_at: "2026-03-27T10:00:00.000Z", + updated_at: "2026-03-27T10:00:00.000Z", + }, + }); + }); + + it("rejects resubmission while a request is pending", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: "req-1", + user_id: "user-123", + status: "pending", + social_links: [], + reason: "Already pending", + id_document_url: null, + rejection_reason: null, + reviewed_by: null, + reviewed_at: null, + created_at: "2026-03-27T10:00:00.000Z", + updated_at: "2026-03-27T10:00:00.000Z", + }, + ], + }); + + const res = await POST( + makeRequest("/api/routes-f/verification", "POST", { + social_links: [ + { socialTitle: "Twitter", socialLink: "https://twitter.com/creator" }, + ], + reason: "Retrying while pending", + }) + ); + + expect(res.status).toBe(409); + await expect(res.json()).resolves.toEqual({ + error: "Cannot resubmit while verification is pending", + }); + }); + + it("allows rejected creators to resubmit", async () => { + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: "req-1", + user_id: "user-123", + status: "rejected", + social_links: [], + reason: "Old request", + id_document_url: null, + rejection_reason: "Need stronger proof", + reviewed_by: "admin-1", + reviewed_at: "2026-03-26T10:00:00.000Z", + created_at: "2026-03-25T10:00:00.000Z", + updated_at: "2026-03-26T10:00:00.000Z", + }, + ], + }); + sqlMock.mockResolvedValueOnce({ rows: [], rowCount: 1 }); // UPDATE + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: "req-1", + user_id: "user-123", + status: "pending", + social_links: [ + { + socialTitle: "YouTube", + socialLink: "https://youtube.com/@creator", + }, + ], + reason: "Updated information", + id_document_url: null, + rejection_reason: null, + reviewed_by: null, + reviewed_at: null, + created_at: "2026-03-25T10:00:00.000Z", + updated_at: "2026-03-27T10:00:00.000Z", + }, + ], + }); + + const res = await POST( + makeRequest("/api/routes-f/verification", "POST", { + social_links: [ + { platform: "YouTube", url: "https://youtube.com/@creator" }, + ], + reason: "Updated information", + }) + ); + + expect(res.status).toBe(201); + const updateCall = sqlMock.mock.calls[3]; + expect(updateCall[1]).toContain("YouTube"); + }); + + it("requires admin session for the admin endpoint", async () => { + process.env.VERIFICATION_ADMIN_EMAILS = "admin@example.com"; + verifySessionMock.mockResolvedValue({ + ...authedSession, + email: "creator@example.com", + }); + + const res = await getAdminVerification( + makeRequest("/api/routes-f/verification/admin", "GET") + ); + + expect(res.status).toBe(403); + await expect(res.json()).resolves.toEqual({ error: "Forbidden" }); + }); + + it("lists pending verification requests for admins", async () => { + process.env.VERIFICATION_ADMIN_EMAILS = "admin@example.com"; + verifySessionMock.mockResolvedValue({ + ...authedSession, + email: "admin@example.com", + }); + + mockEnsureTable(); + sqlMock.mockResolvedValueOnce({ + rows: [ + { + id: "req-9", + user_id: "user-999", + status: "pending", + social_links: [ + { + socialTitle: "Twitter", + socialLink: "https://twitter.com/pending", + }, + ], + reason: "Please verify my creator profile.", + id_document_url: "https://example.com/id.pdf", + rejection_reason: null, + reviewed_by: null, + reviewed_at: null, + created_at: "2026-03-27T09:00:00.000Z", + updated_at: "2026-03-27T09:00:00.000Z", + username: "pending-creator", + email: "pending@example.com", + wallet: "G999", + avatar: "/Images/user.png", + }, + ], + }); + + const res = await getAdminVerification( + makeRequest("/api/routes-f/verification/admin", "GET") + ); + + expect(res.status).toBe(200); + await expect(res.json()).resolves.toEqual({ + requests: [ + { + id: "req-9", + user_id: "user-999", + username: "pending-creator", + email: "pending@example.com", + wallet: "G999", + avatar: "/Images/user.png", + status: "pending", + social_links: [ + { + socialTitle: "Twitter", + socialLink: "https://twitter.com/pending", + }, + ], + reason: "Please verify my creator profile.", + id_document_url: "https://example.com/id.pdf", + rejection_reason: null, + reviewed_by: null, + reviewed_at: null, + created_at: "2026-03-27T09:00:00.000Z", + updated_at: "2026-03-27T09:00:00.000Z", + }, + ], + }); + }); +}); diff --git a/app/api/routes-f/verification/_shared.ts b/app/api/routes-f/verification/_shared.ts new file mode 100644 index 00000000..803868aa --- /dev/null +++ b/app/api/routes-f/verification/_shared.ts @@ -0,0 +1,232 @@ +import { NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import type { VerifiedSession } from "@/lib/auth/verify-session"; + +export const VERIFICATION_STATUSES = [ + "unverified", + "pending", + "verified", + "rejected", +] as const; + +export type VerificationStatus = (typeof VERIFICATION_STATUSES)[number]; + +export interface VerificationSocialLink { + socialTitle: string; + socialLink: string; +} + +export interface VerificationRequestRecord { + id: string; + user_id: string; + status: Exclude; + social_links: VerificationSocialLink[]; + reason: string; + id_document_url: string | null; + rejection_reason: string | null; + reviewed_by: string | null; + reviewed_at: string | null; + created_at: string; + updated_at: string; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parseAllowedSet(value: string | undefined): Set { + return new Set( + (value ?? "") + .split(",") + .map(entry => entry.trim().toLowerCase()) + .filter(Boolean) + ); +} + +export async function ensureVerificationRequestsTable(): Promise { + await sql` + CREATE TABLE IF NOT EXISTS creator_verification_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL CHECK (status IN ('pending', 'verified', 'rejected')), + social_links JSONB NOT NULL DEFAULT '[]'::jsonb, + reason TEXT NOT NULL, + id_document_url TEXT, + rejection_reason TEXT, + reviewed_by UUID REFERENCES users(id) ON DELETE SET NULL, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(user_id) + ) + `; + + await sql` + CREATE INDEX IF NOT EXISTS idx_creator_verification_requests_status + ON creator_verification_requests(status, created_at) + `; +} + +export function normalizeSocialLinks( + value: unknown +): VerificationSocialLink[] | null { + if (!Array.isArray(value) || value.length === 0) { + return null; + } + + const normalized: VerificationSocialLink[] = []; + for (const item of value) { + if (!isRecord(item)) { + return null; + } + + const title = + typeof item.socialTitle === "string" + ? item.socialTitle + : typeof item.title === "string" + ? item.title + : typeof item.platform === "string" + ? item.platform + : null; + + const link = + typeof item.socialLink === "string" + ? item.socialLink + : typeof item.url === "string" + ? item.url + : null; + + if (!title || !link) { + return null; + } + + try { + const parsedUrl = new URL(link); + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + return null; + } + } catch { + return null; + } + + normalized.push({ + socialTitle: title.trim(), + socialLink: link.trim(), + }); + } + + return normalized.every( + link => link.socialTitle.length > 0 && link.socialLink.length > 0 + ) + ? normalized + : null; +} + +export function normalizeOptionalUrl(value: unknown): string | null | undefined { + if (value === undefined) { + return undefined; + } + if (value === null || value === "") { + return null; + } + if (typeof value !== "string") { + return undefined; + } + + try { + const parsedUrl = new URL(value); + if (!["http:", "https:"].includes(parsedUrl.protocol)) { + return undefined; + } + return value.trim(); + } catch { + return undefined; + } +} + +export function parseExistingRequest( + row: Record | undefined +): VerificationRequestRecord | null { + if (!row) { + return null; + } + + const status = + typeof row.status === "string" && + ["pending", "verified", "rejected"].includes(row.status) + ? (row.status as VerificationRequestRecord["status"]) + : "pending"; + + const socialLinks = + Array.isArray(row.social_links) && row.social_links.length > 0 + ? normalizeSocialLinks(row.social_links) ?? [] + : []; + + return { + id: String(row.id), + user_id: String(row.user_id), + status, + social_links: socialLinks, + reason: typeof row.reason === "string" ? row.reason : "", + id_document_url: + typeof row.id_document_url === "string" ? row.id_document_url : null, + rejection_reason: + typeof row.rejection_reason === "string" ? row.rejection_reason : null, + reviewed_by: typeof row.reviewed_by === "string" ? row.reviewed_by : null, + reviewed_at: + row.reviewed_at instanceof Date + ? row.reviewed_at.toISOString() + : typeof row.reviewed_at === "string" + ? row.reviewed_at + : null, + created_at: + row.created_at instanceof Date + ? row.created_at.toISOString() + : String(row.created_at), + updated_at: + row.updated_at instanceof Date + ? row.updated_at.toISOString() + : String(row.updated_at), + }; +} + +export function verificationStatusResponse( + record: VerificationRequestRecord | null +) { + if (!record) { + return { + status: "unverified" as const, + request: null, + }; + } + + return { + status: record.status, + request: { + social_links: record.social_links, + reason: record.reason, + id_document_url: record.id_document_url, + rejection_reason: record.rejection_reason, + reviewed_at: record.reviewed_at, + created_at: record.created_at, + updated_at: record.updated_at, + }, + }; +} + +export function requireAdminSession( + session: Extract +): NextResponse | null { + const allowedUserIds = parseAllowedSet(process.env.VERIFICATION_ADMIN_USER_IDS); + const allowedEmails = parseAllowedSet(process.env.VERIFICATION_ADMIN_EMAILS); + + const isAllowed = + allowedUserIds.has(session.userId.toLowerCase()) || + (!!session.email && allowedEmails.has(session.email.toLowerCase())); + + if (!isAllowed) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + return null; +} diff --git a/app/api/routes-f/verification/admin/route.ts b/app/api/routes-f/verification/admin/route.ts new file mode 100644 index 00000000..e4f0251e --- /dev/null +++ b/app/api/routes-f/verification/admin/route.ts @@ -0,0 +1,82 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { ensureVerificationRequestsTable, requireAdminSession } from "../_shared"; + +export async function GET(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + const forbiddenResponse = requireAdminSession(session); + if (forbiddenResponse) { + return forbiddenResponse; + } + + try { + await ensureVerificationRequestsTable(); + + const { rows } = await sql` + SELECT + cvr.id, + cvr.user_id, + cvr.status, + cvr.social_links, + cvr.reason, + cvr.id_document_url, + cvr.rejection_reason, + cvr.reviewed_by, + cvr.reviewed_at, + cvr.created_at, + cvr.updated_at, + u.username, + u.email, + u.wallet, + u.avatar + FROM creator_verification_requests cvr + JOIN users u ON u.id = cvr.user_id + WHERE cvr.status = 'pending' + ORDER BY cvr.created_at ASC + `; + + const requests = rows.map(row => ({ + id: String(row.id), + user_id: String(row.user_id), + username: typeof row.username === "string" ? row.username : null, + email: typeof row.email === "string" ? row.email : null, + wallet: typeof row.wallet === "string" ? row.wallet : null, + avatar: typeof row.avatar === "string" ? row.avatar : null, + status: "pending" as const, + social_links: Array.isArray(row.social_links) ? row.social_links : [], + reason: typeof row.reason === "string" ? row.reason : "", + id_document_url: + typeof row.id_document_url === "string" ? row.id_document_url : null, + rejection_reason: + typeof row.rejection_reason === "string" ? row.rejection_reason : null, + reviewed_by: typeof row.reviewed_by === "string" ? row.reviewed_by : null, + reviewed_at: + row.reviewed_at instanceof Date + ? row.reviewed_at.toISOString() + : typeof row.reviewed_at === "string" + ? row.reviewed_at + : null, + created_at: + row.created_at instanceof Date + ? row.created_at.toISOString() + : String(row.created_at), + updated_at: + row.updated_at instanceof Date + ? row.updated_at.toISOString() + : String(row.updated_at), + })); + + return NextResponse.json({ requests }); + } catch (error) { + console.error("GET verification admin list error:", error); + return NextResponse.json( + { error: "Failed to fetch verification requests" }, + { status: 500 } + ); + } +} diff --git a/app/api/routes-f/verification/route.ts b/app/api/routes-f/verification/route.ts new file mode 100644 index 00000000..6069ceba --- /dev/null +++ b/app/api/routes-f/verification/route.ts @@ -0,0 +1,163 @@ +import { NextRequest, NextResponse } from "next/server"; +import { sql } from "@vercel/postgres"; +import { verifySession } from "@/lib/auth/verify-session"; +import { + ensureVerificationRequestsTable, + normalizeOptionalUrl, + normalizeSocialLinks, + parseExistingRequest, + verificationStatusResponse, +} from "./_shared"; + +async function getCurrentRequest(userId: string) { + const { rows } = await sql` + SELECT + id, + user_id, + status, + social_links, + reason, + id_document_url, + rejection_reason, + reviewed_by, + reviewed_at, + created_at, + updated_at + FROM creator_verification_requests + WHERE user_id = ${userId} + LIMIT 1 + `; + + return parseExistingRequest(rows[0]); +} + +export async function GET(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + try { + await ensureVerificationRequestsTable(); + const record = await getCurrentRequest(session.userId); + return NextResponse.json(verificationStatusResponse(record)); + } catch (error) { + console.error("GET verification status error:", error); + return NextResponse.json( + { error: "Failed to fetch verification status" }, + { status: 500 } + ); + } +} + +export async function POST(req: NextRequest) { + const session = await verifySession(req); + if (!session.ok) { + return session.response; + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } + + const payload = + typeof body === "object" && body !== null && !Array.isArray(body) + ? (body as Record) + : null; + + if (!payload) { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); + } + + const socialLinks = normalizeSocialLinks(payload.social_links); + if (!socialLinks) { + return NextResponse.json( + { + error: + "social_links must be a non-empty array of link objects with title/url values", + }, + { status: 400 } + ); + } + + const reason = + typeof payload.reason === "string" ? payload.reason.trim() : undefined; + if (!reason) { + return NextResponse.json( + { error: "reason is required" }, + { status: 400 } + ); + } + + const idDocumentUrl = normalizeOptionalUrl(payload.id_document_url); + if (payload.id_document_url !== undefined && idDocumentUrl === undefined) { + return NextResponse.json( + { error: "id_document_url must be a valid http(s) URL" }, + { status: 400 } + ); + } + + try { + await ensureVerificationRequestsTable(); + const existing = await getCurrentRequest(session.userId); + + if (existing?.status === "pending") { + return NextResponse.json( + { error: "Cannot resubmit while verification is pending" }, + { status: 409 } + ); + } + + if (existing?.status === "verified") { + return NextResponse.json( + { error: "Creator is already verified" }, + { status: 409 } + ); + } + + if (existing) { + await sql` + UPDATE creator_verification_requests + SET + status = 'pending', + social_links = ${JSON.stringify(socialLinks)}::jsonb, + reason = ${reason}, + id_document_url = ${idDocumentUrl ?? null}, + rejection_reason = NULL, + reviewed_by = NULL, + reviewed_at = NULL, + updated_at = CURRENT_TIMESTAMP + WHERE user_id = ${session.userId} + `; + } else { + await sql` + INSERT INTO creator_verification_requests ( + user_id, + status, + social_links, + reason, + id_document_url + ) + VALUES ( + ${session.userId}, + 'pending', + ${JSON.stringify(socialLinks)}::jsonb, + ${reason}, + ${idDocumentUrl ?? null} + ) + `; + } + + const record = await getCurrentRequest(session.userId); + return NextResponse.json(verificationStatusResponse(record), { status: 201 }); + } catch (error) { + console.error("POST verification request error:", error); + return NextResponse.json( + { error: "Failed to submit verification request" }, + { status: 500 } + ); + } +}