Skip to content

Commit 4350c1a

Browse files
authored
Merge pull request #385 from akintewe/feat/token-gated-streams
2 parents 7257c80 + 2eb960f commit 4350c1a

File tree

11 files changed

+854
-275
lines changed

11 files changed

+854
-275
lines changed

app/[username]/watch/page.tsx

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"use client";
22

3-
import { use, useEffect, useState, useRef } from "react";
3+
import { use, useEffect, useState, useRef, useCallback } from "react";
44
import { notFound } from "next/navigation";
55
import ViewStream from "@/components/stream/view-stream";
66
import { ViewStreamSkeleton } from "@/components/skeletons/ViewStreamSkeleton";
7+
import { AccessGate } from "@/components/stream/AccessGate";
78
import { toast } from "sonner";
89

910
interface PageProps {
@@ -23,6 +24,12 @@ interface UserData {
2324
follower_count: number;
2425
is_following: boolean;
2526
stellar_address: string | null;
27+
stream_access_type?: string;
28+
stream_access_config?: {
29+
asset_code: string;
30+
asset_issuer: string;
31+
min_balance: string;
32+
} | null;
2633
}
2734

2835
const WatchPage = ({ params }: PageProps) => {
@@ -34,6 +41,10 @@ const WatchPage = ({ params }: PageProps) => {
3441
const [followLoading, setFollowLoading] = useState(false);
3542
const [loggedInUsername, setLoggedInUsername] = useState<string | null>(null);
3643

44+
// Access control state
45+
const [accessAllowed, setAccessAllowed] = useState<boolean | null>(null);
46+
const [accessChecking, setAccessChecking] = useState(false);
47+
3748
// Viewer tracking: one unique ID per page visit
3849
const viewerSessionId = useRef<string | null>(null);
3950
const viewerPlaybackId = useRef<string | null>(null);
@@ -43,6 +54,35 @@ const WatchPage = ({ params }: PageProps) => {
4354
setLoggedInUsername(sessionStorage.getItem("username"));
4455
}, []);
4556

57+
const checkAccess = useCallback(async () => {
58+
if (!userData) return;
59+
// Public streams are always allowed — skip the network call
60+
if (!userData.stream_access_type || userData.stream_access_type === "public") {
61+
setAccessAllowed(true);
62+
return;
63+
}
64+
setAccessChecking(true);
65+
try {
66+
const res = await fetch("/api/streams/access/check", {
67+
method: "POST",
68+
headers: { "Content-Type": "application/json" },
69+
credentials: "include",
70+
body: JSON.stringify({ streamerUsername: username }),
71+
});
72+
const data = await res.json();
73+
setAccessAllowed(data.allowed === true);
74+
} catch {
75+
// On network failure, fail open rather than lock everyone out
76+
setAccessAllowed(true);
77+
} finally {
78+
setAccessChecking(false);
79+
}
80+
}, [userData, username]);
81+
82+
useEffect(() => {
83+
checkAccess();
84+
}, [checkAccess]);
85+
4686
// Poll user/stream data every 5s
4787
useEffect(() => {
4888
let isInitialLoad = true;
@@ -201,6 +241,28 @@ const WatchPage = ({ params }: PageProps) => {
201241
return notFound();
202242
}
203243

244+
// Still waiting for access check result
245+
if (accessAllowed === null || (accessChecking && accessAllowed === null)) {
246+
return <ViewStreamSkeleton />;
247+
}
248+
249+
// Access denied — show gate
250+
if (!accessAllowed && userData.stream_access_config) {
251+
return (
252+
<div className="flex items-center justify-center min-h-screen p-6 bg-secondary">
253+
<div className="w-full max-w-md">
254+
<AccessGate
255+
streamerUsername={username}
256+
assetCode={userData.stream_access_config.asset_code}
257+
minBalance={userData.stream_access_config.min_balance}
258+
onRetry={checkAccess}
259+
isChecking={accessChecking}
260+
/>
261+
</div>
262+
</div>
263+
);
264+
}
265+
204266
const isOwner = loggedInUsername?.toLowerCase() === username.toLowerCase();
205267

206268
const transformedUserData = {

app/api/routes-f/.gitkeep

Whitespace-only changes.
Lines changed: 75 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,57 +1,92 @@
1-
import { NextResponse } from "next/server";
2-
import { checkStreamAccess } from "@/lib/stream/access";
3-
41
/**
5-
* Endpoint to check if a viewer has access to a stream.
6-
* Called by the client before rendering the StreamPlayer.
2+
* POST /api/streams/access/check
73
*
8-
* Request:
9-
* { "streamer_username": "alice", "viewer_public_key": "GABC..." }
4+
* Server-side token-gate check. The client sends the streamer's username and
5+
* the viewer's wallet address (from the viewer's own session cookie). The
6+
* server verifies the session, loads the streamer's access config, then
7+
* queries Stellar Horizon for the viewer's token balance.
108
*
11-
* Response (allowed):
12-
* { "allowed": true }
9+
* Body: { streamerUsername: string }
1310
*
14-
* Response (blocked):
15-
* { "allowed": false, "reason": "paid", "price_usdc": "10.00" }
11+
* Response:
12+
* { allowed: true }
13+
* { allowed: false, reason: "token_gated" | "no_wallet" | "public" }
1614
*/
17-
export async function POST(req: Request) {
18-
try {
19-
const body = await req.json();
20-
const { streamer_username, viewer_public_key } = body;
2115

22-
if (!streamer_username) {
23-
return NextResponse.json(
24-
{ error: "Streamer username is required" },
25-
{ status: 400 }
26-
);
27-
}
16+
import { NextRequest, NextResponse } from "next/server";
17+
import { sql } from "@vercel/postgres";
18+
import { verifySession } from "@/lib/auth/verify-session";
19+
import {
20+
checkTokenGatedAccess,
21+
} from "@/lib/stream/access";
22+
import type { StreamAccessType, TokenGateConfig } from "@/types/stream-access";
23+
24+
export async function POST(req: NextRequest) {
25+
let body: { streamerUsername?: string };
26+
try {
27+
body = await req.json();
28+
} catch {
29+
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
30+
}
2831

29-
const accessResult = await checkStreamAccess(
30-
streamer_username,
31-
viewer_public_key || null
32+
const { streamerUsername } = body;
33+
if (!streamerUsername || typeof streamerUsername !== "string") {
34+
return NextResponse.json(
35+
{ error: "streamerUsername is required" },
36+
{ status: 400 }
3237
);
38+
}
3339

34-
if (accessResult.allowed) {
35-
return NextResponse.json({ allowed: true });
40+
// Resolve viewer wallet from their session (server-trusted)
41+
const session = await verifySession(req);
42+
const viewerWallet = session.ok ? session.wallet : null;
43+
44+
// Load streamer's access settings
45+
type StreamerRow = {
46+
id: string;
47+
stream_access_type: StreamAccessType;
48+
stream_access_config: TokenGateConfig | null;
49+
};
50+
let streamer: StreamerRow;
51+
52+
try {
53+
const result = await sql`
54+
SELECT id, stream_access_type, stream_access_config
55+
FROM users
56+
WHERE username = ${streamerUsername}
57+
LIMIT 1
58+
`;
59+
if (result.rows.length === 0) {
60+
return NextResponse.json({ error: "Streamer not found" }, { status: 404 });
3661
}
62+
streamer = result.rows[0] as unknown as StreamerRow;
63+
} catch (err) {
64+
console.error("[access/check] DB error:", err);
65+
return NextResponse.json({ error: "Server error" }, { status: 500 });
66+
}
3767

38-
// Build response for blocked access
39-
const responseBody: any = {
40-
allowed: false,
41-
reason: accessResult.reason,
42-
};
68+
// Public stream — always allowed
69+
if (!streamer.stream_access_type || streamer.stream_access_type === "public") {
70+
return NextResponse.json({ allowed: true, reason: "public" });
71+
}
4372

44-
// Include config fields if available (e.g. price for paid streams)
45-
if (accessResult.config) {
46-
Object.assign(responseBody, accessResult.config);
73+
// Token-gated stream
74+
if (streamer.stream_access_type === "token_gated") {
75+
if (!streamer.stream_access_config) {
76+
// Misconfigured — fail open to avoid locking out everyone
77+
console.warn(
78+
`[access/check] token_gated stream for ${streamerUsername} has no config — allowing`
79+
);
80+
return NextResponse.json({ allowed: true });
4781
}
4882

49-
return NextResponse.json(responseBody);
50-
} catch (error) {
51-
console.error("API: Check stream access error:", error);
52-
return NextResponse.json(
53-
{ error: "Failed to check stream access" },
54-
{ status: 500 }
83+
const accessResult = await checkTokenGatedAccess(
84+
streamer.stream_access_config,
85+
viewerWallet,
86+
streamer.id
5587
);
88+
return NextResponse.json(accessResult);
5689
}
90+
91+
return NextResponse.json({ allowed: true });
5792
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* GET /api/streams/access/verify-asset?code=STREAM&issuer=GABC...
3+
*
4+
* Utility endpoint used by the dashboard "Verify asset" button.
5+
* Checks Stellar Horizon to confirm the asset has been issued
6+
* (has at least one trustline).
7+
*
8+
* No authentication required — read-only public Horizon data.
9+
*/
10+
11+
import { NextRequest, NextResponse } from "next/server";
12+
import { verifyAssetExists, isValidAssetCode, isValidStellarIssuer } from "@/lib/stream/access";
13+
14+
export async function GET(req: NextRequest) {
15+
const { searchParams } = new URL(req.url);
16+
const code = searchParams.get("code")?.trim() ?? "";
17+
const issuer = searchParams.get("issuer")?.trim() ?? "";
18+
19+
if (!code || !issuer) {
20+
return NextResponse.json(
21+
{ error: "code and issuer query params are required" },
22+
{ status: 400 }
23+
);
24+
}
25+
26+
if (!isValidAssetCode(code)) {
27+
return NextResponse.json(
28+
{ error: "Invalid asset code. Must be 1–12 alphanumeric characters." },
29+
{ status: 400 }
30+
);
31+
}
32+
33+
if (!isValidStellarIssuer(issuer)) {
34+
return NextResponse.json(
35+
{ error: "Invalid issuer address. Must be a valid Stellar public key." },
36+
{ status: 400 }
37+
);
38+
}
39+
40+
const exists = await verifyAssetExists(code, issuer);
41+
42+
return NextResponse.json(
43+
{ exists },
44+
{ headers: { "Cache-Control": "private, max-age=30" } }
45+
);
46+
}

app/api/users/update-creator/route.ts

Lines changed: 68 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { NextResponse } from "next/server";
22
import { sql } from "@vercel/postgres";
3+
import {
4+
isValidAssetCode,
5+
isValidStellarIssuer,
6+
} from "@/lib/stream/access";
7+
import type { StreamAccessType, TokenGateConfig } from "@/types/stream-access";
38

49
export async function PATCH(req: Request) {
510
try {
611
const body = await req.json();
7-
const { email, creator } = body;
12+
const { email, creator, stream_access_type, stream_access_config } = body;
813

914
if (!email || !creator) {
1015
return NextResponse.json(
@@ -19,24 +24,73 @@ export async function PATCH(req: Request) {
1924
category = "",
2025
payout = "",
2126
thumbnail = "",
22-
stream_access_type,
23-
stream_access_config,
2427
} = creator;
2528

26-
const updatedCreator = {
27-
streamTitle,
28-
tags,
29-
category,
30-
payout,
31-
thumbnail,
32-
};
29+
const updatedCreator = { streamTitle, tags, category, payout, thumbnail };
30+
31+
// ── Access control validation ───────────────────────────────────────────
32+
const accessType: StreamAccessType = stream_access_type ?? "public";
33+
if (accessType !== "public" && accessType !== "token_gated") {
34+
return NextResponse.json(
35+
{ error: "Invalid stream_access_type. Must be 'public' or 'token_gated'." },
36+
{ status: 400 }
37+
);
38+
}
39+
40+
let accessConfig: TokenGateConfig | null = null;
41+
42+
if (accessType === "token_gated") {
43+
const cfg = stream_access_config as Partial<TokenGateConfig> | undefined;
44+
45+
if (!cfg?.asset_code || !cfg?.asset_issuer) {
46+
return NextResponse.json(
47+
{
48+
error:
49+
"stream_access_config.asset_code and stream_access_config.asset_issuer are required for token_gated streams.",
50+
},
51+
{ status: 400 }
52+
);
53+
}
54+
55+
if (!isValidAssetCode(cfg.asset_code)) {
56+
return NextResponse.json(
57+
{ error: "Invalid asset_code. Must be 1–12 alphanumeric characters." },
58+
{ status: 400 }
59+
);
60+
}
61+
62+
if (!isValidStellarIssuer(cfg.asset_issuer)) {
63+
return NextResponse.json(
64+
{
65+
error:
66+
"Invalid asset_issuer. Must be a valid Stellar public key (starts with G, 56 chars).",
67+
},
68+
{ status: 400 }
69+
);
70+
}
71+
72+
const minBalance = cfg.min_balance ?? "1";
73+
if (isNaN(parseFloat(minBalance)) || parseFloat(minBalance) <= 0) {
74+
return NextResponse.json(
75+
{ error: "min_balance must be a positive number." },
76+
{ status: 400 }
77+
);
78+
}
79+
80+
accessConfig = {
81+
asset_code: cfg.asset_code,
82+
asset_issuer: cfg.asset_issuer,
83+
min_balance: minBalance,
84+
};
85+
}
3386

3487
const result = await sql`
3588
UPDATE users
36-
SET creator = ${JSON.stringify(updatedCreator)},
37-
stream_access_type = COALESCE(${stream_access_type}, stream_access_type),
38-
stream_access_config = COALESCE(${JSON.stringify(stream_access_config)}, stream_access_config),
39-
updated_at = CURRENT_TIMESTAMP
89+
SET
90+
creator = ${JSON.stringify(updatedCreator)},
91+
stream_access_type = ${accessType},
92+
stream_access_config = ${accessConfig ? JSON.stringify(accessConfig) : null},
93+
updated_at = CURRENT_TIMESTAMP
4094
WHERE email = ${email}
4195
`;
4296

0 commit comments

Comments
 (0)