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..58f1b849b
--- /dev/null
+++ b/apps/web/src/app/api/v1/video/pack/__tests__/route.test.ts
@@ -0,0 +1,44 @@
+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;
+ source_url: 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.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/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/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 b13b23233..19d74bce5 100644
--- a/apps/web/src/components/OneLoopStudio.tsx
+++ b/apps/web/src/components/OneLoopStudio.tsx
@@ -22,7 +22,10 @@ import {
startVideoToActions,
type VideoToActionsResult,
} from '@/lib/studio-workflow';
+import { identityPackJson } from '@/lib/emit-video-pack';
import {
+ studioPackCitation,
+ studioPasteOutcomeMessage,
studioRunQuality,
studioStatusLabel,
studioStatusMessage,
@@ -196,9 +199,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 +420,14 @@ export default function OneLoopStudio() {
{statusText}
+ {selected?.videoPack && (
+
+ {studioPackCitation(selected.videoPack)}
+
+ )}
{(busy || selected?.status === 'processing') && (
)}
+ {selected?.videoPack && (
+
+
+ Video pack
+
+
+ {studioPackCitation(selected.videoPack)}
+
+
+
+
- source_url
+ - {selected.videoPack.sourceUrl}
+
+
+
- source_hash
+ - {selected.videoPack.sourceHash}
+
+
+
+ {identityPackJson(selected.videoPack)}
+
+
+ )}
+
{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__/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 2d95981b3..0a6ad07c6 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,43 @@ 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',
+ 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).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');
+ });
+
+ 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', () => {
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..b3f8d2c1f 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,14 @@ 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.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/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/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 d1b844632..44e292057 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} · ${pack.sourceUrl}`;
+}
+
+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 '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 ee248c301..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);
@@ -352,6 +366,40 @@ 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(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(
+ '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..c4e59631f 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. 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:**
- * 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 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:** 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")
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)