Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 8 additions & 9 deletions src/app/access/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,15 @@ function AccessForm() {
const [error, setError] = useState("");

useEffect(() => {
if (!ready) return;
if (!authenticated || !user) return;
if (!ready || !authenticated || !user) return;

const email =
user.email?.address ||
(user.linkedAccounts.find((a) => a.type === "email") as { address?: string } | undefined)?.address ||
(user as { email?: { address?: string } }).email?.address ??
(user.linkedAccounts?.find((a) => a.type === "email") as { address?: string } | undefined)?.address ??
null;

const xHandle =
(user.linkedAccounts.find((a) => a.type === "twitter_oauth") as { username?: string } | undefined)?.username ||
(user.linkedAccounts?.find((a) => a.type === "twitter_oauth") as { username?: string } | undefined)?.username ??
null;

setIsLoading(true);
Expand All @@ -33,10 +32,10 @@ function AccessForm() {
fetch("/api/access", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ privy: true, userId: user.id, email, xHandle }),
body: JSON.stringify({ userId: user.id, email, xHandle }),
})
.then((res) => res.json())
.then((data) => {
.then((data: { ok?: boolean; error?: string }) => {
if (data.ok) {
window.location.assign(nextPath.startsWith("/") ? nextPath : "/dashboard");
} else {
Expand Down Expand Up @@ -82,7 +81,7 @@ function AccessForm() {
<div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
<button className="access-privy-btn" onClick={login} disabled={!ready}>
<img src="/logo.svg" alt="" width={20} height={20} style={{ borderRadius: "5px" }} />
Continue with Email or X
Continue with Google or X
</button>
</div>
{error && <p className="access-error">{error}</p>}
Expand All @@ -99,7 +98,7 @@ function AccessForm() {
<h1>Something went wrong</h1>
<p>{error}</p>
</div>
<button className="access-privy-btn" onClick={() => { logout(); setError(""); }}>
<button className="access-privy-btn" onClick={() => { void logout(); setError(""); }}>
Try a different account
</button>
</>
Expand Down
128 changes: 20 additions & 108 deletions src/app/api/access/route.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,11 @@
import { NextResponse } from "next/server";
import {
ACCESS_COOKIE_MAX_AGE,
ACCESS_COOKIE_NAME,
createAccessToken,
hashAccessCode,
normalizeAccessCode,
} from "@/lib/access-auth";
import { ACCESS_COOKIE_MAX_AGE, ACCESS_COOKIE_NAME, createAccessToken } from "@/lib/access-auth";
import { getSupabaseAdminClient } from "@/lib/supabase-admin";

const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

function setAccessCookie(response: NextResponse, id: string) {
function setAccessCookie(response: NextResponse, userId: string) {
response.cookies.set({
name: ACCESS_COOKIE_NAME,
value: createAccessToken(id),
value: createAccessToken(userId),
httpOnly: true,
sameSite: "lax",
secure: process.env.NODE_ENV === "production",
Expand All @@ -24,108 +16,28 @@ function setAccessCookie(response: NextResponse, id: string) {

export async function POST(request: Request) {
try {
const body = await request.json();

// Privy auth path — user already authenticated via Privy client SDK
if (body.privy === true) {
const userId = String(body.userId || "").trim();
if (!userId) {
return NextResponse.json({ error: "Missing user ID." }, { status: 400 });
}

// Optionally upsert user record for tracking
try {
const supabase = getSupabaseAdminClient();
await supabase.from("users").upsert(
{
privy_id: userId,
email: body.email || null,
x_handle: body.xHandle || null,
last_seen_at: new Date().toISOString(),
},
{ onConflict: "privy_id", ignoreDuplicates: false },
);
} catch {
// Non-fatal: users table may not exist yet, session still works
}

const response = NextResponse.json({ ok: true });
setAccessCookie(response, `privy:${userId}`);
return response;
}

// Access code path — legacy code-based gate
const { code, email } = body;
const normalizedCode = normalizeAccessCode(String(code || ""));
const normalizedEmail = String(email || "").trim().toLowerCase();

if (normalizedCode.length < 4) {
return NextResponse.json({ error: "Enter a valid access code." }, { status: 400 });
}

if (normalizedEmail && !EMAIL_REGEX.test(normalizedEmail)) {
return NextResponse.json({ error: "Enter a valid email address." }, { status: 400 });
}

const supabase = getSupabaseAdminClient();
const codeHash = hashAccessCode(normalizedCode);
const { data: accessCode, error } = await supabase
.from("access_codes")
.select("id,label,max_uses,use_count,expires_at,revoked_at")
.or(`code.eq.${normalizedCode},code_hash.eq.${codeHash}`)
.maybeSingle();

if (error) {
return NextResponse.json({ error: "Could not verify this code right now." }, { status: 500 });
}

if (!accessCode || accessCode.revoked_at) {
return NextResponse.json({ error: "This access code is not valid." }, { status: 401 });
}

if (accessCode.expires_at && new Date(accessCode.expires_at).getTime() < Date.now()) {
return NextResponse.json({ error: "This access code has expired." }, { status: 401 });
}

if (
typeof accessCode.max_uses === "number" &&
typeof accessCode.use_count === "number" &&
accessCode.use_count >= accessCode.max_uses
) {
return NextResponse.json({ error: "This access code has reached its limit." }, { status: 401 });
}

await supabase.from("access_code_redemptions").insert({
access_code_id: accessCode.id,
email: normalizedEmail || null,
user_agent: request.headers.get("user-agent"),
});

await supabase
.from("access_codes")
.update({ use_count: Number(accessCode.use_count || 0) + 1, last_used_at: new Date().toISOString() })
.eq("id", accessCode.id);

const response = NextResponse.json({ ok: true, label: accessCode.label || "Private beta" });
setAccessCookie(response, accessCode.id);
const body = await request.json() as { userId?: string; email?: string | null; xHandle?: string | null };
const userId = String(body.userId || "").trim();
if (!userId) return NextResponse.json({ error: "Missing user ID." }, { status: 400 });

try {
const supabase = getSupabaseAdminClient();
await supabase.from("users").upsert(
{ privy_id: userId, email: body.email || null, x_handle: body.xHandle || null, last_seen_at: new Date().toISOString() },
{ onConflict: "privy_id", ignoreDuplicates: false },
);
} catch { /* non-fatal */ }

const response = NextResponse.json({ ok: true });
setAccessCookie(response, `privy:${userId}`);
return response;
} catch (error) {
if (error instanceof Error && error.message.includes("Missing Supabase")) {
return NextResponse.json({ error: "Server is not configured yet." }, { status: 500 });
}

return NextResponse.json({ error: "Invalid request payload." }, { status: 400 });
} catch {
return NextResponse.json({ error: "Invalid request." }, { status: 400 });
}
}

export async function DELETE() {
const response = NextResponse.json({ ok: true });
response.cookies.set({
name: ACCESS_COOKIE_NAME,
value: "",
maxAge: 0,
path: "/",
});

response.cookies.set({ name: ACCESS_COOKIE_NAME, value: "", maxAge: 0, path: "/" });
return response;
}
2 changes: 1 addition & 1 deletion src/components/privy-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export function Providers({ children }: { children: ReactNode }) {
<PrivyProvider
appId="cmox5mu2p000e0cjp2stcldpu"
config={{
loginMethods: ["email", "google", "twitter", "wallet"],
loginMethods: ["google", "twitter"],
appearance: {
theme: "dark",
accentColor: "#4AE8A0",
Expand Down
59 changes: 20 additions & 39 deletions src/lib/access-auth.ts
Original file line number Diff line number Diff line change
@@ -1,62 +1,43 @@
import { createHash, createHmac } from "crypto";
import { createHmac } from "crypto";

// New canonical cookie name. Legacy name accepted for backwards compatibility.
export const ACCESS_COOKIE_NAME = "zetta_access";
export const ACCESS_COOKIE_NAME_LEGACY = "x402books_access";
export const ACCESS_COOKIE_MAX_AGE = 60 * 60 * 24 * 14;

export function normalizeAccessCode(code: string) {
return code.trim().replace(/[^a-z0-9]/gi, "").toUpperCase();
}

export function hashAccessCode(code: string) {
const salt = process.env.ACCESS_CODE_SALT || process.env.SUPABASE_SERVICE_ROLE_KEY || "x402books";

return createHash("sha256")
.update(`${salt}:${normalizeAccessCode(code)}`)
.digest("hex");
}
export const ACCESS_COOKIE_MAX_AGE = 60 * 60 * 24 * 14; // 14 days

export function getAccessSecret() {
return process.env.ACCESS_SESSION_SECRET || process.env.SUPABASE_SERVICE_ROLE_KEY || "";
}

export function createAccessToken(codeId: string) {
export function createAccessToken(userId: string) {
const expiresAt = Date.now() + ACCESS_COOKIE_MAX_AGE * 1000;
const payload = `${codeId}.${expiresAt}`;
const payload = `${userId}.${expiresAt}`;
const signature = createHmac("sha256", getAccessSecret()).update(payload).digest("hex");

return `${payload}.${signature}`;
}

// Reads the session cookie from a request and returns the codeId
// (access_codes.id) if the session is valid, null otherwise.
// Accepts both zetta_access (new) and x402books_access (legacy) during migration.
export function getSessionCodeId(req: Request): string | null {
const cookieHeader = req.headers.get("cookie") ?? "";
const cookies = cookieHeader.split(";").map((c) => c.trim());

for (const name of [ACCESS_COOKIE_NAME, ACCESS_COOKIE_NAME_LEGACY]) {
const match = cookies.find((c) => c.startsWith(`${name}=`));
if (!match) continue;
const token = decodeURIComponent(match.slice(name.length + 1));
const codeId = verifyAccessToken(token);
if (codeId) return codeId;
}
return null;
}

// Returns the codeId (access_codes.id) if the token is valid, null otherwise.
export function verifyAccessToken(token: string): string | null {
if (!token) return null;
const parts = token.split(".");
if (parts.length < 3) return null;
const signature = parts[parts.length - 1];
const payload = parts.slice(0, -1).join(".");
const expiresAt = Number(parts[parts.length - 2]);
const codeId = parts.slice(0, -2).join(".");
if (!codeId || !Number.isFinite(expiresAt) || expiresAt < Date.now()) return null;
const userId = parts.slice(0, -2).join(".");
if (!userId || !Number.isFinite(expiresAt) || expiresAt < Date.now()) return null;
const expected = createHmac("sha256", getAccessSecret()).update(payload).digest("hex");
if (signature !== expected) return null;
return codeId;
return userId;
}

export function getSessionCodeId(req: Request): string | null {
const cookieHeader = req.headers.get("cookie") ?? "";
const cookies = cookieHeader.split(";").map((c) => c.trim());
for (const name of [ACCESS_COOKIE_NAME, ACCESS_COOKIE_NAME_LEGACY]) {
const match = cookies.find((c) => c.startsWith(`${name}=`));
if (!match) continue;
const token = decodeURIComponent(match.slice(name.length + 1));
const userId = verifyAccessToken(token);
if (userId) return userId;
}
return null;
}
54 changes: 15 additions & 39 deletions src/middleware.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,14 @@
import { NextResponse, type NextRequest } from "next/server";

// New canonical name. Legacy kept for dual-read during migration.
const ACCESS_COOKIE_NAME = "zetta_access";
const ACCESS_COOKIE_NAME_LEGACY = "x402books_access";

const protectedRoutes = [
"/dashboard",
"/api",
"/settings",
];
const protectedRoutes = ["/dashboard", "/settings"];

function isProtectedPath(pathname: string) {
if (pathname.startsWith("/api/")) return false;
return protectedRoutes.some((route) => pathname === route || pathname.startsWith(`${route}/`));
}

// Paths that always pass through maintenance mode
function isMaintenanceBypass(pathname: string) {
return (
pathname === "/maintenance" ||
Expand All @@ -27,61 +20,44 @@ function isMaintenanceBypass(pathname: string) {

async function getSignature(payload: string, secret: string) {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
"raw",
encoder.encode(secret),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
const key = await crypto.subtle.importKey("raw", encoder.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(payload));

return Array.from(new Uint8Array(signature))
.map((byte) => byte.toString(16).padStart(2, "0"))
.join("");
return Array.from(new Uint8Array(signature)).map((b) => b.toString(16).padStart(2, "0")).join("");
}

async function hasValidAccessCookie(request: NextRequest) {
// Accept both new and legacy cookie names during migration
const token =
request.cookies.get(ACCESS_COOKIE_NAME)?.value ??
request.cookies.get(ACCESS_COOKIE_NAME_LEGACY)?.value;
async function hasValidSession(request: NextRequest) {
const token = request.cookies.get(ACCESS_COOKIE_NAME)?.value ?? request.cookies.get(ACCESS_COOKIE_NAME_LEGACY)?.value;
const secret = (process.env.ACCESS_SESSION_SECRET || process.env.SUPABASE_SERVICE_ROLE_KEY || "").trim();

if (!token || !secret) return false;

const [codeId, expiresAt, signature] = token.split(".");
if (!codeId || !expiresAt || !signature) return false;
const parts = token.split(".");
if (parts.length < 3) return false;

const signature = parts[parts.length - 1];
const expiresAt = Number(parts[parts.length - 2]);
const userId = parts.slice(0, -2).join(".");

const expires = Number(expiresAt);
if (!Number.isFinite(expires) || expires < Date.now()) return false;
if (!userId || !Number.isFinite(expiresAt) || expiresAt < Date.now()) return false;

const expected = await getSignature(`${codeId}.${expiresAt}`, secret);
const expected = await getSignature(`${userId}.${expiresAt}`, secret);
return signature === expected;
}

export async function middleware(request: NextRequest) {
const { pathname, search } = request.nextUrl;

// Maintenance mode — redirect everything public to /maintenance
if (process.env.MAINTENANCE_MODE === "true" && !isMaintenanceBypass(pathname)) {
const url = request.nextUrl.clone();
url.pathname = "/maintenance";
return NextResponse.redirect(url);
}

if (!isProtectedPath(pathname)) {
return NextResponse.next();
}

if (await hasValidAccessCookie(request)) {
return NextResponse.next();
}
if (!isProtectedPath(pathname)) return NextResponse.next();
if (await hasValidSession(request)) return NextResponse.next();

const url = request.nextUrl.clone();
url.pathname = "/access";
url.searchParams.set("next", `${pathname}${search}`);

return NextResponse.redirect(url);
}

Expand Down
Loading