Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 16 additions & 0 deletions apps/web/src/app/api/v1/video/pack/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand All @@ -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(
Expand Down
8 changes: 7 additions & 1 deletion apps/web/src/app/api/v1/video/pack/route.ts
Original file line number Diff line number Diff line change
@@ -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<Response> {
return handleIdentityPackPost(request);
}
50 changes: 3 additions & 47 deletions apps/web/src/app/api/video/pack/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, VideoPackV0Json>();

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<string, unknown> | null = null;
try {
const parsed: unknown = await request.json();
body = parsed !== null && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : 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<Response> {
return handleIdentityPackPost(request);
}
48 changes: 48 additions & 0 deletions apps/web/src/lib/video-pack.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -94,3 +96,49 @@ export function buildIdentityPack(videoId: string, sourceUrl?: string, createdAt
},
};
}

const packs = new Map<string, VideoPackV0Json>();

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<Response> {
let body: Record<string, unknown> | null = null;
try {
const parsed: unknown = await request.json();
body = parsed !== null && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : 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 });
}
41 changes: 20 additions & 21 deletions src/youtube_extension/videopack/PLAN.md
Original file line number Diff line number Diff line change
@@ -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:<id> · v0 · <source_hash>` 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.
Loading