Skip to content
Draft
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
15 changes: 15 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,18 @@ MAILCHIMP_TAG=v2 Website Signups

# The Graph (optional — falls back to hardcoded values)
THEGRAPH_API_KEY=

# PymtHouse integration (required for Studio auth/device flow)
# Issuer must be the full OIDC issuer URL, e.g. http://localhost:3001/api/v1/oidc
# (site origin is derived from this URL in code — no PMTHOUSE_BASE_URL)
PYMTHOUSE_ISSUER_URL=
# Public OIDC client id (app_...)
PYMTHOUSE_PUBLIC_CLIENT_ID=
# Confidential helper client id (m2m_...)
PYMTHOUSE_M2M_CLIENT_ID=
# Confidential helper secret (pmth_cs_...)
PYMTHOUSE_M2M_CLIENT_SECRET=

# Website session signing secret
# Generate with: openssl rand -base64 32
LP_SESSION_SECRET=
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,5 @@ img/
.vscode/
*.code-workspace
.playwright-mcp/

certificates
2 changes: 1 addition & 1 deletion app/(studio)/studio/header-qa/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export default function HeaderQaPage() {
))}
<button
type="button"
onClick={() => disconnect()}
onClick={() => void disconnect()}
disabled={!isConnected}
className="rounded-full border border-white/10 bg-white/[0.03] px-4 py-2 text-sm text-white hover:bg-white/[0.06] transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
Expand Down
45 changes: 45 additions & 0 deletions app/(studio-auth)/studio/device-approved/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"use client";

import Link from "next/link";
import { LivepeerSymbol } from "@/components/icons/LivepeerLogo";

export default function DeviceApprovedPage() {
return (
<main className="relative flex min-h-screen items-center justify-center overflow-hidden bg-dark px-6">
<div
className="pointer-events-none absolute inset-0"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 45%, rgba(24,121,78,0.10) 0%, rgba(24,121,78,0.03) 45%, transparent 70%)",
}}
aria-hidden="true"
/>
<div className="relative z-10 w-full max-w-md rounded-2xl border border-white/10 bg-white/[0.03] p-8 text-center">
<div className="mb-5 flex justify-center">
<LivepeerSymbol className="h-9 w-9 text-white" />
</div>
<h1 className="text-2xl font-medium tracking-tight text-white">
Device login approved
</h1>
<p className="mt-3 text-sm leading-relaxed text-white/60">
You can now return to your terminal. Your python-gateway device flow
should finish automatically in a few seconds.
</p>
<div className="mt-6 flex items-center justify-center gap-3">
<Link
href="/studio"
className="rounded-lg bg-green px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-light"
>
Open Studio
</Link>
<Link
href="/studio/settings?tab=tokens"
className="rounded-lg border border-white/10 px-4 py-2 text-sm text-white/70 transition-colors hover:bg-white/[0.06]"
>
API tokens
</Link>
</div>
</div>
</main>
);
}
48 changes: 41 additions & 7 deletions app/(studio-auth)/studio/login/page.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,55 @@
"use client";

import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Suspense, useEffect } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/components/studio/AuthContext";
import LoginPage from "@/components/studio/LoginPage";

export default function LoginRoute() {
const { isConnected } = useAuth();
function LoginRouteInner() {
const { isConnected, isLoading } = useAuth();
const router = useRouter();
const searchParams = useSearchParams();
const deviceFlow = searchParams.get("flow") === "device";

useEffect(() => {
if (isConnected) {
if (!isLoading && isConnected && !deviceFlow) {
router.replace("/studio");
}
}, [isConnected, router]);
}, [isConnected, isLoading, deviceFlow, router]);

if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-dark">
<div
className="h-9 w-9 animate-spin rounded-full border-2 border-white/10 border-t-green-bright"
role="status"
aria-label="Checking session"
/>
</div>
);
}

if (isConnected) return null;
if (isConnected && !deviceFlow) {
return null;
}

return <LoginPage />;
}

export default function LoginRoute() {
return (
<Suspense
fallback={
<div className="flex min-h-screen items-center justify-center bg-dark">
<div
className="h-9 w-9 animate-spin rounded-full border-2 border-white/10 border-t-green-bright"
role="status"
aria-label="Loading"
/>
</div>
}
>
<LoginRouteInner />
</Suspense>
);
}
78 changes: 78 additions & 0 deletions app/api/auth/device/complete/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { NextRequest, NextResponse } from "next/server";
import { PmtHouseError } from "@pymthouse/builder-api";
import { completeStudioDeviceApprovalWithPymthouse } from "@/lib/pmth-studio-device-approval";
import {
applySessionCookies,
clearDeviceFlowCookie,
readDeviceFlowFromRequest,
readSessionFromRequest,
} from "@/lib/session";

export const runtime = "nodejs";

export async function POST(request: NextRequest) {
const session = await readSessionFromRequest(request);
if (!session) {
return NextResponse.json(
{
error: "unauthorized",
},
{ status: 401 },
);
}

const deviceFlow = await readDeviceFlowFromRequest(request);
if (!deviceFlow) {
return NextResponse.json(
{
error: "no_pending_device_flow",
},
{ status: 400 },
);
}

try {
const pmthUserJwt = await completeStudioDeviceApprovalWithPymthouse({
session,
userCode: deviceFlow.userCode,
});

const response = NextResponse.json({
success: true,
redirectTo: "/studio/device-approved",
});

await applySessionCookies(response, {
externalUserId: session.externalUserId,
email: session.email,
name: session.name,
initials: session.initials,
provider: session.provider,
pmthUserJwt,
apiTokens: session.apiTokens,
});

clearDeviceFlowCookie(response);
return response;
} catch (error) {
console.error("Device approval completion failed", error);
if (error instanceof PmtHouseError) {
return NextResponse.json(
{
error: error.code,
error_description: error.message,
},
{ status: error.status },
);
}

return NextResponse.json(
{
error: "device_approval_failed",
error_description:
error instanceof Error ? error.message : "Unknown device approval error",
},
{ status: 500 },
);
}
}
56 changes: 56 additions & 0 deletions app/api/auth/initiate-login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { createPmtHouseClientFromEnv } from "@pymthouse/builder-api/env";
import { completeStudioDeviceApprovalWithPymthouse } from "@/lib/pmth-studio-device-approval";
import {
applySessionCookies,
clearDeviceFlowCookie,
readSessionFromRequest,
setDeviceFlowCookie,
} from "@/lib/session";

export const runtime = "nodejs";

export async function GET(request: NextRequest) {
try {
const client = createPmtHouseClientFromEnv();
const parsed = client.parseDeviceApprovalRedirect(request.nextUrl.searchParams);
const session = await readSessionFromRequest(request);

if (!session) {
const loginUrl = new URL("/studio/login", request.url);
loginUrl.searchParams.set("flow", "device");
const response = NextResponse.redirect(loginUrl);
await setDeviceFlowCookie(response, {
iss: parsed.issuer,
targetLinkUri: parsed.targetLinkUri,
userCode: parsed.userCode,
clientId: parsed.clientId,
});
return response;
}

const pmthUserJwt = await completeStudioDeviceApprovalWithPymthouse({
session,
userCode: parsed.userCode,
});

const approvedUrl = new URL("/studio/device-approved", request.url);
const response = NextResponse.redirect(approvedUrl);
await applySessionCookies(response, {
externalUserId: session.externalUserId,
email: session.email,
name: session.name,
initials: session.initials,
provider: session.provider,
pmthUserJwt,
apiTokens: session.apiTokens,
});
clearDeviceFlowCookie(response);
return response;
} catch (error) {
console.error("Device initiate-login failed", error);
const loginUrl = new URL("/studio/login", request.url);
loginUrl.searchParams.set("error", "device_init_failed");
return NextResponse.redirect(loginUrl);
}
}
70 changes: 70 additions & 0 deletions app/api/auth/login/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from "next/server";
import { applySessionCookies } from "@/lib/session";
import {
deriveExternalUserId,
resolveLoginProfile,
type StudioAuthProvider,
} from "@/lib/studio-auth";

export const runtime = "nodejs";

function toProvider(value: unknown): StudioAuthProvider {
if (value === "github" || value === "google" || value === "email") {
return value;
}
return "email";
}

/**
* Studio sign-in: website-only stub session. No Pymthouse.
* Device approval uses Pymthouse only from `/api/auth/device/complete` or
* `GET /api/auth/initiate-login` when a session already exists.
*/
export async function POST(request: NextRequest) {
try {
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
const provider = toProvider(body.provider);
const profile = resolveLoginProfile({
provider,
email: typeof body.email === "string" ? body.email : undefined,
name: typeof body.name === "string" ? body.name : undefined,
});

const externalUserId = deriveExternalUserId(profile.email);

const userPayload = {
name: profile.name,
email: profile.email,
initials: profile.initials,
provider: profile.provider,
};

const response = NextResponse.json({
success: true,
redirectTo: "/studio",
deviceApproved: false,
user: userPayload,
});

await applySessionCookies(response, {
externalUserId,
email: profile.email,
name: profile.name,
initials: profile.initials,
provider: profile.provider,
pmthUserJwt: "",
});

return response;
} catch (error) {
console.error("Studio login failed", error);
return NextResponse.json(
{
error: "login_failed",
error_description:
error instanceof Error ? error.message : "Unexpected login error",
},
{ status: 500 },
);
}
}
10 changes: 10 additions & 0 deletions app/api/auth/logout/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { clearSessionCookies } from "@/lib/session";

export const runtime = "nodejs";

export async function POST() {
const response = NextResponse.json({ success: true });
clearSessionCookies(response);
return response;
}
27 changes: 27 additions & 0 deletions app/api/auth/me/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import {
BROWSER_JWT_COOKIE_NAME,
readSessionFromRequest,
toSessionPublicUser,
} from "@/lib/session";

export const runtime = "nodejs";

export async function GET(request: NextRequest) {
const session = await readSessionFromRequest(request);
if (!session) {
return NextResponse.json(
{
authenticated: false,
},
{ status: 401 },
);
}

const browserJwt = request.cookies.get(BROWSER_JWT_COOKIE_NAME)?.value ?? null;
return NextResponse.json({
authenticated: true,
user: toSessionPublicUser(session),
browserJwt,
});
}
Loading