From f81affe4482d00501cdac2ca854314423ce3d848 Mon Sep 17 00:00:00 2001 From: Nikita Date: Wed, 24 Dec 2025 17:14:01 -0800 Subject: [PATCH] Improve billing and access control by adding creator subscription check; update route components to enforce user authentication before viewing streams and replays. --- packages/web/src/lib/billing.ts | 26 +++++++++++++- packages/web/src/routes/$username.tsx | 34 ++++++++++++++++++- .../routes/api/stream-replays.$replayId.ts | 6 ++-- .../routes/api/streams.$username.replays.ts | 6 ++-- 4 files changed, 64 insertions(+), 8 deletions(-) diff --git a/packages/web/src/lib/billing.ts b/packages/web/src/lib/billing.ts index 9fae8fa5..b8185b86 100644 --- a/packages/web/src/lib/billing.ts +++ b/packages/web/src/lib/billing.ts @@ -1,7 +1,7 @@ import { getFlowgladServer } from "./flowglad" import { getAuth } from "./auth" import { db } from "@/db/connection" -import { stripe_subscriptions, storage_usage } from "@/db/schema" +import { stripe_subscriptions, storage_usage, creator_subscriptions } from "@/db/schema" import { eq, and, gte, lte } from "drizzle-orm" // Usage limits @@ -528,3 +528,27 @@ export async function hasActiveSubscription(userId: string): Promise { return !!subscription } + +/** + * Check if a user has an active subscription to a specific creator (server-side only) + */ +export async function hasCreatorSubscription( + subscriberId: string, + creatorId: string +): Promise { + const database = db() + + const [subscription] = await database + .select() + .from(creator_subscriptions) + .where( + and( + eq(creator_subscriptions.subscriber_id, subscriberId), + eq(creator_subscriptions.creator_id, creatorId), + eq(creator_subscriptions.status, "active") + ) + ) + .limit(1) + + return !!subscription +} diff --git a/packages/web/src/routes/$username.tsx b/packages/web/src/routes/$username.tsx index 5fb32a90..16fbf9b7 100644 --- a/packages/web/src/routes/$username.tsx +++ b/packages/web/src/routes/$username.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react" -import { createFileRoute } from "@tanstack/react-router" +import { createFileRoute, Link } from "@tanstack/react-router" import { getStreamByUsername, type StreamPageData } from "@/lib/stream/db" import { VideoPlayer } from "@/components/VideoPlayer" import { CloudflareStreamPlayer } from "@/components/CloudflareStreamPlayer" @@ -13,6 +13,7 @@ import { type SpotifyNowPlayingResponse, } from "@/lib/spotify/now-playing" import { getStreamStatus } from "@/lib/stream/status" +import { authClient } from "@/lib/auth-client" export const Route = createFileRoute("/$username")({ ssr: false, @@ -48,6 +49,7 @@ const NIKIV_DATA: StreamPageData = { function StreamPage() { const { username } = Route.useParams() + const { data: session, isPending: sessionLoading } = authClient.useSession() const [data, setData] = useState(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -63,6 +65,8 @@ function StreamPage() { const [showReadyPulse, setShowReadyPulse] = useState(false) const readyPulseTimeoutRef = useRef | null>(null) + const isAuthenticated = !sessionLoading && !!session?.user + useEffect(() => { let isActive = true const setReadySafe = (ready: boolean) => { @@ -312,6 +316,34 @@ function StreamPage() { } }, [shouldFetchSpotify]) + // Auth gate - require login to view streams + if (sessionLoading) { + return ( +
+
Loading...
+
+ ) + } + + if (!isAuthenticated) { + return ( +
+
+

Sign in to watch

+

+ Create an account or sign in to view this stream +

+ + Sign in + +
+
+ ) + } + if (loading) { return (
diff --git a/packages/web/src/routes/api/stream-replays.$replayId.ts b/packages/web/src/routes/api/stream-replays.$replayId.ts index d506fd7c..3972e401 100644 --- a/packages/web/src/routes/api/stream-replays.$replayId.ts +++ b/packages/web/src/routes/api/stream-replays.$replayId.ts @@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router" import { and, eq } from "drizzle-orm" import { db } from "@/db/connection" import { getAuth } from "@/lib/auth" -import { hasActiveSubscription } from "@/lib/billing" +import { hasCreatorSubscription } from "@/lib/billing" import { stream_replays, streams } from "@/db/schema" const json = (data: unknown, status = 200) => @@ -99,7 +99,7 @@ const handleGet = async ({ return json({ replay }) } - // Non-owners need subscription to view replays + // Non-owners need subscription to this creator to view replays if (!session?.user?.id) { return json( { error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" }, @@ -107,7 +107,7 @@ const handleGet = async ({ ) } - const hasSubscription = await hasActiveSubscription(session.user.id) + const hasSubscription = await hasCreatorSubscription(session.user.id, replay.user_id) if (!hasSubscription) { return json( { error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" }, diff --git a/packages/web/src/routes/api/streams.$username.replays.ts b/packages/web/src/routes/api/streams.$username.replays.ts index bb3cc007..bffc2133 100644 --- a/packages/web/src/routes/api/streams.$username.replays.ts +++ b/packages/web/src/routes/api/streams.$username.replays.ts @@ -2,7 +2,7 @@ import { createFileRoute } from "@tanstack/react-router" import { and, desc, eq } from "drizzle-orm" import { db } from "@/db/connection" import { getAuth } from "@/lib/auth" -import { hasActiveSubscription } from "@/lib/billing" +import { hasCreatorSubscription } from "@/lib/billing" import { stream_replays, users } from "@/db/schema" const json = (data: unknown, status = 200) => @@ -56,7 +56,7 @@ const handleGet = async ({ } } - // Non-owners need subscription to view replays + // Non-owners need subscription to this creator to view replays if (!session?.user?.id) { return json( { error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" }, @@ -64,7 +64,7 @@ const handleGet = async ({ ) } - const hasSubscription = await hasActiveSubscription(session.user.id) + const hasSubscription = await hasCreatorSubscription(session.user.id, user.id) if (!hasSubscription) { return json( { error: "Subscription required", code: "SUBSCRIPTION_REQUIRED" },