feat: Add Cloudflare StreamPlayer component and update schema with billing and access control

- Introduced `CloudflareStreamPlayer` React component for embedding streams
- Updated `package.json` with new dependencies: `@cloudflare/stream-react` and `stripe`
- Extended database schema with user tiers, Stripe billing, storage, and archive management
- Added access control logic in `access.ts` for user tiers and feature permissions
- Enhanced billing logic with archive storage limits and subscription checks
This commit is contained in:
Nikita
2025-12-21 14:56:30 -08:00
parent 8cd4b943a5
commit 103a4ba19c
18 changed files with 1608 additions and 36 deletions
+142
View File
@@ -0,0 +1,142 @@
import { getAuth } from "./auth"
import { db } from "@/db/connection"
import { users } from "@/db/schema"
import { eq } from "drizzle-orm"
// User tiers
export type UserTier = "free" | "creator" | "dev"
// Features and which tiers can access them
const FEATURE_ACCESS: Record<string, UserTier[]> = {
// Archive features - creators and devs only
archive_create: ["creator", "dev"],
archive_view_own: ["creator", "dev"],
archive_view_public: ["free", "creator", "dev"], // Anyone can view public archives
// Stream features - dev only for now
stream_create: ["dev"],
stream_view: ["free", "creator", "dev"],
// Canvas - everyone
canvas_create: ["free", "creator", "dev"],
// Sell content - creators only
sell_content: ["creator", "dev"],
}
export type Feature = keyof typeof FEATURE_ACCESS
/**
* Get user's tier from database
*/
export async function getUserTier(request: Request): Promise<UserTier | null> {
const auth = getAuth()
const session = await auth.api.getSession({ headers: request.headers })
if (!session?.user) {
return null
}
const database = db()
try {
const [user] = await database
.select({ tier: users.tier })
.from(users)
.where(eq(users.id, session.user.id))
.limit(1)
return (user?.tier as UserTier) ?? "free"
} catch (error) {
console.error("[access] Error getting user tier:", error)
return "free"
}
}
/**
* Check if user has access to a feature
*/
export async function hasFeatureAccess(
request: Request,
feature: Feature
): Promise<boolean> {
const tier = await getUserTier(request)
if (!tier) {
// Not authenticated - only allow public features
return FEATURE_ACCESS[feature]?.includes("free") ?? false
}
const allowedTiers = FEATURE_ACCESS[feature]
if (!allowedTiers) {
// Unknown feature - deny by default
return false
}
return allowedTiers.includes(tier)
}
/**
* Get all features user has access to
*/
export async function getUserFeatures(request: Request): Promise<Feature[]> {
const tier = await getUserTier(request)
if (!tier) {
return []
}
return Object.entries(FEATURE_ACCESS)
.filter(([_, tiers]) => tiers.includes(tier))
.map(([feature]) => feature as Feature)
}
/**
* Check access and return appropriate error response if denied
*/
export async function requireFeatureAccess(
request: Request,
feature: Feature
): Promise<Response | null> {
const hasAccess = await hasFeatureAccess(request, feature)
if (!hasAccess) {
const tier = await getUserTier(request)
if (!tier) {
return new Response(JSON.stringify({ error: "Unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
})
}
return new Response(
JSON.stringify({
error: "Feature not available",
feature,
currentTier: tier,
requiredTiers: FEATURE_ACCESS[feature],
}),
{
status: 403,
headers: { "content-type": "application/json" },
}
)
}
return null // Access granted
}
/**
* Upgrade user tier (admin function)
*/
export async function setUserTier(
userId: string,
tier: UserTier
): Promise<void> {
const database = db()
await database
.update(users)
.set({ tier, updatedAt: new Date() })
.where(eq(users.id, userId))
}
+274
View File
@@ -1,5 +1,8 @@
import { getFlowgladServer } from "./flowglad"
import { getAuth } from "./auth"
import { db } from "@/db/connection"
import { stripe_subscriptions, storage_usage } from "@/db/schema"
import { eq, and, gte, lte } from "drizzle-orm"
// Usage limits
const GUEST_FREE_REQUESTS = 5
@@ -234,3 +237,274 @@ export async function getBillingSummary(request: Request) {
}
}
}
// =============================================================================
// Archive Storage Billing (Stripe-based)
// =============================================================================
const ARCHIVE_LIMITS = {
free: { archives: 0, storageBytes: 0 },
paid: { archives: 10, storageBytes: 1073741824 }, // 1GB
}
type StorageCheckResult = {
allowed: boolean
archivesRemaining: number
archivesLimit: number
bytesRemaining: number
bytesLimit: number
reason?: "no_subscription" | "archive_limit" | "storage_limit"
isPaid: boolean
}
/**
* Check if user can create a new archive (requires active Stripe subscription)
*/
export async function checkArchiveAllowed(
request: Request,
fileSizeBytes: number = 0
): Promise<StorageCheckResult> {
const auth = getAuth()
const session = await auth.api.getSession({ headers: request.headers })
if (!session?.user) {
return {
allowed: false,
archivesRemaining: 0,
archivesLimit: 0,
bytesRemaining: 0,
bytesLimit: 0,
reason: "no_subscription",
isPaid: false,
}
}
const database = db()
try {
// Check for active Stripe subscription
const [subscription] = await database
.select()
.from(stripe_subscriptions)
.where(
and(
eq(stripe_subscriptions.user_id, session.user.id),
eq(stripe_subscriptions.status, "active")
)
)
.limit(1)
if (!subscription) {
return {
allowed: false,
archivesRemaining: 0,
archivesLimit: 0,
bytesRemaining: 0,
bytesLimit: 0,
reason: "no_subscription",
isPaid: false,
}
}
// Get usage for current billing period
const now = new Date()
const [usage] = await database
.select()
.from(storage_usage)
.where(
and(
eq(storage_usage.user_id, session.user.id),
lte(storage_usage.period_start, now),
gte(storage_usage.period_end, now)
)
)
.limit(1)
const archivesUsed = usage?.archives_used ?? 0
const archivesLimit = usage?.archives_limit ?? ARCHIVE_LIMITS.paid.archives
const bytesUsed = usage?.storage_bytes_used ?? 0
const bytesLimit =
usage?.storage_bytes_limit ?? ARCHIVE_LIMITS.paid.storageBytes
const archivesRemaining = Math.max(0, archivesLimit - archivesUsed)
const bytesRemaining = Math.max(0, bytesLimit - bytesUsed)
if (archivesRemaining <= 0) {
return {
allowed: false,
archivesRemaining: 0,
archivesLimit,
bytesRemaining,
bytesLimit,
reason: "archive_limit",
isPaid: true,
}
}
if (fileSizeBytes > 0 && fileSizeBytes > bytesRemaining) {
return {
allowed: false,
archivesRemaining,
archivesLimit,
bytesRemaining,
bytesLimit,
reason: "storage_limit",
isPaid: true,
}
}
return {
allowed: true,
archivesRemaining,
archivesLimit,
bytesRemaining,
bytesLimit,
isPaid: true,
}
} catch (error) {
console.error("[billing] Error checking archive allowed:", error)
return {
allowed: false,
archivesRemaining: 0,
archivesLimit: 0,
bytesRemaining: 0,
bytesLimit: 0,
reason: "no_subscription",
isPaid: false,
}
}
}
/**
* Record storage usage after creating an archive
*/
export async function recordStorageUsage(
request: Request,
fileSizeBytes: number
): Promise<void> {
const auth = getAuth()
const session = await auth.api.getSession({ headers: request.headers })
if (!session?.user) {
return
}
const database = db()
try {
const now = new Date()
const [usage] = await database
.select()
.from(storage_usage)
.where(
and(
eq(storage_usage.user_id, session.user.id),
lte(storage_usage.period_start, now),
gte(storage_usage.period_end, now)
)
)
.limit(1)
if (usage) {
await database
.update(storage_usage)
.set({
archives_used: usage.archives_used + 1,
storage_bytes_used: usage.storage_bytes_used + fileSizeBytes,
updated_at: now,
})
.where(eq(storage_usage.id, usage.id))
console.log(
`[billing] Recorded storage: +1 archive, +${fileSizeBytes} bytes`
)
}
} catch (error) {
console.error("[billing] Error recording storage usage:", error)
}
}
/**
* Get archive billing summary for UI display
*/
export async function getArchiveBillingSummary(request: Request) {
const auth = getAuth()
const session = await auth.api.getSession({ headers: request.headers })
if (!session?.user) {
return {
isGuest: true,
isPaid: false,
planName: "Guest",
}
}
const database = db()
try {
const [subscription] = await database
.select()
.from(stripe_subscriptions)
.where(
and(
eq(stripe_subscriptions.user_id, session.user.id),
eq(stripe_subscriptions.status, "active")
)
)
.limit(1)
if (subscription) {
const now = new Date()
const [usage] = await database
.select()
.from(storage_usage)
.where(
and(
eq(storage_usage.user_id, session.user.id),
lte(storage_usage.period_start, now),
gte(storage_usage.period_end, now)
)
)
.limit(1)
return {
isGuest: false,
isPaid: true,
planName: "Archive",
storage: {
archivesUsed: usage?.archives_used ?? 0,
archivesLimit: usage?.archives_limit ?? ARCHIVE_LIMITS.paid.archives,
bytesUsed: usage?.storage_bytes_used ?? 0,
bytesLimit:
usage?.storage_bytes_limit ?? ARCHIVE_LIMITS.paid.storageBytes,
},
currentPeriodEnd: subscription.current_period_end,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
}
}
return {
isGuest: false,
isPaid: false,
planName: "Free",
}
} catch (error) {
console.error("[billing] Error getting archive summary:", error)
return {
isGuest: false,
isPaid: false,
planName: "Free",
}
}
}
/**
* Format bytes to human readable string
*/
export function formatBytes(bytes: number): string {
if (bytes === 0) return "0 B"
const k = 1024
const sizes = ["B", "KB", "MB", "GB", "TB"]
const i = Math.floor(Math.log(bytes) / Math.log(k))
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
}
+3
View File
@@ -1,3 +1,5 @@
import type { StreamPlayback } from "@/lib/stream/playback"
export type StreamPageData = {
user: {
id: string
@@ -12,6 +14,7 @@ export type StreamPageData = {
is_live: boolean
viewer_count: number
hls_url: string | null
playback: StreamPlayback | null
thumbnail_url: string | null
started_at: string | null
} | null
+77
View File
@@ -0,0 +1,77 @@
export type CloudflareStreamRef = {
uid: string
customerCode?: string
}
export type StreamPlayback =
| { type: "cloudflare"; uid: string; customerCode?: string }
| { type: "hls"; url: string }
type PlaybackInput = {
hlsUrl?: string | null
cloudflareUid?: string | null
cloudflareCustomerCode?: string | null
}
export function resolveStreamPlayback({
hlsUrl,
cloudflareUid,
cloudflareCustomerCode,
}: PlaybackInput): StreamPlayback | null {
if (cloudflareUid) {
return {
type: "cloudflare",
uid: cloudflareUid,
customerCode: cloudflareCustomerCode ?? undefined,
}
}
if (!hlsUrl) {
return null
}
const cloudflare = parseCloudflareStreamUrl(hlsUrl)
if (cloudflare) {
return {
type: "cloudflare",
uid: cloudflare.uid,
customerCode: cloudflare.customerCode,
}
}
return { type: "hls", url: hlsUrl }
}
export function parseCloudflareStreamUrl(url: string): CloudflareStreamRef | null {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return null
}
const host = parsed.hostname.toLowerCase()
const isCloudflareHost =
host.endsWith(".cloudflarestream.com") ||
host === "iframe.cloudflarestream.com" ||
host.endsWith("videodelivery.net")
if (!isCloudflareHost) {
return null
}
const pathParts = parsed.pathname.split("/").filter(Boolean)
if (!pathParts.length) {
return null
}
const uid = pathParts[0]
if (!uid) {
return null
}
const customerMatch = host.match(/^customer-([a-z0-9-]+)\.cloudflarestream\.com$/i)
const customerCode = customerMatch?.[1]
return { uid, customerCode }
}
+51
View File
@@ -0,0 +1,51 @@
import Stripe from "stripe"
type StripeEnv = {
STRIPE_SECRET_KEY?: string
STRIPE_WEBHOOK_SECRET?: string
STRIPE_ARCHIVE_PRICE_ID?: string // Archive subscription price
}
const getEnv = (): StripeEnv => {
let STRIPE_SECRET_KEY: string | undefined
let STRIPE_WEBHOOK_SECRET: string | undefined
let STRIPE_ARCHIVE_PRICE_ID: string | undefined
try {
const { getServerContext } = require("@tanstack/react-start/server") as {
getServerContext: () => { cloudflare?: { env?: StripeEnv } } | null
}
const ctx = getServerContext()
STRIPE_SECRET_KEY = ctx?.cloudflare?.env?.STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET = ctx?.cloudflare?.env?.STRIPE_WEBHOOK_SECRET
STRIPE_ARCHIVE_PRICE_ID = ctx?.cloudflare?.env?.STRIPE_ARCHIVE_PRICE_ID
} catch {
// Not in server context
}
STRIPE_SECRET_KEY = STRIPE_SECRET_KEY ?? process.env.STRIPE_SECRET_KEY
STRIPE_WEBHOOK_SECRET =
STRIPE_WEBHOOK_SECRET ?? process.env.STRIPE_WEBHOOK_SECRET
STRIPE_ARCHIVE_PRICE_ID =
STRIPE_ARCHIVE_PRICE_ID ?? process.env.STRIPE_ARCHIVE_PRICE_ID
return { STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_ARCHIVE_PRICE_ID }
}
export const getStripe = (): Stripe | null => {
const env = getEnv()
if (!env.STRIPE_SECRET_KEY) {
return null
}
return new Stripe(env.STRIPE_SECRET_KEY)
}
export const getStripeConfig = () => {
const env = getEnv()
return {
webhookSecret: env.STRIPE_WEBHOOK_SECRET,
archivePriceId: env.STRIPE_ARCHIVE_PRICE_ID,
}
}