🔴
diff --git a/packages/web/src/routes/api/profile.ts b/packages/web/src/routes/api/profile.ts
index ca9ed041..a3e44797 100644
--- a/packages/web/src/routes/api/profile.ts
+++ b/packages/web/src/routes/api/profile.ts
@@ -4,6 +4,7 @@ import { getDb } from "@/db/connection"
import { users, streams } from "@/db/schema"
import { getAuth } from "@/lib/auth"
import { randomUUID } from "crypto"
+import { resolveStreamPlayback } from "@/lib/stream/playback"
const resolveDatabaseUrl = (request: Request) => {
try {
@@ -48,6 +49,10 @@ const getProfile = async ({ request }: { request: Request }) => {
where: eq(streams.user_id, user.id),
})
+ const playback = stream
+ ? resolveStreamPlayback({ hlsUrl: stream.hls_url })
+ : null
+
return new Response(
JSON.stringify({
id: user.id,
@@ -61,6 +66,7 @@ const getProfile = async ({ request }: { request: Request }) => {
title: stream.title,
is_live: stream.is_live,
hls_url: stream.hls_url,
+ playback,
stream_key: stream.stream_key,
}
: null,
diff --git a/packages/web/src/routes/api/stream.ts b/packages/web/src/routes/api/stream.ts
index a6a82691..a29bc0e9 100644
--- a/packages/web/src/routes/api/stream.ts
+++ b/packages/web/src/routes/api/stream.ts
@@ -3,6 +3,7 @@ import { eq } from "drizzle-orm"
import { getDb } from "@/db/connection"
import { streams } from "@/db/schema"
import { getAuth } from "@/lib/auth"
+import { resolveStreamPlayback } from "@/lib/stream/playback"
const resolveDatabaseUrl = (request: Request) => {
try {
@@ -42,7 +43,9 @@ const getStream = async ({ request }: { request: Request }) => {
})
}
- return new Response(JSON.stringify(stream), {
+ const playback = resolveStreamPlayback({ hlsUrl: stream.hls_url })
+
+ return new Response(JSON.stringify({ ...stream, playback }), {
status: 200,
headers: { "content-type": "application/json" },
})
diff --git a/packages/web/src/routes/api/streams.$username.ts b/packages/web/src/routes/api/streams.$username.ts
index e9adb79f..934aad2e 100644
--- a/packages/web/src/routes/api/streams.$username.ts
+++ b/packages/web/src/routes/api/streams.$username.ts
@@ -2,6 +2,7 @@ import { createFileRoute } from "@tanstack/react-router"
import { eq } from "drizzle-orm"
import { getDb } from "@/db/connection"
import { users, streams } from "@/db/schema"
+import { resolveStreamPlayback } from "@/lib/stream/playback"
const resolveDatabaseUrl = (request: Request) => {
try {
@@ -58,6 +59,10 @@ const serve = async ({
where: eq(streams.user_id, user.id),
})
+ const playback = stream
+ ? resolveStreamPlayback({ hlsUrl: stream.hls_url })
+ : null
+
const data = {
user: {
id: user.id,
@@ -73,6 +78,7 @@ const serve = async ({
is_live: stream.is_live,
viewer_count: stream.viewer_count,
hls_url: stream.hls_url,
+ playback,
thumbnail_url: stream.thumbnail_url,
started_at: stream.started_at?.toISOString() ?? null,
}
diff --git a/packages/web/src/routes/api/stripe/checkout.ts b/packages/web/src/routes/api/stripe/checkout.ts
new file mode 100644
index 00000000..efbb5e18
--- /dev/null
+++ b/packages/web/src/routes/api/stripe/checkout.ts
@@ -0,0 +1,111 @@
+import { createFileRoute } from "@tanstack/react-router"
+import { getAuth } from "@/lib/auth"
+import { getStripe, getStripeConfig } from "@/lib/stripe"
+import { db } from "@/db/connection"
+import { stripe_customers } from "@/db/schema"
+import { eq } from "drizzle-orm"
+
+const json = (data: unknown, status = 200) =>
+ new Response(JSON.stringify(data), {
+ status,
+ headers: { "content-type": "application/json" },
+ })
+
+export const Route = createFileRoute("/api/stripe/checkout")({
+ server: {
+ handlers: {
+ POST: async ({ request }) => {
+ const session = await getAuth().api.getSession({
+ headers: request.headers,
+ })
+ if (!session?.user?.id) {
+ return json({ error: "Unauthorized" }, 401)
+ }
+
+ const stripe = getStripe()
+ const { archivePriceId } = getStripeConfig()
+
+ if (!stripe) {
+ console.error("[stripe] Stripe not configured - missing STRIPE_SECRET_KEY")
+ return json({ error: "Stripe not configured" }, 500)
+ }
+
+ if (!archivePriceId) {
+ console.error("[stripe] Price ID not configured - missing STRIPE_ARCHIVE_PRICE_ID")
+ return json({ error: "Price ID not configured" }, 500)
+ }
+
+ const database = db()
+
+ try {
+ // Get or create Stripe customer
+ let [customer] = await database
+ .select()
+ .from(stripe_customers)
+ .where(eq(stripe_customers.user_id, session.user.id))
+ .limit(1)
+
+ let stripeCustomerId: string
+
+ if (customer) {
+ stripeCustomerId = customer.stripe_customer_id
+ } else {
+ // Create new Stripe customer
+ const stripeCustomer = await stripe.customers.create({
+ email: session.user.email,
+ name: session.user.name ?? undefined,
+ metadata: {
+ user_id: session.user.id,
+ },
+ })
+
+ await database.insert(stripe_customers).values({
+ user_id: session.user.id,
+ stripe_customer_id: stripeCustomer.id,
+ })
+
+ stripeCustomerId = stripeCustomer.id
+ }
+
+ // Parse request body for success/cancel URLs
+ const body = (await request.json().catch(() => ({}))) as {
+ successUrl?: string
+ cancelUrl?: string
+ }
+
+ const origin = new URL(request.url).origin
+ const successUrl = body.successUrl ?? `${origin}/archive?billing=success`
+ const cancelUrl = body.cancelUrl ?? `${origin}/archive?billing=canceled`
+
+ // Create checkout session
+ const checkoutSession = await stripe.checkout.sessions.create({
+ customer: stripeCustomerId,
+ mode: "subscription",
+ line_items: [
+ {
+ price: archivePriceId,
+ quantity: 1,
+ },
+ ],
+ success_url: successUrl,
+ cancel_url: cancelUrl,
+ subscription_data: {
+ metadata: {
+ user_id: session.user.id,
+ },
+ },
+ })
+
+ return json({ url: checkoutSession.url })
+ } catch (error) {
+ const err = error as Error
+ console.error("[stripe] Checkout error:", err.message)
+ return json(
+ { error: `Failed to create checkout session: ${err.message}` },
+ 500
+ )
+ }
+ },
+ },
+ },
+})
diff --git a/packages/web/src/routes/api/stripe/webhooks.ts b/packages/web/src/routes/api/stripe/webhooks.ts
new file mode 100644
index 00000000..bff5a0b4
--- /dev/null
+++ b/packages/web/src/routes/api/stripe/webhooks.ts
@@ -0,0 +1,201 @@
+import { createFileRoute } from "@tanstack/react-router"
+import { getStripe, getStripeConfig } from "@/lib/stripe"
+import { db } from "@/db/connection"
+import { stripe_customers, stripe_subscriptions, storage_usage } from "@/db/schema"
+import { eq, and } from "drizzle-orm"
+import type Stripe from "stripe"
+
+// Archive subscription limits
+const ARCHIVE_LIMITS = {
+ archives: 10,
+ storageBytes: 1073741824, // 1GB
+}
+
+export const Route = createFileRoute("/api/stripe/webhooks")({
+ server: {
+ handlers: {
+ POST: async ({ request }) => {
+ const stripe = getStripe()
+ const { webhookSecret } = getStripeConfig()
+
+ if (!stripe || !webhookSecret) {
+ console.error("[stripe] Stripe not configured")
+ return new Response("Stripe not configured", { status: 500 })
+ }
+
+ const body = await request.text()
+ const signature = request.headers.get("stripe-signature")
+
+ if (!signature) {
+ return new Response("Missing stripe-signature header", { status: 400 })
+ }
+
+ let event: Stripe.Event
+ try {
+ event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
+ } catch (err) {
+ console.error("[stripe] Webhook signature verification failed:", err)
+ return new Response("Invalid signature", { status: 400 })
+ }
+
+ const database = db()
+
+ try {
+ switch (event.type) {
+ case "checkout.session.completed": {
+ const session = event.data.object as Stripe.Checkout.Session
+ console.log("[stripe] Checkout completed:", session.id)
+
+ if (session.mode === "subscription" && session.subscription) {
+ const subscription = await stripe.subscriptions.retrieve(
+ session.subscription as string
+ )
+ await handleSubscriptionCreated(database, subscription)
+ }
+ break
+ }
+
+ case "customer.subscription.created":
+ case "customer.subscription.updated": {
+ const subscription = event.data.object as Stripe.Subscription
+ console.log(`[stripe] Subscription ${event.type}:`, subscription.id)
+ await handleSubscriptionCreated(database, subscription)
+ break
+ }
+
+ case "customer.subscription.deleted": {
+ const subscription = event.data.object as Stripe.Subscription
+ console.log("[stripe] Subscription deleted:", subscription.id)
+ await handleSubscriptionDeleted(database, subscription)
+ break
+ }
+
+ case "invoice.payment_succeeded": {
+ const invoice = event.data.object as Stripe.Invoice
+ console.log("[stripe] Invoice paid:", invoice.id)
+ break
+ }
+
+ case "invoice.payment_failed": {
+ const invoice = event.data.object as Stripe.Invoice
+ console.log("[stripe] Invoice payment failed:", invoice.id)
+ break
+ }
+
+ default:
+ console.log(`[stripe] Unhandled event type: ${event.type}`)
+ }
+
+ return new Response(JSON.stringify({ received: true }), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ })
+ } catch (error) {
+ console.error("[stripe] Webhook handler error:", error)
+ return new Response("Webhook handler error", { status: 500 })
+ }
+ },
+ },
+ },
+})
+
+async function handleSubscriptionCreated(
+ database: ReturnType
,
+ subscription: Stripe.Subscription
+) {
+ const customerId = subscription.customer as string
+ const priceId = subscription.items.data[0]?.price.id
+
+ // Find user by Stripe customer ID
+ const [customer] = await database
+ .select()
+ .from(stripe_customers)
+ .where(eq(stripe_customers.stripe_customer_id, customerId))
+ .limit(1)
+
+ if (!customer) {
+ console.error("[stripe] No customer found for:", customerId)
+ return
+ }
+
+ // Upsert subscription
+ const existing = await database
+ .select()
+ .from(stripe_subscriptions)
+ .where(eq(stripe_subscriptions.stripe_subscription_id, subscription.id))
+ .limit(1)
+
+ // Period dates
+ const item = subscription.items.data[0]
+ const periodStart = item?.current_period_start
+ ? new Date(item.current_period_start * 1000)
+ : new Date()
+ const periodEnd = item?.current_period_end
+ ? new Date(item.current_period_end * 1000)
+ : new Date(Date.now() + 30 * 24 * 60 * 60 * 1000)
+
+ const subscriptionData = {
+ stripe_subscription_id: subscription.id,
+ stripe_customer_id: customerId,
+ stripe_price_id: priceId,
+ status: subscription.status,
+ current_period_start: periodStart,
+ current_period_end: periodEnd,
+ cancel_at_period_end: subscription.cancel_at_period_end,
+ updated_at: new Date(),
+ }
+
+ if (existing.length > 0) {
+ await database
+ .update(stripe_subscriptions)
+ .set(subscriptionData)
+ .where(eq(stripe_subscriptions.stripe_subscription_id, subscription.id))
+ } else {
+ await database.insert(stripe_subscriptions).values({
+ user_id: customer.user_id,
+ ...subscriptionData,
+ })
+ }
+
+ // Create or update storage usage record for this billing period
+ const [existingUsage] = await database
+ .select()
+ .from(storage_usage)
+ .where(
+ and(
+ eq(storage_usage.user_id, customer.user_id),
+ eq(storage_usage.period_start, periodStart)
+ )
+ )
+ .limit(1)
+
+ if (!existingUsage) {
+ await database.insert(storage_usage).values({
+ user_id: customer.user_id,
+ archives_used: 0,
+ archives_limit: ARCHIVE_LIMITS.archives,
+ storage_bytes_used: 0,
+ storage_bytes_limit: ARCHIVE_LIMITS.storageBytes,
+ period_start: periodStart,
+ period_end: periodEnd,
+ })
+ console.log(`[stripe] Created storage usage record for user ${customer.user_id}`)
+ }
+
+ console.log(`[stripe] Subscription synced for user ${customer.user_id}`)
+}
+
+async function handleSubscriptionDeleted(
+ database: ReturnType,
+ subscription: Stripe.Subscription
+) {
+ await database
+ .update(stripe_subscriptions)
+ .set({
+ status: "canceled",
+ updated_at: new Date(),
+ })
+ .where(eq(stripe_subscriptions.stripe_subscription_id, subscription.id))
+
+ console.log(`[stripe] Subscription ${subscription.id} marked as canceled`)
+}
diff --git a/packages/web/src/routes/api/users.username.ts b/packages/web/src/routes/api/users.username.ts
new file mode 100644
index 00000000..57b3e58a
--- /dev/null
+++ b/packages/web/src/routes/api/users.username.ts
@@ -0,0 +1,122 @@
+import { createFileRoute } from "@tanstack/react-router"
+import { getAuth } from "@/lib/auth"
+import { db } from "@/db/connection"
+import { users } from "@/db/schema"
+import { eq } from "drizzle-orm"
+
+// Username validation: lowercase letters, numbers, underscores, 3-20 chars
+const isValidUsername = (username: string): boolean => {
+ return /^[a-z0-9_]{3,20}$/.test(username)
+}
+
+const handlePost = async ({ request }: { request: Request }) => {
+ const auth = getAuth()
+ const session = await auth.api.getSession({ headers: request.headers })
+
+ if (!session?.user) {
+ return new Response(JSON.stringify({ error: "Unauthorized" }), {
+ status: 401,
+ headers: { "content-type": "application/json" },
+ })
+ }
+
+ const body = await request.json()
+ const { username } = body as { username?: string }
+
+ if (!username || typeof username !== "string") {
+ return new Response(JSON.stringify({ error: "Username is required" }), {
+ status: 400,
+ headers: { "content-type": "application/json" },
+ })
+ }
+
+ const normalizedUsername = username.toLowerCase().trim()
+
+ if (!isValidUsername(normalizedUsername)) {
+ return new Response(
+ JSON.stringify({
+ error:
+ "Username must be 3-20 characters, lowercase letters, numbers, or underscores only",
+ }),
+ {
+ status: 400,
+ headers: { "content-type": "application/json" },
+ },
+ )
+ }
+
+ const database = db()
+
+ // Check if username is already taken (by another user)
+ const existing = await database
+ .select({ id: users.id })
+ .from(users)
+ .where(eq(users.username, normalizedUsername))
+ .limit(1)
+
+ if (existing.length > 0 && existing[0].id !== session.user.id) {
+ return new Response(JSON.stringify({ error: "Username is already taken" }), {
+ status: 409,
+ headers: { "content-type": "application/json" },
+ })
+ }
+
+ // Update username
+ await database
+ .update(users)
+ .set({ username: normalizedUsername, updatedAt: new Date() })
+ .where(eq(users.id, session.user.id))
+
+ return new Response(
+ JSON.stringify({ success: true, username: normalizedUsername }),
+ {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ },
+ )
+}
+
+const handleGet = async ({ request }: { request: Request }) => {
+ const auth = getAuth()
+ const session = await auth.api.getSession({ headers: request.headers })
+
+ if (!session?.user) {
+ return new Response(JSON.stringify({ error: "Unauthorized" }), {
+ status: 401,
+ headers: { "content-type": "application/json" },
+ })
+ }
+
+ const database = db()
+ const user = await database
+ .select({ username: users.username })
+ .from(users)
+ .where(eq(users.id, session.user.id))
+ .limit(1)
+
+ return new Response(
+ JSON.stringify({ username: user[0]?.username ?? null }),
+ {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ },
+ )
+}
+
+export const Route = createFileRoute("/api/users/username")({
+ server: {
+ handlers: {
+ GET: handleGet,
+ POST: handlePost,
+ OPTIONS: () =>
+ new Response(null, {
+ status: 204,
+ headers: {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type",
+ },
+ }),
+ },
+ },
+})
diff --git a/packages/web/src/routes/login.tsx b/packages/web/src/routes/login.tsx
index 7dddefd2..727b5bd2 100644
--- a/packages/web/src/routes/login.tsx
+++ b/packages/web/src/routes/login.tsx
@@ -7,22 +7,27 @@ export const Route = createFileRoute("/login")({
ssr: false,
})
-type Step = "email" | "otp"
+type Step = "email" | "otp" | "username"
function AuthPage() {
const [step, setStep] = useState("email")
const emailInputRef = useRef(null)
const otpInputRef = useRef(null)
+ const usernameInputRef = useRef(null)
useEffect(() => {
if (step === "email") {
emailInputRef.current?.focus()
- } else {
+ } else if (step === "otp") {
otpInputRef.current?.focus()
+ } else if (step === "username") {
+ usernameInputRef.current?.focus()
}
}, [step])
+
const [email, setEmail] = useState("")
const [otp, setOtp] = useState("")
+ const [username, setUsername] = useState("")
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState("")
@@ -60,7 +65,6 @@ function AuthPage() {
setError("")
try {
- // Use signIn.emailOtp for sign-in type OTPs (not verifyEmail which is for email verification)
const { error } = await authClient.signIn.emailOtp({
email,
otp,
@@ -69,7 +73,17 @@ function AuthPage() {
if (error) {
setError(error.message || "Invalid code")
} else {
- window.location.href = "/"
+ // Check if user has a username
+ const response = await fetch("/api/users/username")
+ const data = await response.json()
+
+ if (!data.username) {
+ // New user or user without username - show username setup
+ setStep("username")
+ } else {
+ // Existing user with username - go to home
+ window.location.href = "/"
+ }
}
} catch (err) {
console.error("Verify OTP error:", err)
@@ -79,6 +93,35 @@ function AuthPage() {
}
}
+ const handleSetUsername = async (e: React.FormEvent) => {
+ e.preventDefault()
+ if (!username.trim()) return
+
+ setIsLoading(true)
+ setError("")
+
+ try {
+ const response = await fetch("/api/users/username", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ username: username.toLowerCase().trim() }),
+ })
+
+ const data = await response.json()
+
+ if (!response.ok) {
+ setError(data.error || "Failed to set username")
+ } else {
+ window.location.href = "/"
+ }
+ } catch (err) {
+ console.error("Set username error:", err)
+ setError("Failed to set username")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
const handleResend = async () => {
setIsLoading(true)
setError("")
@@ -93,7 +136,7 @@ function AuthPage() {
if (error) {
setError(error.message || "Failed to resend code")
}
- } catch (err) {
+ } catch {
setError("Failed to resend code")
} finally {
setIsLoading(false)
@@ -101,26 +144,42 @@ function AuthPage() {
}
const handleBack = () => {
- setStep("email")
- setOtp("")
+ if (step === "otp") {
+ setStep("email")
+ setOtp("")
+ } else if (step === "username") {
+ // Can't go back from username step - they're already signed in
+ // Just skip for now
+ window.location.href = "/"
+ }
setError("")
}
+ const handleSkipUsername = () => {
+ window.location.href = "/"
+ }
+
return (
- {step === "email" ? "Sign in" : "Enter code"}
+ {step === "email"
+ ? "Sign in"
+ : step === "otp"
+ ? "Enter code"
+ : "Choose username"}
{step === "email"
? "Enter your email to receive a verification code"
- : `We sent a 6-digit code to ${email}`}
+ : step === "otp"
+ ? `We sent a 6-digit code to ${email}`
+ : "Pick a unique username for your profile"}
- {step === "email" ? (
+ {step === "email" && (