Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
bc6bd53
fix(history): join orchestrator 8-hex tickets onto MCP job_* Cost
eliteprox Sep 9, 2026
622260b
feat(history): add first-party asset previews and schema-driven details
peacenode Sep 9, 2026
294f445
test(history): provide tooltip context in surface tests
peacenode Sep 9, 2026
062f11a
fix(history): keep media expiry render pure
peacenode Sep 9, 2026
d8ebdca
feat(history): persist exact costs and secure assets
peacenode Sep 10, 2026
de85e09
feat(history): normalize receipts and asset lineage
peacenode Sep 10, 2026
b03dda6
feat(preview): seed realistic history automatically
peacenode Sep 10, 2026
b1da0c2
fix(preview): hide superseded manual fixtures
peacenode Sep 10, 2026
f392968
fix(ui): layer tooltips above history drawer
peacenode Sep 10, 2026
712d40d
fix(ui): align tooltip content
peacenode Sep 10, 2026
6839201
fix(ui): constrain tooltip options
peacenode Sep 10, 2026
bb9ccaf
fix(ui): preserve tooltip bottom padding
peacenode Sep 10, 2026
3468c59
fix(history): explicitly type billing sync receipts
peacenode Sep 11, 2026
7818f98
fix(history): correlate billing through payment manifests
peacenode Sep 11, 2026
93115d6
fix(history): harden asset delivery and repair preview fixtures
peacenode Sep 11, 2026
aebbf4d
fix(history): complete billing polling and preview pagination
peacenode Sep 11, 2026
6da2059
Merge remote-tracking branch 'origin/main' into codex/history-cost-as…
peacenode Sep 11, 2026
e6ac606
chore: bump @pymthouse/gateway-web to 0.3.5
eliteprox Sep 11, 2026
d0e4ecf
fix(history): retry accepted payment persist and strip compound media…
eliteprox Sep 11, 2026
39a5b97
chore: collapse drizzle snapshots in GitHub review (#81)
eliteprox Sep 11, 2026
3564112
feat(public): add public event metadata handling and sanitize URLs
eliteprox Sep 11, 2026
e04cde5
feat: add autoComplete="off" to buttons and checkboxes for improved UX
eliteprox Sep 11, 2026
be83f23
refactor: remove autoComplete attribute from buttons in AccessManager
eliteprox Sep 11, 2026
606caac
refactor(auth): streamline authentication redirects and remove identi…
eliteprox Sep 11, 2026
416551d
feat(public): enhance media sanitization by adding wrapper key handling
eliteprox Sep 11, 2026
e0f5bd3
feat(billing): implement manifest billing refresh and streamline bill…
eliteprox Sep 11, 2026
923ce96
feat(public): add error message sanitization to publicRunDetail
eliteprox Sep 11, 2026
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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ EMAIL_FROM=Livepeer Waitlist <waitlist@mail.example.com>
EMAIL_REPLY_TO=help@example.com
INTERNAL_OUTBOX_SECRET=replace-with-at-least-32-random-bytes
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# Required in deployed environments. Generate a high-entropy secret and keep it
# stable so one-hour first-party media links remain valid across instances.
ASSET_URL_SIGNING_SECRET=replace-with-at-least-32-random-bytes
# Exact hosts or wildcard subdomains, comma-separated. Redirects are rechecked.
ASSET_PROXY_ALLOWED_HOSTS=fal.media,*.fal.media
# Preview-only, branch-scoped verification records. Never enable in production.
CONSOLE_PREVIEW_FIXTURES=0
NEXT_PUBLIC_CONSOLE_PREVIEW_FIXTURES=0
# Publishable token; analytics safely no-ops when unset.
NEXT_PUBLIC_POSTHOG_KEY=

Expand Down
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Drizzle Kit writes a full schema dump per migration, not a delta.
# Keep the files for generate/migrate; collapse them in GitHub review.
drizzle-baseline/meta/*_snapshot.json linguist-generated=true
drizzle/meta/*_snapshot.json linguist-generated=true
3 changes: 1 addition & 2 deletions app/(app)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { redirect } from "next/navigation";
import { identitySyncPath } from "@/lib/identity/sync-return";

export const dynamic = "force-dynamic";

Expand All @@ -11,5 +10,5 @@ export default async function RootPage({
const params = await searchParams;
if (params.ref?.trim())
redirect(`/waitlist?ref=${encodeURIComponent(params.ref.trim())}`);
redirect(identitySyncPath("/home"));
redirect("/home");
}
6 changes: 4 additions & 2 deletions app/(auth)/login/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { redirect } from "next/navigation";
import { getAuthenticatedIdentity } from "@/lib/authentication/session";
import { authLoginHref, safeReturnTo } from "@/lib/console/auth-login";
import LoginPage from "@/components/console/LoginPage";
import { identitySyncPath } from "@/lib/identity/sync-return";
import { signedInLandingPath } from "@/lib/identity/signed-in-landing";

import type { Metadata } from "next";

Expand All @@ -26,7 +26,9 @@ export default async function LoginRoute({

const identity = await getAuthenticatedIdentity();
if (identity)
redirect(identitySyncPath(mcpOauth ? MCP_CALLBACK_PATH : returnTo));
redirect(
await signedInLandingPath(mcpOauth ? MCP_CALLBACK_PATH : returnTo)
);

// MCP flow must go directly to Auth0 — no interactive UI step.
if (mcpOauth) {
Expand Down
4 changes: 2 additions & 2 deletions app/(auth)/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { redirect } from "next/navigation";
import { getAuthenticatedIdentity } from "@/lib/authentication/session";
import { safeReturnTo } from "@/lib/console/auth-login";
import LoginPage from "@/components/console/LoginPage";
import { identitySyncPath } from "@/lib/identity/sync-return";
import { signedInLandingPath } from "@/lib/identity/signed-in-landing";

export const metadata: Metadata = {
title: "Sign up — Livepeer Early Access",
Expand All @@ -17,6 +17,6 @@ export default async function SignupRoute({
const params = await searchParams;
const returnTo = safeReturnTo(params.returnTo);
const identity = await getAuthenticatedIdentity();
if (identity) redirect(identitySyncPath(returnTo));
if (identity) redirect(await signedInLandingPath(returnTo));
return <LoginPage mode="signup" returnTo={returnTo} />;
}
3 changes: 2 additions & 1 deletion app/api/admin/runs/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getAdminPrincipal } from "@/lib/admin/auth";
import { getAdminRun } from "@/lib/runs/store";
import { runError, RUN_HEADERS } from "@/lib/runs/http";
import { publicRunDetail } from "@/lib/assets/public";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(
Expand All @@ -17,7 +18,7 @@ export async function GET(
const { id } = await context.params;
const result = await getAdminRun(actor, id);
if (!result) throw new Error("run_not_found");
return Response.json(result, { headers: RUN_HEADERS });
return Response.json(publicRunDetail(result), { headers: RUN_HEADERS });
} catch (error) {
return runError(error);
}
Expand Down
262 changes: 262 additions & 0 deletions app/api/assets/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { lookup } from "node:dns/promises";
import { fetchPinnedAsset } from "@/lib/assets/transport";
import { isIP } from "node:net";
import { assetSignature, ASSET_URL_TTL_SECONDS } from "@/lib/assets/public";
import { getAssetSource } from "@/lib/mcp/store";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const FORWARDED_HEADERS = [
"accept-ranges",
"content-length",
"content-range",
"etag",
"last-modified",
] as const;

const MEDIA_CONTENT_TYPE = /^(?:image|video|audio)\/[a-z0-9.+-]+$/i;

function isPlayableMediaType(type: string): boolean {
return MEDIA_CONTENT_TYPE.test(type) && !/^image\/svg\b/i.test(type);
}

/** Provider media types pass through. HTML/JS/SVG never do; Chrome's player cannot sandbox a video document. */
function mediaContentType(
upstream: string | null,
stored: string | null
): string | null {
const offered = (upstream ?? "").split(";")[0]!.trim().toLowerCase();
if (isPlayableMediaType(offered)) return offered;
if (offered && offered !== "application/octet-stream") return null;
const kind = (stored ?? "").trim().toLowerCase();
if (isPlayableMediaType(kind)) return kind;
if (kind === "video") return "video/mp4";
if (kind === "image") return "image/jpeg";
if (kind === "audio") return "audio/mpeg";
return null;
}

function notFound(): Response {
return new Response("Not found", {
status: 404,
headers: { "cache-control": "private, no-store" },
});
}

function isPrivateIp(address: string): boolean {
let normalized = address.toLowerCase();
if (normalized.startsWith("::ffff:")) {
normalized = normalized.slice(7);
if (!normalized.includes(".")) {
const words = normalized.split(":").map((word) => parseInt(word, 16));
if (words.length !== 2 || words.some((word) => !Number.isFinite(word)))
return true;
normalized = [
words[0]! >> 8,
words[0]! & 255,
words[1]! >> 8,
words[1]! & 255,
].join(".");
}
}
if (isIP(normalized) === 4) {
const [a, b] = normalized.split(".").map(Number);
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 198 && (b === 18 || b === 19)) ||
(a === 192 && b === 0) ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
a >= 224
);
}
return (
isIP(normalized) !== 6 ||
!/^[23]/.test(normalized) ||
normalized.startsWith("2001:db8:") ||
normalized.startsWith("2001:0:") ||
normalized.startsWith("2001::") ||
normalized.startsWith("2002:") ||
normalized === "::" ||
normalized === "::1" ||
normalized.startsWith("fc") ||
normalized.startsWith("fd") ||
/^fe[89ab]/.test(normalized)
);
}

function allowedHosts(): string[] {
const configured = process.env.ASSET_PROXY_ALLOWED_HOSTS?.trim();
if (configured)
return configured
.split(",")
.map((host) => host.trim().toLowerCase())
.filter(Boolean);
if (process.env.NODE_ENV !== "production")
return ["fal.media", "*.fal.media", "media.example.test"];
throw new Error("ASSET_PROXY_ALLOWED_HOSTS is required");
}

function isAllowedHost(hostname: string): boolean {
const host = hostname.toLowerCase();
return allowedHosts().some((rule) =>
rule.startsWith("*.")
? host.endsWith(rule.slice(1)) && host !== rule.slice(2)
: host === rule
);
}

async function assertPublicHttps(raw: string) {
const url = new URL(raw);
if (
url.protocol !== "https:" ||
url.port ||
url.username ||
url.password ||
!isAllowedHost(url.hostname)
)
throw new Error("unsafe_asset_origin");
const addresses = await lookup(url.hostname, { all: true, verbatim: true });
if (
!addresses.length ||
addresses.some(({ address }) => isPrivateIp(address))
)
throw new Error("unsafe_asset_origin");
return { url, addresses };
}

function validSignature(
id: string,
principalId: string,
exp: number,
supplied: string
): boolean {
const expected = Buffer.from(assetSignature(id, principalId, exp));
const actual = Buffer.from(supplied);
return expected.length === actual.length && timingSafeEqual(expected, actual);
}

async function proxy(request: Request, id: string): Promise<Response> {
if (!/^[A-Za-z0-9_-]{1,160}$/.test(id)) return notFound();
const requestUrl = new URL(request.url);
const expText = requestUrl.searchParams.get("exp") ?? "";
const sig = requestUrl.searchParams.get("sig") ?? "";
const exp = Number(expText);
const nowSeconds = Math.floor(Date.now() / 1000);
if (
!/^\d{10,}$/.test(expText) ||
!Number.isSafeInteger(exp) ||
exp <= nowSeconds ||
exp > nowSeconds + ASSET_URL_TTL_SECONDS
)
return notFound();

const asset = await getAssetSource(id);
if (
!asset ||
asset.unavailableAt ||
!validSignature(id, asset.principalId, exp, sig) ||
(asset.expiresAt && asset.expiresAt.getTime() <= Date.now())
)
return notFound();

// Synthetic fixtures are bundled public images, never arbitrary proxy origins.
if (
process.env.VERCEL_ENV === "preview" &&
process.env.CONSOLE_PREVIEW_FIXTURES === "1"
) {
const suffix = createHash("sha256")
.update(asset.principalId)
.digest("hex")
.slice(0, 12);
const fixturePaths: Record<string, string> = {
[`asset_preview_v2_${suffix}_portrait`]:
"/images/console/explore/flux-schnell.webp",
[`asset_preview_v2_${suffix}_variation`]:
"/images/console/explore/img2img-sdxl.webp",
};
const location = fixturePaths[id];
if (location)
return new Response(null, {
status: 307,
headers: { location, "cache-control": "private, no-store" },
});
}

try {
let target = await assertPublicHttps(asset.url);
let upstream: Response | undefined;
for (let redirects = 0; redirects <= 3; redirects += 1) {
upstream = await fetchPinnedAsset(target.url, target.addresses, {
method: request.method,
signal: AbortSignal.any([request.signal, AbortSignal.timeout(30_000)]),
headers: {
Accept: request.headers.get("accept") ?? "*/*",
"Accept-Encoding": "identity",
...(request.headers.get("range")
? { Range: request.headers.get("range")! }
: {}),
},
});
if (![301, 302, 303, 307, 308].includes(upstream.status)) break;
const location = upstream.headers.get("location");
await upstream.body?.cancel();
if (!location || redirects === 3) throw new Error("asset_redirect");
target = await assertPublicHttps(new URL(location, target.url).href);
}
if (!upstream) throw new Error("asset_unavailable");
const providerSeconds = asset.expiresAt
? Math.max(0, Math.floor((asset.expiresAt.getTime() - Date.now()) / 1000))
: Number.POSITIVE_INFINITY;
const maxAge = Math.max(
0,
Math.min(60, exp - Math.floor(Date.now() / 1000), providerSeconds)
);
const type = mediaContentType(
upstream.headers.get("content-type"),
asset.mediaType
);
const headers = new Headers({
"cache-control": upstream.ok
? `private, max-age=${maxAge}`
: "private, no-store",
"x-content-type-options": "nosniff",
...(type
? { "content-type": type }
: { "content-security-policy": "default-src 'none'; sandbox" }),
});
for (const name of FORWARDED_HEADERS) {
const value = upstream.headers.get(name);
if (value) headers.set(name, value);
}
return new Response(request.method === "HEAD" ? null : upstream.body, {
status: upstream.status,
headers,
});
} catch {
return new Response("Asset unavailable", {
status: 502,
headers: { "cache-control": "private, no-store" },
});
}
}

export async function GET(
request: Request,
context: { params: Promise<{ id: string }> }
) {
return proxy(request, (await context.params).id);
}

export async function HEAD(
request: Request,
context: { params: Promise<{ id: string }> }
) {
return proxy(request, (await context.params).id);
}
3 changes: 2 additions & 1 deletion app/api/console/runs/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { getOwnRun } from "@/lib/runs/store";
import { requireRunOwner, runError, RUN_HEADERS } from "@/lib/runs/http";
import { publicRunDetail } from "@/lib/assets/public";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(
Expand All @@ -11,7 +12,7 @@ export async function GET(
const { id } = await context.params;
const result = await getOwnRun(owner, id);
if (!result) throw new Error("run_not_found");
return Response.json(result, { headers: RUN_HEADERS });
return Response.json(publicRunDetail(result), { headers: RUN_HEADERS });
} catch (error) {
return runError(error);
}
Expand Down
27 changes: 27 additions & 0 deletions app/api/console/runs/[id]/schema/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { getOwnRun } from "@/lib/runs/store";
import { requireRunOwner, runError, RUN_HEADERS } from "@/lib/runs/http";
import {
loadFalInputSchema,
resolveFalCatalogEntry,
} from "@/lib/mcp/fal-input-schema";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

export async function GET(
_request: Request,
context: { params: Promise<{ id: string }> }
) {
try {
const owner = await requireRunOwner();
const result = await getOwnRun(owner, (await context.params).id);
if (!result) throw new Error("run_not_found");
const catalog = resolveFalCatalogEntry(result);
return Response.json(
{ inputSchema: catalog ? await loadFalInputSchema(catalog) : null },
{ headers: RUN_HEADERS }
);
} catch (error) {
return runError(error);
}
}
Loading