diff --git a/apps/web/REVENUE-PROCESS.md b/apps/web/REVENUE-PROCESS.md index 92d9cc12f..ce938971c 100644 --- a/apps/web/REVENUE-PROCESS.md +++ b/apps/web/REVENUE-PROCESS.md @@ -50,6 +50,7 @@ npm run dev |------|------| | `src/lib/billing/checkout-config.ts` | Pure price/URL resolution | | `src/lib/billing/stripe-checkout.ts` | Stripe session creation | +| `src/lib/billing/pro-feature-gate.ts` | Pro verification + checkout handoff for gated downloads | | `src/lib/billing/turnstile.ts` | Cloudflare siteverify | | `src/lib/billing/grok-lead.ts` | Grok/Composer metadata on paid tier | | `src/lib/billing/kaizen-trace.ts` | Structured trace for renewal/debug | @@ -66,6 +67,8 @@ npm run dev |---------|------|-----| | AI chat | 5/day | Unlimited, `grok-composer` lead model | | Agent dispatch | Blocked (402) | Allowed | +| Workspace ZIP export | Blocked (402 + checkout) | Download enabled | +| `/api/v1/refinery/dataset` | Blocked (402 + checkout) | Metadata + JSONL download | | API headers | — | `X-Lead-Model`, `X-Billing-Plan` on backend proxy | ## Returning users diff --git a/apps/web/src/app/api/__tests__/workspace-export-route.test.ts b/apps/web/src/app/api/__tests__/workspace-export-route.test.ts new file mode 100644 index 000000000..2336aa951 --- /dev/null +++ b/apps/web/src/app/api/__tests__/workspace-export-route.test.ts @@ -0,0 +1,125 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/billing/billing-context', () => ({ + BILLING_EMAIL_COOKIE: 'er_billing_email', + resolveTrustedBillingEmail: vi.fn(async () => null), +})); + +vi.mock('@/lib/billing/entitlement-store', () => ({ + getEntitlement: vi.fn(async () => null), + isProSubscriber: vi.fn(async () => false), +})); + +vi.mock('@/lib/billing/stripe-checkout', () => ({ + getCheckoutSession: vi.fn(async () => ({ + id: 'cs_test_paid_workspace_zip', + payment_status: 'paid', + status: 'complete', + customer_email: 'paid@example.com', + metadata: { plan: 'pro' }, + })), + createProCheckoutSession: vi.fn(async () => ({ + sessionId: 'cs_test_workspace_zip', + url: 'https://checkout.stripe.com/c/pay/cs_test_workspace_zip', + })), +})); + +vi.mock('@/lib/billing/subscription-events', () => ({ + activateFromCheckoutSession: vi.fn(async () => ({ + email: 'paid@example.com', + plan: 'pro', + status: 'active', + leadModel: 'grok-4-1-fast', + updatedAt: '2026-09-08T00:00:00.000Z', + })), +})); + +import { POST } from '@/app/api/workspace/export/route'; +import { getEntitlement, isProSubscriber } from '@/lib/billing/entitlement-store'; +import { activateFromCheckoutSession } from '@/lib/billing/subscription-events'; +import { createProCheckoutSession, getCheckoutSession } from '@/lib/billing/stripe-checkout'; + +function request(body: unknown): Request { + return new Request('http://localhost/api/workspace/export', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('POST /api/workspace/export', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 402 with a Stripe checkout session when the caller is not Pro', async () => { + vi.mocked(isProSubscriber).mockResolvedValue(false); + vi.mocked(getEntitlement).mockResolvedValue(null); + + const response = await POST( + request({ + projectName: 'workspace-pack', + files: { 'README.md': '# workspace-pack\n' }, + }), + ); + const body = await response.json(); + + expect(response.status).toBe(402); + expect(body).toEqual({ + error: 'Workspace ZIP exports require Pro. Upgrade to continue.', + code: 'payment_required', + upgradeRequired: true, + plan: 'free', + checkoutUrl: 'https://checkout.stripe.com/c/pay/cs_test_workspace_zip', + sessionId: 'cs_test_workspace_zip', + retryable: true, + verificationUrl: '/api/billing/activate', + }); + expect(createProCheckoutSession).toHaveBeenCalledWith({ + annual: false, + customerEmail: undefined, + flow: 'acquisition', + }); + }); + + it('returns a ZIP attachment for Pro callers', async () => { + vi.mocked(isProSubscriber).mockResolvedValue(true); + + const response = await POST( + request({ + projectName: 'workspace-pack', + files: { + 'README.md': '# workspace-pack\n', + 'src/index.ts': "console.log('ok');\n", + }, + }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/zip'); + expect(response.headers.get('content-disposition')).toContain('workspace-pack.zip'); + expect(new Uint8Array(await response.arrayBuffer()).slice(0, 4)).toEqual( + new Uint8Array([0x50, 0x4b, 0x03, 0x04]), + ); + expect(createProCheckoutSession).not.toHaveBeenCalled(); + }); + + it('verifies a paid checkout session and sets the billing cookie before downloading', async () => { + vi.mocked(isProSubscriber).mockResolvedValue(false); + vi.mocked(getEntitlement).mockResolvedValue(null); + + const response = await POST( + request({ + projectName: 'workspace-pack', + files: { 'README.md': '# workspace-pack\n' }, + sessionId: 'cs_test_paid_workspace_zip', + }), + ); + + expect(response.status).toBe(200); + expect(response.headers.get('set-cookie')).toContain('er_billing_email='); + expect(getCheckoutSession).toHaveBeenCalledWith('cs_test_paid_workspace_zip'); + expect(activateFromCheckoutSession).toHaveBeenCalled(); + expect(createProCheckoutSession).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/billing/status/route.ts b/apps/web/src/app/api/billing/status/route.ts index b2c57be55..eeb849957 100644 --- a/apps/web/src/app/api/billing/status/route.ts +++ b/apps/web/src/app/api/billing/status/route.ts @@ -16,6 +16,8 @@ export async function GET(req: NextRequest) { agentDispatch: false, apiAccess: false, chatDailyLimit: FREE_CHAT_DAILY_LIMIT, + workspaceZipExport: false, + refineryDatasetAccess: false, }, routing: resolvePaidTierRouting(false), renewalEligible: false, @@ -39,6 +41,8 @@ export async function GET(req: NextRequest) { agentDispatch: false, apiAccess: false, chatDailyLimit: FREE_CHAT_DAILY_LIMIT, + workspaceZipExport: false, + refineryDatasetAccess: false, }, routing, renewalEligible: Boolean(entitlement?.stripeCustomerId || email), diff --git a/apps/web/src/app/api/v1/refinery/dataset/__tests__/route.test.ts b/apps/web/src/app/api/v1/refinery/dataset/__tests__/route.test.ts new file mode 100644 index 000000000..1fcba03b8 --- /dev/null +++ b/apps/web/src/app/api/v1/refinery/dataset/__tests__/route.test.ts @@ -0,0 +1,92 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/lib/billing/billing-context', () => ({ + BILLING_EMAIL_COOKIE: 'er_billing_email', + resolveTrustedBillingEmail: vi.fn(async () => null), +})); + +vi.mock('@/lib/billing/entitlement-store', () => ({ + getEntitlement: vi.fn(async () => null), + isProSubscriber: vi.fn(async () => false), +})); + +vi.mock('@/lib/billing/stripe-checkout', () => ({ + createProCheckoutSession: vi.fn(async () => ({ + sessionId: 'cs_test_refinery_dataset', + url: 'https://checkout.stripe.com/c/pay/cs_test_refinery_dataset', + })), +})); + +vi.mock('@/lib/training-store', () => ({ + getTrainingStatus: vi.fn(async () => ({ + metadata: { + totalExamples: 2, + lastUpdated: '2026-09-08T00:00:00.000Z', + lastVideoUrl: 'https://youtu.be/auJzb1D-fag', + lastVideoTitle: 'Canon', + tuningTriggered: false, + tuningTriggeredAt: null, + tuningJobId: null, + videosProcessed: ['https://youtu.be/auJzb1D-fag'], + }, + readyForTuning: false, + progress: 2, + nextMilestone: 25, + })), + readTrainingFile: vi.fn(async () => '{"contents":[{"role":"user","parts":[{"text":"Analyze this video: https://youtu.be/auJzb1D-fag"}]}]}\n'), +})); + +import { GET } from '@/app/api/v1/refinery/dataset/route'; +import { getEntitlement, isProSubscriber } from '@/lib/billing/entitlement-store'; +import { createProCheckoutSession } from '@/lib/billing/stripe-checkout'; + +function request(query = ''): Request { + return new Request(`http://localhost/api/v1/refinery/dataset${query}`, { + method: 'GET', + }); +} + +describe('GET /api/v1/refinery/dataset', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns 402 with Stripe checkout details for free callers', async () => { + vi.mocked(isProSubscriber).mockResolvedValue(false); + vi.mocked(getEntitlement).mockResolvedValue(null); + + const response = await GET(request()); + const body = await response.json(); + + expect(response.status).toBe(402); + expect(body).toEqual({ + error: 'Refinery dataset access requires Pro. Upgrade to continue.', + code: 'payment_required', + upgradeRequired: true, + plan: 'free', + checkoutUrl: 'https://checkout.stripe.com/c/pay/cs_test_refinery_dataset', + sessionId: 'cs_test_refinery_dataset', + retryable: true, + verificationUrl: '/api/billing/activate', + }); + expect(createProCheckoutSession).toHaveBeenCalledWith({ + annual: false, + customerEmail: undefined, + flow: 'acquisition', + }); + }); + + it('returns the raw dataset as a download for Pro callers', async () => { + vi.mocked(isProSubscriber).mockResolvedValue(true); + + const response = await GET(request('?download=1')); + const text = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('application/x-ndjson; charset=utf-8'); + expect(response.headers.get('content-disposition')).toContain('refinery-dataset.jsonl'); + expect(response.headers.get('x-training-examples')).toBe('2'); + expect(text).toContain('Analyze this video: https://youtu.be/auJzb1D-fag'); + expect(createProCheckoutSession).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/app/api/v1/refinery/dataset/route.ts b/apps/web/src/app/api/v1/refinery/dataset/route.ts new file mode 100644 index 000000000..d0ff22022 --- /dev/null +++ b/apps/web/src/app/api/v1/refinery/dataset/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from 'next/server'; +import { applyVerifiedBillingCookie, requireProFeatureAccess } from '@/lib/billing/pro-feature-gate'; +import { getTrainingStatus, readTrainingFile } from '@/lib/training-store'; + +export const runtime = 'nodejs'; + +export async function GET(request: Request) { + const url = new URL(request.url); + const access = await requireProFeatureAccess(request, { + featureKey: 'refinery_dataset', + featureLabel: 'Refinery dataset access', + sessionId: url.searchParams.get('sessionId') ?? url.searchParams.get('session_id'), + }); + if (!access.ok) { + return access.response; + } + + const status = await getTrainingStatus(); + if (url.searchParams.get('download') !== '1') { + const response = NextResponse.json({ + dataset: { + totalExamples: status.metadata.totalExamples, + readyForTuning: status.readyForTuning, + progress: status.progress, + nextMilestone: status.nextMilestone, + lastUpdated: status.metadata.lastUpdated, + }, + downloadUrl: '/api/v1/refinery/dataset?download=1', + }); + applyVerifiedBillingCookie(response, access.signedBillingEmail); + return response; + } + + const dataset = await readTrainingFile(); + if (!dataset) { + return NextResponse.json( + { error: 'dataset_not_found', code: 'dataset_not_found' }, + { status: 404 }, + ); + } + + const response = new NextResponse(dataset, { + status: 200, + headers: { + 'cache-control': 'no-store', + 'content-disposition': 'attachment; filename="refinery-dataset.jsonl"', + 'content-type': 'application/x-ndjson; charset=utf-8', + 'x-training-examples': String(status.metadata.totalExamples), + }, + }); + applyVerifiedBillingCookie(response, access.signedBillingEmail); + return response; +} diff --git a/apps/web/src/app/api/workspace/export/route.ts b/apps/web/src/app/api/workspace/export/route.ts new file mode 100644 index 000000000..787af80cd --- /dev/null +++ b/apps/web/src/app/api/workspace/export/route.ts @@ -0,0 +1,71 @@ +import { NextResponse } from 'next/server'; +import { applyVerifiedBillingCookie, requireProFeatureAccess } from '@/lib/billing/pro-feature-gate'; +import { studioExportFilename } from '@/lib/studio-pipeline-status'; +import { zipUtf8Files } from '@/lib/zip-store'; + +export const runtime = 'nodejs'; + +type WorkspaceExportRequest = { + projectName?: string; + files?: Record; + sessionId?: string; +}; + +function normalizeFiles(files: WorkspaceExportRequest['files']): Record | null { + if (!files || typeof files !== 'object') return null; + + const normalized: Record = {}; + for (const [path, content] of Object.entries(files)) { + if (typeof content !== 'string') return null; + const trimmedPath = path.trim().replace(/\\/g, '/'); + if (!trimmedPath || trimmedPath.startsWith('/')) return null; + if (trimmedPath.split('/').some((part) => !part || part === '.' || part === '..')) return null; + normalized[trimmedPath] = content; + } + + return Object.keys(normalized).length > 0 ? normalized : null; +} + +function quotedFilename(filename: string): string { + return filename.replace(/"/g, ''); +} + +export async function POST(request: Request) { + let body: WorkspaceExportRequest; + try { + body = await request.json(); + } catch { + return NextResponse.json({ error: 'invalid_json', code: 'invalid_json' }, { status: 400 }); + } + + const files = normalizeFiles(body.files); + if (!files) { + return NextResponse.json( + { error: 'files_required', code: 'files_required' }, + { status: 400 }, + ); + } + + const access = await requireProFeatureAccess(request, { + featureKey: 'workspace_export', + featureLabel: 'Workspace ZIP exports', + sessionId: body.sessionId, + }); + if (!access.ok) { + return access.response; + } + + const filename = studioExportFilename(body.projectName); + const zip = zipUtf8Files(files); + const response = new NextResponse(Buffer.from(zip), { + status: 200, + headers: { + 'cache-control': 'no-store', + 'content-disposition': `attachment; filename="${quotedFilename(filename)}"`, + 'content-length': String(zip.byteLength), + 'content-type': 'application/zip', + }, + }); + applyVerifiedBillingCookie(response, access.signedBillingEmail); + return response; +} diff --git a/apps/web/src/components/OneLoopStudio.tsx b/apps/web/src/components/OneLoopStudio.tsx index f8e92fe98..9bbd7519d 100644 --- a/apps/web/src/components/OneLoopStudio.tsx +++ b/apps/web/src/components/OneLoopStudio.tsx @@ -488,7 +488,7 @@ export default function OneLoopStudio() { } }; - const exportPkg = () => { + const exportPkg = async () => { const insightActions = (selected?.insights?.actions || []).flatMap((action) => { if (typeof action === 'string') { return action.trim() ? [{ title: action.trim() }] : []; @@ -524,8 +524,21 @@ export default function OneLoopStudio() { tools: packFormation.tools, }, }); - downloadScaffoldPackage(pkg); - const filename = studioExportFilename(pkg.projectName); + const result = await downloadScaffoldPackage(pkg); + if (!result.ok) { + if (result.status === 402 && result.checkoutUrl) { + setExportToast({ tone: 'error', text: 'Workspace ZIP exports require Pro. Redirecting to checkout…' }); + window.location.href = result.checkoutUrl; + return; + } + const toast = studioExportToastMessage({ + ok: false, + error: result.error, + }); + setExportToast(toast); + return; + } + const filename = result.filename || studioExportFilename(pkg.projectName); const kind = packFormation.architecture || packFormation.artifacts.length > 0 ? 'pack' diff --git a/apps/web/src/components/dashboard/panels.tsx b/apps/web/src/components/dashboard/panels.tsx index 986b05484..73948cd4d 100644 --- a/apps/web/src/components/dashboard/panels.tsx +++ b/apps/web/src/components/dashboard/panels.tsx @@ -222,7 +222,7 @@ export function ActionsPanel({ const projectScaffold = video.insights?.project_scaffold; const scaffoldPreview = summarizeProjectScaffold(projectScaffold); - const exportScaffold = () => { + const exportScaffold = async () => { // Prefer tool-fulfilled titles; fall back to planned analysis actions. const fromTools: ActionCardLike[] = fulfilled .filter((a) => typeof a.input?.title === 'string' || a.tool) @@ -252,7 +252,10 @@ export function ActionsPanel({ actions, projectScaffold, }); - downloadScaffoldPackage(pkg); + const result = await downloadScaffoldPackage(pkg); + if (!result.ok && result.checkoutUrl) { + window.location.href = result.checkoutUrl; + } }; const canExport = diff --git a/apps/web/src/lib/__tests__/action-surface.test.ts b/apps/web/src/lib/__tests__/action-surface.test.ts index c5eb51725..2d4152330 100644 --- a/apps/web/src/lib/__tests__/action-surface.test.ts +++ b/apps/web/src/lib/__tests__/action-surface.test.ts @@ -1,12 +1,48 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { actionsFromStudioRun, buildScaffoldPackage, + downloadScaffoldPackage, summarizeProjectScaffold, } from '@/lib/action-surface'; import { zipEntryNames, zipUtf8Files } from '@/lib/zip-store'; describe('action-surface (F3)', () => { + const fetchMock = vi.fn(); + const clickMock = vi.fn(); + const appendChildMock = vi.fn(); + const removeMock = vi.fn(); + const createObjectURLMock = vi.fn(() => 'blob:zip'); + const revokeObjectURLMock = vi.fn(); + + beforeEach(() => { + vi.stubGlobal('fetch', fetchMock); + vi.stubGlobal('URL', { + createObjectURL: createObjectURLMock, + revokeObjectURL: revokeObjectURLMock, + }); + vi.stubGlobal('document', { + body: { appendChild: appendChildMock }, + createElement: vi.fn(() => ({ + click: clickMock, + remove: removeMock, + href: '', + download: '', + rel: '', + })), + }); + }); + + afterEach(() => { + fetchMock.mockReset(); + clickMock.mockReset(); + appendChildMock.mockReset(); + removeMock.mockReset(); + createObjectURLMock.mockClear(); + revokeObjectURLMock.mockClear(); + vi.unstubAllGlobals(); + }); + it('buildScaffoldPackage emits README, tasks.json, and stub index', () => { const pkg = buildScaffoldPackage({ projectName: 'My Cool App!', @@ -156,4 +192,89 @@ describe('action-surface (F3)', () => { expect(actionsFromStudioRun({ insightActions: [{ title: ' ' }], events: [] })).toEqual([]); }); + + it('surfaces payment-required export responses without triggering a download', async () => { + fetchMock.mockResolvedValue( + new Response( + JSON.stringify({ + error: 'Workspace ZIP exports require Pro. Upgrade to continue.', + code: 'payment_required', + upgradeRequired: true, + checkoutUrl: 'https://checkout.stripe.com/c/pay/cs_test_export', + retryable: true, + }), + { + status: 402, + headers: { 'content-type': 'application/json' }, + }, + ), + ); + + const result = await downloadScaffoldPackage({ + projectName: 'paid-pack', + files: { 'README.md': '# paid-pack\n' }, + }); + + expect(result).toEqual({ + ok: false, + status: 402, + error: 'Workspace ZIP exports require Pro. Upgrade to continue.', + code: 'payment_required', + upgradeRequired: true, + checkoutUrl: 'https://checkout.stripe.com/c/pay/cs_test_export', + retryable: true, + }); + expect(clickMock).not.toHaveBeenCalled(); + expect(createObjectURLMock).not.toHaveBeenCalled(); + }); + + it('downloads ZIP bytes returned by the workspace export API', async () => { + fetchMock.mockResolvedValue( + new Response(new Uint8Array([0x50, 0x4b, 0x03, 0x04]), { + status: 200, + headers: { + 'content-type': 'application/zip', + 'content-disposition': 'attachment; filename="studio-pack.zip"', + }, + }), + ); + + const result = await downloadScaffoldPackage({ + projectName: 'studio-pack', + files: { 'README.md': '# studio-pack\n' }, + }); + + expect(result).toEqual({ ok: true, status: 200, filename: 'studio-pack.zip' }); + expect(clickMock).toHaveBeenCalledTimes(1); + expect(createObjectURLMock).toHaveBeenCalledTimes(1); + expect(revokeObjectURLMock).toHaveBeenCalledWith('blob:zip'); + }); + + it('retries transient workspace export failures before downloading', async () => { + fetchMock + .mockResolvedValueOnce( + new Response(JSON.stringify({ error: 'temporary' }), { + status: 503, + headers: { 'content-type': 'application/json' }, + }), + ) + .mockResolvedValueOnce( + new Response(new Uint8Array([0x50, 0x4b, 0x03, 0x04]), { + status: 200, + headers: { + 'content-type': 'application/zip', + 'content-disposition': 'attachment; filename="retry-pack.zip"', + }, + }), + ); + + const result = await downloadScaffoldPackage({ + projectName: 'retry-pack', + files: { 'README.md': '# retry-pack\n' }, + }); + + expect(result).toEqual({ ok: true, status: 200, filename: 'retry-pack.zip' }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(clickMock).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/web/src/lib/action-surface.ts b/apps/web/src/lib/action-surface.ts index 9f795041f..b51fc2f5c 100644 --- a/apps/web/src/lib/action-surface.ts +++ b/apps/web/src/lib/action-surface.ts @@ -41,6 +41,22 @@ export interface ScaffoldPackage { files: Record; } +export type ScaffoldDownloadResult = + | { + ok: true; + status: number; + filename: string; + } + | { + ok: false; + status: number; + error: string; + code?: string; + upgradeRequired?: boolean; + checkoutUrl?: string; + retryable?: boolean; + }; + function safeProjectName(name: string): string { return ( name @@ -274,19 +290,84 @@ export function summarizeProjectScaffold(scaffold: unknown, maxItems = 6): strin } /** One zip so Chrome does not swallow official starter files after the first blob. */ -export function downloadScaffoldPackage(pkg: ScaffoldPackage): void { - if (typeof document === 'undefined') return; - const zip = zipUtf8Files(pkg.files); - const bytes = new ArrayBuffer(zip.byteLength); - new Uint8Array(bytes).set(zip); - const blob = new Blob([bytes], { type: 'application/zip' }); +function exportFallbackName(projectName: string): string { + const trimmed = projectName.trim(); + if (!trimmed) return 'uvai-project.zip'; + return trimmed.toLowerCase().endsWith('.zip') ? trimmed : `${trimmed}.zip`; +} + +function attachmentFilename(disposition: string | null, fallback: string): string { + const match = disposition?.match(/filename="([^"]+)"/i); + return match?.[1]?.trim() || fallback; +} + +async function readExportError(response: Response): Promise { + const body = await response + .json() + .catch(() => ({} as Record)); + + return { + ok: false, + status: response.status, + error: + typeof body.error === 'string' && body.error.trim() + ? body.error + : 'Export failed.', + code: typeof body.code === 'string' ? body.code : undefined, + upgradeRequired: body.upgradeRequired === true, + checkoutUrl: typeof body.checkoutUrl === 'string' ? body.checkoutUrl : undefined, + retryable: body.retryable === true, + }; +} + +/** One zip so Chrome does not swallow official starter files after the first blob. */ +export async function downloadScaffoldPackage( + pkg: ScaffoldPackage, +): Promise { + if (typeof document === 'undefined' || typeof fetch === 'undefined') { + return { ok: false, status: 0, error: 'Downloads require a browser context.' }; + } + + let response: Response | null = null; + let lastError: Error | null = null; + for (let attempt = 0; attempt < 3; attempt++) { + try { + response = await fetch('/api/workspace/export', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(pkg), + }); + if (response.ok || response.status < 500) break; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + } + } + + if (!response) { + return { + ok: false, + status: 0, + error: lastError?.message || 'Export failed.', + }; + } + + if (!response.ok) { + return readExportError(response); + } + + const blob = await response.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `${pkg.projectName || 'uvai-project'}.zip`; + const filename = attachmentFilename( + response.headers.get('content-disposition'), + exportFallbackName(pkg.projectName || 'uvai-project'), + ); + a.download = filename; a.rel = 'noopener'; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(url); + return { ok: true, status: response.status, filename }; } diff --git a/apps/web/src/lib/billing/paid-tier-model.ts b/apps/web/src/lib/billing/paid-tier-model.ts index b4a65f5f8..d3ec11611 100644 --- a/apps/web/src/lib/billing/paid-tier-model.ts +++ b/apps/web/src/lib/billing/paid-tier-model.ts @@ -29,6 +29,8 @@ export const PRO_FEATURES = { agentDispatch: true, apiAccess: true, priorityProcessing: true, + workspaceZipExport: true, + refineryDatasetAccess: true, } as const; export const FREE_CHAT_DAILY_LIMIT = 5; \ No newline at end of file diff --git a/apps/web/src/lib/billing/pro-feature-gate.ts b/apps/web/src/lib/billing/pro-feature-gate.ts new file mode 100644 index 000000000..9558fb761 --- /dev/null +++ b/apps/web/src/lib/billing/pro-feature-gate.ts @@ -0,0 +1,149 @@ +import { NextResponse } from 'next/server'; +import { BILLING_EMAIL_COOKIE, resolveTrustedBillingEmail } from './billing-context'; +import { signBillingEmail } from './billing-cookie'; +import { getCheckoutActivation } from './checkout-session-store'; +import type { EntitlementRecord } from './entitlement-store'; +import { getEntitlement, isProSubscriber } from './entitlement-store'; +import { kaizenObserve } from './kaizen-trace'; +import { createProCheckoutSession, getCheckoutSession } from './stripe-checkout'; +import { activateFromCheckoutSession } from './subscription-events'; + +type ProFeatureLabel = 'Workspace ZIP exports' | 'Refinery dataset access'; + +type ProAccessGranted = { + ok: true; + signedBillingEmail?: string; +}; + +type ProAccessBlocked = { + ok: false; + response: NextResponse; +}; + +function proRequirementVerb(featureLabel: ProFeatureLabel): 'require' | 'requires' { + return featureLabel === 'Workspace ZIP exports' ? 'require' : 'requires'; +} + +function entitlementFromActivationLink( + link: NonNullable>>, +): EntitlementRecord { + return { + email: link.email, + plan: link.plan, + status: link.status, + stripeCustomerId: link.stripeCustomerId, + stripeSubscriptionId: link.stripeSubscriptionId, + leadModel: link.leadModel, + updatedAt: link.fulfilledAt, + }; +} + +async function verifySessionEntitlement( + sessionId?: string | null, +): Promise { + const trimmed = sessionId?.trim(); + if (!trimmed) return null; + + const linked = await getCheckoutActivation(trimmed); + if (linked?.plan === 'pro' && (linked.status === 'active' || linked.status === 'trialing')) { + return entitlementFromActivationLink(linked); + } + + const session = await getCheckoutSession(trimmed); + return activateFromCheckoutSession(session); +} + +export function applyVerifiedBillingCookie( + response: NextResponse, + signedBillingEmail?: string, +): void { + if (!signedBillingEmail) return; + + response.cookies.set(BILLING_EMAIL_COOKIE, signedBillingEmail, { + httpOnly: true, + sameSite: 'lax', + path: '/', + maxAge: 60 * 60 * 24 * 365, + secure: process.env.NODE_ENV === 'production', + }); +} + +export async function requireProFeatureAccess( + request: Request, + input: { + featureKey: 'workspace_export' | 'refinery_dataset'; + featureLabel: ProFeatureLabel; + sessionId?: string | null; + }, +): Promise { + const billingEmail = await resolveTrustedBillingEmail(request); + if (await isProSubscriber(billingEmail)) { + return { ok: true }; + } + + try { + const verified = await verifySessionEntitlement(input.sessionId); + if (verified?.plan === 'pro' && (verified.status === 'active' || verified.status === 'trialing')) { + return { + ok: true, + signedBillingEmail: signBillingEmail(verified.email) ?? undefined, + }; + } + } catch (error) { + const message = error instanceof Error ? error.message : 'verification_failed'; + console.error(`[billing] ${input.featureKey} verification failed:`, message); + kaizenObserve('billing', `${input.featureKey}_verification_error`, message, { + fix: 'inspect_stripe_checkout_session', + }); + } + + try { + const entitlement = billingEmail ? await getEntitlement(billingEmail) : null; + const checkout = await createProCheckoutSession({ + annual: false, + customerEmail: billingEmail ?? undefined, + customerId: entitlement?.stripeCustomerId, + flow: entitlement?.stripeCustomerId ? 'renewal' : 'acquisition', + }); + + kaizenObserve( + 'billing', + `${input.featureKey}_blocked`, + `${input.featureLabel} ${proRequirementVerb(input.featureLabel)} Pro`, + { + decision: `email=${billingEmail ?? 'anonymous'}`, + fix: 'upgrade_to_pro', + }, + ); + + return { + ok: false, + response: NextResponse.json( + { + error: `${input.featureLabel} ${proRequirementVerb(input.featureLabel)} Pro. Upgrade to continue.`, + code: 'payment_required', + upgradeRequired: true, + plan: 'free', + checkoutUrl: checkout.url, + sessionId: checkout.sessionId, + retryable: true, + verificationUrl: '/api/billing/activate', + }, + { status: 402 }, + ), + }; + } catch (error) { + const message = error instanceof Error ? error.message : 'payment_gate_failed'; + console.error(`[billing] ${input.featureKey} payment gate failed:`, message); + kaizenObserve('billing', `${input.featureKey}_error`, message, { + fix: 'verify_stripe_env', + }); + return { + ok: false, + response: NextResponse.json( + { error: 'payment_gate_failed', code: 'payment_gate_failed' }, + { status: 500 }, + ), + }; + } +}