diff --git a/Dockerfile b/Dockerfile index 2cb1f619..e3cead7b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -21,8 +21,8 @@ ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY ENV VITE_AUTHENTIK_PROVIDER=$VITE_AUTHENTIK_PROVIDER -# Build the client so the Activity gets compiled JS (no Vite dev mode in Discord iframe) -RUN npm run build:client +# Build everything +RUN npm run build # Set environment ENV NODE_ENV=production @@ -31,5 +31,4 @@ ENV PORT=3000 # Expose port EXPOSE 3000 -# Start the server -CMD ["npm", "run", "dev"] +CMD ["npm", "run", "start"] diff --git a/api/stream/session.ts b/api/stream/session.ts new file mode 100644 index 00000000..1e77121c --- /dev/null +++ b/api/stream/session.ts @@ -0,0 +1,200 @@ +import type { Request, Response } from "express"; +import { createClient } from "@supabase/supabase-js"; + +const RUNPOD_API_KEY = process.env.RUNPOD_API_KEY || ""; +const RUNPOD_ENDPOINT_ID = process.env.RUNPOD_ENDPOINT_ID || ""; +const SIGNALING_URL = process.env.SIGNALING_URL || "wss://signal.aethex.tech/ws"; +const TURN_SERVER = process.env.TURN_SERVER || "turn://aethex:changeme-turn-password@turn.aethex.tech:3478"; + +const supabaseAdmin = createClient( + process.env.SUPABASE_URL!, + process.env.SUPABASE_SERVICE_ROLE! +); + +async function getUserFromToken(token: string) { + const { data: { user }, error } = await supabaseAdmin.auth.getUser(token); + if (error || !user) return null; + return user; +} + +async function spawnJob(input: Record) { + if (!RUNPOD_ENDPOINT_ID) throw new Error("RUNPOD_ENDPOINT_ID not configured"); + const res = await fetch(`https://api.runpod.io/v2/${RUNPOD_ENDPOINT_ID}/run`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${RUNPOD_API_KEY}`, + }, + body: JSON.stringify({ input }), + }); + const data = await res.json() as any; + if (!res.ok || data.error) throw new Error(data.error || `RunPod error ${res.status}`); + return data as { id: string; status: string }; +} + +async function cancelJob(jobId: string) { + if (!RUNPOD_ENDPOINT_ID) return; + await fetch(`https://api.runpod.io/v2/${RUNPOD_ENDPOINT_ID}/cancel/${jobId}`, { + method: "POST", + headers: { "Authorization": `Bearer ${RUNPOD_API_KEY}` }, + }); +} + +// POST /api/stream/session/start +export async function startSession(req: Request, res: Response) { + const token = req.headers.authorization?.replace("Bearer ", ""); + if (!token) return res.status(401).json({ error: "Unauthorized" }); + + const user = await getUserFromToken(token); + if (!user) return res.status(401).json({ error: "Invalid token" }); + + const { mode = "game", game } = req.body as { mode?: string; game?: string }; + + const { data: existing } = await supabaseAdmin + .from("stream_sessions") + .select("id, pod_id") + .eq("user_id", user.id) + .in("status", ["active", "starting"]) + .maybeSingle(); + + if (existing) { + return res.json({ sessionId: existing.id, jobId: existing.pod_id, alreadyActive: true }); + } + + if (mode === "devstation") { + const { data: profile } = await supabaseAdmin + .from("profiles") + .select("role") + .eq("id", user.id) + .maybeSingle(); + if (!["developer", "admin", "oversee"].includes(profile?.role ?? "")) { + return res.status(403).json({ error: "Dev station access requires developer role" }); + } + } + + try { + const job = await spawnJob({ + MODE: mode, + SIGNALING_URL, + TURN_SERVER, + AETHEX_USER_ID: user.id, + IDLE_TIMEOUT: "600", + MAX_SESSION: "7200", + ...(game ? { GAME: game } : {}), + }); + + console.log(`[stream] serverless job spawned: ${job.id} status=${job.status}`); + + const { data: session } = await supabaseAdmin + .from("stream_sessions") + .insert({ + user_id: user.id, + pod_id: job.id, + mode, + game: game || null, + status: "starting", + }) + .select("id") + .single(); + + res.json({ sessionId: session!.id, jobId: job.id, status: "starting" }); + } catch (err: any) { + console.error("[stream/start] error:", err.message); + res.status(500).json({ error: err.message }); + } +} + +// POST /api/stream/session/stop +export async function stopSession(req: Request, res: Response) { + const token = req.headers.authorization?.replace("Bearer ", ""); + if (!token) return res.status(401).json({ error: "Unauthorized" }); + + const user = await getUserFromToken(token); + if (!user) return res.status(401).json({ error: "Invalid token" }); + + const { data: session } = await supabaseAdmin + .from("stream_sessions") + .select("id, pod_id") + .eq("user_id", user.id) + .in("status", ["active", "starting"]) + .maybeSingle(); + + if (!session) return res.json({ ok: true, message: "No active session" }); + + await cancelJob(session.pod_id); + + await supabaseAdmin + .from("stream_sessions") + .update({ status: "stopped", ended_at: new Date().toISOString() }) + .eq("id", session.id); + + res.json({ ok: true }); +} + +// POST /api/stream/session/cpu — VP8 job for free users (same endpoint, FORCE_VP8=1) +export async function startCpuSession(req: Request, res: Response) { + const token = req.headers.authorization?.replace("Bearer ", ""); + if (!token) return res.status(401).json({ error: "Unauthorized" }); + const user = await getUserFromToken(token); + if (!user) return res.status(401).json({ error: "Invalid token" }); + + const { data: existing } = await supabaseAdmin + .from("stream_sessions") + .select("id, pod_id") + .eq("user_id", user.id) + .in("status", ["active", "starting"]) + .maybeSingle(); + if (existing) return res.json({ sessionId: existing.id, jobId: existing.pod_id, alreadyActive: true }); + + try { + const job = await spawnJob({ + MODE: "game", + SIGNALING_URL, + TURN_SERVER, + AETHEX_USER_ID: user.id, + FORCE_VP8: "1", + IDLE_TIMEOUT: "600", + MAX_SESSION: "7200", + }); + + console.log(`[stream/cpu] serverless job spawned: ${job.id}`); + + const { data: session } = await supabaseAdmin + .from("stream_sessions") + .insert({ user_id: user.id, pod_id: job.id, mode: "game", status: "starting" }) + .select("id").single(); + + res.json({ sessionId: session!.id, jobId: job.id, status: "starting" }); + } catch (err: any) { + console.error("[stream/cpu] error:", err.message); + res.status(500).json({ error: err.message }); + } +} + +// GET /api/stream/online (public — no auth) +export async function onlineCount(_req: Request, res: Response) { + const { count } = await supabaseAdmin + .from("stream_sessions") + .select("*", { count: "exact", head: true }) + .in("status", ["active", "starting"]); + res.json({ count: count ?? 0 }); +} + +// GET /api/stream/session/status +export async function sessionStatus(req: Request, res: Response) { + const token = req.headers.authorization?.replace("Bearer ", ""); + if (!token) return res.status(401).json({ error: "Unauthorized" }); + + const user = await getUserFromToken(token); + if (!user) return res.status(401).json({ error: "Invalid token" }); + + const { data: session } = await supabaseAdmin + .from("stream_sessions") + .select("id, pod_id, mode, game, status, created_at") + .eq("user_id", user.id) + .order("created_at", { ascending: false }) + .limit(1) + .maybeSingle(); + + res.json({ session: session || null }); +} diff --git a/client/pages/StreamUpgrade.tsx b/client/pages/StreamUpgrade.tsx new file mode 100644 index 00000000..de498800 --- /dev/null +++ b/client/pages/StreamUpgrade.tsx @@ -0,0 +1,204 @@ +import { useState, useEffect } from "react"; +import { useSearchParams, useNavigate } from "react-router-dom"; +import { Loader2, Zap, Users, Check } from "lucide-react"; +import { useAuth } from "@/contexts/AuthContext"; +import { supabase } from "@/lib/supabase"; +import { aethexToast } from "@/lib/aethex-toast"; +import Layout from "@/components/Layout"; + +const PLANS = [ + { + id: "stream_pro", + name: "Stream Pro", + price: "$9.99", + period: "/mo", + icon: Zap, + seats: 1, + resolution: "1080p60", + bitrate: "15 Mbps", + features: ["GPU-accelerated stream", "1080p60 quality", "1 guaranteed seat", "Priority over free viewers", "No ads"], + }, + { + id: "stream_team", + name: "Stream Team", + price: "$24.99", + period: "/mo", + icon: Users, + seats: 4, + resolution: "1080p60", + bitrate: "15 Mbps", + features: ["GPU-accelerated stream", "1080p60 quality", "4 guaranteed seats", "Priority over free viewers", "No ads", "Team management"], + highlighted: true, + }, +] as const; + +function StreamSuccess() { + const [token, setToken] = useState(""); + useEffect(() => { + supabase.auth.getSession().then(({ data: { session } }) => { + if (session?.access_token) setToken(session.access_token); + }); + }, []); + return ( + +
+
+
+
+ SUBSCRIPTION ACTIVE +
+

+ Your GPU stream access is now live. Head back to aethex.live to start playing. +

+ + LAUNCH STREAM → + +
+
+
+ ); +} + +export default function StreamUpgrade() { + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + const { user } = useAuth(); + const [loading, setLoading] = useState(null); + + const defaultPlan = searchParams.get("plan") === "stream_team" ? "stream_team" : "stream_pro"; + const [selectedPlan, setSelectedPlan] = useState(defaultPlan); + + async function handleCheckout() { + if (!user) { + navigate("/login?redirect=/stream/upgrade?plan=" + selectedPlan); + return; + } + + setLoading(selectedPlan); + try { + const { data: { session } } = await supabase.auth.getSession(); + const token = session?.access_token; + if (!token) throw new Error("Not authenticated"); + + const res = await fetch("/api/subscriptions/create-checkout", { + method: "POST", + headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` }, + body: JSON.stringify({ + tier: selectedPlan, + successUrl: `${window.location.origin}/stream/upgrade?success=true&plan=${selectedPlan}`, + cancelUrl: `${window.location.origin}/stream/upgrade?plan=${selectedPlan}`, + }), + }); + + const data = await res.json(); + if (!res.ok) throw new Error(data.error || "Checkout failed"); + + // Pass token back to aethex.live after upgrade + if (data.url) { + window.location.href = data.url; + } + } catch (err: any) { + aethexToast.error(err.message || "Something went wrong"); + } finally { + setLoading(null); + } + } + + const success = searchParams.get("success") === "true"; + + if (success) { + return ; + } + + return ( + +
+
+
+ STREAM ACCESS +
+

+ Upgrade for guaranteed GPU-accelerated seats on aethex.live. Free slots fill up fast. +

+
+ +
+ {PLANS.map((plan) => { + const Icon = plan.icon; + const isSelected = selectedPlan === plan.id; + return ( +
setSelectedPlan(plan.id)} + style={{ + width: 260, + border: `1px solid ${isSelected ? "#00D4FF" : plan.highlighted ? "#C9A84C" : "#161E38"}`, + borderRadius: 3, + padding: "24px 20px", + background: isSelected ? "rgba(0,212,255,0.06)" : "rgba(8,12,24,0.8)", + cursor: "pointer", + transition: "border-color .2s, background .2s", + position: "relative", + }} + > + {plan.highlighted && ( +
+ BEST VALUE +
+ )} +
+ + + {plan.name.toUpperCase()} + +
+
+ {plan.price} + {plan.period} +
+
    + {plan.features.map((f) => ( +
  • + + {f} +
  • + ))} +
+
+ ); + })} +
+ + + +

+ Cancel anytime · Secured by Stripe +

+
+
+ ); +} diff --git a/docker-compose.yml b/docker-compose.yml index ffe8c5ee..2d3a8bd8 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -11,4 +11,4 @@ services: ports: - "5050:5000" env_file: .env - command: npm run dev + command: npm run start diff --git a/server/index.ts b/server/index.ts index 60285b76..ba85771e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -44,6 +44,7 @@ import aiChatHandler from "../api/ai/chat"; import aiTitleHandler from "../api/ai/title"; import createCheckoutHandler from "../api/subscriptions/create-checkout"; import manageSubscriptionHandler from "../api/subscriptions/manage"; +import { startSession, stopSession, sessionStatus, onlineCount, startCpuSession } from "../api/stream/session"; // Developer API Keys handlers import { @@ -335,7 +336,15 @@ export function createServer() { ); // Middleware - app.use(cors()); + app.use(cors({ + origin: [ + "https://aethex.live", + "https://aethex.dev", + "https://aethex.tech", + /^https?:\/\/localhost(:\d+)?$/, + ], + credentials: true, + })); app.use(express.json()); app.use(express.urlencoded({ extended: true })); @@ -8273,6 +8282,14 @@ export function createServer() { app.post("/api/ai/chat", aiChatHandler); app.post("/api/ai/title", aiTitleHandler); + // Stream session API + app.post("/api/stream/session/start", startSession); + app.post("/api/stream/session/stop", stopSession); + app.get("/api/stream/session/status", sessionStatus); + app.get("/api/stream/online", onlineCount); + app.post("/api/stream/session/cpu", startCpuSession); + + // Subscription API routes app.post("/api/subscriptions/create-checkout", (req: express.Request, res: express.Response) => { return createCheckoutHandler(req as any, res as any); diff --git a/supabase/migrations/20260418_add_stream_sessions.sql b/supabase/migrations/20260418_add_stream_sessions.sql new file mode 100644 index 00000000..231f9e34 --- /dev/null +++ b/supabase/migrations/20260418_add_stream_sessions.sql @@ -0,0 +1,19 @@ +create table if not exists stream_sessions ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null references auth.users(id) on delete cascade, + pod_id text not null, + mode text not null default 'game', + game text, + status text not null default 'starting', + created_at timestamptz not null default now(), + ended_at timestamptz +); + +alter table stream_sessions enable row level security; + +create policy "Users see own sessions" + on stream_sessions for select + using (auth.uid() = user_id); + +create index on stream_sessions(user_id, status); +create index on stream_sessions(created_at desc);