Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
27547bf
feat: port waitlist and add canonical users
peacenode Sep 4, 2026
44aa3a9
Verify waitlist cutover parity and harden canonical reconciliation
peacenode Sep 4, 2026
a638bb3
docs: freeze early-access domain contracts and specialist ownership
peacenode Sep 4, 2026
fe986b7
feat: separate identity access subscription and external account schemas
peacenode Sep 4, 2026
932b32f
test: add guarded disposable database and synthetic fixture harness
peacenode Sep 4, 2026
73def06
test: protect runtime previews and guard legacy route integration suite
peacenode Sep 4, 2026
7f8c431
feat: resolve provider independent identities and persisted external …
peacenode Sep 4, 2026
d53a551
ci: wire secret-free validation and record isolated foundation evidence
peacenode Sep 4, 2026
daa9a90
feat: add additive early access migration and reviewed grandfather to…
peacenode Sep 4, 2026
fedaa4f
test: seed original immutable legacy accounts in compatibility fixture
peacenode Sep 4, 2026
fa9fad6
feat: isolate preview delivery and remove legacy middleware allowlist
peacenode Sep 4, 2026
e0b5c92
feat: add shared admission enrollment and administrator resolvers
peacenode Sep 4, 2026
0d759e3
feat: add approval administration and waiting room interfaces
peacenode Sep 4, 2026
4d2c919
feat: enforce admission across MCP device and token boundaries
peacenode Sep 4, 2026
4e204f2
docs: record production dry-run counts and explicit release blockers
peacenode Sep 4, 2026
2874df8
fix: reconcile billing conflicts and audit activation; add OAuth code…
peacenode Sep 4, 2026
dbcbdc5
feat: add transactional approvals consent authority and safe invitati…
peacenode Sep 4, 2026
cf06217
style: format shared admission entry points
peacenode Sep 4, 2026
2fd9cb6
fix: atomically consume OAuth codes and verify usage identity aliases
peacenode Sep 4, 2026
0b59c88
fix: align grandfather reconciliation lock order with runtime identity
peacenode Sep 4, 2026
0e32248
fix: distinguish failed enrollment and isolate outbox test work
peacenode Sep 4, 2026
bd8572b
fix: canonicalize code receipts and complete admission recovery UX
peacenode Sep 4, 2026
48b45dd
fix: prelock complete grandfather identity set before user mutations
peacenode Sep 4, 2026
1ffe171
docs: include OAuth receipt migration in environment guide
peacenode Sep 4, 2026
87f94c3
Record independent early-access audit and isolated preview gates
peacenode Sep 4, 2026
91a19d2
docs: freeze Auth0-first waitlist and admin admission contracts
peacenode Sep 4, 2026
85a96eb
feat: route authenticated entry through shared admission landing
peacenode Sep 4, 2026
b0ce454
merge: preserve current main MCP client compatibility
peacenode Sep 4, 2026
053dc5f
test: cover Auth0 enrollment and retire legacy session authority
peacenode Sep 5, 2026
8738def
feat: authenticate waitlist and administration through Auth0
peacenode Sep 5, 2026
48e462c
feat: unify waitlist sign-in and Console admin navigation
peacenode Sep 5, 2026
83d34f0
fix: clarify legacy verification does not authenticate
peacenode Sep 5, 2026
6b4c071
fix: keep membership reads from consuming referral enrollment
peacenode Sep 5, 2026
c18fc0f
test: preserve referral attribution across authenticated membership r…
peacenode Sep 5, 2026
d1acfd3
docs: track inherited MCP compatibility decisions as release holds
peacenode Sep 5, 2026
91def27
Record independent early-access audit and isolated preview gates
peacenode Sep 5, 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
27 changes: 23 additions & 4 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,27 @@ PYMTHOUSE_ALLOW_INSECURE_HTTP=
# MCP_PUBLIC_ORIGIN=https://dashboard.livepeer.org
# DISCOVERY_SERVICE_URL=https://discovery-service-production-8955.up.railway.app/v1/discovery/raw
# PYMTHOUSE_SIGNER_URL=https://signer.pymthouse.com
# Non-prod mint requires PYMTHOUSE_PUBLIC_CLIENT_ID=app_98575870d7ae33589a3f0660
# Preview credential operations require BOTH staging issuer and app:
# PYMTHOUSE_ISSUER_URL=https://staging.pymthouse.com/api/v1/oidc
# PYMTHOUSE_PUBLIC_CLIENT_ID=app_088f2082a8f1161d60179431

# Invite-only Console HTML (/home, /usage, /keys, /calls, /settings).
# Empty = no gate. Comma-separated emails, matched case-insensitively.
# CONSOLE_EMAIL_ALLOWLIST=alice@livepeer.org,bob@studio.com
# Console admission is database-authoritative; no environment email allowlist.

# Waitlist + identity/access + single-use OAuth receipts (migrations 0000-0008).
# Production must use the existing waitlist database. Previews and tests must
# use an isolated database branch; never point them at the production branch.
DATABASE_URL=postgresql://user:password@host/database?sslmode=require
ATTRIBUTION_HASH_SECRET=replace-with-at-least-32-random-bytes
RESEND_API_KEY=re_replace_me
RESEND_NEWSLETTER_SEGMENT_ID=replace-with-resend-segment-id
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
# Publishable token; analytics safely no-ops when unset.
NEXT_PUBLIC_POSTHOG_KEY=

# Mandatory on Vercel Preview. Capture only; never write production contacts.
# EMAIL_DELIVERY_MODE=capture
# Disposable automated tests additionally require independently supplied
# TEST_DATABASE_URL, TEST_DATABASE_HOST, TEST_DATABASE_BRANCH_ID and DB marker.
25 changes: 25 additions & 0 deletions .github/workflows/validate-console.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
name: Validate Console
on:
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
validate:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm test
- run: pnpm test:console
- run: pnpm lint
- run: pnpm build
- run: pnpm typecheck
# No cloud/database/email credentials in untrusted PR jobs. Disposable DB
# integration and protected preview evidence are coordinator-run gates.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@

# next.js
/.next/
/.next-cutover/
/.agent-worktrees/
/out/

# production
Expand Down
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -1 +1,4 @@
pnpm-lock.yaml
.next/
.next-cutover/
.agent-worktrees/
52 changes: 52 additions & 0 deletions app/(app)/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { redirect } from "next/navigation";
import ConsolePageHeader from "@/components/console/ConsolePageHeader";
import AccessManager from "@/components/admin/AccessManager";
import { getAdminWaitlistSummary } from "@/lib/waitlist/admin";
import { getAdminPrincipal } from "@/lib/admin/auth";

export const dynamic = "force-dynamic";

export default async function AdminPage() {
const admin = await getAdminPrincipal();
if (!admin) redirect("/waitlist");
const summary = await getAdminWaitlistSummary();
return (
<main
id="main-content"
className="flex min-h-full flex-1 flex-col bg-dark text-fg"
>
<ConsolePageHeader
title="Waitlist administration"
actions={
<a
href="/api/admin/signups.csv"
className="rounded border border-hairline px-3 py-1 text-xs hover:bg-hover"
>
Export CSV
</a>
}
/>
<section className="mx-auto w-full max-w-7xl px-5 py-8 sm:px-7">
<h1 className="text-2xl font-medium tracking-tight">
Waitlist administration
</h1>
<dl className="mt-8 grid grid-cols-2 gap-x-6 gap-y-8 md:grid-cols-4">
{[
["Total signups", summary.totalSignups],
["Verified signups", summary.confirmedSignups],
["Total verified referrals", summary.totalVerifiedReferrals],
["Newsletter opt-ins", summary.newsletterSubscribers],
].map(([label, value]) => (
<div key={label}>
<dt className="text-xs text-fg-muted">{label}</dt>
<dd className="mt-2 text-3xl font-light tabular-nums">
{Number(value).toLocaleString()}
</dd>
</div>
))}
</dl>
<AccessManager />
</section>
</main>
);
}
4 changes: 4 additions & 0 deletions app/(app)/calls/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { redirect } from "next/navigation";
import { requireConsolePage } from "@/lib/access/page";

/**
* `/calls` folded into `/home` for the creator pilot — the call log now
Expand All @@ -17,5 +18,8 @@ export default async function CallsPage({
const params = await searchParams;
const request = params.request;
const id = Array.isArray(request) ? request[0] : request;
await requireConsolePage(
id ? `/calls?request=${encodeURIComponent(id)}` : "/calls"
);
redirect(id ? `/home?request=${encodeURIComponent(id)}` : "/home");
}
43 changes: 26 additions & 17 deletions app/(app)/device/DeviceApproveForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,29 @@ export default function DeviceApproveForm({
async function approve() {
setError("");
setPhase("submitting");
const response = await fetch("/api/v1/auth/device/approve", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
iss,
target_link_uri: targetLinkUri,
}),
});
const json = (await response.json()) as {
ok?: boolean;
error?: string;
};
if (!response.ok || !json.ok) {
try {
const response = await fetch("/api/v1/auth/device/approve", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
iss,
target_link_uri: targetLinkUri,
}),
});
const json = (await response.json()) as {
ok?: boolean;
error?: string;
};
if (!response.ok || !json.ok) {
setPhase("error");
setError(json.error ?? "Approval failed");
return;
}
setPhase("ok");
} catch {
setPhase("error");
setError(json.error ?? "Approval failed");
return;
setError("We couldn’t confirm device approval. Please try again.");
}
setPhase("ok");
}

if (phase === "ok") {
Expand Down Expand Up @@ -78,7 +83,11 @@ export default function DeviceApproveForm({
>
{phase === "submitting" ? "Approving…" : "Approve device"}
</Button>
{error ? <p className="text-sm text-red-400">{error}</p> : null}
{error ? (
<p role="alert" className="text-sm text-red-400">
{error}
</p>
) : null}
</div>
);
}
15 changes: 6 additions & 9 deletions app/(app)/device/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { redirect } from "next/navigation";

import { auth0 } from "@/lib/auth0";
import { requireConsolePage } from "@/lib/access/page";
import { parseDeviceInitiateParams } from "@/lib/console/device-approval";
import DeviceApproveForm, { DevicePageChrome } from "./DeviceApproveForm";

Expand All @@ -18,20 +16,19 @@ export default async function DevicePage({
const params = await searchParams;
const query = new URLSearchParams();
if (params.iss) query.set("iss", params.iss);
if (params.target_link_uri) query.set("target_link_uri", params.target_link_uri);
if (params.target_link_uri)
query.set("target_link_uri", params.target_link_uri);
if (params.login_hint) query.set("login_hint", params.login_hint);
const returnTo = `/device${query.size ? `?${query.toString()}` : ""}`;

const session = await auth0.getSession();
if (!session?.user?.sub) {
redirect(`/auth/login?returnTo=${encodeURIComponent(returnTo)}`);
}
await requireConsolePage(returnTo);

let parsed;
try {
parsed = parseDeviceInitiateParams(query);
} catch (error) {
const message = error instanceof Error ? error.message : "Invalid device request";
const message =
error instanceof Error ? error.message : "Invalid device request";
return (
<DevicePageChrome>
<p className="text-sm text-red-400">{message}</p>
Expand Down
34 changes: 9 additions & 25 deletions app/(app)/home/page.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,18 @@
"use client";

import { Suspense } from "react";
import { useAuth } from "@/components/console/AuthContext";
import { requireConsolePage } from "@/lib/access/page";
import ConsolePageSkeleton from "@/components/console/ConsolePageSkeleton";
import SignInWall from "@/components/console/SignInWall";
import UsageView from "@/components/console/UsageView";

export default function HomePage() {
export const dynamic = "force-dynamic";
export default async function HomePage() {
await requireConsolePage("/home");
return (
<Suspense fallback={<ConsolePageSkeleton kpiCount={4} withChart />}>
<HomeContent />
<main id="main-content" className="flex flex-1 flex-col bg-dark">
<div className="flex flex-1 flex-col">
<UsageView />
</div>
</main>
</Suspense>
);
}

function HomeContent() {
const { isConnected, isLoading } = useAuth();

// Avoid flashing the wall while auth hydrates.
if (isLoading) return null;

// Organization-only route — logged-out users see the in-shell sign-in wall
// instead of a hard redirect.
if (!isConnected) return <SignInWall route="home" />;

return (
<main id="main-content" className="flex flex-1 flex-col bg-dark">
<div className="flex flex-1 flex-col">
<UsageView />
</div>
</main>
);
}
Loading
Loading