diff --git a/apps/web/src/app/api/v1/video/pack/__tests__/route.test.ts b/apps/web/src/app/api/v1/video/pack/__tests__/route.test.ts index 58f1b849b..23c9fce99 100644 --- a/apps/web/src/app/api/v1/video/pack/__tests__/route.test.ts +++ b/apps/web/src/app/api/v1/video/pack/__tests__/route.test.ts @@ -1,7 +1,14 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { GOLDEN_IDENTITY_HASHES } from '@/lib/video-pack'; const CANON_B = 'jNQXAC9IVRw'; +const V1_ROUTE_SOURCE = readFileSync( + join(dirname(fileURLToPath(import.meta.url)), '../route.ts'), + 'utf8', +); afterEach(() => { vi.resetModules(); @@ -17,6 +24,15 @@ function postRequest(body: unknown) { } describe('POST /api/v1/video/pack', () => { + it('declares a literal runtime so Next can statically parse route-segment config', () => { + // Re-exporting `runtime` from the sibling pack route is what failed + // v0-uvai `next build` after #1612 (`25b4de455`): + // "The exported configuration object in a source file needs to have a + // very specific format from which some properties can be statically parsed" + expect(V1_ROUTE_SOURCE).toMatch(/export const runtime = ['"]nodejs['"]/); + expect(V1_ROUTE_SOURCE).not.toMatch(/export\s*\{[^}]*\bruntime\b/); + }); + it('emits the same identity pack as /api/video/pack without a transcript', async () => { const { POST } = await import('../route'); const res = await POST( diff --git a/apps/web/src/app/api/v1/video/pack/route.ts b/apps/web/src/app/api/v1/video/pack/route.ts index e26df8171..fa212b053 100644 --- a/apps/web/src/app/api/v1/video/pack/route.ts +++ b/apps/web/src/app/api/v1/video/pack/route.ts @@ -1 +1,7 @@ -export { POST, runtime } from '../../../video/pack/route'; +import { handleIdentityPackPost } from '@/lib/video-pack'; + +export const runtime = 'nodejs'; + +export async function POST(request: Request): Promise { + return handleIdentityPackPost(request); +} diff --git a/apps/web/src/app/api/video/pack/route.ts b/apps/web/src/app/api/video/pack/route.ts index dedc7ddaf..fa212b053 100644 --- a/apps/web/src/app/api/video/pack/route.ts +++ b/apps/web/src/app/api/video/pack/route.ts @@ -1,51 +1,7 @@ -import { NextResponse } from 'next/server'; -import { resolveVideoUrl } from '@/lib/video-url-request'; -import { buildIdentityPack, resolveYouTubeVideoId, type VideoPackV0Json } from '@/lib/video-pack'; +import { handleIdentityPackPost } from '@/lib/video-pack'; export const runtime = 'nodejs'; -const packs = new Map(); - -function getOrCreatePack(videoId: string, sourceUrl?: string): VideoPackV0Json { - const existing = packs.get(videoId); - if (existing) { - return existing; - } - const pack = buildIdentityPack(videoId, sourceUrl); - packs.set(videoId, pack); - return pack; -} - -export async function POST(request: Request) { - let body: Record | null = null; - try { - const parsed: unknown = await request.json(); - body = parsed !== null && typeof parsed === 'object' ? (parsed as Record) : null; - } catch { - return NextResponse.json({ status: 'error', error: 'Invalid request body' }, { status: 400 }); - } - - const url = resolveVideoUrl(body); - const rawId = body?.video_id ?? body?.videoId; - const fromField = typeof rawId === 'string' ? resolveYouTubeVideoId(rawId) : null; - const videoId = fromField || (url ? resolveYouTubeVideoId(url) : null); - - if (!videoId) { - return NextResponse.json( - { status: 'error', error: 'A YouTube URL or video id is required' }, - { status: 400 }, - ); - } - - const pack = getOrCreatePack(videoId, url || undefined); - if (!pack.source_url.startsWith('http') || !pack.provenance.source_hash) { - return NextResponse.json( - { - status: 'error', - error: 'Video pack verification failed: source_url and source_hash are required.', - }, - { status: 500 }, - ); - } - return NextResponse.json({ status: 'success', data: pack }); +export async function POST(request: Request): Promise { + return handleIdentityPackPost(request); } diff --git a/apps/web/src/lib/video-pack.ts b/apps/web/src/lib/video-pack.ts index 099f8fcf1..98f88bab7 100644 --- a/apps/web/src/lib/video-pack.ts +++ b/apps/web/src/lib/video-pack.ts @@ -1,4 +1,6 @@ import { createHash } from 'node:crypto'; +import { NextResponse } from 'next/server'; +import { resolveVideoUrl } from '@/lib/video-url-request'; export const IDENTITY_VERSION = 'v0' as const; @@ -94,3 +96,49 @@ export function buildIdentityPack(videoId: string, sourceUrl?: string, createdAt }, }; } + +const packs = new Map(); + +function getOrCreatePack(videoId: string, sourceUrl?: string): VideoPackV0Json { + const existing = packs.get(videoId); + if (existing) { + return existing; + } + const pack = buildIdentityPack(videoId, sourceUrl); + packs.set(videoId, pack); + return pack; +} + +export async function handleIdentityPackPost(request: Request): Promise { + let body: Record | null = null; + try { + const parsed: unknown = await request.json(); + body = parsed !== null && typeof parsed === 'object' ? (parsed as Record) : null; + } catch { + return NextResponse.json({ status: 'error', error: 'Invalid request body' }, { status: 400 }); + } + + const url = resolveVideoUrl(body); + const rawId = body?.video_id ?? body?.videoId; + const fromField = typeof rawId === 'string' ? resolveYouTubeVideoId(rawId) : null; + const videoId = fromField || (url ? resolveYouTubeVideoId(url) : null); + + if (!videoId) { + return NextResponse.json( + { status: 'error', error: 'A YouTube URL or video id is required' }, + { status: 400 }, + ); + } + + const pack = getOrCreatePack(videoId, url || undefined); + if (!pack.source_url.startsWith('http') || !pack.provenance.source_hash) { + return NextResponse.json( + { + status: 'error', + error: 'Video pack verification failed: source_url and source_hash are required.', + }, + { status: 500 }, + ); + } + return NextResponse.json({ status: 'success', data: pack }); +} diff --git a/src/youtube_extension/videopack/PLAN.md b/src/youtube_extension/videopack/PLAN.md index c4e59631f..9b665e15b 100644 --- a/src/youtube_extension/videopack/PLAN.md +++ b/src/youtube_extension/videopack/PLAN.md @@ -1,31 +1,30 @@ -# TASK: Public identity Video Pack emit after #1609 401 +# TASK: Fix Next.js static parse of /api/v1/video/pack runtime ## 1. Goal & Scope -* **Objective:** Anonymous paste-URL on uvai.io emits a hashed Video Pack v0 even when transcript fetch fails. Pack JSON must include `source_url` + `source_hash`. Fail closed on verification — not a silent empty UI. `POST /api/video/pack` must return 200 without sign-in. -* **Context:** PR 1609 (`5ccbdf7`) added identity helpers and wired `processVideo` → `emitVideoPack`. Live uvai.io still 401s both pack URLs because `needsAuthentication('/api/video/pack')` is true. Home then shows "No transcript yet" / "source evidence could not be verified" with no `source_hash` or `cite:youtube`. +* **Objective:** Restore a passing Vercel `v0-uvai` `next build` after PR 1612. The v1 pack alias must declare a literal `export const runtime = 'nodejs'` (or omit it) and must not re-export route-segment config. Anonymous POST `/api/video/pack` and `/api/v1/video/pack` keep emitting hashed Video Pack v0 with `source_url` + `source_hash` even without a transcript. +* **Context:** Commit `25b4de4552030dd0e00e41f11ad7bfda72a3ec4f` added `apps/web/src/app/api/v1/video/pack/route.ts` as `export { POST, runtime } from '../../../video/pack/route'`. Next.js cannot statically parse a re-exported `runtime`. Production uvai.io is not serving that commit. * **Scope:** - * Allowlist `/api/video/pack` and `/api/v1/video/pack` in Next.js auth-paths (exact paths only; do not open `/api/video` or `/api/video/generate`). - * Alias `POST /api/v1/video/pack` on the Next.js surface to the existing 1609 handler. - * Allowlist FastAPI `/api/v1/video/pack` so a backend hit is not API-key gated. - * Persist and show the identity citation on home paste+Run when speech evidence is missing. - * Tests: 401-not-required, hash stability, emit-without-transcript. -* **Initial check:** Modify existing `auth-paths.ts`, `OneLoopStudio.tsx`, `dashboard-store` tests, and 1609 videopack helpers. Do not invent a second pack format. -* **Out of scope:** Qwen/Qwen3.8-27B, vLLM, Origin wasm, FORGE, slingshot, reach, ClipToAction. + * `apps/web/src/app/api/v1/video/pack/route.ts` — literal runtime + POST that calls the same identity-pack handler. + * Shared handler extracted into existing `apps/web/src/lib/video-pack.ts` so neither route re-exports config or imports a sibling route file. + * `apps/web/src/app/api/video/pack/route.ts` — call the shared handler; keep literal runtime. + * Regression test: v1 route source is statically parseable (literal `runtime`, no `export { … runtime }`). + * Existing CoS tests remain locked. + * *Initial Check: Existing v1 route file is the defect; do not add a second pack format or a new route tree.* ## 2. Execution Plan -- [x] Lock failing tests (auth-paths public pack, FastAPI public pack, processVideo emit-without-transcript, citation label) -- [x] Allowlist exact pack paths; add Next.js `/api/v1/video/pack` alias -- [x] Show persisted pack citation on home even when transcript is missing -- [x] Verify tests; open PR against main (Closes #1611; #1610 already closed) +- [x] Lock failing source-format test on the v1 route +- [x] Extract identity-pack POST handler; give v1 a literal `runtime` + wrapper POST +- [x] Verify CoS pack tests + `next build` no longer fails on this file +- [x] Open PR against main with Closes #1613 (issue create returned 403; reuse the open pack-emit issue) ## 3. Definition of Done (Success Verification) -* **Expected Outcome:** Anonymous `POST /api/video/pack` with `{"url":"https://www.youtube.com/watch?v=jNQXAC9IVRw"}` returns 200 Video Pack v0 (`video_id`, `version`, `source_hash`). Same URL retry same hash; different video ID different hash. Home paste+Run shows/persists that citation if transcript is missing. +* **Expected Outcome:** `next build` does not report a route-segment-config parse error at `apps/web/src/app/api/v1/video/pack/route.ts`. Anonymous POSTs still return 200 Video Pack v0 with `source_url` + `source_hash`. * **Verification Method:** - * `cd apps/web && npx vitest run src/lib/__tests__/auth-paths.test.ts src/lib/__tests__/video-pack.test.ts src/lib/__tests__/studio-pipeline-status.test.ts src/app/api/video/pack src/app/api/v1/video/pack src/store/__tests__/dashboard-store.test.ts` - * `PYTHONPATH=src pytest tests/unit/test_api_key_auth.py tests/unit/test_videopack_identity.py tests/unit/test_videopack_store.py -o addopts=` -* **Proof Artifact:** Frontend 76 passed (7 files, including emit-video-pack fail-closed). Python 23 passed. Pack JSON locks `source_url` + `source_hash`. Missing either field throws verification failed (not a silent empty UI). Live production still 401 until this branch deploys. Golden hashes unchanged. + * `cd apps/web && npx vitest run src/app/api/v1/video/pack src/app/api/video/pack src/lib/__tests__/video-pack.test.ts src/lib/__tests__/emit-video-pack.test.ts src/lib/__tests__/auth-paths.test.ts` + * `cd apps/web && npm run build` +* **Proof Artifact:** Head `346b276f8`. Vitest 42 passed (5 files). `next build` compiled successfully and listed `ƒ /api/v1/video/pack` and `ƒ /api/video/pack`. No route-segment-config parse error. ## 4. Post-Task Reflection -* **What was done:** Ungated exact `/api/video/pack` and `/api/v1/video/pack` from Next.js login wall and FastAPI API-key wall; aliased the v1 Next.js path to the 1609 identity handler; home paste now shows `cite:youtube: · v0 · ` even when the workflow has no transcript. -* **Why it was needed:** After #1609, anonymous paste called `emitVideoPack`, middleware 401'd, and the UI never persisted a pack citation. -* **How it was tested:** TDD red (auth-paths public=false, FastAPI 401, missing v1 route, missing citation helper) then green. Hash-stability and emit-without-transcript tests remain locked. +* **What was done:** Replaced `export { POST, runtime }` on the v1 pack alias with a literal `export const runtime = 'nodejs'` and a POST wrapper that calls shared `handleIdentityPackPost`. Moved that handler into `apps/web/src/lib/video-pack.ts` so neither route re-exports config. +* **Why it was needed:** After #1612, Vercel `v0-uvai` `next build` failed because Next cannot statically parse a re-exported `runtime`. uvai.io is not serving `25b4de455`. +* **How it was tested:** Source-format test failed on the re-export (RED), then passed after the literal runtime (GREEN). CoS emit tests still return hashed v0 packs with `source_url` + `source_hash`. `npm run build` in `apps/web` exited 0.