diff --git a/.env.example b/.env.example index d184751..920b0b8 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,30 @@ # The Graph (optional — falls back to hardcoded values) THEGRAPH_API_KEY= + +# Discovery Service — full raw endpoint (as-is for gateway tokens). +# Explore uses the URL origin for `/v1/discovery/capabilities` etc. +# Aliases: DISCOVERY_URL, LIVEPEER_DISCOVERY_SERVICE_URL +DISCOVERY_SERVICE_URL=https://discovery-service-production-8955.up.railway.app/v1/discovery/raw + +# PymtHouse (usage + API keys + signer sessions) +# Production: +PYMTHOUSE_ISSUER_URL=https://pymthouse.com/api/v1/oidc +# Local pymthouse: http://localhost:3001/api/v1/oidc +# Public app client id (OAuth client_id) — used for Builder API path {clientId} +PYMTHOUSE_PUBLIC_CLIENT_ID= +PYMTHOUSE_M2M_CLIENT_ID= +PYMTHOUSE_M2M_CLIENT_SECRET= +# Remote signer DMZ base included as signer_url in API-key exchange responses +# (fallback when the issuer does not return signer_url). +# Production: https://pymthouse-production.up.railway.app +# Local compose: http://127.0.0.1:8080 +PYMTHOUSE_SIGNER_URL=https://pymthouse-production.up.railway.app +# Set to 1 for local dev when issuer uses http://127.0.0.1 +PYMTHOUSE_ALLOW_INSECURE_HTTP= + +# Live-runner discovery (orchestrator /discovery endpoint). +# Local example-apps stack: http://localhost:8935/discovery +# Remote hello-world orch: https://kiloutcorp.link:11111/discovery +RUNNER_DISCOVERY_URL=http://localhost:8935/discovery +# Accept self-signed orch TLS (local/dev only). +# RUNNER_GATEWAY_ALLOW_INSECURE_TLS=1 diff --git a/app/(app)/apps/[id]/page.tsx b/app/(app)/apps/[...id]/page.tsx similarity index 84% rename from app/(app)/apps/[id]/page.tsx rename to app/(app)/apps/[...id]/page.tsx index 6c2d313..deac82e 100644 --- a/app/(app)/apps/[id]/page.tsx +++ b/app/(app)/apps/[...id]/page.tsx @@ -22,7 +22,6 @@ import KeyBadge from "@/components/dashboard/KeyBadge"; import CallsTable from "@/components/dashboard/CallsTable"; import StatusDot from "@/components/dashboard/StatusDot"; import { - getAppById, effectiveVisibility, setPipelineVisibility, organizationSlug, @@ -30,6 +29,8 @@ import { SETTINGS_API_KEYS, MOCK_RECENT_REQUESTS, } from "@/lib/dashboard/mock-data"; +import { useDiscoveryModel } from "@/lib/dashboard/useDiscoveryModel"; +import DashboardPageSkeleton from "@/components/dashboard/DashboardPageSkeleton"; import { getAppIcon } from "@/lib/dashboard/utils"; import PlaygroundForm from "@/components/dashboard/playground/PlaygroundForm"; import JsonInput from "@/components/dashboard/playground/JsonInput"; @@ -37,6 +38,15 @@ import PlaygroundOutput from "@/components/dashboard/playground/PlaygroundOutput import TranscodingOutput from "@/components/dashboard/playground/TranscodingOutput"; import CodeSnippets from "@/components/dashboard/playground/CodeSnippets"; import WebcamPlayground from "@/components/dashboard/playground/WebcamPlayground"; +import { + RunnerGatewayProvider, + useRunnerGatewayContext, +} from "@/components/dashboard/playground/RunnerGatewayContext"; +import { + buildLiveRunnerPayload, + extractRunnerResultText, + runnerGatewayPostUrl, +} from "@/lib/dashboard/runner-gateway-client"; import AppAnalytics from "@/components/dashboard/stats/AppAnalytics"; import { OverviewTab, @@ -106,17 +116,33 @@ function modelMatchesRow(catalogId: string, runModel: string): boolean { // ─── Playground Tab ─── function PlaygroundTab({ model }: { model: App }) { + return ( + + + + ); +} + +function PlaygroundTabContent({ model }: { model: App }) { + const { user } = useAuth(); + const { + canRunLive, + state: runnerGatewayState, + signerJwt, + } = useRunnerGatewayContext(); const [inputMode, setInputMode] = useState<"form" | "json" | "python" | "node" | "http">("form"); const [isRunning, setIsRunning] = useState(false); const [result, setResult] = useState(null); const [inferenceTime, setInferenceTime] = useState(); const [lastRunValues, setLastRunValues] = useState | null>(null); + const [runError, setRunError] = useState(null); - const handleRun = useCallback( + const runMock = useCallback( (values: Record) => { setLastRunValues(values); setIsRunning(true); setResult(null); + setRunError(null); const time = 0.3 + Math.random() * 1.5; setTimeout(() => { setIsRunning(false); @@ -156,6 +182,87 @@ function PlaygroundTab({ model }: { model: App }) { [model], ); + const runLive = useCallback( + async (values: Record) => { + if (runnerGatewayState.status !== "ready" || !user?.id?.trim()) { + runMock(values); + return; + } + + setLastRunValues(values); + setIsRunning(true); + setResult(null); + setRunError(null); + const started = performance.now(); + + try { + const runnerPath = + model.playgroundConfig?.runnerPath?.trim() || "chat/completions"; + const payload = buildLiveRunnerPayload(model, values); + const url = runnerGatewayPostUrl( + runnerGatewayState.gatewayBaseUrl, + runnerGatewayState.runnerAppId, + runnerPath, + ); + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-external-user-id": user.id.trim(), + }, + body: JSON.stringify(payload), + }); + + const contentType = response.headers.get("content-type") ?? ""; + if (!response.ok) { + let message = `Gateway error (${response.status})`; + try { + const errBody = (await response.json()) as { error?: string }; + if (errBody.error) message = errBody.error; + } catch { + // ignore + } + throw new Error(message); + } + + if (contentType.includes("text/event-stream") && response.body) { + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let streamed = ""; + while (true) { + const { done, value } = await reader.read(); + if (done) break; + streamed += decoder.decode(value, { stream: true }); + setResult(streamed); + } + } else { + const data = await response.json(); + setResult(extractRunnerResultText(data)); + } + + setInferenceTime(parseFloat(((performance.now() - started) / 1000).toFixed(1))); + } catch (error) { + const message = error instanceof Error ? error.message : "Run failed"; + setRunError(message); + setResult(null); + } finally { + setIsRunning(false); + } + }, + [model, runMock, runnerGatewayState, user?.id], + ); + + const handleRun = useCallback( + (values: Record) => { + if (canRunLive) { + void runLive(values); + return; + } + runMock(values); + }, + [canRunLive, runLive, runMock], + ); + // Ctrl+Enter shortcut useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -231,6 +338,7 @@ function PlaygroundTab({ model }: { model: App }) { config={model.playgroundConfig} onRun={handleRun} isRunning={isRunning} + signerJwt={signerJwt} /> )} {inputMode === "json" && ( @@ -244,6 +352,10 @@ function PlaygroundTab({ model }: { model: App }) { inputMode === "node" || inputMode === "http") && (
+ {/* Signer JWT is minted server-side and kept off the visible UI. */} + {signerJwt ? ( + + ) : null}
@@ -281,7 +393,22 @@ function PlaygroundTab({ model }: { model: App }) { {/* Right: Output */}
-

Output

+
+

Output

+ {model.runnerAppId && runnerGatewayState.status === "ready" && ( + + Live runner + + )} + {model.runnerAppId && runnerGatewayState.status === "loading" && ( + Preparing signer… + )} +
+ {runError && ( +

+ {runError} +

+ )} {model.playgroundConfig.playgroundVariant === "transcoding" ? ( decodeURIComponent(segment)).join("/"); + } + return id ? decodeURIComponent(id) : ""; +} + export default function AppDetailPage() { - const { id } = useParams<{ id: string }>(); + const params = useParams<{ id: string | string[] }>(); + const id = capabilityIdFromParams(params.id); const { isConnected } = useAuth(); - - // The unified app — its catalog face and (always, since unification) its - // deployment manifest under `app.deployment`. One id-based lookup resolves - // both the org's own apps and the third-party catalog models. - const app = getAppById(id); + // The app detail is powered by live Discovery Service data — the capability + // is resolved by id, with loading/error/not-found states handled below. + const discovery = useDiscoveryModel(id || undefined); + const app = discovery.status === "ready" ? discovery.model : undefined; // Owner/operator chrome (Settings/manage tab, publish controls) lives in the // stacked apps PR. In the consumer base the app detail is view-only for // everyone, so owner mode is gated off here; the stacked PR's revert removes @@ -657,6 +791,31 @@ export default function AppDetailPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [id, isOwner]); + if (discovery.status === "loading") { + return ( +
+ +
+ ); + } + + if (discovery.status === "error") { + return ( +
+
+

Could not load capability from Discovery Service.

+

{discovery.message}

+ + Back to Explore + +
+
+ ); + } + if (!app) { return (
diff --git a/app/(app)/usage/page.tsx b/app/(app)/usage/page.tsx index 94a75d1..1c17602 100644 --- a/app/(app)/usage/page.tsx +++ b/app/(app)/usage/page.tsx @@ -1,13 +1,9 @@ "use client"; -import { Suspense, useState } from "react"; +import { Suspense } from "react"; import Link from "next/link"; import { BarChart3, Box, ChevronDown } from "lucide-react"; import { useAuth } from "@/components/dashboard/AuthContext"; -import { useEnvironment } from "@/components/dashboard/EnvironmentContext"; -import EnvironmentFilter, { - ALL_ENVIRONMENTS as ALL, -} from "@/components/dashboard/EnvironmentFilter"; import DashboardPageHeader from "@/components/dashboard/DashboardPageHeader"; import DashboardPageSkeleton from "@/components/dashboard/DashboardPageSkeleton"; import SignInWall from "@/components/dashboard/SignInWall"; @@ -23,29 +19,26 @@ export default function UsagePage() { function UsageContent() { const { isConnected, isLoading } = useAuth(); - const { environments } = useEnvironment(); - const [envFilter, setEnvFilter] = useState(ALL); // Avoid flashing the wall while auth hydrates. if (isLoading) return null; - if (!isConnected) return ; - const selected = environments.find((e) => e.id === envFilter); - // Consumption split: production carries the bulk, development the rest. - const weight = - envFilter === ALL ? 1 : selected?.kind === "production" ? 0.91 : 0.09; - const filterName = - envFilter === ALL ? "all environments" : (selected?.name ?? "all environments"); + // Workspace-only route — logged-out users see "Usage is workspace-only" + // wall in place of the dashboard. The previous behavior (a hard redirect + // to /login) was wrong per the v4 prototype: it dropped the + // user out of context. The wall keeps them inside the app shell, leaves + // the sidebar in its logged-out variant, and offers an explicit + // "Explore capabilities" escape hatch. + if (!isConnected) return ; return (
-
); diff --git a/app/(auth)/signup/page.tsx b/app/(auth)/signup/page.tsx index a211dc6..4fe8030 100644 --- a/app/(auth)/signup/page.tsx +++ b/app/(auth)/signup/page.tsx @@ -1,17 +1,11 @@ "use client"; -import { useEffect } from "react"; +import { Suspense, useEffect } from "react"; import { useRouter } from "next/navigation"; import { useAuth } from "@/components/dashboard/AuthContext"; import LoginPage from "@/components/dashboard/LoginPage"; -/** - * Signup route — sibling of `/login`. Renders the same - * `LoginPage` component but seeds it with `initialMode="signup"`. The - * footer toggle inside the page is a `` to `/login`, so - * URL and visible mode stay in sync without query-param trickery. - */ -export default function SignupRoute() { +function SignupRouteInner() { const { isConnected } = useAuth(); const router = useRouter(); @@ -25,3 +19,11 @@ export default function SignupRoute() { return ; } + +export default function SignupRoute() { + return ( + + + + ); +} diff --git a/app/api/discovery/explore/route.ts b/app/api/discovery/explore/route.ts new file mode 100644 index 0000000..fae5bd3 --- /dev/null +++ b/app/api/discovery/explore/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { + DEFAULT_DISCOVERY_SERVICE_TYPE, + fetchExploreModels, + type DiscoveryServiceType, +} from "@/lib/discovery/client"; + +function parseServiceType(value: string | null): DiscoveryServiceType { + if (value === "registry") return "registry"; + return DEFAULT_DISCOVERY_SERVICE_TYPE; +} + +export async function GET(request: Request): Promise { + const { searchParams } = new URL(request.url); + const serviceType = parseServiceType(searchParams.get("serviceType")); + + try { + const payload = await fetchExploreModels(serviceType); + return NextResponse.json(payload); + } catch (error) { + const message = error instanceof Error ? error.message : "Discovery Service request failed"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/app/api/discovery/models/[...id]/route.ts b/app/api/discovery/models/[...id]/route.ts new file mode 100644 index 0000000..7231934 --- /dev/null +++ b/app/api/discovery/models/[...id]/route.ts @@ -0,0 +1,56 @@ +import { NextResponse } from "next/server"; +import { + DEFAULT_DISCOVERY_SERVICE_TYPE, + fetchDiscoveryCapabilities, + queryDiscoveryCapabilities, + type DiscoveryServiceType, +} from "@/lib/discovery/client"; +import { mapCapabilityToModel } from "@/lib/discovery/map-to-model"; + +function parseServiceType(value: string | null): DiscoveryServiceType { + if (value === "registry") return "registry"; + return DEFAULT_DISCOVERY_SERVICE_TYPE; +} + +function capabilityFromSegments(segments: string[]): string { + return segments.map((segment) => decodeURIComponent(segment)).join("/"); +} + +export async function GET( + request: Request, + context: { params: Promise<{ id: string[] }> }, +): Promise { + const { id: segments } = await context.params; + const capability = capabilityFromSegments(segments ?? []); + const { searchParams } = new URL(request.url); + const serviceType = parseServiceType(searchParams.get("serviceType")); + + if (!capability) { + return NextResponse.json({ error: "Capability not found" }, { status: 404 }); + } + + try { + const capabilitiesResponse = await fetchDiscoveryCapabilities(serviceType); + const entries = capabilitiesResponse.entries ?? []; + const known = + capabilitiesResponse.capabilities.includes(capability) || + entries.some((entry) => entry.capability === capability); + + if (!known) { + return NextResponse.json({ error: "Capability not found" }, { status: 404 }); + } + + const entry = entries.find((item) => item.capability === capability); + const queryResponse = await queryDiscoveryCapabilities([capability], serviceType); + const model = mapCapabilityToModel( + capability, + entry, + queryResponse.results[capability] ?? [], + ); + + return NextResponse.json({ model, serviceType }); + } catch (error) { + const message = error instanceof Error ? error.message : "Discovery Service request failed"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/app/api/pymthouse/account-requests/route.ts b/app/api/pymthouse/account-requests/route.ts new file mode 100644 index 0000000..49fd627 --- /dev/null +++ b/app/api/pymthouse/account-requests/route.ts @@ -0,0 +1,49 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PmtHouseError } from "@pymthouse/builder-sdk"; +import { fetchAccountRequestsForExternalUser } from "@/lib/dashboard/pymthouse-bff"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const NO_STORE_HEADERS = { "Cache-Control": "no-store, max-age=0" } as const; + +export async function GET(request: NextRequest) { + const externalUserId = request.nextUrl.searchParams.get("externalUserId")?.trim(); + if (!externalUserId) { + return NextResponse.json( + { error: "externalUserId is required" }, + { status: 400, headers: NO_STORE_HEADERS }, + ); + } + + const cursor = request.nextUrl.searchParams.get("cursor")?.trim() || undefined; + const limitRaw = request.nextUrl.searchParams.get("limit"); + const limit = limitRaw ? Number.parseInt(limitRaw, 10) : 50; + if (!Number.isFinite(limit) || limit < 1 || limit > 50) { + return NextResponse.json( + { error: "limit must be between 1 and 50" }, + { status: 400, headers: NO_STORE_HEADERS }, + ); + } + + try { + const payload = await fetchAccountRequestsForExternalUser({ + externalUserId, + cursor, + limit, + }); + return NextResponse.json(payload, { headers: NO_STORE_HEADERS }); + } catch (error) { + if (error instanceof PmtHouseError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status, headers: NO_STORE_HEADERS }, + ); + } + const message = error instanceof Error ? error.message : "Requests fetch failed"; + return NextResponse.json( + { error: message }, + { status: 502, headers: NO_STORE_HEADERS }, + ); + } +} diff --git a/app/api/pymthouse/account-usage/route.ts b/app/api/pymthouse/account-usage/route.ts new file mode 100644 index 0000000..51f7622 --- /dev/null +++ b/app/api/pymthouse/account-usage/route.ts @@ -0,0 +1,59 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PmtHouseError } from "@pymthouse/builder-sdk"; +import { fetchAccountUsageForExternalUser } from "@/lib/dashboard/pymthouse-bff"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +// Usage/balance is live data; never let the browser or any CDN replay a stale +// response (the balance would otherwise freeze at the first cached read). +const NO_STORE_HEADERS = { "Cache-Control": "no-store, max-age=0" } as const; + +export async function GET(request: NextRequest) { + const externalUserId = request.nextUrl.searchParams.get("externalUserId")?.trim(); + if (!externalUserId) { + return NextResponse.json( + { error: "externalUserId is required" }, + { status: 400 }, + ); + } + + const windowRaw = request.nextUrl.searchParams.get("window")?.trim().toLowerCase(); + const window = + windowRaw === "mtd" || windowRaw === "rolling" ? windowRaw : "rolling"; + + const rawDays = request.nextUrl.searchParams.get("days"); + const periodDays = rawDays ? Number.parseInt(rawDays, 10) : 30; + if (window === "rolling" && (!Number.isFinite(periodDays) || periodDays < 1 || periodDays > 90)) { + return NextResponse.json( + { error: "days must be between 1 and 90" }, + { status: 400 }, + ); + } + + const includePriorRaw = request.nextUrl.searchParams.get("includePrior"); + const includePrior = + includePriorRaw == null ? true : !["0", "false", "no"].includes(includePriorRaw.toLowerCase()); + + try { + const payload = await fetchAccountUsageForExternalUser({ + externalUserId, + periodDays, + window, + includePrior, + }); + return NextResponse.json(payload, { headers: NO_STORE_HEADERS }); + } catch (error) { + if (error instanceof PmtHouseError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status, headers: NO_STORE_HEADERS }, + ); + } + const message = error instanceof Error ? error.message : "Usage fetch failed"; + return NextResponse.json( + { error: message }, + { status: 502, headers: NO_STORE_HEADERS }, + ); + } +} diff --git a/app/api/pymthouse/keys/exchange/route.ts b/app/api/pymthouse/keys/exchange/route.ts new file mode 100644 index 0000000..0352729 --- /dev/null +++ b/app/api/pymthouse/keys/exchange/route.ts @@ -0,0 +1,190 @@ +import { PmtHouseError } from "@pymthouse/builder-sdk"; +import { + normalizeDeviceExchangeResponse, + parseApiKeyExchangeRequestBody, +} from "@pymthouse/builder-sdk/signer/server"; + +const TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"; +const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"; + +type ExchangeConfig = { + issuerUrl: string; + publicClientId: string; + m2mClientId: string; + m2mClientSecret: string; + signerUrl: string | undefined; +}; + +/** Thin BFF; canonical issuer route is POST …/apps/{clientId}/oidc/token (RFC 8693). */ +function readApiKeyExchangeConfig(): ExchangeConfig | null { + const issuerUrl = process.env.PYMTHOUSE_ISSUER_URL?.trim(); + const publicClientId = + process.env.PYMTHOUSE_PUBLIC_CLIENT_ID?.trim() || + process.env.DASHBOARD_DEVICE_PUBLIC_CLIENT_ID?.trim(); + if (!issuerUrl || !publicClientId) { + return null; + } + const signerUrl = + process.env.PYMTHOUSE_CLIENT_SIGNER_API_URL?.trim() || + process.env.PYMTHOUSE_SIGNER_URL?.trim() || + process.env.SIGNER_PUBLIC_URL?.trim() || + undefined; + return { + issuerUrl, + publicClientId, + m2mClientId: process.env.PYMTHOUSE_M2M_CLIENT_ID?.trim() ?? "", + m2mClientSecret: process.env.PYMTHOUSE_M2M_CLIENT_SECRET?.trim() ?? "", + signerUrl, + }; +} + +function appsOrigin(issuerUrl: string): string { + return issuerUrl.replace(/\/api\/v1\/oidc\/?$/i, ""); +} + +function readStringField( + body: Record, + key: string, +): string | undefined { + const value = body[key]; + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +async function exchangeApiKeyViaOidcToken(input: { + config: ExchangeConfig; + apiKey: string; + scope?: string; +}): Promise { + const { config, apiKey, scope } = input; + const url = `${appsOrigin(config.issuerUrl)}/api/v1/apps/${encodeURIComponent(config.publicClientId)}/oidc/token`; + + const form = new URLSearchParams({ + grant_type: TOKEN_EXCHANGE_GRANT, + subject_token: apiKey, + subject_token_type: ACCESS_TOKEN_TYPE, + }); + if (scope) { + form.set("scope", scope); + } + + const headers: Record = { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }; + if (config.m2mClientId && config.m2mClientSecret) { + const basic = Buffer.from( + [config.m2mClientId, config.m2mClientSecret].join(":"), + ).toString("base64"); + headers.Authorization = `Basic ${basic}`; + } + + const response = await fetch(url, { + method: "POST", + headers, + body: form.toString(), + cache: "no-store", + }); + + let parsed: Record; + try { + parsed = (await response.json()) as Record; + } catch { + throw new PmtHouseError("Token exchange returned invalid JSON", { + status: 502, + code: "invalid_exchange_response", + }); + } + + if (!response.ok) { + const description = + readStringField(parsed, "error_description") || + readStringField(parsed, "error") || + `Token exchange failed (${response.status})`; + throw new PmtHouseError(description, { + status: response.status, + code: readStringField(parsed, "error") ?? "api_key_exchange_failed", + }); + } + + const accessToken = readStringField(parsed, "access_token"); + if (!accessToken) { + throw new PmtHouseError("Token exchange response missing access_token", { + status: 502, + code: "invalid_exchange_response", + }); + } + + const signerUrl = + readStringField(parsed, "signer_url") || config.signerUrl || undefined; + + const expiresIn = + typeof parsed.expires_in === "number" && Number.isFinite(parsed.expires_in) + ? parsed.expires_in + : 3600; + + const body = normalizeDeviceExchangeResponse( + { + access_token: accessToken, + expires_in: expiresIn, + scope: readStringField(parsed, "scope") || scope || "sign:job", + balanceUsdMicros: readStringField(parsed, "balanceUsdMicros") ?? "0", + lifetimeGrantedUsdMicros: + readStringField(parsed, "lifetimeGrantedUsdMicros") ?? "0", + }, + { signer_url: signerUrl }, + ); + + return Response.json(body, { + status: 200, + headers: { "Cache-Control": "no-store" }, + }); +} + +function errorResponse(error: unknown): Response { + if (error instanceof PmtHouseError) { + return Response.json( + { + error: error.code ?? "api_key_exchange_failed", + error_description: error.message, + }, + { status: error.status ?? 500 }, + ); + } + const message = error instanceof Error ? error.message : "API key exchange failed"; + return Response.json( + { error: "api_key_exchange_failed", error_description: message }, + { status: 500 }, + ); +} + +export async function POST(request: Request) { + const config = readApiKeyExchangeConfig(); + if (!config) { + return Response.json( + { + error: "server_misconfigured", + error_description: + "PYMTHOUSE_ISSUER_URL and PYMTHOUSE_PUBLIC_CLIENT_ID are required", + }, + { status: 503 }, + ); + } + + try { + const parsed = await parseApiKeyExchangeRequestBody(request); + const effectiveClientId = parsed.clientId?.trim() || config.publicClientId; + if (effectiveClientId !== config.publicClientId) { + throw new PmtHouseError("clientId does not match configured public client", { + status: 400, + code: "invalid_request", + }); + } + return await exchangeApiKeyViaOidcToken({ + config, + apiKey: parsed.apiKey, + scope: parsed.scope, + }); + } catch (error) { + return errorResponse(error); + } +} diff --git a/app/api/pymthouse/keys/route.ts b/app/api/pymthouse/keys/route.ts new file mode 100644 index 0000000..a615e82 --- /dev/null +++ b/app/api/pymthouse/keys/route.ts @@ -0,0 +1,94 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PmtHouseError } from "@pymthouse/builder-sdk"; +import { + createDashboardApiKey, + listDashboardApiKeys, + revokeDashboardApiKey, +} from "@/lib/dashboard/pymthouse-keys-bff"; + +export const runtime = "nodejs"; + +export async function GET(request: NextRequest) { + const externalUserId = request.nextUrl.searchParams.get("externalUserId")?.trim(); + const email = request.nextUrl.searchParams.get("email")?.trim() || undefined; + if (!externalUserId) { + return NextResponse.json( + { error: "externalUserId is required" }, + { status: 400 }, + ); + } + + try { + const keys = await listDashboardApiKeys(externalUserId, email); + return NextResponse.json({ keys }); + } catch (error) { + if (error instanceof PmtHouseError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status }, + ); + } + const message = error instanceof Error ? error.message : "Failed to list API keys"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} + +export async function POST(request: NextRequest) { + let body: { externalUserId?: string; email?: string; label?: string }; + try { + body = (await request.json()) as typeof body; + } catch { + return NextResponse.json({ error: "invalid_json" }, { status: 400 }); + } + + const externalUserId = body.externalUserId?.trim(); + if (!externalUserId) { + return NextResponse.json( + { error: "externalUserId is required" }, + { status: 400 }, + ); + } + + try { + const created = await createDashboardApiKey({ + externalUserId, + email: body.email?.trim() || undefined, + label: body.label, + }); + return NextResponse.json(created, { status: 201 }); + } catch (error) { + if (error instanceof PmtHouseError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status }, + ); + } + const message = error instanceof Error ? error.message : "Failed to create API key"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} + +export async function DELETE(request: NextRequest) { + const externalUserId = request.nextUrl.searchParams.get("externalUserId")?.trim(); + const keyId = request.nextUrl.searchParams.get("keyId")?.trim(); + if (!externalUserId || !keyId) { + return NextResponse.json( + { error: "externalUserId and keyId are required" }, + { status: 400 }, + ); + } + + try { + await revokeDashboardApiKey({ externalUserId, keyId }); + return NextResponse.json({ success: true }); + } catch (error) { + if (error instanceof PmtHouseError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status }, + ); + } + const message = error instanceof Error ? error.message : "Failed to revoke API key"; + return NextResponse.json({ error: message }, { status: 502 }); + } +} diff --git a/app/api/pymthouse/signer-session/route.ts b/app/api/pymthouse/signer-session/route.ts new file mode 100644 index 0000000..8ba9a5a --- /dev/null +++ b/app/api/pymthouse/signer-session/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PmtHouseError } from "@pymthouse/builder-sdk"; +import { + getSignerSessionStatus, + isRunnerSignerConfigured, +} from "@/lib/dashboard/signer-session-bff"; +import { isRunnerGatewayConfigured } from "@/lib/runner-gateway"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const NO_STORE_HEADERS = { "Cache-Control": "no-store, max-age=0" } as const; + +export async function POST(request: NextRequest) { + if (!isRunnerGatewayConfigured()) { + return NextResponse.json( + { + error: "server_misconfigured", + error_description: "RUNNER_DISCOVERY_URL is required", + }, + { status: 503, headers: NO_STORE_HEADERS }, + ); + } + + if (!isRunnerSignerConfigured()) { + // Offchain path: no JWT to mint — playground can still hit local runners. + return NextResponse.json( + { + ready: true, + expiresIn: 0, + balanceUsdMicros: "0", + lifetimeGrantedUsdMicros: "0", + jwt: "", + }, + { headers: NO_STORE_HEADERS }, + ); + } + + let externalUserId = request.nextUrl.searchParams.get("externalUserId")?.trim() ?? ""; + if (!externalUserId) { + try { + const body = (await request.json()) as { externalUserId?: string }; + externalUserId = body.externalUserId?.trim() ?? ""; + } catch { + // ignore — query param is the primary interface + } + } + + if (!externalUserId) { + return NextResponse.json( + { error: "externalUserId is required" }, + { status: 400, headers: NO_STORE_HEADERS }, + ); + } + + try { + const status = await getSignerSessionStatus(externalUserId); + return NextResponse.json(status, { headers: NO_STORE_HEADERS }); + } catch (error) { + if (error instanceof PmtHouseError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status, headers: NO_STORE_HEADERS }, + ); + } + const message = error instanceof Error ? error.message : "Signer session failed"; + return NextResponse.json( + { error: message }, + { status: 502, headers: NO_STORE_HEADERS }, + ); + } +} diff --git a/app/api/runner-gateway/v1/[...path]/route.ts b/app/api/runner-gateway/v1/[...path]/route.ts new file mode 100644 index 0000000..d902037 --- /dev/null +++ b/app/api/runner-gateway/v1/[...path]/route.ts @@ -0,0 +1,119 @@ +import { NextRequest, NextResponse } from "next/server"; +import { PmtHouseError } from "@pymthouse/builder-sdk"; +import { + forwardRunnerRequest, + isRunnerGatewayConfigured, + RunnerGatewayError, +} from "@/lib/runner-gateway"; +import { isRunnerSignerConfigured } from "@/lib/dashboard/signer-session-bff"; +import "@/lib/runner-gateway/tls"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const maxDuration = 300; + +const NO_STORE_HEADERS = { "Cache-Control": "no-store, max-age=0" } as const; + +function readExternalUserId(request: NextRequest): string { + return ( + request.headers.get("x-external-user-id")?.trim() || + request.nextUrl.searchParams.get("externalUserId")?.trim() || + "" + ); +} + +function readRunnerApp(request: NextRequest): string { + return ( + request.nextUrl.searchParams.get("app")?.trim() || + request.headers.get("x-runner-app")?.trim() || + "" + ); +} + +export async function POST( + request: NextRequest, + context: { params: Promise<{ path: string[] }> }, +) { + if (!isRunnerGatewayConfigured()) { + return NextResponse.json( + { + error: "server_misconfigured", + error_description: "RUNNER_DISCOVERY_URL is required", + code: "runner_misconfigured", + }, + { status: 503, headers: NO_STORE_HEADERS }, + ); + } + + const externalUserId = readExternalUserId(request); + if (!externalUserId) { + return NextResponse.json( + { error: "x-external-user-id is required", code: "unauthorized" }, + { status: 401, headers: NO_STORE_HEADERS }, + ); + } + + const appId = readRunnerApp(request); + if (!appId) { + return NextResponse.json( + { error: "app query parameter or x-runner-app header is required", code: "invalid_app" }, + { status: 400, headers: NO_STORE_HEADERS }, + ); + } + + if (!isRunnerSignerConfigured()) { + // Offchain path: signer optional when PymtHouse is not configured. + } + + const { path } = await context.params; + const runnerPath = path.join("/"); + + let payload: Record; + try { + payload = (await request.json()) as Record; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("invalid body"); + } + } catch { + return NextResponse.json( + { error: "Request body must be a JSON object", code: "invalid_body" }, + { status: 400, headers: NO_STORE_HEADERS }, + ); + } + + const discoveryUrl = process.env.RUNNER_DISCOVERY_URL!.trim(); + + try { + const response = await forwardRunnerRequest({ + externalUserId, + appId, + runnerPath, + payload, + discoveryUrl, + }); + const headers = new Headers(response.headers); + headers.set("Cache-Control", "no-store, max-age=0"); + return new Response(response.body, { + status: response.status, + headers, + }); + } catch (error) { + if (error instanceof RunnerGatewayError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status, headers: NO_STORE_HEADERS }, + ); + } + if (error instanceof PmtHouseError) { + return NextResponse.json( + { error: error.message, code: error.code }, + { status: error.status, headers: NO_STORE_HEADERS }, + ); + } + const message = error instanceof Error ? error.message : "Runner gateway failed"; + return NextResponse.json( + { error: message, code: "runner_error" }, + { status: 502, headers: NO_STORE_HEADERS }, + ); + } +} diff --git a/components/dashboard/AuthContext.tsx b/components/dashboard/AuthContext.tsx index 41ec97f..797ba0a 100644 --- a/components/dashboard/AuthContext.tsx +++ b/components/dashboard/AuthContext.tsx @@ -11,6 +11,8 @@ import { export type AuthProvider = "github" | "google" | "email"; export interface MockUser { + /** Persistent machine id used as PymtHouse `externalUserId` (never email). */ + id: string; name: string; email: string; initials: string; @@ -22,7 +24,7 @@ interface AuthContextValue { isConnected: boolean; isLoading: boolean; user: MockUser | null; - connect: (user: MockUser) => void; + connect: (user: Omit & { id?: string }) => void; updateUser: (patch: Partial) => void; disconnect: () => void; } @@ -40,6 +42,24 @@ export function useAuth() { return useContext(AuthContext); } +function newMachineId(): string { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + return `dash_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`; +} + +function hydrateMockUser(parsed: Partial): MockUser { + return { + id: typeof parsed.id === "string" && parsed.id.trim() ? parsed.id.trim() : newMachineId(), + name: parsed.name ?? "Demo User", + email: parsed.email ?? "demo@livepeer.org", + initials: parsed.initials ?? "DU", + provider: (parsed.provider as AuthProvider) ?? "email", + avatarUrl: parsed.avatarUrl, + }; +} + export function AuthProvider({ children }: { children: ReactNode }) { const [isConnected, setIsConnected] = useState(false); const [isLoading, setIsLoading] = useState(true); @@ -52,16 +72,11 @@ export function AuthProvider({ children }: { children: ReactNode }) { if (stored) { try { const parsed = JSON.parse(stored) as Partial; - // Backfill provider for any pre-existing localStorage entries - const hydrated: MockUser = { - name: parsed.name ?? "Demo User", - email: parsed.email ?? "demo@livepeer.org", - initials: parsed.initials ?? "DU", - provider: (parsed.provider as AuthProvider) ?? "email", - avatarUrl: parsed.avatarUrl, - }; + // Backfill provider + machine id for pre-existing localStorage entries + const hydrated = hydrateMockUser(parsed); setUser(hydrated); setIsConnected(true); + localStorage.setItem("dashboard-user", JSON.stringify(hydrated)); } catch { // ignore } @@ -70,16 +85,17 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, []); - const connect = (u: MockUser) => { - setUser(u); + const connect = (u: Omit & { id?: string }) => { + const next = hydrateMockUser(u); + setUser(next); setIsConnected(true); - localStorage.setItem("dashboard-user", JSON.stringify(u)); + localStorage.setItem("dashboard-user", JSON.stringify(next)); }; const updateUser = (patch: Partial) => { setUser((prev) => { if (!prev) return prev; - const next = { ...prev, ...patch }; + const next = hydrateMockUser({ ...prev, ...patch }); localStorage.setItem("dashboard-user", JSON.stringify(next)); return next; }); diff --git a/components/dashboard/CallsView.tsx b/components/dashboard/CallsView.tsx index 34f52a8..0d20d2f 100644 --- a/components/dashboard/CallsView.tsx +++ b/components/dashboard/CallsView.tsx @@ -9,10 +9,8 @@ import CallDetailDrawer from "@/components/dashboard/CallDetailDrawer"; import EnvironmentFilter, { ALL_ENVIRONMENTS, } from "@/components/dashboard/EnvironmentFilter"; -import { - recentRequestsForEnvironment, - MOCK_RECENT_REQUESTS, -} from "@/lib/dashboard/mock-data"; +import { useAuth } from "@/components/dashboard/AuthContext"; +import { useAccountRequests } from "@/lib/dashboard/useAccountRequests"; import type { AccountActivityRow } from "@/lib/dashboard/types"; type KindFilter = "all" | "batch" | "live"; @@ -23,14 +21,13 @@ const KIND_TABS: { key: KindFilter; label: string }[] = [ { key: "batch", label: "Batch" }, ]; +const EMPTY_ROWS: AccountActivityRow[] = []; + /** - * CallsView — the standalone /calls list: every call this organization made - * across the network (what counts toward its usage). A Batch / Live segmented - * filter splits the two invocation shapes the Runner SDK exposes — batch - * `predict` request/response vs live streaming `session` — and the table's - * metric column follows suit (latency for batch, session duration for live). - * Clicking a row opens the per-call inspector (a right-side drawer) via - * `?request={id}` — useSearchParams needs the Suspense boundary below. + * CallsView — the standalone /calls list: every signed-ticket request this + * account made (PymtHouse OpenMeter history). A Batch / Live segmented filter + * splits invocation shapes inferred from pipeline. Clicking a row opens the + * per-call inspector via `?request={id}`. */ export default function CallsView() { return ( @@ -41,18 +38,20 @@ export default function CallsView() { } function CallsViewInner() { + const { user } = useAuth(); + const requests = useAccountRequests(user?.id?.trim()); const [query, setQuery] = useState(""); const [envFilter, setEnvFilter] = useState(ALL_ENVIRONMENTS); const [kind, setKind] = useState("all"); - // The open call is URL-addressable (`/calls?request={id}`) so the inspector - // is deep-linkable and the back button closes it. `shownRow` is held through - // the close transition so the drawer animates out with its content intact. const router = useRouter(); const searchParams = useSearchParams(); const requestId = searchParams.get("request"); + + const allRows = requests.status === "ready" ? requests.rows : EMPTY_ROWS; + const openCall = requestId - ? (MOCK_RECENT_REQUESTS.find((r) => r.id === requestId) ?? null) + ? (allRows.find((r) => r.id === requestId) ?? null) : null; const [shownRow, setShownRow] = useState(null); useEffect(() => { @@ -61,11 +60,10 @@ function CallsViewInner() { const allEnvs = envFilter === ALL_ENVIRONMENTS; - // Env-scoped set drives the segmented-filter counts (before the kind filter). const envScoped = useMemo( () => - allEnvs ? MOCK_RECENT_REQUESTS : recentRequestsForEnvironment(envFilter), - [allEnvs, envFilter], + allEnvs ? allRows : allRows.filter((r) => r.environmentId === envFilter), + [allEnvs, allRows, envFilter], ); const counts = useMemo( () => ({ @@ -87,8 +85,6 @@ function CallsViewInner() { r.pipeline.toLowerCase().includes(q), ); } - // Live, in-progress sessions float to the top — they're happening now. - // (Array.sort is stable, so terminal rows keep their newest-first order.) return [...scoped].sort( (a, b) => (a.status === "active" ? 0 : 1) - (b.status === "active" ? 0 : 1), @@ -114,7 +110,6 @@ function CallsViewInner() { } /> - {/* Filter bar — Batch / Live segmented control + search. */}
- {/* Calls list — shared `CallsTable` (cozy density for the full-bleed view) */} - {rows.length === 0 ? ( + {requests.status === "loading" || requests.status === "idle" ? ( + diff --git a/components/dashboard/SidebarUsageCard.tsx b/components/dashboard/SidebarUsageCard.tsx index 5a03665..207e5dc 100644 --- a/components/dashboard/SidebarUsageCard.tsx +++ b/components/dashboard/SidebarUsageCard.tsx @@ -1,76 +1,130 @@ "use client"; +import type { ReactNode } from "react"; import Link from "next/link"; -import { ACCOUNT_USAGE_SUMMARY } from "@/lib/dashboard/mock-data"; +import { useAuth } from "@/components/dashboard/AuthContext"; +import { useAccountUsage } from "@/lib/dashboard/useAccountUsage"; +import { + formatPeriodResetLabel, + microsToUsd, +} from "@/lib/dashboard/usage-capability-display"; /** * SidebarUsageCard — bottom-of-sidebar plan + usage indicator. * - * Per the Livepeer Console design v2 (Apr 2026, `.side-usage` in styles.css): - * compact bordered card placed between the sidebar's flex spacer and the - * footer (Network / Docs / Settings + status row). Clicking it routes to the - * Usage page. Active state when on that route. - * - * Visual spec (exact): - * - margin 8px 4px 0; padding 9px 10px 8px; border-radius var(--r-md) - * - 1px hairline border, bg dark-lighter; hover: border-2 + bg dark-card - * - Active: border-green + green-soft tint - * - Top row: mono uppercase "Free tier" label · mono 12px "8.7K / 10K" - * - 4px gradient bar (lp → lp-bright) at exact pct fill - * - Bottom row: "{pct}% used" left · mono "resets {window}" right + * Data comes from PymtHouse (OpenMeter) via `/api/pymthouse/account-usage`. + * Prefer the plan included-discount allowance (USD). If that is unavailable, + * show period spend in dollars — never a fake request-count free tier. */ export default function SidebarUsageCard() { - const used = ACCOUNT_USAGE_SUMMARY.freeTierUsed; - const limit = ACCOUNT_USAGE_SUMMARY.freeTierLimit; - const pct = Math.min(100, (used / limit) * 100); - const pctDisplay = pct >= 10 ? pct.toFixed(0) : pct.toFixed(2); + const { user } = useAuth(); + const usage = useAccountUsage(user?.id?.trim(), 30); + + if (usage.status === "loading" || usage.status === "idle") { + return ( +