From b3f13e62c3af1cba232d1d1c2833dcf9cc76eea7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 22:28:43 +0000 Subject: [PATCH 1/2] fix(web): ungate anonymous Video Pack v0 emit (#1611) Home paste was public but POST /api/video/pack and /api/v1/video/pack were session-gated after #1609, so identity packs never emitted. Allowlist those exact paths, alias the v1 Next.js route, and show the cite/hash even when transcript fetch fails. Co-authored-by: Hayden --- .../api/v1/video/pack/__tests__/route.test.ts | 42 +++++++++++++++++ apps/web/src/app/api/v1/video/pack/route.ts | 1 + apps/web/src/components/OneLoopStudio.tsx | 45 +++++++++++++++++-- apps/web/src/lib/__tests__/auth-paths.test.ts | 21 +++++++++ .../__tests__/studio-pipeline-status.test.ts | 26 +++++++++++ apps/web/src/lib/__tests__/video-pack.test.ts | 10 +++++ apps/web/src/lib/auth-paths.ts | 10 +++++ apps/web/src/lib/studio-pipeline-status.ts | 19 ++++++++ .../store/__tests__/dashboard-store.test.ts | 38 ++++++++++++++++ .../backend/middleware/api_key_auth.py | 3 ++ src/youtube_extension/videopack/PLAN.md | 42 ++++++++--------- tests/unit/test_api_key_auth.py | 16 +++++++ 12 files changed, 249 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/app/api/v1/video/pack/__tests__/route.test.ts create mode 100644 apps/web/src/app/api/v1/video/pack/route.ts 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 new file mode 100644 index 000000000..5766c04e4 --- /dev/null +++ b/apps/web/src/app/api/v1/video/pack/__tests__/route.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { GOLDEN_IDENTITY_HASHES } from '@/lib/video-pack'; + +const CANON_B = 'jNQXAC9IVRw'; + +afterEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); +}); + +function postRequest(body: unknown) { + return new Request('http://localhost:3000/api/v1/video/pack', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); +} + +describe('POST /api/v1/video/pack', () => { + it('emits the same identity pack as /api/video/pack without a transcript', async () => { + const { POST } = await import('../route'); + const res = await POST( + postRequest({ url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw' }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + status: string; + data: { + version: string; + video_id: string; + transcript: { full_text: string; segments: unknown[] }; + provenance: { source_hash: string }; + }; + }; + expect(body.status).toBe('success'); + expect(body.data.version).toBe('v0'); + expect(body.data.video_id).toBe(CANON_B); + expect(body.data.provenance.source_hash).toBe(GOLDEN_IDENTITY_HASHES[CANON_B]); + expect(body.data.transcript.full_text).toBe(`cite:youtube:${CANON_B}`); + expect(body.data.transcript.segments).toEqual([]); + }); +}); diff --git a/apps/web/src/app/api/v1/video/pack/route.ts b/apps/web/src/app/api/v1/video/pack/route.ts new file mode 100644 index 000000000..e26df8171 --- /dev/null +++ b/apps/web/src/app/api/v1/video/pack/route.ts @@ -0,0 +1 @@ +export { POST, runtime } from '../../../video/pack/route'; diff --git a/apps/web/src/components/OneLoopStudio.tsx b/apps/web/src/components/OneLoopStudio.tsx index b13b23233..6a9987e46 100644 --- a/apps/web/src/components/OneLoopStudio.tsx +++ b/apps/web/src/components/OneLoopStudio.tsx @@ -23,6 +23,8 @@ import { type VideoToActionsResult, } from '@/lib/studio-workflow'; import { + studioPackCitation, + studioPasteOutcomeMessage, studioRunQuality, studioStatusLabel, studioStatusMessage, @@ -196,9 +198,10 @@ export default function OneLoopStudio() { const ready = (video?.transcript?.trim().length ?? 0) >= 40 || (video?.events?.length ?? 0) > 0; setMessage( - ready - ? 'Ready — run tools, export, or save from this page.' - : 'No usable transcript. Try another public video.', + studioPasteOutcomeMessage({ + hasUsableTranscript: ready, + packCitation: video?.videoPack ? studioPackCitation(video.videoPack) : null, + }), ); } catch (err) { setMessage(err instanceof Error ? err.message : 'Analysis failed.'); @@ -416,6 +419,14 @@ export default function OneLoopStudio() {

{statusText}

+ {selected?.videoPack && ( +

+ {studioPackCitation(selected.videoPack)} +

+ )} {(busy || selected?.status === 'processing') && (
)} + {selected?.videoPack && ( +
+

+ Video pack +

+

+ {studioPackCitation(selected.videoPack)} +

+
+
+
video_id
+
{selected.videoPack.videoId}
+
+
+
version
+
{selected.videoPack.version}
+
+
+
source_hash
+
{selected.videoPack.sourceHash}
+
+
+
+ )} + {selected?.insights && (

diff --git a/apps/web/src/lib/__tests__/auth-paths.test.ts b/apps/web/src/lib/__tests__/auth-paths.test.ts index da1c7daa7..362eb8765 100644 --- a/apps/web/src/lib/__tests__/auth-paths.test.ts +++ b/apps/web/src/lib/__tests__/auth-paths.test.ts @@ -67,6 +67,20 @@ describe('auth path policy', () => { expect(isProtectedPagePath('/dashboard/agents')).toBe(true); }); + it('does not require a session for the public identity pack emit path', () => { + // Home paste is anonymous. Middleware 401 on these paths is the live + // uvai.io failure after #1609: pack never emits, UI shows no source_hash. + expect(isPublicApiPath('/api/video/pack')).toBe(true); + expect(isPublicApiPath('/api/v1/video/pack')).toBe(true); + expect(needsAuthentication('/api/video/pack')).toBe(false); + expect(needsAuthentication('/api/v1/video/pack')).toBe(false); + // Exact allowlist only — siblings stay gated. + expect(isPublicApiPath('/api/video')).toBe(false); + expect(isPublicApiPath('/api/video/generate')).toBe(false); + expect(needsAuthentication('/api/video/generate')).toBe(true); + expect(isPublicApiPath('/api/v1/video')).toBe(false); + }); + it('does not gate marketing pages', () => { expect(needsAuthentication('/')).toBe(false); expect(needsAuthentication('/pricing')).toBe(false); @@ -144,6 +158,13 @@ describe('AI route classification (rate-limit budget)', () => { } }); + it('does not meter identity pack emit as AI work', () => { + expect(isAiRoute('/api/video/pack', 'POST')).toBe(false); + expect(isAiRoute('/api/v1/video/pack', 'POST')).toBe(false); + expect(isAiRoute('/api/video', 'POST')).toBe(true); + expect(isAiRoute('/api/video/generate', 'POST')).toBe(true); + }); + it('leaves non-AI API routes on the general budget', () => { expect(isAiRoute('/api/billing/status', 'GET')).toBe(false); expect(isAiRoute('/api/health', 'GET')).toBe(false); diff --git a/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts b/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts index 2d95981b3..f0aacb341 100644 --- a/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts +++ b/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + studioPackCitation, + studioPasteOutcomeMessage, studioRunQuality, studioStatusLabel, studioStatusMessage, @@ -42,6 +44,30 @@ describe('studio-pipeline-status', () => { expect(studioStatusLabel('live', 'ready')).toBe('Analysis ready'); }); + it('shows the identity pack cite when transcript evidence is missing', () => { + const citation = studioPackCitation({ + version: 'v0', + videoId: 'jNQXAC9IVRw', + packId: 'vp:v0:jNQXAC9IVRw', + sourceHash: '97150a5c21eef3d12a4543ce2108ca28fd6f829db1da120d7e75655ab471f97d', + }); + expect(citation).toBe( + 'cite:youtube:jNQXAC9IVRw · v0 · 97150a5c21eef3d12a4543ce2108ca28fd6f829db1da120d7e75655ab471f97d', + ); + expect( + studioPasteOutcomeMessage({ + hasUsableTranscript: false, + packCitation: citation, + }), + ).toContain('cite:youtube:jNQXAC9IVRw'); + expect( + studioPasteOutcomeMessage({ + hasUsableTranscript: false, + packCitation: citation, + }), + ).toContain('97150a5c21eef3d12a4543ce2108ca28fd6f829db1da120d7e75655ab471f97d'); + }); + it('does not send the user to a second product when ready', () => { const draft = studioStatusMessage('draft', 'ready', 'App', false); const live = studioStatusMessage('live', 'ready', 'App', false); diff --git a/apps/web/src/lib/__tests__/video-pack.test.ts b/apps/web/src/lib/__tests__/video-pack.test.ts index f876fddbb..76c484872 100644 --- a/apps/web/src/lib/__tests__/video-pack.test.ts +++ b/apps/web/src/lib/__tests__/video-pack.test.ts @@ -2,6 +2,7 @@ import { createHash } from 'node:crypto'; import { describe, expect, it } from 'vitest'; import { GOLDEN_IDENTITY_HASHES, + buildIdentityPack, identityHash, identityPayload, resolveYouTubeVideoId, @@ -44,4 +45,13 @@ describe('video-pack identity', () => { it('rejects a non-YouTube URL', () => { expect(resolveYouTubeVideoId('https://example.com/watch')).toBeNull(); }); + + it('emits an identity pack without extracted speech', () => { + const pack = buildIdentityPack(CANON_B); + expect(pack.version).toBe('v0'); + expect(pack.video_id).toBe(CANON_B); + expect(pack.provenance.source_hash).toBe(GOLDEN_IDENTITY_HASHES[CANON_B]); + expect(pack.transcript.full_text).toBe(`cite:youtube:${CANON_B}`); + expect(pack.transcript.segments).toEqual([]); + }); }); diff --git a/apps/web/src/lib/auth-paths.ts b/apps/web/src/lib/auth-paths.ts index 878c193cd..60ba170fa 100644 --- a/apps/web/src/lib/auth-paths.ts +++ b/apps/web/src/lib/auth-paths.ts @@ -35,6 +35,11 @@ const PUBLIC_API_EXACT = new Set([ // Must be accessible without a session so anonymous users can run the // pipeline; the route handler applies its own rate limiting via proxy.ts. '/api/pipeline/stream', + // Home paste-URL identity pack. Hash is version+video_id only; no login + // and no speech evidence. Exact paths so /api/video and /api/video/generate + // stay gated (live 401 after #1609). + '/api/video/pack', + '/api/v1/video/pack', ]); /** App routes that require a session when NEXTAUTH_SECRET is configured. */ @@ -120,6 +125,9 @@ const AI_ROUTE_METHOD_EXEMPT: Record> = { '/api/workflows': new Set(['GET', 'HEAD']), }; +/** Identity hash only — not model work. Keep /api/video siblings on the AI budget. */ +const IDENTITY_PACK_PATHS = new Set(['/api/video/pack', '/api/v1/video/pack']); + /** * Whether a request should be metered against the AI budget rather than the * general one. @@ -128,6 +136,8 @@ const AI_ROUTE_METHOD_EXEMPT: Record> = { * limit) rather than silently widening the budget. */ export function isAiRoute(pathname: string, method: string = 'POST'): boolean { + if (IDENTITY_PACK_PATHS.has(pathname)) return false; + const prefix = AI_ROUTE_PREFIXES.find((candidate) => pathname.startsWith(candidate), ); diff --git a/apps/web/src/lib/studio-pipeline-status.ts b/apps/web/src/lib/studio-pipeline-status.ts index d1b844632..1e2aeee07 100644 --- a/apps/web/src/lib/studio-pipeline-status.ts +++ b/apps/web/src/lib/studio-pipeline-status.ts @@ -1,3 +1,5 @@ +import type { VideoPackCitation } from '@/lib/emit-video-pack'; + export type StudioRunQuality = 'idle' | 'live' | 'draft'; export interface StudioPipelineCheck { @@ -57,4 +59,21 @@ export function studioStatusMessage( return `No usable transcript or events came back. Try another public video or sign in if the enrich path is gated.`; } return 'Paste a YouTube URL. UVAI transcribes it, extracts events, then you can act.'; +} + +export function studioPackCitation(pack: VideoPackCitation): string { + return `cite:youtube:${pack.videoId} · ${pack.version} · ${pack.sourceHash}`; +} + +export function studioPasteOutcomeMessage(input: { + hasUsableTranscript: boolean; + packCitation?: string | null; +}): string { + if (input.hasUsableTranscript) { + return 'Ready — run tools, export, or save from this page.'; + } + if (input.packCitation) { + return `Identity pack ${input.packCitation}. No usable transcript.`; + } + return 'No usable transcript. Try another public video.'; } \ No newline at end of file diff --git a/apps/web/src/store/__tests__/dashboard-store.test.ts b/apps/web/src/store/__tests__/dashboard-store.test.ts index ee248c301..3949c0634 100644 --- a/apps/web/src/store/__tests__/dashboard-store.test.ts +++ b/apps/web/src/store/__tests__/dashboard-store.test.ts @@ -352,6 +352,44 @@ describe('dashboard-store · processVideo (durable evidence workflow)', () => { expect(video.title).toContain('Analysis blocked'); }); + it('emits the identity pack even when the workflow has no transcript', async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce(jsonResponse(VIDEO_PACK_BODY)) + .mockResolvedValueOnce(jsonResponse({ + ok: true, + runId: 'wrun_no_speech', + statusUrl: '/api/workflows/video-to-actions/wrun_no_speech', + })) + .mockResolvedValueOnce(jsonResponse({ + ok: true, + runId: 'wrun_no_speech', + runStatus: 'failed', + error: 'No usable transcript. Try another public video.', + })); + vi.stubGlobal('fetch', fetchMock); + + const id = await store().processVideo(provenance.sourceUrl); + const video = store().videos.find((item) => item.id === id)!; + + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + '/api/video/pack', + expect.objectContaining({ method: 'POST' }), + ); + expect(video.videoPack).toEqual({ + version: 'v0', + videoId: 'auJzb1D-fag', + packId: 'vp:v0:auJzb1D-fag', + sourceHash: '2778c5fc08a1b7f19fe0a83bca959e24ecf20040c3cc1a3b6edd244d68c5e4ea', + }); + expect(video.transcript).toBeUndefined(); + expect(video.status).toBe('failed'); + expect(video.insights?.summary).toBe( + 'Analysis was not generated because source evidence could not be verified.', + ); + }); + it('fails closed when no durable run id is created', async () => { vi.stubGlobal('fetch', vi.fn() .mockResolvedValueOnce(jsonResponse(VIDEO_PACK_BODY)) diff --git a/src/youtube_extension/backend/middleware/api_key_auth.py b/src/youtube_extension/backend/middleware/api_key_auth.py index 2e7156808..f9537c0ff 100644 --- a/src/youtube_extension/backend/middleware/api_key_auth.py +++ b/src/youtube_extension/backend/middleware/api_key_auth.py @@ -33,6 +33,9 @@ PUBLIC_PREFIXES: tuple[str, ...] = ( "/health", "/api/v1/health", + # Identity Video Pack v0: version + video_id hash. Public home paste + # must emit this without an API key and without speech evidence. + "/api/v1/video/pack", "/docs", "/redoc", "/openapi.json", diff --git a/src/youtube_extension/videopack/PLAN.md b/src/youtube_extension/videopack/PLAN.md index 46ad7056b..92959d171 100644 --- a/src/youtube_extension/videopack/PLAN.md +++ b/src/youtube_extension/videopack/PLAN.md @@ -1,31 +1,31 @@ -# TASK: UVAI Step 2 — paste-URL emits a hashed Video Pack +# TASK: Public identity Video Pack emit after #1609 401 ## 1. Goal & Scope -* **Objective:** Pasting a YouTube URL (or calling the existing pack API) produces a real VideoPack v0 with a stable hash/version tied to that video ID. Retries of the same URL reuse the same pack; a different video ID gets a different hash. -* **Context:** Step 1 (Workflow Pro checkout) is done. `src/youtube_extension/videopack` already defines VideoPackV0 + `stable_hash`. Live `POST /api/v1/video/pack` is a shell: new UUID, `datetime.now()`, no `source_hash`, no persist. The uvai.io paste path (`processVideo`) never calls it. +* **Objective:** Anonymous paste-URL on uvai.io emits a hashed Video Pack v0 even when transcript fetch fails. `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`. * **Scope:** - * Use existing VideoPackV0. Do not invent a second pack format. - * Hash = SHA-256 of compact canonical JSON `{"version":"v0","video_id":""}` stored on `provenance.source_hash`. - * Persist under `storage/video_packs//pack.json` via existing `write_pack` / `read_pack`. - * Wire FastAPI `POST /api/v1/video/pack` and the existing uvai.io paste surface (`POST /api/video/pack` + `processVideo`). - * Tests: same video ID → same hash; different video ID → different hash; URL variants collapse to one video ID. -* **Out of scope:** Qwen/Qwen3.8-27B extract, vLLM, Origin wasm, new public brand, FORGE/slingshot/ClipToAction. + * 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. ## 2. Execution Plan -- [x] Confirm existing videopack schema/hash and the live paste-URL gap -- [x] Lock failing hash-stability tests (Python identity/store + Next.js route) -- [x] Implement identity hash, get-or-create store, and wire both APIs -- [x] Attach the returned pack citation on dashboard `processVideo` -- [x] Verify tests; open PR against main (issue create returned 403) +- [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) ## 3. Definition of Done (Success Verification) -* **Expected Outcome:** Paste or `POST` of a YouTube URL returns VideoPack v0 whose `provenance.source_hash` is stable for that video ID and different for another video ID. A second request for the same ID reuses the stored pack. +* **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. * **Verification Method:** - * `PYTHONPATH=src pytest tests/unit/test_videopack_identity.py tests/unit/test_videopack_store.py -v` - * `cd apps/web && npx vitest run src/lib/__tests__/video-pack.test.ts src/app/api/video/pack src/store/__tests__/dashboard-store.test.ts` -* **Proof Artifact:** Python 102 passed (`test_videopack_*`); frontend 47 passed (video-pack + `/api/video/pack` + dashboard-store). Live hashes: `auJzb1D-fag` → `2778c5fc08a1b7f19fe0a83bca959e24ecf20040c3cc1a3b6edd244d68c5e4ea`; `jNQXAC9IVRw` → `97150a5c21eef3d12a4543ce2108ca28fd6f829db1da120d7e75655ab471f97d`. + * `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 71 passed (6 files). Python 23 passed. Live production still 401 until this branch deploys: `curl POST https://uvai.io/api/video/pack` → `{"error":"Authentication required"}`. Golden hashes unchanged: `auJzb1D-fag` → `2778c5fc08a1b7f19fe0a83bca959e24ecf20040c3cc1a3b6edd244d68c5e4ea`; `jNQXAC9IVRw` → `97150a5c21eef3d12a4543ce2108ca28fd6f829db1da120d7e75655ab471f97d`. ## 4. Post-Task Reflection -* **What was done:** Wired existing VideoPackV0 so paste-URL / `POST /api/v1/video/pack` / `POST /api/video/pack` emit a v0 identity pack with a stable `provenance.source_hash` and persist/reuse by video ID. -* **Why it was needed:** The library existed; the live path synthesized a new unhashed pack on every call and the uvai.io paste path never called it. -* **How it was tested:** TDD hash-stability tests (same ID / URL variants / different ID), store reuse on `pack.json`, Next.js route tests, dashboard `processVideo` attaches the citation. GitHub `issue_write` returned 403 so no `Closes #` issue could be opened from this agent. +* **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. diff --git a/tests/unit/test_api_key_auth.py b/tests/unit/test_api_key_auth.py index 30179b0ee..077f6d9ac 100644 --- a/tests/unit/test_api_key_auth.py +++ b/tests/unit/test_api_key_auth.py @@ -36,6 +36,10 @@ def get_video(vid: str): def dispatch(): return {"dispatched": True} + @app.post("/api/v1/video/pack") + def video_pack(): + return {"ok": True} + return TestClient(app) @@ -83,6 +87,18 @@ def test_dev_optin_opens_everything(monkeypatch): assert c.post("/api/v1/agents/dispatch").status_code == 200 +def test_video_pack_identity_emit_does_not_require_api_key(monkeypatch): + c = _make_client(monkeypatch, api_key="secret") + r = c.post( + "/api/v1/video/pack", + json={"url": "https://www.youtube.com/watch?v=auJzb1D-fag"}, + ) + assert r.status_code == 200 + assert r.json() == {"ok": True} + # Sibling video routes stay deny-by-default. + assert c.get("/api/v1/videos/abc").status_code == 401 + + def test_options_preflight_not_blocked_by_auth(monkeypatch): c = _make_client(monkeypatch, api_key="secret") r = c.options("/api/v1/videos/abc") From b333a393e629abac1091033fbcf3878b5ab4acdc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 2 Sep 2026 22:31:47 +0000 Subject: [PATCH 2/2] fix(web): fail closed unless pack JSON has source_url and hash CoS lock: anonymous paste must emit identity Video Pack v0 even without a transcript. Verification requires source_url + source_hash; missing either is a visible failure, not an empty UI. Co-authored-by: Hayden --- .../api/v1/video/pack/__tests__/route.test.ts | 2 + .../api/video/pack/__tests__/route.test.ts | 1 + apps/web/src/app/api/video/pack/route.ts | 9 +++ apps/web/src/components/OneLoopStudio.tsx | 17 +++-- .../src/lib/__tests__/emit-video-pack.test.ts | 51 +++++++++++++ .../__tests__/studio-pipeline-status.test.ts | 31 +++++--- apps/web/src/lib/__tests__/video-pack.test.ts | 1 + apps/web/src/lib/emit-video-pack.ts | 72 +++++++++++++++---- apps/web/src/lib/studio-pipeline-status.ts | 4 +- .../store/__tests__/dashboard-store.test.ts | 34 +++++---- src/youtube_extension/videopack/PLAN.md | 4 +- tests/unit/test_videopack_identity.py | 1 + 12 files changed, 183 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/lib/__tests__/emit-video-pack.test.ts 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 5766c04e4..58f1b849b 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 @@ -28,6 +28,7 @@ describe('POST /api/v1/video/pack', () => { data: { version: string; video_id: string; + source_url: string; transcript: { full_text: string; segments: unknown[] }; provenance: { source_hash: string }; }; @@ -35,6 +36,7 @@ describe('POST /api/v1/video/pack', () => { expect(body.status).toBe('success'); expect(body.data.version).toBe('v0'); expect(body.data.video_id).toBe(CANON_B); + expect(body.data.source_url).toBe('https://www.youtube.com/watch?v=jNQXAC9IVRw'); expect(body.data.provenance.source_hash).toBe(GOLDEN_IDENTITY_HASHES[CANON_B]); expect(body.data.transcript.full_text).toBe(`cite:youtube:${CANON_B}`); expect(body.data.transcript.segments).toEqual([]); diff --git a/apps/web/src/app/api/video/pack/__tests__/route.test.ts b/apps/web/src/app/api/video/pack/__tests__/route.test.ts index a95ffaa13..44e43d7b4 100644 --- a/apps/web/src/app/api/video/pack/__tests__/route.test.ts +++ b/apps/web/src/app/api/video/pack/__tests__/route.test.ts @@ -42,6 +42,7 @@ describe('POST /api/video/pack', () => { expect(a.data.version).toBe('v0'); expect(a.data.video_id).toBe(CANON_A); expect(a.data.id).toBe(`vp:v0:${CANON_A}`); + expect(a.data.source_url).toBe('https://www.youtube.com/watch?v=auJzb1D-fag'); expect(hashA).toBe(GOLDEN_IDENTITY_HASHES[CANON_A]); expect(hashA).toBe(identityHash(CANON_A)); expect(hashA).toBe(hashB); diff --git a/apps/web/src/app/api/video/pack/route.ts b/apps/web/src/app/api/video/pack/route.ts index 1b143fb5a..dedc7ddaf 100644 --- a/apps/web/src/app/api/video/pack/route.ts +++ b/apps/web/src/app/api/video/pack/route.ts @@ -38,5 +38,14 @@ export async function POST(request: Request) { } 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/apps/web/src/components/OneLoopStudio.tsx b/apps/web/src/components/OneLoopStudio.tsx index 6a9987e46..19d74bce5 100644 --- a/apps/web/src/components/OneLoopStudio.tsx +++ b/apps/web/src/components/OneLoopStudio.tsx @@ -22,6 +22,7 @@ import { startVideoToActions, type VideoToActionsResult, } from '@/lib/studio-workflow'; +import { identityPackJson } from '@/lib/emit-video-pack'; import { studioPackCitation, studioPasteOutcomeMessage, @@ -657,20 +658,22 @@ export default function OneLoopStudio() {

{studioPackCitation(selected.videoPack)}

-
+
-
video_id
-
{selected.videoPack.videoId}
-
-
-
version
-
{selected.videoPack.version}
+
source_url
+
{selected.videoPack.sourceUrl}
source_hash
{selected.videoPack.sourceHash}
+
+              {identityPackJson(selected.videoPack)}
+            

)} diff --git a/apps/web/src/lib/__tests__/emit-video-pack.test.ts b/apps/web/src/lib/__tests__/emit-video-pack.test.ts new file mode 100644 index 000000000..c2743aa48 --- /dev/null +++ b/apps/web/src/lib/__tests__/emit-video-pack.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { GOLDEN_IDENTITY_HASHES } from '@/lib/video-pack'; +import { identityPackJson, verifyIdentityPack } from '@/lib/emit-video-pack'; + +const CANON = 'jNQXAC9IVRw'; +const SOURCE_URL = `https://www.youtube.com/watch?v=${CANON}`; +const HASH = GOLDEN_IDENTITY_HASHES[CANON]; + +const VALID = { + status: 'success', + data: { + version: 'v0', + id: `vp:v0:${CANON}`, + video_id: CANON, + source_url: SOURCE_URL, + transcript: { full_text: `cite:youtube:${CANON}`, segments: [] }, + provenance: { source_hash: HASH }, + }, +}; + +describe('verifyIdentityPack (CoS: fail closed)', () => { + it('accepts a v0 pack with source_url and source_hash', () => { + const citation = verifyIdentityPack(VALID); + expect(citation.sourceUrl).toBe(SOURCE_URL); + expect(citation.sourceHash).toBe(HASH); + expect(citation.videoId).toBe(CANON); + expect(citation.pack.source_url).toBe(SOURCE_URL); + expect(citation.pack.provenance.source_hash).toBe(HASH); + expect(identityPackJson(citation)).toContain(SOURCE_URL); + expect(identityPackJson(citation)).toContain(HASH); + }); + + it('fails closed when source_url is missing', () => { + const { source_url: _omit, ...data } = VALID.data; + expect(() => verifyIdentityPack({ status: 'success', data })).toThrow(/source_url/i); + }); + + it('fails closed when source_hash is missing', () => { + expect(() => + verifyIdentityPack({ + status: 'success', + data: { ...VALID.data, provenance: {} }, + }), + ).toThrow(/source_hash/i); + }); + + it('fails closed on an empty or 401 payload', () => { + expect(() => verifyIdentityPack({ error: 'Authentication required' })).toThrow(/verif/i); + expect(() => verifyIdentityPack(null)).toThrow(/verif/i); + }); +}); diff --git a/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts b/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts index f0aacb341..0a6ad07c6 100644 --- a/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts +++ b/apps/web/src/lib/__tests__/studio-pipeline-status.test.ts @@ -49,23 +49,36 @@ describe('studio-pipeline-status', () => { version: 'v0', videoId: 'jNQXAC9IVRw', packId: 'vp:v0:jNQXAC9IVRw', + sourceUrl: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', sourceHash: '97150a5c21eef3d12a4543ce2108ca28fd6f829db1da120d7e75655ab471f97d', + pack: { + version: 'v0', + id: 'vp:v0:jNQXAC9IVRw', + video_id: 'jNQXAC9IVRw', + source_url: 'https://www.youtube.com/watch?v=jNQXAC9IVRw', + provenance: { + source_hash: '97150a5c21eef3d12a4543ce2108ca28fd6f829db1da120d7e75655ab471f97d', + }, + }, }); - expect(citation).toBe( - 'cite:youtube:jNQXAC9IVRw · v0 · 97150a5c21eef3d12a4543ce2108ca28fd6f829db1da120d7e75655ab471f97d', - ); + expect(citation).toContain('cite:youtube:jNQXAC9IVRw'); + expect(citation).toContain('https://www.youtube.com/watch?v=jNQXAC9IVRw'); + expect(citation).toContain('97150a5c21eef3d12a4543ce2108ca28fd6f829db1da120d7e75655ab471f97d'); expect( studioPasteOutcomeMessage({ hasUsableTranscript: false, packCitation: citation, }), ).toContain('cite:youtube:jNQXAC9IVRw'); - expect( - studioPasteOutcomeMessage({ - hasUsableTranscript: false, - packCitation: citation, - }), - ).toContain('97150a5c21eef3d12a4543ce2108ca28fd6f829db1da120d7e75655ab471f97d'); + }); + + it('fails closed when paste finishes without a verified pack', () => { + const message = studioPasteOutcomeMessage({ + hasUsableTranscript: false, + packCitation: null, + }); + expect(message).toMatch(/pack emit failed|verification failed/i); + expect(message.toLowerCase()).not.toBe('no usable transcript. try another public video.'); }); it('does not send the user to a second product when ready', () => { diff --git a/apps/web/src/lib/__tests__/video-pack.test.ts b/apps/web/src/lib/__tests__/video-pack.test.ts index 76c484872..b3f8d2c1f 100644 --- a/apps/web/src/lib/__tests__/video-pack.test.ts +++ b/apps/web/src/lib/__tests__/video-pack.test.ts @@ -51,6 +51,7 @@ describe('video-pack identity', () => { expect(pack.version).toBe('v0'); expect(pack.video_id).toBe(CANON_B); expect(pack.provenance.source_hash).toBe(GOLDEN_IDENTITY_HASHES[CANON_B]); + expect(pack.source_url).toBe(`https://www.youtube.com/watch?v=${CANON_B}`); expect(pack.transcript.full_text).toBe(`cite:youtube:${CANON_B}`); expect(pack.transcript.segments).toEqual([]); }); diff --git a/apps/web/src/lib/emit-video-pack.ts b/apps/web/src/lib/emit-video-pack.ts index ded94264d..20368eb6d 100644 --- a/apps/web/src/lib/emit-video-pack.ts +++ b/apps/web/src/lib/emit-video-pack.ts @@ -1,14 +1,69 @@ +export interface EmittedVideoPack { + version: string; + id: string; + video_id: string; + source_url: string; + provenance: { source_hash: string }; +} + export interface VideoPackCitation { version: string; videoId: string; packId: string; + sourceUrl: string; sourceHash: string; + pack: EmittedVideoPack; } +const SOURCE_HASH = /^[a-f0-9]{64}$/; + function asRecord(value: unknown): Record | null { return value !== null && typeof value === 'object' ? (value as Record) : null; } +export function verifyIdentityPack(payload: unknown): VideoPackCitation { + const envelope = asRecord(payload); + const data = asRecord(envelope?.data); + const provenance = asRecord(data?.provenance); + const sourceUrl = typeof data?.source_url === 'string' ? data.source_url.trim() : ''; + const sourceHash = typeof provenance?.source_hash === 'string' ? provenance.source_hash.trim() : ''; + const videoId = typeof data?.video_id === 'string' ? data.video_id : ''; + const version = typeof data?.version === 'string' ? data.version : ''; + const packId = typeof data?.id === 'string' ? data.id : ''; + + if ( + envelope?.status !== 'success' || + version !== 'v0' || + !videoId || + !packId || + !sourceUrl.startsWith('http') || + !SOURCE_HASH.test(sourceHash) + ) { + throw new Error( + 'Video pack verification failed: source_url and source_hash are required.', + ); + } + + return { + version, + videoId, + packId, + sourceUrl, + sourceHash, + pack: { + version, + id: packId, + video_id: videoId, + source_url: sourceUrl, + provenance: { source_hash: sourceHash }, + }, + }; +} + +export function identityPackJson(pack: VideoPackCitation): string { + return JSON.stringify(pack.pack, null, 2); +} + export async function emitVideoPack(url: string): Promise { const response = await fetch('/api/video/pack', { method: 'POST', @@ -17,18 +72,11 @@ export async function emitVideoPack(url: string): Promise { body: JSON.stringify({ url }), signal: AbortSignal.timeout(15_000), }); - const payload = asRecord(await response.json().catch(() => null)); - const data = asRecord(payload?.data); - const provenance = asRecord(data?.provenance); - const sourceHash = typeof provenance?.source_hash === 'string' ? provenance.source_hash : ''; - const videoId = typeof data?.video_id === 'string' ? data.video_id : ''; - const version = typeof data?.version === 'string' ? data.version : ''; - const packId = typeof data?.id === 'string' ? data.id : ''; - - if (!response.ok || payload?.status !== 'success' || !sourceHash || !videoId || version !== 'v0') { - const error = typeof payload?.error === 'string' ? payload.error : 'Video pack emit failed.'; + const payload: unknown = await response.json().catch(() => null); + if (!response.ok) { + const envelope = asRecord(payload); + const error = typeof envelope?.error === 'string' ? envelope.error : 'Video pack emit failed.'; throw new Error(error); } - - return { version, videoId, packId, sourceHash }; + return verifyIdentityPack(payload); } diff --git a/apps/web/src/lib/studio-pipeline-status.ts b/apps/web/src/lib/studio-pipeline-status.ts index 1e2aeee07..44e292057 100644 --- a/apps/web/src/lib/studio-pipeline-status.ts +++ b/apps/web/src/lib/studio-pipeline-status.ts @@ -62,7 +62,7 @@ export function studioStatusMessage( } export function studioPackCitation(pack: VideoPackCitation): string { - return `cite:youtube:${pack.videoId} · ${pack.version} · ${pack.sourceHash}`; + return `cite:youtube:${pack.videoId} · ${pack.version} · ${pack.sourceHash} · ${pack.sourceUrl}`; } export function studioPasteOutcomeMessage(input: { @@ -75,5 +75,5 @@ export function studioPasteOutcomeMessage(input: { if (input.packCitation) { return `Identity pack ${input.packCitation}. No usable transcript.`; } - return 'No usable transcript. Try another public video.'; + return 'Pack emit failed: verification failed (source_url + source_hash required).'; } \ No newline at end of file diff --git a/apps/web/src/store/__tests__/dashboard-store.test.ts b/apps/web/src/store/__tests__/dashboard-store.test.ts index 3949c0634..792098ebe 100644 --- a/apps/web/src/store/__tests__/dashboard-store.test.ts +++ b/apps/web/src/store/__tests__/dashboard-store.test.ts @@ -196,6 +196,25 @@ const VIDEO_PACK_BODY = { version: 'v0', id: 'vp:v0:auJzb1D-fag', video_id: 'auJzb1D-fag', + source_url: 'https://www.youtube.com/watch?v=auJzb1D-fag', + transcript: { full_text: 'cite:youtube:auJzb1D-fag', segments: [] }, + provenance: { + source_hash: '2778c5fc08a1b7f19fe0a83bca959e24ecf20040c3cc1a3b6edd244d68c5e4ea', + }, + }, +}; + +const VIDEO_PACK_CITATION = { + version: 'v0', + videoId: 'auJzb1D-fag', + packId: 'vp:v0:auJzb1D-fag', + sourceUrl: 'https://www.youtube.com/watch?v=auJzb1D-fag', + sourceHash: '2778c5fc08a1b7f19fe0a83bca959e24ecf20040c3cc1a3b6edd244d68c5e4ea', + pack: { + version: 'v0', + id: 'vp:v0:auJzb1D-fag', + video_id: 'auJzb1D-fag', + source_url: 'https://www.youtube.com/watch?v=auJzb1D-fag', provenance: { source_hash: '2778c5fc08a1b7f19fe0a83bca959e24ecf20040c3cc1a3b6edd244d68c5e4ea', }, @@ -295,12 +314,7 @@ describe('dashboard-store · processVideo (durable evidence workflow)', () => { '/api/workflows/video-to-actions/wrun_verified', expect.objectContaining({ method: 'GET' }), ); - expect(video.videoPack).toEqual({ - version: 'v0', - videoId: 'auJzb1D-fag', - packId: 'vp:v0:auJzb1D-fag', - sourceHash: '2778c5fc08a1b7f19fe0a83bca959e24ecf20040c3cc1a3b6edd244d68c5e4ea', - }); + expect(video.videoPack).toEqual(VIDEO_PACK_CITATION); expect(video.status).toBe('complete'); expect(video.runId).toBe('wrun_verified'); expect(video.quality?.passed).toBe(true); @@ -377,12 +391,8 @@ describe('dashboard-store · processVideo (durable evidence workflow)', () => { '/api/video/pack', expect.objectContaining({ method: 'POST' }), ); - expect(video.videoPack).toEqual({ - version: 'v0', - videoId: 'auJzb1D-fag', - packId: 'vp:v0:auJzb1D-fag', - sourceHash: '2778c5fc08a1b7f19fe0a83bca959e24ecf20040c3cc1a3b6edd244d68c5e4ea', - }); + expect(video.videoPack).toEqual(VIDEO_PACK_CITATION); + expect(video.videoPack?.sourceUrl).toBe('https://www.youtube.com/watch?v=auJzb1D-fag'); expect(video.transcript).toBeUndefined(); expect(video.status).toBe('failed'); expect(video.insights?.summary).toBe( diff --git a/src/youtube_extension/videopack/PLAN.md b/src/youtube_extension/videopack/PLAN.md index 92959d171..c4e59631f 100644 --- a/src/youtube_extension/videopack/PLAN.md +++ b/src/youtube_extension/videopack/PLAN.md @@ -1,7 +1,7 @@ # TASK: Public identity Video Pack emit after #1609 401 ## 1. Goal & Scope -* **Objective:** Anonymous paste-URL on uvai.io emits a hashed Video Pack v0 even when transcript fetch fails. `POST /api/video/pack` must return 200 without sign-in. +* **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`. * **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`). @@ -23,7 +23,7 @@ * **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 71 passed (6 files). Python 23 passed. Live production still 401 until this branch deploys: `curl POST https://uvai.io/api/video/pack` → `{"error":"Authentication required"}`. Golden hashes unchanged: `auJzb1D-fag` → `2778c5fc08a1b7f19fe0a83bca959e24ecf20040c3cc1a3b6edd244d68c5e4ea`; `jNQXAC9IVRw` → `97150a5c21eef3d12a4543ce2108ca28fd6f829db1da120d7e75655ab471f97d`. +* **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. ## 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. diff --git a/tests/unit/test_videopack_identity.py b/tests/unit/test_videopack_identity.py index 534b32481..e380607fe 100644 --- a/tests/unit/test_videopack_identity.py +++ b/tests/unit/test_videopack_identity.py @@ -88,6 +88,7 @@ def test_pack_is_v0_with_source_hash(self): assert pack.video_id == CANON_A assert pack.id == f"vp:v0:{CANON_A}" assert pack.provenance.source_hash == GOLDEN_A + assert str(pack.source_url) == f"https://www.youtube.com/watch?v={CANON_A}" def test_different_video_ids_get_different_pack_hashes(self): a = build_identity_pack(CANON_A)