From 65dcc539ef126c0338fdd424c90d302ed67f9c8a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 8 Sep 2026 22:53:01 +0000 Subject: [PATCH 1/3] Initial plan From ecfa45b68988e1a398e71e8bc2903b82d3d171c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 8 Sep 2026 23:53:40 +0000 Subject: [PATCH 2/3] feat(web): open GitHub PRs for approved studio specs Co-authored-by: groupthinking <154503486+groupthinking@users.noreply.github.com> --- .../src/app/studio/__tests__/actions.test.ts | 72 +++++++++ apps/web/src/app/studio/actions.ts | 61 ++++++++ apps/web/src/components/OneLoopStudio.tsx | 77 +++++++++- .../lib/__tests__/github-pr-client.test.ts | 124 ++++++++++++++++ apps/web/src/lib/github-pr-client.ts | 138 ++++++++++++++++++ 5 files changed, 469 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/app/studio/__tests__/actions.test.ts create mode 100644 apps/web/src/app/studio/actions.ts create mode 100644 apps/web/src/lib/__tests__/github-pr-client.test.ts create mode 100644 apps/web/src/lib/github-pr-client.ts diff --git a/apps/web/src/app/studio/__tests__/actions.test.ts b/apps/web/src/app/studio/__tests__/actions.test.ts new file mode 100644 index 000000000..1bbfd6079 --- /dev/null +++ b/apps/web/src/app/studio/__tests__/actions.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/github-pr-client', () => ({ + createOrUpdatePullRequestsForApprovedSpecs: vi.fn(), +})); + +import { openGitHubPrsForApprovedSpecs } from '@/app/studio/actions'; +import { createOrUpdatePullRequestsForApprovedSpecs } from '@/lib/github-pr-client'; + +const mockedOpen = vi.mocked(createOrUpdatePullRequestsForApprovedSpecs); + +describe('openGitHubPrsForApprovedSpecs', () => { + const originalToken = process.env.GITHUB_TOKEN; + const originalRepo = process.env.GITHUB_REPOSITORY; + + beforeEach(() => { + vi.clearAllMocks(); + process.env.GITHUB_TOKEN = 'test-token'; + process.env.GITHUB_REPOSITORY = 'octo/eventrelay'; + }); + + afterEach(() => { + if (originalToken === undefined) delete process.env.GITHUB_TOKEN; + else process.env.GITHUB_TOKEN = originalToken; + if (originalRepo === undefined) delete process.env.GITHUB_REPOSITORY; + else process.env.GITHUB_REPOSITORY = originalRepo; + }); + + it('fails closed when no spec is approved', async () => { + const result = await openGitHubPrsForApprovedSpecs([ + { id: 'spec-1', title: 'Spec', body: 'Body', head: 'spec/one', approved: false }, + ]); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/approve at least one spec/i); + expect(mockedOpen).not.toHaveBeenCalled(); + }); + + it('calls the GitHub write flow with only approved specs', async () => { + mockedOpen.mockResolvedValue([ + { specId: 'spec-1', number: 10, htmlUrl: 'https://github.com/o/r/pull/10', action: 'created' }, + ]); + + const result = await openGitHubPrsForApprovedSpecs([ + { id: 'spec-1', title: 'Approved', body: 'Body', head: 'spec/approved', approved: true }, + { id: 'spec-2', title: 'Skipped', body: 'Body', head: 'spec/skipped', approved: false }, + ]); + + expect(result.ok).toBe(true); + expect(mockedOpen).toHaveBeenCalledWith( + [ + { id: 'spec-1', title: 'Approved', body: 'Body', head: 'spec/approved', approved: true }, + ], + expect.objectContaining({ + owner: 'octo', + repo: 'eventrelay', + token: 'test-token', + }), + ); + }); + + it('fails closed when GitHub integration is not configured', async () => { + delete process.env.GITHUB_TOKEN; + const result = await openGitHubPrsForApprovedSpecs([ + { id: 'spec-1', title: 'Approved', body: 'Body', head: 'spec/approved', approved: true }, + ]); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/not configured/i); + expect(mockedOpen).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/studio/actions.ts b/apps/web/src/app/studio/actions.ts new file mode 100644 index 000000000..f05998c6d --- /dev/null +++ b/apps/web/src/app/studio/actions.ts @@ -0,0 +1,61 @@ +'use server'; + +import 'server-only'; +import { + createOrUpdatePullRequestsForApprovedSpecs, + type ApprovedSpecPullRequestInput, + type GitHubPullRequestWriteResult, +} from '@/lib/github-pr-client'; + +export interface OpenGitHubPrsResult { + ok: boolean; + error?: string; + pullRequests: GitHubPullRequestWriteResult[]; +} + +function parseRepository(value: string): { owner: string; repo: string } | null { + const [owner, repo, ...rest] = value.trim().split('/'); + if (!owner || !repo || rest.length > 0) return null; + return { owner, repo }; +} + +export async function openGitHubPrsForApprovedSpecs( + specs: ApprovedSpecPullRequestInput[], +): Promise { + const approvedSpecs = specs.filter((spec) => spec.approved); + if (approvedSpecs.length === 0) { + return { + ok: false, + error: 'Approve at least one spec before opening GitHub pull requests.', + pullRequests: [], + }; + } + + const token = (process.env.GITHUB_TOKEN || '').trim(); + const repository = parseRepository(process.env.GITHUB_REPOSITORY || ''); + if (!token || !repository) { + return { + ok: false, + error: 'GitHub integration is not configured.', + pullRequests: [], + }; + } + + try { + const pullRequests = await createOrUpdatePullRequestsForApprovedSpecs( + approvedSpecs, + { + owner: repository.owner, + repo: repository.repo, + token, + }, + ); + return { ok: true, pullRequests }; + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Could not open GitHub pull requests.', + pullRequests: [], + }; + } +} diff --git a/apps/web/src/components/OneLoopStudio.tsx b/apps/web/src/components/OneLoopStudio.tsx index f8e92fe98..345afb76c 100644 --- a/apps/web/src/components/OneLoopStudio.tsx +++ b/apps/web/src/components/OneLoopStudio.tsx @@ -3,7 +3,7 @@ import { FormEvent, useEffect, useMemo, useRef, useState } from 'react'; import Link from 'next/link'; import { useSearchParams } from 'next/navigation'; -import { Download, Play, Rocket } from 'lucide-react'; +import { Download, GitPullRequest, Play, Rocket } from 'lucide-react'; import { formatSeconds, parseTimestampToSeconds, extractYouTubeId } from '@/lib/timestamp'; import { applyPackStackChecks, compileLinkedSop, type LinkedSop } from '@/lib/linked-sop'; import { @@ -70,6 +70,7 @@ import { import { CANONICAL_STUDIO_PATH } from '@/lib/auth-paths'; import type { ExtractedEvent } from '@/lib/types'; import type { VideoPackArchitecture, VideoPackArtifact } from '@/lib/video-pack-types'; +import { openGitHubPrsForApprovedSpecs } from '@/app/studio/actions'; const FIXTURE = 'https://www.youtube.com/watch?v=auJzb1D-fag'; @@ -94,6 +95,15 @@ function getYouTubeId(url: string) { return extractYouTubeId(url) || ''; } +function toSpecBranch(videoId: string, stepId: string): string { + const slug = `${videoId || 'video'}-${stepId}` + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 48); + return `spec/${slug || 'approved'}`; +} + function mapExtractedEvents(raw: unknown[], videoId: string): ExtractedEvent[] { return raw .filter((item): item is Record => Boolean(item) && typeof item === 'object') @@ -225,6 +235,8 @@ export default function OneLoopStudio() { const [deployReceiptVideoId, setDeployReceiptVideoId] = useState(null); const [gateReceipt, setGateReceipt] = useState(null); const [completedChecks, setCompletedChecks] = useState([]); + const [approvedSpecIds, setApprovedSpecIds] = useState([]); + const [openingPrs, setOpeningPrs] = useState(false); const [playerEpoch, setPlayerEpoch] = useState(0); const [exportToast, setExportToast] = useState<{ tone: 'success' | 'error'; text: string } | null>( null, @@ -276,6 +288,7 @@ export default function OneLoopStudio() { useEffect(() => { setCompletedChecks([]); + setApprovedSpecIds([]); setDeployReceiptUrl(null); setDeployReceiptVideoId(null); }, [selectedVideoId]); @@ -621,6 +634,35 @@ export default function OneLoopStudio() { } }; + const openApprovedSpecsPrs = async () => { + if (!linkedSop || linkedSop.steps.length === 0) { + setMessage('Analyze a video with SOP steps before opening GitHub pull requests.'); + return; + } + const sourceVideoId = getYouTubeId(selected?.url || url || ''); + const specs = linkedSop.steps.map((step) => ({ + id: step.id, + title: step.title, + body: [step.description || '', step.quote ? `\nQuote: ${step.quote}` : ''].join('').trim(), + head: toSpecBranch(sourceVideoId, step.id), + base: 'main', + approved: approvedSpecIds.includes(step.id), + })); + setOpeningPrs(true); + try { + const result = await openGitHubPrsForApprovedSpecs(specs); + if (!result.ok) { + setMessage(result.error || 'Could not open GitHub pull requests for approved specs.'); + return; + } + setMessage(`Opened or updated ${result.pullRequests.length} GitHub pull request(s) for approved specs.`); + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Could not open GitHub pull requests.'); + } finally { + setOpeningPrs(false); + } + }; + const transcriptStage = studioTranscriptStage({ busy: transcriptWorking, elapsedSeconds: elapsed, @@ -968,7 +1010,9 @@ export default function OneLoopStudio() { {linkedSop.steps.length === 0 && (
  • No ordered SOP in this run.
  • )} - {linkedSop.steps.map((step) => ( + {linkedSop.steps.map((step) => { + const approved = approvedSpecIds.includes(step.id); + return (
  • {step.timestamp != null ? (
  • - ))} + ); + })} {linkedSop.checklist.some((item) => item.source === 'stack') && ( @@ -1216,6 +1276,17 @@ export default function OneLoopStudio() { {deployBusy ? 'Attempting deploy…' : studioDeployButtonLabel(Boolean(scopedDeployReceipt))} + {holdReason && (

    {holdReason} diff --git a/apps/web/src/lib/__tests__/github-pr-client.test.ts b/apps/web/src/lib/__tests__/github-pr-client.test.ts new file mode 100644 index 000000000..0ab4dea8b --- /dev/null +++ b/apps/web/src/lib/__tests__/github-pr-client.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + createOrUpdatePullRequest, + createOrUpdatePullRequestsForApprovedSpecs, + type ApprovedSpecPullRequestInput, +} from '@/lib/github-pr-client'; + +describe('github-pr-client', () => { + it('creates a pull request when no open PR exists for the spec branch', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ number: 7, html_url: 'https://github.com/o/r/pull/7' }), { + status: 201, + }), + ); + + const result = await createOrUpdatePullRequest( + { + id: 'spec-1', + title: 'Add onboarding workflow', + body: '## Spec\n\n- Add onboarding flow', + head: 'spec/add-onboarding-workflow', + base: 'main', + }, + { owner: 'octo', repo: 'eventrelay', token: 'token', fetchImpl }, + ); + + expect(result).toEqual( + expect.objectContaining({ + specId: 'spec-1', + number: 7, + htmlUrl: 'https://github.com/o/r/pull/7', + action: 'created', + }), + ); + + expect(fetchImpl).toHaveBeenCalledTimes(2); + expect(String(fetchImpl.mock.calls[0]?.[0])).toContain('/repos/octo/eventrelay/pulls?'); + expect(String(fetchImpl.mock.calls[1]?.[0])).toContain('/repos/octo/eventrelay/pulls'); + const body = JSON.parse(String((fetchImpl.mock.calls[1]?.[1] as RequestInit).body)); + expect(body).toMatchObject({ + title: 'Add onboarding workflow', + head: 'spec/add-onboarding-workflow', + base: 'main', + }); + }); + + it('updates the existing pull request when one already exists for the spec branch', async () => { + const fetchImpl = vi + .fn() + .mockResolvedValueOnce( + new Response( + JSON.stringify([{ number: 42, html_url: 'https://github.com/o/r/pull/42' }]), + { status: 200 }, + ), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ number: 42, html_url: 'https://github.com/o/r/pull/42' }), { + status: 200, + }), + ); + + const result = await createOrUpdatePullRequest( + { + id: 'spec-2', + title: 'Revise payment handoff', + body: 'Updated spec body', + head: 'spec/revise-payment-handoff', + base: 'main', + }, + { owner: 'octo', repo: 'eventrelay', token: 'token', fetchImpl }, + ); + + expect(result.action).toBe('updated'); + expect(result.number).toBe(42); + expect(String(fetchImpl.mock.calls[1]?.[0])).toContain('/repos/octo/eventrelay/pulls/42'); + const body = JSON.parse(String((fetchImpl.mock.calls[1]?.[1] as RequestInit).body)); + expect(body).toMatchObject({ + title: 'Revise payment handoff', + body: 'Updated spec body', + base: 'main', + }); + }); + + it('opens pull requests only for approved specs', async () => { + const specs: ApprovedSpecPullRequestInput[] = [ + { + id: 'spec-approved', + title: 'Approved spec', + body: 'Approved body', + head: 'spec/approved', + approved: true, + }, + { + id: 'spec-unapproved', + title: 'Unapproved spec', + body: 'Unapproved body', + head: 'spec/unapproved', + approved: false, + }, + ]; + const fetchImpl = vi + .fn() + .mockResolvedValueOnce(new Response(JSON.stringify([]), { status: 200 })) + .mockResolvedValueOnce( + new Response(JSON.stringify({ number: 9, html_url: 'https://github.com/o/r/pull/9' }), { + status: 201, + }), + ); + + const result = await createOrUpdatePullRequestsForApprovedSpecs(specs, { + owner: 'octo', + repo: 'eventrelay', + token: 'token', + fetchImpl, + }); + + expect(result).toHaveLength(1); + expect(result[0]?.specId).toBe('spec-approved'); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/web/src/lib/github-pr-client.ts b/apps/web/src/lib/github-pr-client.ts new file mode 100644 index 000000000..8a65b5a85 --- /dev/null +++ b/apps/web/src/lib/github-pr-client.ts @@ -0,0 +1,138 @@ +import 'server-only'; + +export interface ApprovedSpecPullRequestInput { + id: string; + title: string; + body: string; + head: string; + base?: string; + approved: boolean; +} + +export interface GitHubPullRequestWriteResult { + specId: string; + number: number; + htmlUrl: string; + action: 'created' | 'updated'; +} + +export interface GitHubPullRequestClientConfig { + owner: string; + repo: string; + token: string; + fetchImpl?: typeof fetch; +} + +interface GitHubPullRequestPayload { + number?: unknown; + html_url?: unknown; +} + +function apiBase(config: GitHubPullRequestClientConfig): string { + return `https://api.github.com/repos/${encodeURIComponent(config.owner)}/${encodeURIComponent(config.repo)}`; +} + +function githubHeaders(token: string): Record { + return { + Accept: 'application/vnd.github+json', + Authorization: ['Bearer', token].join(' '), + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + }; +} + +function readResult( + payload: GitHubPullRequestPayload | null, + specId: string, + action: 'created' | 'updated', +): GitHubPullRequestWriteResult { + const number = typeof payload?.number === 'number' ? payload.number : 0; + const htmlUrl = typeof payload?.html_url === 'string' ? payload.html_url : ''; + if (!number || !htmlUrl) { + throw new Error(`GitHub ${action} response was missing pull request fields for spec "${specId}"`); + } + return { specId, number, htmlUrl, action }; +} + +async function parsePayload(response: Response): Promise { + return (await response.json().catch(() => null)) as GitHubPullRequestPayload | null; +} + +export async function createOrUpdatePullRequest( + spec: Omit, + config: GitHubPullRequestClientConfig, +): Promise { + const fetchImpl = config.fetchImpl ?? fetch; + const base = spec.base?.trim() || 'main'; + const encodedHead = encodeURIComponent(`${config.owner}:${spec.head}`); + const encodedBase = encodeURIComponent(base); + const listUrl = `${apiBase(config)}/pulls?state=open&head=${encodedHead}&base=${encodedBase}&per_page=1`; + + const list = await fetchImpl(listUrl, { + headers: githubHeaders(config.token), + method: 'GET', + signal: AbortSignal.timeout(30_000), + }); + if (!list.ok) { + const detail = await list.text(); + throw new Error(`Could not list open pull requests (${list.status}): ${detail}`); + } + + const open = (await list.json().catch(() => [])) as Array<{ number?: unknown }>; + const existingNumber = typeof open[0]?.number === 'number' ? open[0].number : null; + + if (existingNumber != null) { + const update = await fetchImpl(`${apiBase(config)}/pulls/${existingNumber}`, { + headers: githubHeaders(config.token), + method: 'PATCH', + body: JSON.stringify({ title: spec.title, body: spec.body, base }), + signal: AbortSignal.timeout(30_000), + }); + if (!update.ok) { + const detail = await update.text(); + throw new Error(`Could not update pull request #${existingNumber} (${update.status}): ${detail}`); + } + return readResult(await parsePayload(update), spec.id, 'updated'); + } + + const created = await fetchImpl(`${apiBase(config)}/pulls`, { + headers: githubHeaders(config.token), + method: 'POST', + body: JSON.stringify({ + title: spec.title, + body: spec.body, + head: spec.head, + base, + }), + signal: AbortSignal.timeout(30_000), + }); + if (!created.ok) { + const detail = await created.text(); + throw new Error(`Could not create pull request for spec "${spec.id}" (${created.status}): ${detail}`); + } + + return readResult(await parsePayload(created), spec.id, 'created'); +} + +export async function createOrUpdatePullRequestsForApprovedSpecs( + specs: ApprovedSpecPullRequestInput[], + config: GitHubPullRequestClientConfig, +): Promise { + const approvedSpecs = specs.filter((spec) => spec.approved); + const results: GitHubPullRequestWriteResult[] = []; + for (const spec of approvedSpecs) { + results.push( + await createOrUpdatePullRequest( + { + id: spec.id, + title: spec.title, + body: spec.body, + head: spec.head, + base: spec.base, + }, + config, + ), + ); + } + return results; +} From b72aa6673795554ec484144917926ed32764892c Mon Sep 17 00:00:00 2001 From: "vercel[bot]" <35613825+vercel[bot]@users.noreply.github.com> Date: Sat, 12 Sep 2026 09:04:37 +0000 Subject: [PATCH 3/3] Fix: The `openGitHubPrsForApprovedSpecs` server action performs privileged GitHub writes with the server's `GITHUB_TOKEN` but has no authentication/authorization check, so any anonymous visitor to the public `/studio` page can invoke it. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit fixes the issue reported at apps/web/src/app/studio/actions.ts:22 ## The bug `apps/web/src/app/studio/actions.ts` exposes a `'use server'` action, `openGitHubPrsForApprovedSpecs`, which uses the server-side `GITHUB_TOKEN` to create/update pull requests in `GITHUB_REPOSITORY`. Next.js server actions compile to POST endpoints reachable by anyone who can reach the page they are imported into. The action is imported and called from `OneLoopStudio.tsx` (line 73 / 653), which renders on `/studio`. That page is intentionally public: - `auth-paths.ts` sets `const PROTECTED_PAGE_PREFIXES: readonly string[] = [];` - `needsAuthentication('/studio')` → `isProtectedPagePath('/studio')` → `false` So the middleware/proxy auth gate does **not** apply to the `/studio` action endpoint. There is no other global gate for server actions. Result: the action ran with **zero** authorization. ### Concrete trigger / failure mode An anonymous client POSTs a server-action request to `/studio` with a crafted `specs` array (all fields — `id/title/body/head/base/approved` — are client-controlled). The only gate in the action was `specs.filter(s => s.approved)`, but `approved` is client-supplied, so it is trivially set to `true`. Consequences: 1. **Arbitrary PR creation** in the configured repo using the server's credentials (attacker-chosen `head`/`base`/`title`/`body`). 2. **Tampering with existing PRs**: `createOrUpdatePullRequest` looks up an open PR by `(head, base)` and, if found, `PATCH`es its `title`, `body`, and `base`. A caller who supplies a `head` matching an existing open PR can overwrite that PR's content — spam/tampering on real PRs via server credentials. ## The fix Added an authorization check at the very top of the action, before any GitHub credential is read or any GitHub API call is made. It reuses the repo's existing trusted-identity resolution (`resolveTrustedBillingEmail`, session email → HMAC-signed billing cookie, never client body) and the same Pro-entitlement gate (`isProSubscriber`) that protects the analogous privileged mutation in `api/agents/dispatch`. Because a server action has no `Request` argument, a minimal `Request` is rebuilt from the incoming `cookie` header (`headers()` from `next/headers`) so `getToken` and the billing-cookie fallback resolve identity exactly as in API routes: ```ts async function resolveActionBillingEmail(): Promise { const cookieHeader = (await headers()).get('cookie') ?? ''; const request = new Request('https://studio.internal/studio', { headers: { cookie: cookieHeader }, }); return resolveTrustedBillingEmail(request); } ``` Non-Pro / anonymous callers now receive `{ ok: false, error: 'Opening GitHub pull requests requires a Pro subscription…' }` and the GitHub write path is never reached. Tests in `actions.test.ts` were updated to mock the new dependencies (default: authorized Pro user, preserving the existing behavioral assertions) and a new case asserts the action fails closed and never calls the GitHub client when the caller lacks Pro entitlement. Co-authored-by: Vercel Co-authored-by: groupthinking --- .../src/app/studio/__tests__/actions.test.ts | 26 ++++++++++++++ apps/web/src/app/studio/actions.ts | 34 +++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/apps/web/src/app/studio/__tests__/actions.test.ts b/apps/web/src/app/studio/__tests__/actions.test.ts index 1bbfd6079..e8c338b8c 100644 --- a/apps/web/src/app/studio/__tests__/actions.test.ts +++ b/apps/web/src/app/studio/__tests__/actions.test.ts @@ -4,10 +4,24 @@ vi.mock('@/lib/github-pr-client', () => ({ createOrUpdatePullRequestsForApprovedSpecs: vi.fn(), })); +vi.mock('next/headers', () => ({ + headers: vi.fn(async () => new Headers()), +})); + +vi.mock('@/lib/billing/billing-context', () => ({ + resolveTrustedBillingEmail: vi.fn(async () => 'pro@example.com'), +})); + +vi.mock('@/lib/billing/entitlement-store', () => ({ + isProSubscriber: vi.fn(async () => true), +})); + import { openGitHubPrsForApprovedSpecs } from '@/app/studio/actions'; import { createOrUpdatePullRequestsForApprovedSpecs } from '@/lib/github-pr-client'; +import { isProSubscriber } from '@/lib/billing/entitlement-store'; const mockedOpen = vi.mocked(createOrUpdatePullRequestsForApprovedSpecs); +const mockedIsPro = vi.mocked(isProSubscriber); describe('openGitHubPrsForApprovedSpecs', () => { const originalToken = process.env.GITHUB_TOKEN; @@ -26,6 +40,18 @@ describe('openGitHubPrsForApprovedSpecs', () => { else process.env.GITHUB_REPOSITORY = originalRepo; }); + it('refuses callers without a Pro entitlement', async () => { + mockedIsPro.mockResolvedValueOnce(false); + + const result = await openGitHubPrsForApprovedSpecs([ + { id: 'spec-1', title: 'Approved', body: 'Body', head: 'spec/approved', approved: true }, + ]); + + expect(result.ok).toBe(false); + expect(result.error).toMatch(/pro subscription/i); + expect(mockedOpen).not.toHaveBeenCalled(); + }); + it('fails closed when no spec is approved', async () => { const result = await openGitHubPrsForApprovedSpecs([ { id: 'spec-1', title: 'Spec', body: 'Body', head: 'spec/one', approved: false }, diff --git a/apps/web/src/app/studio/actions.ts b/apps/web/src/app/studio/actions.ts index f05998c6d..a86fda2c2 100644 --- a/apps/web/src/app/studio/actions.ts +++ b/apps/web/src/app/studio/actions.ts @@ -1,11 +1,14 @@ 'use server'; import 'server-only'; +import { headers } from 'next/headers'; import { createOrUpdatePullRequestsForApprovedSpecs, type ApprovedSpecPullRequestInput, type GitHubPullRequestWriteResult, } from '@/lib/github-pr-client'; +import { resolveTrustedBillingEmail } from '@/lib/billing/billing-context'; +import { isProSubscriber } from '@/lib/billing/entitlement-store'; export interface OpenGitHubPrsResult { ok: boolean; @@ -19,9 +22,40 @@ function parseRepository(value: string): { owner: string; repo: string } | null return { owner, repo }; } +/** + * Resolve the trusted billing identity for this server action. + * + * Server actions POST to the page path, and `/studio` is intentionally public + * (see auth-paths.ts), so the middleware auth gate does *not* run here. Without + * an explicit check any anonymous visitor could invoke this action and drive + * GitHub writes with the server's GITHUB_TOKEN. We rebuild a minimal Request + * from the incoming cookies so the same trusted-identity resolution used by + * privileged API routes (session email → signed billing cookie) applies here. + */ +async function resolveActionBillingEmail(): Promise { + const cookieHeader = (await headers()).get('cookie') ?? ''; + const request = new Request('https://studio.internal/studio', { + headers: { cookie: cookieHeader }, + }); + return resolveTrustedBillingEmail(request); +} + export async function openGitHubPrsForApprovedSpecs( specs: ApprovedSpecPullRequestInput[], ): Promise { + // Authorize before touching any server-side GitHub credentials. Opening PRs + // in the configured repository is a privileged, Pro-gated action — the same + // entitlement class as agent dispatch — so anonymous/free callers are refused + // before any GitHub API request is made. + const billingEmail = await resolveActionBillingEmail(); + if (!(await isProSubscriber(billingEmail))) { + return { + ok: false, + error: 'Opening GitHub pull requests requires a Pro subscription. Upgrade at /pricing.', + pullRequests: [], + }; + } + const approvedSpecs = specs.filter((spec) => spec.approved); if (approvedSpecs.length === 0) { return {